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:
pr-daaif
2026-07-15 21:55:04 +01:00
parent ddc9dc52b5
commit 8adb561b63
62 changed files with 8509 additions and 2 deletions

View 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('lAPI 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);
});
});