Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/modules/gdpr/gdpr.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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';
import { SessionService } from '../../session/session.service';

@Injectable()
Expand Down Expand Up @@ -53,6 +54,12 @@
throw new NotFoundException('User not found');
}

if (user.deletedAt) {
return {
success: true,
alreadyErased: true,
};
}
await this.sessionService.deleteAllSessionsForUser(userId);

await this.usersService.update(userId, {
Expand All @@ -65,7 +72,36 @@
refreshToken: null,
});

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(

Check failure on line 88 in src/modules/gdpr/gdpr.service.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎··········['email',·'firstName',·'lastName',·'deletedAt'],⏎··········['id'],⏎········` with `['email',·'firstName',·'lastName',·'deletedAt'],·['id']`
['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,
Expand Down
47 changes: 47 additions & 0 deletions src/modules/gdpr/tests/gdpr.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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', () => {
Expand Down Expand Up @@ -87,6 +100,40 @@ describe('GdprService', () => {
);
});

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',
Expand Down
Loading