From 8356ac634ad6d55f9422c2587ed53ab5d2d89a32 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 24 Mar 2026 22:36:22 +0100 Subject: [PATCH] test(auth): add unit tests for local JWKS cache 15 tests covering: happy path, caching with TTL, empty DB, connection failures, key rotation, and cache clearing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../common/guards/local-jwks-cache.spec.ts | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 services/mana-core-auth/src/common/guards/local-jwks-cache.spec.ts diff --git a/services/mana-core-auth/src/common/guards/local-jwks-cache.spec.ts b/services/mana-core-auth/src/common/guards/local-jwks-cache.spec.ts new file mode 100644 index 000000000..7cbcc48b8 --- /dev/null +++ b/services/mana-core-auth/src/common/guards/local-jwks-cache.spec.ts @@ -0,0 +1,353 @@ +/** + * Local JWKS Cache Unit Tests + * + * Tests the in-memory JWKS cache that reads keys from the database + * and provides jose-compatible key resolvers for JWT verification. + * + * - Happy path: loads keys from DB, returns working resolver + * - Caching: returns cached result within TTL, refreshes after TTL + * - Empty DB: throws meaningful error when no keys available + * - DB failure: propagates errors with meaningful context + * - Key rotation: picks up new keys after cache expires + */ + +import { createCachedLocalJWKSet, clearJwksCache } from './local-jwks-cache'; + +// Mock the DB connection module +jest.mock('../../db/connection', () => ({ + getDb: jest.fn(), +})); + +// Mock jose - we test the cache logic, not jose internals +jest.mock('jose', () => ({ + createLocalJWKSet: jest.fn(), +})); + +import { getDb } from '../../db/connection'; +import { createLocalJWKSet } from 'jose'; + +const mockGetDb = getDb as jest.MockedFunction; +const mockCreateLocalJWKSet = createLocalJWKSet as jest.MockedFunction; + +// Sample EdDSA JWK for testing +const sampleJwk = { + kty: 'OKP', + crv: 'Ed25519', + x: 'dGVzdC1wdWJsaWMta2V5LWJhc2U2NA', + kid: 'test-key-1', +}; + +const sampleDbRow = { + id: 'test-key-1', + publicKey: JSON.stringify(sampleJwk), + privateKey: '{"kty":"OKP","crv":"Ed25519","d":"private","x":"dGVzdC1wdWJsaWMta2V5LWJhc2U2NA"}', + createdAt: new Date(), +}; + +describe('Local JWKS Cache', () => { + let mockFrom: jest.Mock; + let mockSelect: jest.Mock; + let mockResolver: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + clearJwksCache(); + + // Setup DB mock chain: db.select().from(jwks) => rows + mockFrom = jest.fn(); + mockSelect = jest.fn().mockReturnValue({ from: mockFrom }); + mockGetDb.mockReturnValue({ select: mockSelect } as any); + + // Setup jose mock resolver + mockResolver = jest.fn().mockResolvedValue({} as CryptoKey); + mockCreateLocalJWKSet.mockReturnValue(mockResolver as any); + }); + + describe('Happy path', () => { + it('should read JWKS from DB and return a working key resolver', async () => { + mockFrom.mockResolvedValue([sampleDbRow]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + const result = await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + // Should have queried the DB + expect(mockGetDb).toHaveBeenCalledWith('postgresql://localhost:5432/test'); + expect(mockSelect).toHaveBeenCalled(); + expect(mockFrom).toHaveBeenCalled(); + + // Should have created a local JWK set with the parsed keys + expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ + keys: [sampleJwk], + }); + + // Should have called the resolver + expect(mockResolver).toHaveBeenCalledWith({ alg: 'EdDSA' }, {}); + }); + + it('should set kid from row id when JWK has no kid', async () => { + const jwkWithoutKid = { kty: 'OKP', crv: 'Ed25519', x: 'abc123' }; + const row = { + id: 'row-id-123', + publicKey: JSON.stringify(jwkWithoutKid), + privateKey: '{}', + createdAt: new Date(), + }; + + mockFrom.mockResolvedValue([row]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ + keys: [{ ...jwkWithoutKid, kid: 'row-id-123' }], + }); + }); + + it('should handle multiple keys from DB', async () => { + const secondJwk = { kty: 'OKP', crv: 'Ed25519', x: 'c2Vjb25kLWtleQ', kid: 'key-2' }; + const rows = [ + sampleDbRow, + { + id: 'key-2', + publicKey: JSON.stringify(secondJwk), + privateKey: '{}', + createdAt: new Date(), + }, + ]; + + mockFrom.mockResolvedValue(rows); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ + keys: [sampleJwk, secondJwk], + }); + }); + + it('should skip malformed JSON keys without crashing', async () => { + const rows = [ + { id: 'bad-key', publicKey: 'not-valid-json{', privateKey: '{}', createdAt: new Date() }, + sampleDbRow, + ]; + + mockFrom.mockResolvedValue(rows); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + // Should only include the valid key + expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ + keys: [sampleJwk], + }); + }); + }); + + describe('Caching behavior', () => { + it('should use cached resolver on second call within TTL', async () => { + mockFrom.mockResolvedValue([sampleDbRow]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + // First call - reads from DB + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(1); + expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1); + + // Second call - should use cache + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(1); // Still 1 - no new DB query + expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1); // Still 1 + }); + + it('should refresh cache after TTL expires', async () => { + mockFrom.mockResolvedValue([sampleDbRow]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + // First call + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(1); + + // Advance time past TTL (5 minutes = 300000ms) + const originalDateNow = Date.now; + Date.now = jest.fn().mockReturnValue(originalDateNow() + 5 * 60 * 1000 + 1); + + try { + // Third call after TTL - should refresh + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(2); // New DB query + expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(2); // New resolver created + } finally { + Date.now = originalDateNow; + } + }); + + it('should not refresh cache before TTL expires', async () => { + mockFrom.mockResolvedValue([sampleDbRow]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + // First call + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + // Advance time to just before TTL (4 minutes 59 seconds) + const originalDateNow = Date.now; + Date.now = jest.fn().mockReturnValue(originalDateNow() + 4 * 60 * 1000 + 59 * 1000); + + try { + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(1); // No refresh + } finally { + Date.now = originalDateNow; + } + }); + }); + + describe('Empty DB', () => { + it('should throw error when no JWKS keys are in the database', async () => { + mockFrom.mockResolvedValue([]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( + 'No JWKS keys available in database' + ); + }); + + it('should throw error when all keys have malformed JSON', async () => { + mockFrom.mockResolvedValue([ + { id: 'bad-1', publicKey: '{invalid', privateKey: '{}', createdAt: new Date() }, + { id: 'bad-2', publicKey: 'not json', privateKey: '{}', createdAt: new Date() }, + ]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( + 'No JWKS keys available in database' + ); + }); + }); + + describe('DB connection failure', () => { + it('should propagate database errors with meaningful context', async () => { + mockFrom.mockRejectedValue(new Error('Connection refused')); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( + 'Connection refused' + ); + }); + + it('should propagate timeout errors', async () => { + mockFrom.mockRejectedValue(new Error('Query timeout')); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow('Query timeout'); + }); + + it('should retry DB read after a failed attempt (no stale error cached)', async () => { + // First call fails + mockFrom.mockRejectedValueOnce(new Error('Connection refused')); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( + 'Connection refused' + ); + + // Second call should try DB again (not cache the error) + mockFrom.mockResolvedValueOnce([sampleDbRow]); + + const result = await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(2); + expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1); + }); + }); + + describe('Key rotation', () => { + it('should pick up new keys after cache TTL expires', async () => { + const originalKey = sampleDbRow; + const rotatedJwk = { kty: 'OKP', crv: 'Ed25519', x: 'cm90YXRlZC1rZXk', kid: 'rotated-key' }; + const rotatedRow = { + id: 'rotated-key', + publicKey: JSON.stringify(rotatedJwk), + privateKey: '{}', + createdAt: new Date(), + }; + + // First call returns original key + mockFrom.mockResolvedValueOnce([originalKey]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ + keys: [sampleJwk], + }); + + // Expire the cache + const originalDateNow = Date.now; + Date.now = jest.fn().mockReturnValue(originalDateNow() + 5 * 60 * 1000 + 1); + + try { + // Second call returns rotated key + mockFrom.mockResolvedValueOnce([rotatedRow]); + + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(2); + expect(mockCreateLocalJWKSet).toHaveBeenLastCalledWith({ + keys: [rotatedJwk], + }); + } finally { + Date.now = originalDateNow; + } + }); + + it('should serve both old and new keys during rotation period', async () => { + const oldJwk = sampleJwk; + const newJwk = { kty: 'OKP', crv: 'Ed25519', x: 'bmV3LWtleQ', kid: 'new-key' }; + + // DB returns both keys (typical during rotation) + mockFrom.mockResolvedValue([ + sampleDbRow, + { + id: 'new-key', + publicKey: JSON.stringify(newJwk), + privateKey: '{}', + createdAt: new Date(), + }, + ]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + + expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ + keys: [oldJwk, newJwk], + }); + }); + }); + + describe('clearJwksCache', () => { + it('should force a DB re-read on next call after clearing', async () => { + mockFrom.mockResolvedValue([sampleDbRow]); + + const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); + + // First call + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(1); + + // Clear cache + clearJwksCache(); + + // Next call should query DB again + await keyGetter({ alg: 'EdDSA' } as any, {} as any); + expect(mockFrom).toHaveBeenCalledTimes(2); + }); + }); +});