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
252 lines
9.0 KiB
TypeScript
252 lines
9.0 KiB
TypeScript
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);
|
||
});
|
||
});
|