mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r0.10): apps/api — auth fermée par défaut, matrice en base, démo-login ADR-002, seed
- packages/shared : rôles/catégories, schémas Zod, contrat d'API ; pnpm contract → docs/openapi.json committée (règle d'or ADR-001) - apps/api : NestJS 11 + Prisma 6, migration r0_identity (Role/Permission/User) ; guard JWT global + @Public() ; PermissionsGuard (@RequirePermission, matrice relue en base, cache 60 s) ; FileStorage (seul import MinIO) ; /health - démo-login ADR-002 : module conditionnel DEMO_MODE (404 sinon, testé e2e), double verrou production, refus des comptes isDemo=false - seed idempotent : 7 rôles, matrice complète (70 lignes), 7 comptes démo - 19 tests Jest (unit + e2e) ; smoke test sur build de prod Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
20
apps/api/.env.example
Normal file
20
apps/api/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copier en .env pour le dev local (valeurs alignées sur infra/docker-compose.yml).
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# 16 caractères minimum — générer un vrai secret hors dev : openssl rand -hex 32
|
||||
JWT_SECRET=dev-only-secret-0123456789abcdef
|
||||
JWT_EXPIRES_IN=8h
|
||||
|
||||
# ADR-002 — sélecteur de compte démo. En production : ABSENT.
|
||||
# (DEMO_MODE=true + NODE_ENV=production exige DEMO_MODE_I_KNOW=true.)
|
||||
DEMO_MODE=true
|
||||
# Mot de passe commun des comptes seedés (connexion classique) :
|
||||
SEED_DEMO_PASSWORD=Demo!2026
|
||||
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=siop
|
||||
MINIO_SECRET_KEY=siop-minio
|
||||
14
apps/api/jest.config.js
Normal file
14
apps/api/jest.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
testMatch: ['<rootDir>/src/**/*.spec.ts', '<rootDir>/test/**/*.e2e-spec.ts'],
|
||||
moduleNameMapper: {
|
||||
// Les tests consomment la source du contrat, pas le dist
|
||||
'^@siop/shared$': '<rootDir>/../../packages/shared/src/index.ts',
|
||||
},
|
||||
setupFiles: ['<rootDir>/test/setup-env.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/main.ts'],
|
||||
testTimeout: 30000,
|
||||
};
|
||||
9
apps/api/nest-cli.json
Normal file
9
apps/api/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"tsConfigPath": "tsconfig.build.json",
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
49
apps/api/package.json
Normal file
49
apps/api/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@siop/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "SIOP V2 — API NestJS (auth fermée par défaut, matrice de permissions en base, démo-login ADR-002)",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "jest",
|
||||
"test:cov": "jest --coverage",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.0",
|
||||
"@nestjs/core": "^11.1.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.1.0",
|
||||
"@prisma/client": "^6.8.0",
|
||||
"@siop/shared": "workspace:*",
|
||||
"argon2": "^0.43.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"ioredis": "^5.4.0",
|
||||
"minio": "^8.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/testing": "^11.1.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^6.8.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.3.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Role" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Permission" (
|
||||
"id" UUID NOT NULL,
|
||||
"roleId" UUID NOT NULL,
|
||||
"objectCategory" TEXT NOT NULL,
|
||||
"canView" BOOLEAN NOT NULL DEFAULT false,
|
||||
"canViewOther" BOOLEAN NOT NULL DEFAULT false,
|
||||
"canCreate" BOOLEAN NOT NULL DEFAULT false,
|
||||
"canEdit" BOOLEAN NOT NULL DEFAULT false,
|
||||
"canDelete" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" UUID NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"roleId" UUID NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isDemo" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Permission_roleId_objectCategory_key" ON "Permission"("roleId", "objectCategory");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_roleId_idx" ON "User"("roleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
47
apps/api/prisma/schema.prisma
Normal file
47
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,47 @@
|
||||
// Modèle R0 — identité & permissions (docs/03-architecture/modele-donnees.md).
|
||||
// La matrice rôles × objets × droits vit EN BASE ; le JWT ne porte jamais de droits.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique // 7 rôles seedés — voir @siop/shared ROLE_NAMES
|
||||
users User[]
|
||||
permissions Permission[]
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
roleId String @db.Uuid
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
objectCategory String // enum applicatif — voir @siop/shared OBJECT_CATEGORIES
|
||||
canView Boolean @default(false)
|
||||
canViewOther Boolean @default(false) // « voir autre » : au-delà de ses propres objets
|
||||
canCreate Boolean @default(false)
|
||||
canEdit Boolean @default(false)
|
||||
canDelete Boolean @default(false)
|
||||
|
||||
@@unique([roleId, objectCategory])
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
email String @unique
|
||||
displayName String
|
||||
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||||
roleId String @db.Uuid
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([roleId])
|
||||
}
|
||||
168
apps/api/prisma/seed.ts
Normal file
168
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Seed R0 — idempotent (upserts) : 7 rôles, matrice COMPLÈTE (une ligne par
|
||||
* rôle × catégorie, invariant R0) et 7 comptes de démonstration (ADR-002).
|
||||
* La matrice ci-dessous est le point de DÉPART pédagogique : elle vit en base
|
||||
* et sera administrable (R1) — la modifier ici ne change pas une base déjà
|
||||
* seedée (les lignes existantes ne sont pas écrasées, voir plus bas).
|
||||
* Usage : pnpm seed (ou prisma db seed)
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
OBJECT_CATEGORIES,
|
||||
ROLE_NAMES,
|
||||
type ObjectCategory,
|
||||
type RoleName,
|
||||
} from '@siop/shared';
|
||||
import * as argon2 from 'argon2';
|
||||
|
||||
type Grant = Partial<{
|
||||
view: boolean;
|
||||
viewOther: boolean;
|
||||
create: boolean;
|
||||
edit: boolean;
|
||||
delete: boolean;
|
||||
}>;
|
||||
|
||||
const FULL: Grant = { view: true, viewOther: true, create: true, edit: true, delete: true };
|
||||
const READ: Grant = { view: true, viewOther: true };
|
||||
|
||||
/** Matrice de départ (rôle → catégorie → droits ; absent = tout à false). */
|
||||
const MATRIX: Record<RoleName, Partial<Record<ObjectCategory, Grant>>> = {
|
||||
Administrateur: Object.fromEntries(
|
||||
OBJECT_CATEGORIES.map((c) => [c, FULL]),
|
||||
) as Record<ObjectCategory, Grant>,
|
||||
Gestionnaire: {
|
||||
WORK_ORDERS: FULL,
|
||||
REQUESTS: FULL,
|
||||
ASSETS: FULL,
|
||||
LOCATIONS: FULL,
|
||||
METERS: FULL,
|
||||
PARTS: FULL,
|
||||
PURCHASE_ORDERS: FULL,
|
||||
PEOPLE_TEAMS: { view: true, viewOther: true, create: true, edit: true },
|
||||
ANALYTICS: READ,
|
||||
SETTINGS: { view: true },
|
||||
},
|
||||
Dispatcher: {
|
||||
WORK_ORDERS: { view: true, viewOther: true, create: true, edit: true },
|
||||
REQUESTS: { view: true, viewOther: true, create: true, edit: true },
|
||||
ASSETS: READ,
|
||||
LOCATIONS: READ,
|
||||
METERS: READ,
|
||||
PARTS: { view: true },
|
||||
PEOPLE_TEAMS: READ,
|
||||
ANALYTICS: READ,
|
||||
},
|
||||
Technicien: {
|
||||
WORK_ORDERS: { view: true, edit: true }, // ses OT uniquement (viewOther=false)
|
||||
REQUESTS: { view: true },
|
||||
ASSETS: READ,
|
||||
LOCATIONS: READ,
|
||||
METERS: { view: true, viewOther: true, create: true }, // relevés
|
||||
PARTS: { view: true },
|
||||
},
|
||||
'Technicien limité': {
|
||||
WORK_ORDERS: { view: true, edit: true }, // ses OT, sans consultation du parc
|
||||
ASSETS: { view: true },
|
||||
},
|
||||
Demandeur: {
|
||||
REQUESTS: { view: true, create: true }, // ses demandes uniquement
|
||||
},
|
||||
'Vue seule': {
|
||||
WORK_ORDERS: READ,
|
||||
REQUESTS: READ,
|
||||
ASSETS: READ,
|
||||
LOCATIONS: READ,
|
||||
METERS: READ,
|
||||
PARTS: READ,
|
||||
PURCHASE_ORDERS: READ,
|
||||
PEOPLE_TEAMS: READ,
|
||||
ANALYTICS: READ,
|
||||
},
|
||||
};
|
||||
|
||||
/** 7 comptes démo — un par rôle (personas des maquettes 02-design). */
|
||||
const DEMO_USERS: { email: string; displayName: string; role: RoleName }[] = [
|
||||
{ email: 'admin@demo.siop.ma', displayName: 'Amina Benali', role: 'Administrateur' },
|
||||
{ email: 'dispatcher@demo.siop.ma', displayName: 'Salma Radi', role: 'Dispatcher' },
|
||||
{ email: 'technicien@demo.siop.ma', displayName: 'Ahmed Meskini', role: 'Technicien' },
|
||||
{ email: 'technicien-limite@demo.siop.ma', displayName: 'Yassine Bouzid', role: 'Technicien limité' },
|
||||
{ email: 'gestionnaire@demo.siop.ma', displayName: 'Nadia Cherkaoui', role: 'Gestionnaire' },
|
||||
{ email: 'demandeur@demo.siop.ma', displayName: 'Karim El Fassi', role: 'Demandeur' },
|
||||
{ email: 'vue-seule@demo.siop.ma', displayName: 'Omar Senhaji', role: 'Vue seule' },
|
||||
];
|
||||
|
||||
export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
const roleIds = new Map<RoleName, string>();
|
||||
for (const name of ROLE_NAMES) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { name },
|
||||
update: {},
|
||||
create: { name },
|
||||
});
|
||||
roleIds.set(name, role.id);
|
||||
}
|
||||
|
||||
// Matrice complète : une ligne par rôle × catégorie. Les lignes existantes
|
||||
// ne sont PAS écrasées (la base est la source de vérité, pas ce fichier).
|
||||
for (const name of ROLE_NAMES) {
|
||||
const roleId = roleIds.get(name)!;
|
||||
for (const category of OBJECT_CATEGORIES) {
|
||||
const g = MATRIX[name][category] ?? {};
|
||||
await prisma.permission.upsert({
|
||||
where: { roleId_objectCategory: { roleId, objectCategory: category } },
|
||||
update: {},
|
||||
create: {
|
||||
roleId,
|
||||
objectCategory: category,
|
||||
canView: g.view ?? false,
|
||||
canViewOther: g.viewOther ?? false,
|
||||
canCreate: g.create ?? false,
|
||||
canEdit: g.edit ?? false,
|
||||
canDelete: g.delete ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mot de passe commun des comptes démo (la connexion classique reste testable).
|
||||
const passwordHash = await argon2.hash(
|
||||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||
);
|
||||
for (const u of DEMO_USERS) {
|
||||
await prisma.user.upsert({
|
||||
where: { email: u.email },
|
||||
update: {},
|
||||
create: {
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
passwordHash,
|
||||
roleId: roleIds.get(u.role)!,
|
||||
isDemo: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start — wrapper CLI */
|
||||
if (require.main === module) {
|
||||
const prisma = new PrismaClient();
|
||||
seed(prisma)
|
||||
.then(async () => {
|
||||
const [roles, permissions, users] = await Promise.all([
|
||||
prisma.role.count(),
|
||||
prisma.permission.count(),
|
||||
prisma.user.count({ where: { isDemo: true } }),
|
||||
]);
|
||||
console.log(
|
||||
`Seed OK — ${roles} rôles, ${permissions} lignes de matrice, ${users} comptes démo.`,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
41
apps/api/src/app.module.ts
Normal file
41
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { demoModeEnabled } from './config/env';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
/**
|
||||
* Composé via forRoot() (et non statiquement) pour que l'enregistrement
|
||||
* conditionnel de DemoAuthModule (ADR-002) soit décidé au démarrage —
|
||||
* et testable dans les deux états (module présent / routes 404).
|
||||
*/
|
||||
@Module({})
|
||||
export class AppModule {
|
||||
static forRoot(): DynamicModule {
|
||||
return {
|
||||
module: AppModule,
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PermissionsModule,
|
||||
FilesModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
HealthModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
providers: [
|
||||
// Ordre significatif : authentification puis permissions
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
17
apps/api/src/auth/auth.controller.ts
Normal file
17
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/auth.module.ts
Normal file
26
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
|
||||
import { loadEnv } from '../config/env';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
useFactory: () => {
|
||||
const env = loadEnv();
|
||||
return {
|
||||
secret: env.JWT_SECRET,
|
||||
signOptions: {
|
||||
expiresIn: env.JWT_EXPIRES_IN as JwtSignOptions['expiresIn'],
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
104
apps/api/src/auth/auth.service.ts
Normal file
104
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type {
|
||||
AuthResponse,
|
||||
DemoAccountsResponse,
|
||||
RoleName,
|
||||
} from '@siop/shared';
|
||||
import * as argon2 from 'argon2';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type UserWithRole = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
isDemo: boolean;
|
||||
role: { id: string; name: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { role: true },
|
||||
});
|
||||
// argon2.verify même sur compte inconnu = temps de réponse homogène
|
||||
const valid =
|
||||
user?.passwordHash != null &&
|
||||
(await argon2.verify(user.passwordHash, password));
|
||||
if (!user || !user.isActive || !valid) {
|
||||
throw new UnauthorizedException('Identifiants invalides');
|
||||
}
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
/** ADR-002 — liste publique en DEMO_MODE, jamais de secret. */
|
||||
async listDemoAccounts(): Promise<DemoAccountsResponse> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { isDemo: true, isActive: true },
|
||||
include: { role: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return {
|
||||
accounts: users.map((u) => ({
|
||||
id: u.id,
|
||||
displayName: u.displayName,
|
||||
roleName: u.role.name as RoleName,
|
||||
initials: this.initials(u.displayName),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** ADR-002 — refuse tout compte isDemo=false, quoi qu'il arrive. */
|
||||
async demoLogin(userId: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { role: true },
|
||||
});
|
||||
if (!user || !user.isActive) throw new NotFoundException('Compte inconnu');
|
||||
if (!user.isDemo) {
|
||||
throw new ForbiddenException(
|
||||
'Ce compte n’est pas un compte de démonstration',
|
||||
);
|
||||
}
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||
// Identité seule — les droits restent en base (invariant R0)
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: user.id,
|
||||
roleId: user.role.id,
|
||||
});
|
||||
return {
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: { id: user.role.id, name: user.role.name as RoleName },
|
||||
isDemo: user.isDemo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private initials(displayName: string): string {
|
||||
return displayName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]!.toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
14
apps/api/src/auth/current-user.decorator.ts
Normal file
14
apps/api/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/** Identité portée par le JWT — jamais de droits (invariant R0) :
|
||||
* la matrice est relue en base par PermissionsGuard. */
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { DemoLoginRequestSchema, type DemoLoginRequest } from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../../common/zod-validation.pipe';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { Public } from '../public.decorator';
|
||||
|
||||
/** ADR-002 — ces routes n'existent que si DemoAuthModule est enregistré
|
||||
* (DEMO_MODE=true) ; sinon : 404, vérifié par test e2e. */
|
||||
@Controller('auth')
|
||||
export class DemoAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Get('demo-accounts')
|
||||
listDemoAccounts() {
|
||||
return this.authService.listDemoAccounts();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('demo-login')
|
||||
@HttpCode(200)
|
||||
demoLogin(
|
||||
@Body(new ZodValidationPipe(DemoLoginRequestSchema)) body: DemoLoginRequest,
|
||||
) {
|
||||
return this.authService.demoLogin(body.userId);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { assertDemoModeAllowed } from '../../config/env';
|
||||
import { AuthModule } from '../auth.module';
|
||||
import { DemoAuthController } from './demo-auth.controller';
|
||||
|
||||
/**
|
||||
* ADR-002 : module enregistré UNIQUEMENT quand DEMO_MODE=true
|
||||
* (voir AppModule.forRoot). Double verrou : refuse de démarrer en production
|
||||
* sans DEMO_MODE_I_KNOW=true.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [DemoAuthController],
|
||||
})
|
||||
export class DemoAuthModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(DemoAuthModule.name);
|
||||
|
||||
constructor() {
|
||||
assertDemoModeAllowed();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.warn(
|
||||
'DEMO_MODE actif — /auth/demo-accounts et /auth/demo-login sont exposées.',
|
||||
);
|
||||
}
|
||||
}
|
||||
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
/** Guard global : API fermée par défaut, ouverture explicite via @Public(). */
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const [scheme, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
throw new UnauthorizedException('Jeton manquant');
|
||||
}
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{
|
||||
sub: string;
|
||||
roleId: string;
|
||||
}>(token);
|
||||
(request as Request & { user: unknown }).user = {
|
||||
userId: payload.sub,
|
||||
roleId: payload.roleId,
|
||||
};
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Jeton invalide ou expiré');
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/api/src/auth/public.decorator.ts
Normal file
7
apps/api/src/auth/public.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
/** L'API est fermée par défaut (guard JWT global) : toute route publique
|
||||
* doit l'être EXPLICITEMENT via ce décorateur. */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
22
apps/api/src/common/zod-validation.pipe.ts
Normal file
22
apps/api/src/common/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
||||
import type { z } from 'zod';
|
||||
|
||||
/** Valide un body contre un schéma du contrat (@siop/shared). */
|
||||
@Injectable()
|
||||
export class ZodValidationPipe implements PipeTransform {
|
||||
constructor(private readonly schema: z.ZodType) {}
|
||||
|
||||
transform(value: unknown) {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
message: 'Corps de requête invalide',
|
||||
issues: result.error.issues.map((i) => ({
|
||||
path: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
37
apps/api/src/config/env.spec.ts
Normal file
37
apps/api/src/config/env.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { assertDemoModeAllowed, demoModeEnabled } from './env';
|
||||
|
||||
describe('ADR-002 — double verrou DEMO_MODE', () => {
|
||||
const saved = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
it('refuse DEMO_MODE=true en production sans DEMO_MODE_I_KNOW', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.DEMO_MODE_I_KNOW;
|
||||
expect(() => assertDemoModeAllowed()).toThrow(/ADR-002/);
|
||||
});
|
||||
|
||||
it('accepte DEMO_MODE=true en production AVEC DEMO_MODE_I_KNOW=true (instance démo)', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.DEMO_MODE_I_KNOW = 'true';
|
||||
expect(() => assertDemoModeAllowed()).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepte DEMO_MODE=true hors production', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(() => assertDemoModeAllowed()).not.toThrow();
|
||||
});
|
||||
|
||||
it("demoModeEnabled ne s'active que sur la valeur exacte « true »", () => {
|
||||
process.env.DEMO_MODE = '1';
|
||||
expect(demoModeEnabled()).toBe(false);
|
||||
process.env.DEMO_MODE = 'true';
|
||||
expect(demoModeEnabled()).toBe(true);
|
||||
delete process.env.DEMO_MODE;
|
||||
expect(demoModeEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
52
apps/api/src/config/env.ts
Normal file
52
apps/api/src/config/env.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dotenv/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
JWT_SECRET: z.string().min(16, 'JWT_SECRET : 16 caractères minimum'),
|
||||
JWT_EXPIRES_IN: z.string().default('8h'),
|
||||
MINIO_ENDPOINT: z.string().default('localhost'),
|
||||
MINIO_PORT: z.coerce.number().default(9000),
|
||||
MINIO_USE_SSL: z.string().optional(),
|
||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
/** Validée au démarrage — l'API refuse de booter sur une config invalide. */
|
||||
export function loadEnv(): Env {
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Configuration invalide :\n${parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** Lues à l'appel (et non à l'import) pour rester testables. */
|
||||
export function demoModeEnabled(): boolean {
|
||||
return process.env.DEMO_MODE === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-002, double verrou : DEMO_MODE=true en production exige l'aveu explicite
|
||||
* DEMO_MODE_I_KNOW=true (réservé à l'instance de démonstration publique).
|
||||
*/
|
||||
export function assertDemoModeAllowed(): void {
|
||||
if (
|
||||
demoModeEnabled() &&
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.DEMO_MODE_I_KNOW !== 'true'
|
||||
) {
|
||||
throw new Error(
|
||||
'DEMO_MODE=true en production sans DEMO_MODE_I_KNOW=true — démarrage refusé (ADR-002).',
|
||||
);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/files/file-storage.ts
Normal file
12
apps/api/src/files/file-storage.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
||||
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
||||
*/
|
||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||
|
||||
export interface FileStorage {
|
||||
/** Lève une exception si le stockage est injoignable. */
|
||||
healthCheck(): Promise<void>;
|
||||
}
|
||||
10
apps/api/src/files/files.module.ts
Normal file
10
apps/api/src/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { FILE_STORAGE } from './file-storage';
|
||||
import { MinioStorageService } from './minio-storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: FILE_STORAGE, useClass: MinioStorageService }],
|
||||
exports: [FILE_STORAGE],
|
||||
})
|
||||
export class FilesModule {}
|
||||
25
apps/api/src/files/minio-storage.service.ts
Normal file
25
apps/api/src/files/minio-storage.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Minio from 'minio';
|
||||
import { loadEnv } from '../config/env';
|
||||
import type { FileStorage } from './file-storage';
|
||||
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
|
||||
@Injectable()
|
||||
export class MinioStorageService implements FileStorage {
|
||||
private readonly client: Minio.Client;
|
||||
|
||||
constructor() {
|
||||
const env = loadEnv();
|
||||
this.client = new Minio.Client({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
useSSL: env.MINIO_USE_SSL === 'true',
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<void> {
|
||||
await this.client.listBuckets();
|
||||
}
|
||||
}
|
||||
53
apps/api/src/health/health.controller.ts
Normal file
53
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import type { HealthResponse } from '@siop/shared';
|
||||
import Redis from 'ioredis';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { loadEnv } from '../config/env';
|
||||
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
async health(): Promise<HealthResponse> {
|
||||
const [database, redis, storage] = await Promise.all([
|
||||
this.check(() => this.prisma.$queryRaw`SELECT 1`),
|
||||
this.check(async () => {
|
||||
const client = new Redis(loadEnv().REDIS_URL, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 0,
|
||||
connectTimeout: 2000,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.ping();
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
}),
|
||||
this.check(() => this.storage.healthCheck()),
|
||||
]);
|
||||
const services = { database, redis, storage };
|
||||
return {
|
||||
status: Object.values(services).every((s) => s === 'up')
|
||||
? 'ok'
|
||||
: 'degraded',
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
private async check(fn: () => Promise<unknown>): Promise<'up' | 'down'> {
|
||||
try {
|
||||
await fn();
|
||||
return 'up';
|
||||
} catch {
|
||||
return 'down';
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/api/src/health/health.module.ts
Normal file
7
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
17
apps/api/src/main.ts
Normal file
17
apps/api/src/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { assertDemoModeAllowed, loadEnv } from './config/env';
|
||||
|
||||
async function bootstrap() {
|
||||
const env = loadEnv();
|
||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||
|
||||
const app = await NestFactory.create(AppModule.forRoot());
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(env.PORT);
|
||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
48
apps/api/src/permissions/permissions.guard.ts
Normal file
48
apps/api/src/permissions/permissions.guard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
PERMISSION_KEY,
|
||||
RequiredPermission,
|
||||
} from './require-permission.decorator';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
|
||||
/** Second guard global (après JwtAuthGuard) : applique @RequirePermission
|
||||
* en relisant la matrice EN BASE — le JWT ne porte jamais de droits. */
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<RequiredPermission>(
|
||||
PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!required) return true; // pas d'exigence déclarée (ex. routes @Public)
|
||||
|
||||
const user: AuthenticatedUser | undefined = context
|
||||
.switchToHttp()
|
||||
.getRequest().user;
|
||||
if (!user) throw new ForbiddenException();
|
||||
|
||||
const allowed = await this.permissionsService.can(
|
||||
user.roleId,
|
||||
required.category,
|
||||
required.right,
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Droit manquant : ${required.category}.${required.right}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
apps/api/src/permissions/permissions.module.ts
Normal file
9
apps/api/src/permissions/permissions.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PermissionsService],
|
||||
exports: [PermissionsService],
|
||||
})
|
||||
export class PermissionsModule {}
|
||||
62
apps/api/src/permissions/permissions.service.spec.ts
Normal file
62
apps/api/src/permissions/permissions.service.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
|
||||
const row = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||
id: 'p1',
|
||||
roleId: 'role-1',
|
||||
objectCategory: 'WORK_ORDERS',
|
||||
canView: true,
|
||||
canViewOther: false,
|
||||
canCreate: false,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PermissionsService (matrice en base, cache 60 s)', () => {
|
||||
let findMany: jest.Mock;
|
||||
let service: PermissionsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
findMany = jest.fn().mockResolvedValue([row()]);
|
||||
service = new PermissionsService({
|
||||
permission: { findMany },
|
||||
} as unknown as PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('applique les cinq droits de la ligne', async () => {
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'view')).resolves.toBe(true);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'edit')).resolves.toBe(true);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'viewOther')).resolves.toBe(false);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'create')).resolves.toBe(false);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'delete')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('refuse une catégorie sans ligne (fermé par défaut)', async () => {
|
||||
await expect(service.can('role-1', 'SETTINGS', 'view')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('ne relit la base qu’à l’expiration du cache (60 s)', async () => {
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
await service.can('role-1', 'WORK_ORDERS', 'edit');
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(59_000);
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(2_000);
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidate() force la relecture immédiate', async () => {
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
service.invalidate('role-1');
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
66
apps/api/src/permissions/permissions.service.ts
Normal file
66
apps/api/src/permissions/permissions.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
ObjectCategory,
|
||||
PermissionEntry,
|
||||
PermissionRight,
|
||||
} from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const CACHE_TTL_MS = 60_000; // invariant R0 : la matrice est relue au plus toutes les 60 s
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
permissions: PermissionEntry[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsService {
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getForRole(roleId: string): Promise<PermissionEntry[]> {
|
||||
const cached = this.cache.get(roleId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.permissions;
|
||||
|
||||
const rows = await this.prisma.permission.findMany({ where: { roleId } });
|
||||
const permissions = rows.map((r) => ({
|
||||
objectCategory: r.objectCategory as ObjectCategory,
|
||||
canView: r.canView,
|
||||
canViewOther: r.canViewOther,
|
||||
canCreate: r.canCreate,
|
||||
canEdit: r.canEdit,
|
||||
canDelete: r.canDelete,
|
||||
}));
|
||||
this.cache.set(roleId, { expiresAt: Date.now() + CACHE_TTL_MS, permissions });
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async can(
|
||||
roleId: string,
|
||||
category: ObjectCategory,
|
||||
right: PermissionRight,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.getForRole(roleId);
|
||||
const entry = permissions.find((p) => p.objectCategory === category);
|
||||
if (!entry) return false;
|
||||
switch (right) {
|
||||
case 'view':
|
||||
return entry.canView;
|
||||
case 'viewOther':
|
||||
return entry.canViewOther;
|
||||
case 'create':
|
||||
return entry.canCreate;
|
||||
case 'edit':
|
||||
return entry.canEdit;
|
||||
case 'delete':
|
||||
return entry.canDelete;
|
||||
}
|
||||
}
|
||||
|
||||
/** À appeler après toute modification de la matrice (admin, R1+). */
|
||||
invalidate(roleId?: string): void {
|
||||
if (roleId) this.cache.delete(roleId);
|
||||
else this.cache.clear();
|
||||
}
|
||||
}
|
||||
16
apps/api/src/permissions/require-permission.decorator.ts
Normal file
16
apps/api/src/permissions/require-permission.decorator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||
|
||||
export const PERMISSION_KEY = 'requiredPermission';
|
||||
|
||||
export interface RequiredPermission {
|
||||
category: ObjectCategory;
|
||||
right: PermissionRight;
|
||||
}
|
||||
|
||||
/** Exige un droit de la matrice (relue en base, cache 60 s) :
|
||||
* @RequirePermission('WORK_ORDERS', 'create') */
|
||||
export const RequirePermission = (
|
||||
category: ObjectCategory,
|
||||
right: PermissionRight,
|
||||
) => SetMetadata(PERMISSION_KEY, { category, right } satisfies RequiredPermission);
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
16
apps/api/src/prisma/prisma.service.ts
Normal file
16
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
34
apps/api/src/users/users.controller.ts
Normal file
34
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||
import type { MeResponse, RoleName } from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
|
||||
@Get('me')
|
||||
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: current.userId },
|
||||
include: { role: true },
|
||||
});
|
||||
if (!user || !user.isActive) throw new NotFoundException();
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: { id: user.role.id, name: user.role.name as RoleName },
|
||||
isDemo: user.isDemo,
|
||||
permissions: await this.permissionsService.getForRole(user.roleId),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/api/src/users/users.module.ts
Normal file
7
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
})
|
||||
export class UsersModule {}
|
||||
124
apps/api/test/auth.e2e-spec.ts
Normal file
124
apps/api/test/auth.e2e-spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* E2E auth — nécessite l'infra locale (pnpm infra:up) : base seedée au beforeAll.
|
||||
* DEMO_MODE est activé AVANT la composition d'AppModule (voir demo-off.e2e-spec
|
||||
* pour l'état inverse : les routes démo doivent répondre 404).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Auth (e2e, DEMO_MODE=true)', () => {
|
||||
let app: INestApplication;
|
||||
let http: () => request.Agent;
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
http = () => request(app.getHttpServer());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('GET /health est publique', async () => {
|
||||
const res = await http().get('/health').expect(200);
|
||||
expect(res.body.services.database).toBe('up');
|
||||
});
|
||||
|
||||
it('l’API est fermée par défaut (401 sans jeton)', async () => {
|
||||
await http().get('/users/me').expect(401);
|
||||
});
|
||||
|
||||
it('GET /auth/demo-accounts liste les 7 comptes seedés, sans secret', async () => {
|
||||
const res = await http().get('/auth/demo-accounts').expect(200);
|
||||
expect(res.body.accounts).toHaveLength(7);
|
||||
for (const account of res.body.accounts) {
|
||||
expect(Object.keys(account).sort()).toEqual([
|
||||
'displayName',
|
||||
'id',
|
||||
'initials',
|
||||
'roleName',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('demo-login émet un jeton utilisable sur /users/me (critère < 3 s)', async () => {
|
||||
const start = Date.now();
|
||||
const { body: accounts } = await http().get('/auth/demo-accounts');
|
||||
const admin = accounts.accounts.find(
|
||||
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||
);
|
||||
const login = await http()
|
||||
.post('/auth/demo-login')
|
||||
.send({ userId: admin.id })
|
||||
.expect(200);
|
||||
const me = await http()
|
||||
.get('/users/me')
|
||||
.set('Authorization', `Bearer ${login.body.accessToken}`)
|
||||
.expect(200);
|
||||
expect(Date.now() - start).toBeLessThan(3000);
|
||||
expect(me.body.role.name).toBe('Administrateur');
|
||||
expect(me.body.isDemo).toBe(true);
|
||||
// La matrice complète accompagne le profil (une ligne par catégorie)
|
||||
expect(me.body.permissions).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('demo-login refuse un compte isDemo=false (403)', async () => {
|
||||
const role = await prisma.role.findUniqueOrThrow({
|
||||
where: { name: 'Administrateur' },
|
||||
});
|
||||
const real = await prisma.user.upsert({
|
||||
where: { email: 'real@spelev.ma' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'real@spelev.ma',
|
||||
displayName: 'Compte Réel',
|
||||
roleId: role.id,
|
||||
isDemo: false,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await http().post('/auth/demo-login').send({ userId: real.id }).expect(403);
|
||||
} finally {
|
||||
await prisma.user.delete({ where: { id: real.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('demo-login sur un id inconnu → 404', async () => {
|
||||
await http()
|
||||
.post('/auth/demo-login')
|
||||
.send({ userId: '00000000-0000-4000-8000-000000000000' })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('la connexion classique fonctionne (seed) et rejette un mauvais mot de passe', async () => {
|
||||
const password = process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026';
|
||||
const ok = await http()
|
||||
.post('/auth/login')
|
||||
.send({ email: 'dispatcher@demo.siop.ma', password })
|
||||
.expect(200);
|
||||
expect(ok.body.user.role.name).toBe('Dispatcher');
|
||||
|
||||
await http()
|
||||
.post('/auth/login')
|
||||
.send({ email: 'dispatcher@demo.siop.ma', password: 'mauvais' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('valide les corps de requête via le contrat Zod (400)', async () => {
|
||||
await http().post('/auth/demo-login').send({ userId: 'pas-un-uuid' }).expect(400);
|
||||
await http().post('/auth/login').send({ email: 'x' }).expect(400);
|
||||
});
|
||||
});
|
||||
47
apps/api/test/demo-off.e2e-spec.ts
Normal file
47
apps/api/test/demo-off.e2e-spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* ADR-002, test dédié : quand DEMO_MODE n'est pas actif, les routes démo
|
||||
* N'EXISTENT PAS (404) — le module n'est pas enregistré.
|
||||
*/
|
||||
// « false » plutôt que delete : dotenv (importé par config/env.ts) repeuplerait
|
||||
// une variable supprimée depuis .env, mais n'écrase jamais une valeur existante.
|
||||
process.env.DEMO_MODE = 'false';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Auth démo (e2e, DEMO_MODE absent)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('GET /auth/demo-accounts → 404', async () => {
|
||||
await request(app.getHttpServer()).get('/auth/demo-accounts').expect(404);
|
||||
});
|
||||
|
||||
it('POST /auth/demo-login → 404', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/demo-login')
|
||||
.send({ userId: '00000000-0000-4000-8000-000000000000' })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('la connexion classique, elle, reste disponible', async () => {
|
||||
// 401 (identifiants) et non 404 : la route existe bien
|
||||
await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: 'nobody@spelev.ma', password: 'x' })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
5
apps/api/test/setup-env.ts
Normal file
5
apps/api/test/setup-env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET ??= 'test-secret-0123456789abcdef';
|
||||
process.env.DATABASE_URL ??= 'postgresql://siop:siop@localhost:5432/siop';
|
||||
5
apps/api/tsconfig.build.json
Normal file
5
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||
}
|
||||
18
apps/api/tsconfig.json
Normal file
18
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["src", "test", "prisma", "scripts"]
|
||||
}
|
||||
Reference in New Issue
Block a user