From 270d79d1421873204100c4f0762855c623f16121 Mon Sep 17 00:00:00 2001 From: dot-enny Date: Sun, 28 Jun 2026 13:09:37 +0100 Subject: [PATCH 1/2] 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', From 7518ba71a9f0a25afc9f9eb67adfbc7913745689 Mon Sep 17 00:00:00 2001 From: dot-enny Date: Tue, 30 Jun 2026 22:17:17 +0100 Subject: [PATCH 2/2] fixed: prettier formatting errors an unused import warnings. --- src/modules/gdpr/gdpr.service.ts | 7 ++----- src/workers/base/base.worker.ts | 2 +- src/workers/orchestration/worker-orchestration.service.ts | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/modules/gdpr/gdpr.service.ts b/src/modules/gdpr/gdpr.service.ts index 776bc193..6b80f1f8 100644 --- a/src/modules/gdpr/gdpr.service.ts +++ b/src/modules/gdpr/gdpr.service.ts @@ -35,7 +35,7 @@ export class GdprService { @InjectRepository(Notification) private readonly notificationRepository: Repository, private readonly sessionService: SessionService, - ) { } + ) {} async exportUserData(userId: string): Promise { const user = await this.userRepository.findOne({ @@ -132,10 +132,7 @@ export class GdprService { lastName: '[DELETED]', deletedAt: new Date(), }) - .orUpdate( - ['email', 'firstName', 'lastName', 'deletedAt'], - ['id'], - ) + .orUpdate(['email', 'firstName', 'lastName', 'deletedAt'], ['id']) .execute(); await this.usersService.update(userId, { diff --git a/src/workers/base/base.worker.ts b/src/workers/base/base.worker.ts index e4f58cec..70f16101 100644 --- a/src/workers/base/base.worker.ts +++ b/src/workers/base/base.worker.ts @@ -1,4 +1,4 @@ -import { Logger, Inject } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { Job } from 'bull'; import Redis from 'ioredis'; import { getSharedRedisClient } from '../../config/cache.config'; diff --git a/src/workers/orchestration/worker-orchestration.service.ts b/src/workers/orchestration/worker-orchestration.service.ts index 91275f5d..59eb3c13 100644 --- a/src/workers/orchestration/worker-orchestration.service.ts +++ b/src/workers/orchestration/worker-orchestration.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Job } from 'bull'; import { BaseWorker } from '../base/base.worker';