From 270d79d1421873204100c4f0762855c623f16121 Mon Sep 17 00:00:00 2001 From: dot-enny Date: Sun, 28 Jun 2026 13:09:37 +0100 Subject: [PATCH] feat: add idempotent GDPR erasure that safely handles repeated invocations. --- src/modules/gdpr/gdpr.service.ts | 46 ++++++++++++++++---- src/modules/gdpr/tests/gdpr.service.spec.ts | 47 +++++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/modules/gdpr/gdpr.service.ts b/src/modules/gdpr/gdpr.service.ts index 57085613..bc347c44 100644 --- a/src/modules/gdpr/gdpr.service.ts +++ b/src/modules/gdpr/gdpr.service.ts @@ -5,6 +5,7 @@ import { plainToInstance, instanceToPlain } from 'class-transformer'; import { UserConsent } from './entities/user-consent.entity'; import { ConsentDto } from './dto/consent.dto'; import { GdprExportDto } from './dto/gdpr-export.dto'; +import { User } from '../../users/entities/user.entity'; @Injectable() export class GdprService { @@ -50,16 +51,43 @@ export class GdprService { throw new NotFoundException('User not found'); } - await this.usersService.update(userId, { - email: null, - firstName: '[DELETED]', - lastName: '[DELETED]', - phone: null, - address: null, - deletedAt: new Date(), - }); + if (user.deletedAt) { + return { + success: true, + alreadyErased: true, + }; + } - await this.auditService.log('GDPR_ERASURE', userId); + await this.consentRepository.manager.transaction(async (manager) => { + // Wrap all DB writes in a transaction with ON CONFLICT DO NOTHING or upsert semantics. + await manager + .createQueryBuilder() + .insert() + .into(User) + .values({ + id: userId, + email: null as any, + firstName: '[DELETED]', + lastName: '[DELETED]', + deletedAt: new Date(), + }) + .orUpdate( + ['email', 'firstName', 'lastName', 'deletedAt'], + ['id'], + ) + .execute(); + + await this.usersService.update(userId, { + email: null, + firstName: '[DELETED]', + lastName: '[DELETED]', + phone: null, + address: null, + deletedAt: new Date(), + }); + + await this.auditService.log('GDPR_ERASURE', userId); + }); return { success: true, diff --git a/src/modules/gdpr/tests/gdpr.service.spec.ts b/src/modules/gdpr/tests/gdpr.service.spec.ts index c5bd101d..ba5549eb 100644 --- a/src/modules/gdpr/tests/gdpr.service.spec.ts +++ b/src/modules/gdpr/tests/gdpr.service.spec.ts @@ -26,6 +26,19 @@ const mockConsentRepository = { find: jest.fn().mockResolvedValue([]), create: jest.fn((dto) => ({ ...dto, id: 'consent-1' })), save: jest.fn((consent) => Promise.resolve(consent)), + manager: { + transaction: jest.fn(async (cb) => { + const mockEntityManager = { + createQueryBuilder: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + into: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orUpdate: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + }; + return cb(mockEntityManager); + }), + }, }; describe('GdprService', () => { @@ -67,6 +80,40 @@ describe('GdprService', () => { expect(result.success).toBe(true); }); + it('supports idempotent erasure on repeated calls', async () => { + // Reset mock history + mockUsersService.update.mockClear(); + mockAuditService.log.mockClear(); + + // First call + const result1 = await service.eraseUserData('user-1'); + expect(result1.success).toBe(true); + expect(mockUsersService.update).toHaveBeenCalledTimes(1); + expect(mockAuditService.log).toHaveBeenCalledWith('GDPR_ERASURE', 'user-1'); + + // Simulate database state change by updating the mock return value to have deletedAt + const originalFindById = mockUsersService.findById; + mockUsersService.findById = jest.fn().mockResolvedValue({ + id: 'user-1', + email: null, + firstName: '[DELETED]', + lastName: '[DELETED]', + deletedAt: new Date(), + }); + + // Second call + const result2 = await service.eraseUserData('user-1'); + expect(result2.success).toBe(true); + expect(result2.alreadyErased).toBe(true); + + // Verify no extra DB updates or audit logs were created + expect(mockUsersService.update).toHaveBeenCalledTimes(1); + expect(mockAuditService.log).toHaveBeenCalledTimes(1); + + // Restore original mock + mockUsersService.findById = originalFindById; + }); + it('stores consent changes', async () => { const result = await service.updateConsent('user-1', { consentType: 'MARKETING',