xpeditis2.0/apps/backend/src/application/services/gdpr.service.spec.ts
David 5c59ef044b
Some checks failed
Dev CI / Backend — Lint (push) Successful in 1m4s
Dev CI / Security gate (push) Failing after 1m36s
Dev CI / Backend — Unit Tests (push) Successful in 1m7s
Dev CI / Frontend — Lint & Type-check (push) Successful in 1m51s
Dev CI / Frontend — Unit Tests (push) Successful in 1m22s
Dev CI / Notify Failure (push) Has been skipped
fix
2026-09-23 22:56:46 +02:00

252 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>) =>
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<UserOrmEntity>;
const consentRepository = {
findOne: jest.fn(async () => null),
create: jest.fn((value: Partial<CookieConsentOrmEntity>) => ({ ...value })),
save: jest.fn(async (value: CookieConsentOrmEntity) => value),
} as unknown as Repository<CookieConsentOrmEntity>;
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);
});
});