import { NotFoundException } from '@nestjs/common'; import { DataSource, EntityManager, Repository } from 'typeorm'; import { GDPRService } from './gdpr.service'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity'; import { AuditService } from './audit.service'; import { RETENTION_RULES, ANONYMISED } from '@domain/services/data-retention'; import { AuditAction } from '@domain/entities/audit-log.entity'; /** * Ces tests portent sur une promesse faite à une personne : « vos données sont * effacées ». La version précédente la faisait sans rien effacer. Ils vérifient * donc d'abord ce qui est réellement exécuté en base, table par table. */ interface ExecutedQuery { sql: string; parameters: unknown[]; } const USER_ID = '11111111-2222-3333-4444-555555555555'; const buildUser = (): UserOrmEntity => ({ id: USER_ID, organizationId: 'org-1', email: 'jean@example.com', firstName: 'Jean', lastName: 'Durand', phoneNumber: '+33600000000', passwordHash: 'argon2-hash', totpSecret: 'TOTPSECRET', role: 'USER', preferredLanguage: 'fr', isEmailVerified: true, isActive: true, lastLoginAt: new Date('2026-09-01T10:00:00Z'), createdAt: new Date('2026-01-01T10:00:00Z'), updatedAt: new Date('2026-09-01T10:00:00Z'), }) as unknown as UserOrmEntity; /** Nombre de lignes renvoyé par le pilote PostgreSQL pour chaque écriture. */ const ROWS_TOUCHED = 3; function buildService(options: { user?: UserOrmEntity | null } = {}) { const executed: ExecutedQuery[] = []; const manager = { // Forme réelle du pilote pour UPDATE et DELETE : [lignes, nombre]. query: jest.fn(async (sql: string, parameters: unknown[]) => { executed.push({ sql, parameters }); return [[], ROWS_TOUCHED]; }), } as unknown as EntityManager; const dataSource = { transaction: jest.fn(async (callback: (m: EntityManager) => Promise) => callback(manager) ), query: jest.fn(async (sql: string, parameters: unknown[]) => { executed.push({ sql, parameters }); return []; }), } as unknown as DataSource; const user = options.user === undefined ? buildUser() : options.user; const userRepository = { findOne: jest.fn(async () => user), } as unknown as Repository; const consentRepository = { findOne: jest.fn(async () => null), create: jest.fn((value: Partial) => ({ ...value })), save: jest.fn(async (value: CookieConsentOrmEntity) => value), } as unknown as Repository; const audit = { log: jest.fn(async () => undefined) } as unknown as AuditService; const service = new GDPRService(userRepository, consentRepository, dataSource, audit); return { service, executed, manager, dataSource, consentRepository, audit }; } /** Toutes les instructions écrites contre une table donnée. */ const statementsFor = (executed: ExecutedQuery[], table: string, verb: 'DELETE' | 'UPDATE') => executed.filter(query => query.sql.includes(verb) && query.sql.includes(table)); describe('GDPRService — effacement (art. 17)', () => { it('applique à chaque table le traitement décrit par la politique de conservation', async () => { const { service, executed } = buildService(); await service.deleteUserData(USER_ID, 'Fin de collaboration'); // La politique et le code ne peuvent pas diverger sans faire échouer ce // test : c'est la politique qui pilote l'assertion, pas une liste recopiée. for (const rule of RETENTION_RULES) { if (rule.onErasure === 'delete') { expect(statementsFor(executed, rule.table, 'DELETE').length).toBeGreaterThan(0); } if (rule.onErasure === 'anonymise') { expect(statementsFor(executed, rule.table, 'UPDATE').length).toBeGreaterThan(0); } } }); it('ne supprime jamais la ligne du compte : les réservations la référencent en cascade', async () => { const { service, executed } = buildService(); await service.deleteUserData(USER_ID); expect(statementsFor(executed, 'FROM users', 'DELETE')).toHaveLength(0); expect(statementsFor(executed, 'csv_bookings', 'DELETE')).toHaveLength(0); }); it("remplace l'identité et rend le compte inutilisable", async () => { const { service, executed } = buildService(); await service.deleteUserData(USER_ID); const [update] = statementsFor(executed, 'UPDATE users', 'UPDATE'); expect(update.sql).toContain('is_active = false'); expect(update.sql).toContain('totp_secret = NULL'); expect(update.parameters[1]).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`); // Mot de passe remplacé par une valeur aléatoire : la colonne est NOT NULL, // et une constante partagée signerait tous les comptes effacés. expect(update.parameters[3]).toMatch(/^erased-/); }); it('compte les lignes réellement touchées, pas la forme du résultat', async () => { const { service } = buildService(); const report = await service.deleteUserData(USER_ID); // Le pilote renvoie `[lignes, nombre]` : mesurer la longueur du tableau // renverrait 2 partout, quel que soit le contenu de la base. expect(report.deleted.notifications).toBe(ROWS_TOUCHED); expect(report.anonymised.user).toBe(ROWS_TOUCHED); expect(Object.values(report.deleted)).not.toContain(2); }); it("journalise l'effacement sans y réinscrire l'identité effacée", async () => { const { service, audit } = buildService(); await service.deleteUserData(USER_ID, 'Fin de collaboration'); const [entry] = (audit.log as jest.Mock).mock.calls[0]; expect(entry.action).toBe(AuditAction.GDPR_ERASURE_EXECUTED); // Journaliser avant l'effacement effacerait la trace ; y écrire l'adresse // réelle réintroduirait l'identité qu'on vient de supprimer. expect(entry.userEmail).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`); expect(entry.userEmail).not.toContain('jean@example.com'); }); it('opère dans une transaction', async () => { const { service, dataSource } = buildService(); await service.deleteUserData(USER_ID); expect(dataSource.transaction).toHaveBeenCalledTimes(1); }); it("n'écrit rien si le compte n'existe pas", async () => { const { service, executed } = buildService({ user: null }); await expect(service.deleteUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException); expect(executed).toHaveLength(0); }); }); describe('GDPRService — portabilité (art. 20)', () => { it("couvre l'ensemble des données rattachées au compte", async () => { const { service, executed } = buildService(); const data = await service.exportUserData(USER_ID); const read = executed.map(query => query.sql).join(' '); for (const table of [ 'organizations', 'csv_bookings', 'notifications', 'trade_conversations', 'api_keys', 'audit_logs', ]) { expect(read).toContain(table); } expect(data.userId).toBe(USER_ID); }); it("n'expose aucun secret d'authentification", async () => { const { service, executed } = buildService(); const data = await service.exportUserData(USER_ID); const serialised = JSON.stringify(data); expect(serialised).not.toContain('argon2-hash'); expect(serialised).not.toContain('TOTPSECRET'); // Le condensat d'une clé d'API reste un secret d'accès. expect(executed.map(query => query.sql).join(' ')).not.toContain('key_hash'); }); it("refuse d'exporter pour un compte inconnu", async () => { const { service } = buildService({ user: null }); await expect(service.exportUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException); }); }); describe('GDPRService — consentement (art. 7)', () => { it('force les cookies essentiels et horodate le recueil', async () => { const { service } = buildService(); const consent = await service.recordConsent(USER_ID, { essential: false, functional: true, analytics: false, marketing: false, }); expect(consent.essential).toBe(true); expect(consent.functional).toBe(true); expect(consent.consentDate).toBeInstanceOf(Date); }); it('retire tout ce qui est facultatif quand aucune catégorie n’est précisée', async () => { const { service } = buildService(); const consent = await service.withdrawConsent(USER_ID); expect(consent).toMatchObject({ functional: false, analytics: false, marketing: false }); expect(consent.essential).toBe(true); }); it('ne retire que la catégorie visée', async () => { const { service, consentRepository } = buildService(); (consentRepository.findOne as jest.Mock).mockResolvedValue({ userId: USER_ID, essential: true, functional: true, analytics: true, marketing: true, }); const consent = await service.withdrawConsent(USER_ID, 'marketing'); expect(consent.marketing).toBe(false); expect(consent.analytics).toBe(true); expect(consent.functional).toBe(true); }); });