import { randomBytes } from 'node:crypto'; import type { Logger } from 'nestjs-pino'; import { DownstreamTokenCache, type CachedToken } from './downstream-token-cache.service'; const KEY = randomBytes(32); const OTHER_KEY = randomBytes(32); interface RedisStub { get: jest.Mock; set: jest.Mock; } function makeRedis(opts?: { get?: jest.Mock; set?: jest.Mock }): RedisStub { return { get: opts?.get ?? jest.fn().mockResolvedValue(null), set: opts?.set ?? jest.fn().mockResolvedValue('OK'), }; } function makeLogger() { return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & { log: jest.Mock; warn: jest.Mock; error: jest.Mock; }; } function makeCache( redis: RedisStub, key: Buffer = KEY, ): { cache: DownstreamTokenCache; logger: ReturnType } { const logger = makeLogger(); // The service treats the injected redis client as the bare // ioredis surface; we only need `.get` and `.set` for these tests. const cache = new DownstreamTokenCache(redis as never, key, logger); return { cache, logger }; } const INPUT = { actorIdHash: 'hash(jane)', resource: 'api://downstream-svc' }; describe('DownstreamTokenCache.get', () => { it('returns null on a true cache miss (Redis returned null)', async () => { const redis = makeRedis({ get: jest.fn().mockResolvedValue(null) }); const { cache } = makeCache(redis); expect(await cache.get(INPUT)).toBeNull(); expect(redis.get).toHaveBeenCalledWith('obo:hash(jane):api://downstream-svc'); }); it('decrypts a well-formed entry round-tripped through set()', async () => { // Use the cache itself to write, then read back — exercises the // encrypt/decrypt round-trip with the same key. let stored: string | null = null; const redis = makeRedis({ get: jest.fn().mockImplementation(() => Promise.resolve(stored)), set: jest.fn().mockImplementation((_k: string, v: string) => { stored = v; return Promise.resolve('OK'); }), }); const { cache } = makeCache(redis); const token: CachedToken = { accessToken: 'down-token', expiresAt: Date.now() + 600_000 }; await cache.set({ ...INPUT, token }); const read = await cache.get(INPUT); expect(read).toEqual(token); }); it('returns null and logs when decryption fails (tampered ciphertext)', async () => { let stored: string | null = null; const redis = makeRedis({ get: jest.fn().mockImplementation(() => Promise.resolve(stored)), set: jest.fn().mockImplementation((_k: string, v: string) => { stored = v; return Promise.resolve('OK'); }), }); const { cache, logger } = makeCache(redis); await cache.set({ ...INPUT, token: { accessToken: 'down-token', expiresAt: Date.now() + 600_000 }, }); // Flip the very last char of the ciphertext to break the GCM // auth tag — decrypt() rejects the tag verification. stored = stored ? `${stored.slice(0, -1)}${stored.endsWith('A') ? 'B' : 'A'}` : null; expect(await cache.get(INPUT)).toBeNull(); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ event: 'downstream.obo_cache.decrypt_failed' }), 'DownstreamTokenCache', ); }); it('returns null when the entry is decryptable but malformed JSON shape', async () => { // Round-trip a non-CachedToken value through encryption, then // ask the cache to read it — the shape guard collapses it to a // miss so the strategy re-acquires. let stored: string | null = null; const writerRedis = makeRedis({ set: jest.fn().mockImplementation((_k: string, v: string) => { stored = v; return Promise.resolve('OK'); }), }); const { cache: writer } = makeCache(writerRedis); // Manually write a bogus shape with the same key. // We piggyback on the cache's encrypt path by calling set with // a wide token then handcraft the stored payload after. await writer.set({ ...INPUT, token: { accessToken: 'x', expiresAt: Date.now() + 600_000 }, }); // Now read with a fresh cache instance pointing at the same // stored payload but interpreted with a STRICT shape check — // mock the get to return an encrypted payload of a non-CachedToken. // Use a separately-encrypted bogus payload by routing through // session-crypto directly. // eslint-disable-next-line @typescript-eslint/no-require-imports const { encrypt } = require('../session/session-crypto') as { encrypt: (plaintext: string, key: Buffer) => string; }; stored = encrypt(JSON.stringify({ accessToken: 42, expiresAt: 'soon' }), KEY); const reader = new DownstreamTokenCache( { get: jest.fn().mockResolvedValue(stored), set: jest.fn() } as never, KEY, makeLogger(), ); expect(await reader.get(INPUT)).toBeNull(); }); it('returns null when the value was encrypted under a different key', async () => { // Write with one key, read with another — `decipher.final()` // throws on the GCM auth-tag mismatch and the cache treats it // as a miss. let stored: string | null = null; const writerRedis = makeRedis({ set: jest.fn().mockImplementation((_k: string, v: string) => { stored = v; return Promise.resolve('OK'); }), }); const { cache: writer } = makeCache(writerRedis, KEY); await writer.set({ ...INPUT, token: { accessToken: 'down-token', expiresAt: Date.now() + 600_000 }, }); const reader = new DownstreamTokenCache( { get: jest.fn().mockResolvedValue(stored), set: jest.fn() } as never, OTHER_KEY, makeLogger(), ); expect(await reader.get(INPUT)).toBeNull(); }); it('returns null and logs on a Redis read failure', async () => { const redis = makeRedis({ get: jest.fn().mockRejectedValue(new Error('connection refused')), }); const { cache, logger } = makeCache(redis); expect(await cache.get(INPUT)).toBeNull(); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ event: 'downstream.obo_cache.read_failed' }), 'DownstreamTokenCache', ); }); }); describe('DownstreamTokenCache.set', () => { it('writes the encrypted token to Redis with a PX TTL equal to expiry minus 60 s', async () => { const now = 10_000_000_000; jest.useFakeTimers(); jest.setSystemTime(now); try { const redis = makeRedis(); const { cache } = makeCache(redis); const distinctiveToken = 'PLAINTEXT_DOWNSTREAM_TOKEN_SENTINEL'; const token: CachedToken = { accessToken: distinctiveToken, expiresAt: now + 600_000, }; await cache.set({ ...INPUT, token }); expect(redis.set).toHaveBeenCalledWith( 'obo:hash(jane):api://downstream-svc', expect.any(String), 'PX', 540_000, // 600s expiry - 60s buffer ); // The ciphertext stored is NOT the raw access token. const ciphertext = redis.set.mock.calls[0]?.[1] as string; expect(ciphertext).not.toContain(distinctiveToken); expect(ciphertext.startsWith('v1.')).toBe(true); } finally { jest.useRealTimers(); } }); it('skips the write when the token expires within the safety buffer', async () => { const now = 10_000_000_000; jest.useFakeTimers(); jest.setSystemTime(now); try { const redis = makeRedis(); const { cache } = makeCache(redis); const token: CachedToken = { accessToken: 'x', expiresAt: now + 30_000 }; // 30s < 60s buffer await cache.set({ ...INPUT, token }); expect(redis.set).not.toHaveBeenCalled(); } finally { jest.useRealTimers(); } }); it('logs but does not throw when the Redis SET fails', async () => { const redis = makeRedis({ set: jest.fn().mockRejectedValue(new Error('OOM')), }); const { cache, logger } = makeCache(redis); await expect( cache.set({ ...INPUT, token: { accessToken: 'x', expiresAt: Date.now() + 600_000 }, }), ).resolves.toBeUndefined(); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ event: 'downstream.obo_cache.write_failed' }), 'DownstreamTokenCache', ); }); });