diff --git a/apps/gateway/src/gc/session-gc.service.spec.ts b/apps/gateway/src/gc/session-gc.service.spec.ts index 4a24acc..c6f3948 100644 --- a/apps/gateway/src/gc/session-gc.service.spec.ts +++ b/apps/gateway/src/gc/session-gc.service.spec.ts @@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { LogService } from '@mosaicstack/log'; import { SessionGCService } from './session-gc.service.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; type MockRedis = { scan: ReturnType; @@ -65,11 +66,57 @@ describe('SessionGCService', () => { expect(result.cleaned.valkeyKeys).toBeUndefined(); }); - it('collect() only scans its session namespace and cannot reach durable approvals', async () => { - await service.collect('abc'); + it('escapes glob metacharacters in a session identifier', async () => { + await service.collect('abc*?[tenant]\\escape'); - expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'mosaic:session:abc:*', 'COUNT', 100); - expect(mockRedis.del).not.toHaveBeenCalledWith(expect.stringContaining('command-approval')); + expect(mockRedis.scan).toHaveBeenCalledWith( + '0', + 'MATCH', + 'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*', + 'COUNT', + 100, + ); + }); + + it('preserves a valid durable approval after session GC', async () => { + const entries = new Map(); + const redis = { + scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]), + get: vi.fn(async (key: string) => entries.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => entries.set(key, value)), + del: vi.fn(async (...keys: string[]) => { + let deleted = 0; + for (const key of keys) deleted += Number(entries.delete(key)); + return deleted; + }), + }; + const authorization = new CommandAuthorizationService( + { + select: () => ({ + from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }), + }), + } as never, + redis, + ); + const command = { + name: 'gc', + description: 'System-wide garbage collection', + aliases: [], + scope: 'admin', + execution: 'socket', + available: true, + } as never; + const payload = { command: 'gc', conversationId: 'owned' }; + const approval = await authorization.createApproval(command, payload, 'admin-1'); + const approvalKey = `tess:command-approval:${approval!.approvalId}`; + const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService); + + await gc.collect('owned'); + + expect(entries.has(approvalKey)).toBe(true); + await expect( + authorization.authorize(command, payload, 'admin-1', approval!.approvalId), + ).resolves.toEqual({ allowed: true }); }); it('collect() returns sessionId in result', async () => { diff --git a/apps/gateway/src/gc/session-gc.service.ts b/apps/gateway/src/gc/session-gc.service.ts index ea81969..3146b84 100644 --- a/apps/gateway/src/gc/session-gc.service.ts +++ b/apps/gateway/src/gc/session-gc.service.ts @@ -13,6 +13,11 @@ export interface GCResult { }; } +/** Escape Redis glob metacharacters so a session identifier is always literal. */ +function escapeRedisGlobLiteral(value: string): string { + return value.replace(/[\\*?\[\]]/g, '\\$&'); +} + @Injectable() export class SessionGCService { constructor( @@ -43,7 +48,7 @@ export class SessionGCService { const result: GCResult = { sessionId, cleaned: {} }; // 1. Valkey: delete all session-scoped keys - const pattern = `mosaic:session:${sessionId}:*`; + const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`; const valkeyKeys = await this.scanKeys(pattern); if (valkeyKeys.length > 0) { await this.redis.del(...valkeyKeys);