fix(security): M2-008 Valkey key audit — SCAN over KEYS, restrict /gc to admin (#298)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #298.
This commit is contained in:
2026-03-21 20:45:43 +00:00
committed by jason.woltje
parent 02ff3b3256
commit 5b089392fd
5 changed files with 58 additions and 26 deletions

View File

@@ -5,7 +5,7 @@ import type { LogService } from '@mosaic/log';
import { SessionGCService } from './session-gc.service.js';
type MockRedis = {
keys: ReturnType<typeof vi.fn>;
scan: ReturnType<typeof vi.fn>;
del: ReturnType<typeof vi.fn>;
};
@@ -14,9 +14,17 @@ describe('SessionGCService', () => {
let mockRedis: MockRedis;
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
/**
* Helper: build a scan mock that returns all provided keys in a single
* cursor iteration (cursor '0' in → ['0', keys] out).
*/
function makeScanMock(keys: string[]): ReturnType<typeof vi.fn> {
return vi.fn().mockResolvedValue(['0', keys]);
}
beforeEach(() => {
mockRedis = {
keys: vi.fn().mockResolvedValue([]),
scan: makeScanMock([]),
del: vi.fn().mockResolvedValue(0),
};
@@ -36,7 +44,7 @@ describe('SessionGCService', () => {
});
it('collect() deletes Valkey keys for session', async () => {
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:abc:foo']);
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:abc:foo']);
const result = await service.collect('abc');
expect(mockRedis.del).toHaveBeenCalledWith(
'mosaic:session:abc:system',
@@ -46,7 +54,7 @@ describe('SessionGCService', () => {
});
it('collect() with no keys returns empty cleaned valkeyKeys', async () => {
mockRedis.keys.mockResolvedValue([]);
mockRedis.scan = makeScanMock([]);
const result = await service.collect('abc');
expect(result.cleaned.valkeyKeys).toBeUndefined();
});
@@ -57,14 +65,14 @@ describe('SessionGCService', () => {
});
it('fullCollect() deletes all session keys', async () => {
mockRedis.keys.mockResolvedValue(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
const result = await service.fullCollect();
expect(mockRedis.del).toHaveBeenCalled();
expect(result.valkeyKeys).toBe(2);
});
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
mockRedis.keys.mockResolvedValue([]);
mockRedis.scan = makeScanMock([]);
const result = await service.fullCollect();
expect(result.valkeyKeys).toBe(0);
expect(mockRedis.del).not.toHaveBeenCalled();
@@ -76,11 +84,18 @@ describe('SessionGCService', () => {
});
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
mockRedis.keys.mockResolvedValue([
'mosaic:session:abc:system',
'mosaic:session:abc:messages',
'mosaic:session:xyz:system',
]);
// First scan call returns the global session list; subsequent calls return
// per-session keys during collect().
mockRedis.scan = vi
.fn()
.mockResolvedValueOnce([
'0',
['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'],
])
// collect('abc') scan
.mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']])
// collect('xyz') scan
.mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]);
mockRedis.del.mockResolvedValue(1);
const result = await service.sweepOrphans();
@@ -89,7 +104,7 @@ describe('SessionGCService', () => {
});
it('sweepOrphans() returns empty when no session keys', async () => {
mockRedis.keys.mockResolvedValue([]);
mockRedis.scan = makeScanMock([]);
const result = await service.sweepOrphans();
expect(result.orphanedSessions).toBe(0);
expect(result.totalCleaned).toHaveLength(0);