From a4b9d84605694f6bf5e061634eee6779766b7bfc Mon Sep 17 00:00:00 2001 From: GiftedGiftB Date: Mon, 29 Jun 2026 13:22:48 +0100 Subject: [PATCH 1/2] Implement session deletion for GDPR erasure and update tests --- src/modules/gdpr/gdpr.module.ts | 3 +- src/modules/gdpr/gdpr.service.ts | 6 ++++ src/modules/gdpr/tests/gdpr.service.spec.ts | 22 ++++++++++++++- src/session/session.service.spec.ts | 17 +++++++++++ src/session/session.service.ts | 31 +++++++++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/modules/gdpr/gdpr.module.ts b/src/modules/gdpr/gdpr.module.ts index 87b8167f..caa73f96 100644 --- a/src/modules/gdpr/gdpr.module.ts +++ b/src/modules/gdpr/gdpr.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; +import { SessionModule } from '../../session/session.module'; @Module({ + imports: [SessionModule], controllers: [GdprController], - providers: [GdprService], }) export class GdprModule {} diff --git a/src/modules/gdpr/gdpr.service.ts b/src/modules/gdpr/gdpr.service.ts index 57085613..ab1a3c24 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 { SessionService } from '../../session/session.service'; @Injectable() export class GdprService { @@ -17,6 +18,8 @@ export class GdprService { @InjectRepository(UserConsent) private readonly consentRepository: Repository, + + private readonly sessionService: SessionService, ) {} async exportUserData(userId: string) { @@ -50,6 +53,8 @@ export class GdprService { throw new NotFoundException('User not found'); } + await this.sessionService.deleteAllSessionsForUser(userId); + await this.usersService.update(userId, { email: null, firstName: '[DELETED]', @@ -57,6 +62,7 @@ export class GdprService { phone: null, address: null, deletedAt: new Date(), + refreshToken: null, }); await this.auditService.log('GDPR_ERASURE', userId); diff --git a/src/modules/gdpr/tests/gdpr.service.spec.ts b/src/modules/gdpr/tests/gdpr.service.spec.ts index c5bd101d..912bbae1 100644 --- a/src/modules/gdpr/tests/gdpr.service.spec.ts +++ b/src/modules/gdpr/tests/gdpr.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { GdprService } from '../gdpr.service'; import { UserConsent } from '../entities/user-consent.entity'; +import { SessionService } from '../../../session/session.service'; const mockUsersService = { findById: jest.fn().mockResolvedValue({ @@ -22,6 +23,10 @@ const mockAuditService = { log: jest.fn().mockResolvedValue(undefined), }; +const mockSessionService = { + deleteAllSessionsForUser: jest.fn().mockResolvedValue(undefined), +}; + const mockConsentRepository = { find: jest.fn().mockResolvedValue([]), create: jest.fn((dto) => ({ ...dto, id: 'consent-1' })), @@ -37,6 +42,7 @@ describe('GdprService', () => { GdprService, { provide: 'UsersService', useValue: mockUsersService }, { provide: 'AuditService', useValue: mockAuditService }, + { provide: SessionService, useValue: mockSessionService }, { provide: getRepositoryToken(UserConsent), useValue: mockConsentRepository }, ], }).compile(); @@ -62,9 +68,23 @@ describe('GdprService', () => { expect(result.profile.lastName).toBe('Doe'); }); - it('erases user data', async () => { + it('erases user data and invalidates sessions', async () => { const result = await service.eraseUserData('user-1'); + expect(result.success).toBe(true); + expect(mockSessionService.deleteAllSessionsForUser).toHaveBeenCalledWith('user-1'); + expect(mockUsersService.update).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + email: null, + firstName: '[DELETED]', + lastName: '[DELETED]', + phone: null, + address: null, + deletedAt: expect.any(Date), + refreshToken: null, + }), + ); }); it('stores consent changes', async () => { diff --git a/src/session/session.service.spec.ts b/src/session/session.service.spec.ts index 700ed47d..5b256361 100644 --- a/src/session/session.service.spec.ts +++ b/src/session/session.service.spec.ts @@ -13,6 +13,7 @@ const mockRedis = { zadd: jest.fn().mockResolvedValue(1), zrem: jest.fn().mockResolvedValue(1), zrange: jest.fn().mockResolvedValue([]), + scan: jest.fn(), status: 'ready', quit: jest.fn(), }; @@ -170,6 +171,22 @@ describe('SessionService', () => { }); }); + describe('deleteAllSessionsForUser', () => { + it('should remove all Redis sessions for a user', async () => { + mockRedis.scan.mockResolvedValueOnce(['0', ['auth:sess:one', 'auth:sess:two']]); + mockRedis.get + .mockResolvedValueOnce(JSON.stringify({ sid: 'one', userId: 'user-123' })) + .mockResolvedValueOnce(JSON.stringify({ sid: 'two', userId: 'user-999' })); + mockRedis.del.mockResolvedValue(1); + + const deletedCount = await service.deleteAllSessionsForUser('user-123'); + + expect(deletedCount).toBe(1); + expect(mockRedis.del).toHaveBeenCalledWith('auth:sess:one'); + expect(mockRedis.zrem).toHaveBeenCalledWith('user:sessions:user-123', 'one'); + }); + }); + describe('migrateSession', () => { it('should migrate session to new sid and delete old one', async () => { const sessionData = { diff --git a/src/session/session.service.ts b/src/session/session.service.ts index 6031a503..fbb52086 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -139,6 +139,37 @@ export class SessionService implements OnModuleDestroy { } } + async deleteAllSessionsForUser(userId: string): Promise { + const pattern = `${this.sessionPrefix}*`; + let cursor = '0'; + let deletedCount = 0; + + do { + const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + + for (const key of keys) { + const sessionData = await this.redis.get(key); + if (!sessionData) { + continue; + } + + try { + const session = JSON.parse(sessionData) as ISessionRecord; + if (session.userId === userId) { + await this.redis.del(key); + await this.removeSessionFromUserIndex(userId, session.sid); + deletedCount += 1; + } + } catch { + this.logger.warn(`Invalid session payload for key=${key}`); + } + } + } while (cursor !== '0'); + + return deletedCount; + } + async addSessionToUserIndex(userId: string, sid: string): Promise { await this.redis.zadd(`user:sessions:${userId}`, Date.now(), sid); } From d2690cd02ce1ddb3c6f7b5ab1ae2b2c65253c754 Mon Sep 17 00:00:00 2001 From: GiftedGiftB Date: Mon, 29 Jun 2026 13:25:13 +0100 Subject: [PATCH 2/2] Add PR description for GDPR session revocation implementation --- PR_DESCRIPTION.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..d618ef24 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,33 @@ +# PR Title + +fix(gdpr): revoke active sessions during user erasure + +## Summary + +Fixes #821. + +GDPR erasure previously anonymized the user profile but left Redis-backed sessions and refresh-token material intact. This allowed an erased user to continue authenticating with previously issued session state. + +## What changed + +- Added session revocation during GDPR erasure by deleting all Redis sessions belonging to the user. +- Cleared the user's refresh token during erasure so old refresh-token-based flows are invalidated. +- Added regression tests covering both: + - GDPR erasure invoking session cleanup, and + - session service removal of all sessions for a specific user. + +## Why + +This brings the erasure flow into compliance with GDPR data-erasure expectations by ensuring previously valid session state is invalidated immediately when a user is erased. + +## Testing + +Verified locally with: + +```bash +cd /home/gift/teachLink_backend && npx jest --runInBand src/modules/gdpr/tests/gdpr.service.spec.ts src/session/session.service.spec.ts +``` + +Result: +- 2/2 test suites passed +- 17/17 tests passed