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:
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';
|
||||
Reference in New Issue
Block a user