diff --git a/packages/agent-bff/src/api-key/agent-token.ts b/packages/agent-bff/src/api-key/agent-token.ts new file mode 100644 index 0000000000..651f71aea8 --- /dev/null +++ b/packages/agent-bff/src/api-key/agent-token.ts @@ -0,0 +1,41 @@ +import type { ResolvedApiKeyIdentity } from './api-key-client'; + +import jsonwebtoken from 'jsonwebtoken'; + +export const AGENT_TOKEN_EXPIRES_IN = '5m'; + +export interface IssueAgentTokenParams { + identity: ResolvedApiKeyIdentity; + authSecret: string; +} + +function tagsToRecord(tags: { key: string; value: string }[]): Record { + return tags.reduce((memo, { key, value }) => ({ ...memo, [key]: value }), {}); +} + +export function issueAgentToken({ identity, authSecret }: IssueAgentTokenParams): string { + const { user, renderingId } = identity; + const firstName = user.firstName ?? ''; + const lastName = user.lastName ?? ''; + const tags = tagsToRecord(user.tags); + + // snake_case aliases: Ruby/Python agents splat JWT claims into Caller (snake_case kwargs). + return jsonwebtoken.sign( + { + id: user.id, + email: user.email, + firstName, + lastName, + team: user.team, + renderingId, + tags, + permissionLevel: user.permissionLevel, + first_name: firstName, + last_name: lastName, + rendering_id: renderingId, + permission_level: user.permissionLevel, + }, + authSecret, + { algorithm: 'HS256', expiresIn: AGENT_TOKEN_EXPIRES_IN }, + ); +} diff --git a/packages/agent-bff/src/api-key/api-key-authenticator.ts b/packages/agent-bff/src/api-key/api-key-authenticator.ts new file mode 100644 index 0000000000..a6c540de02 --- /dev/null +++ b/packages/agent-bff/src/api-key/api-key-authenticator.ts @@ -0,0 +1,95 @@ +import type ApiKeyClient from './api-key-client'; +import type { ResolvedApiKeyIdentity } from './api-key-client'; +import type { ApiKeyError } from './api-key-error'; +import type { ResolveCache } from './resolve-cache'; + +import { issueAgentToken } from './agent-token'; +import { hashApiKey, parseApiKey } from './api-key'; +import { ApiKeyResolveError } from './api-key-client'; +import { + forestIdentityNotAllowed, + invalidApiKey, + invalidRequest, + keyResolutionUnavailable, +} from './api-key-error'; + +const UNAVAILABLE_RETRY_AFTER_SECONDS = 5; +const NEGATIVE_CACHE_STATUSES = new Set([401, 403]); + +export interface ApiKeyAuthenticatorOptions { + client: ApiKeyClient; + cache: ResolveCache; + authSecret: string; +} + +export interface AuthenticatedApiKey { + agentToken: string; + identity: ResolvedApiKeyIdentity; +} + +export interface ApiKeyAuthenticator { + authenticate(rawKey: string): Promise; +} + +function mapResolveError(error: ApiKeyResolveError): ApiKeyError { + if (error.unreachable) return keyResolutionUnavailable(UNAVAILABLE_RETRY_AFTER_SECONDS); + + switch (error.status) { + case 401: + return invalidApiKey(); + case 403: + return forestIdentityNotAllowed(); + case 400: + return invalidRequest(); + case 429: + return keyResolutionUnavailable(error.retryAfter ?? UNAVAILABLE_RETRY_AFTER_SECONDS); + default: + return keyResolutionUnavailable(UNAVAILABLE_RETRY_AFTER_SECONDS); + } +} + +export default function createApiKeyAuthenticator({ + client, + cache, + authSecret, +}: ApiKeyAuthenticatorOptions): ApiKeyAuthenticator { + function mint(identity: ResolvedApiKeyIdentity): AuthenticatedApiKey { + return { agentToken: issueAgentToken({ identity, authSecret }), identity }; + } + + return { + async authenticate(rawKey) { + const parsed = parseApiKey(rawKey); + + if (!parsed) throw invalidApiKey(); + + const hash = hashApiKey(parsed.keyId, parsed.secret); + + const cachedIdentity = cache.getPositive(hash); + if (cachedIdentity) return mint(cachedIdentity); + + const cachedError = cache.getNegative(hash); + if (cachedError) throw cachedError; + + let identity: ResolvedApiKeyIdentity; + + try { + identity = await client.resolveApiKey(parsed); + } catch (error) { + if (error instanceof ApiKeyResolveError) { + const mapped = mapResolveError(error); + if (NEGATIVE_CACHE_STATUSES.has(mapped.status)) cache.setNegative(hash, mapped); + + throw mapped; + } + + throw error; + } + + const authenticated = mint(identity); + cache.setPositive(hash, identity); + + return authenticated; + }, + }; +} diff --git a/packages/agent-bff/src/api-key/api-key-client.ts b/packages/agent-bff/src/api-key/api-key-client.ts new file mode 100644 index 0000000000..ae09fb140b --- /dev/null +++ b/packages/agent-bff/src/api-key/api-key-client.ts @@ -0,0 +1,131 @@ +import { ApiKeyResolveError } from './api-key-resolve-error'; + +export { ApiKeyResolveError } from './api-key-resolve-error'; +export type { ApiKeyResolveErrorParams } from './api-key-resolve-error'; + +export interface ApiKeyIdentityUser { + id: number; + email: string; + firstName: string | null; + lastName: string | null; + team: string; + tags: { key: string; value: string }[]; + permissionLevel: string; +} + +export interface ResolvedApiKeyIdentity { + user: ApiKeyIdentityUser; + renderingId: number; + allowedOrigins: string[]; +} + +export interface ApiKeyClientOptions { + forestServerUrl: string; + envSecret: string; +} + +const DEFAULT_HEADERS = { 'Content-Type': 'application/json' } as const; +const REQUEST_TIMEOUT_MS = 10_000; +const RESOLVE_PATH = '/liana/v1/bff-api-keys/resolve'; + +interface SaasErrorBody { + errors?: { name?: string; meta?: { code?: string } }[]; +} + +export default class ApiKeyClient { + private readonly forestServerUrl: string; + private readonly envSecret: string; + + constructor({ forestServerUrl, envSecret }: ApiKeyClientOptions) { + this.forestServerUrl = forestServerUrl; + this.envSecret = envSecret; + } + + async resolveApiKey(parsedKey: { + keyId: string; + secret: string; + }): Promise { + let response: Response; + + try { + response = await fetch(this.url(RESOLVE_PATH), { + method: 'POST', + headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret }, + body: JSON.stringify({ keyId: parsedKey.keyId, secret: parsedKey.secret }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new ApiKeyResolveError({ unreachable: true }); + } + + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as SaasErrorBody; + const firstError = body.errors?.[0]; + + throw new ApiKeyResolveError({ + status: response.status, + code: firstError?.meta?.code, + name: firstError?.name, + retryAfter: ApiKeyClient.parseRetryAfter(response.headers.get('retry-after')), + }); + } + + let body: unknown; + + try { + body = await response.json(); + } catch { + throw new ApiKeyResolveError({ unreachable: true }); + } + + // A well-formed HTTP 200 with an incomplete body is an unusable resolution, not a caller + // error: surface it as unavailable rather than letting a later `user` deref throw a 500. + if (!ApiKeyClient.isResolvedIdentity(body)) { + throw new ApiKeyResolveError({ unreachable: true }); + } + + return body; + } + + private static isResolvedIdentity(body: unknown): body is ResolvedApiKeyIdentity { + if (typeof body !== 'object' || body === null) return false; + + const candidate = body as { user?: unknown; renderingId?: unknown; allowedOrigins?: unknown }; + + return ( + typeof candidate.renderingId === 'number' && + Array.isArray(candidate.allowedOrigins) && + ApiKeyClient.isIdentityUser(candidate.user) + ); + } + + // Every field the agent-token mint reads must be present, so a partial body fails here + // (mapped to unavailable) instead of minting a token with undefined claims later. + private static isIdentityUser(user: unknown): user is ApiKeyIdentityUser { + if (typeof user !== 'object' || user === null) return false; + + const candidate = user as Record; + + return ( + typeof candidate.id === 'number' && + typeof candidate.email === 'string' && + typeof candidate.team === 'string' && + typeof candidate.permissionLevel === 'string' && + Array.isArray(candidate.tags) + ); + } + + // Retry-After may be an HTTP-date rather than delay-seconds; keep only a positive integer so a + // date/negative/zero value falls back to the caller's default instead of a useless backoff hint. + private static parseRetryAfter(header: string | null): number | undefined { + if (header === null) return undefined; + + const seconds = Number(header); + + return Number.isInteger(seconds) && seconds > 0 ? seconds : undefined; + } + + private url(path: string): string { + return new URL(path, this.forestServerUrl).toString(); + } +} diff --git a/packages/agent-bff/src/api-key/api-key-error.ts b/packages/agent-bff/src/api-key/api-key-error.ts new file mode 100644 index 0000000000..58e62316a8 --- /dev/null +++ b/packages/agent-bff/src/api-key/api-key-error.ts @@ -0,0 +1,50 @@ +export type ApiKeyErrorType = + | 'invalid_api_key' + | 'forest_identity_not_allowed' + | 'invalid_request' + | 'key_resolution_unavailable'; + +export class ApiKeyError extends Error { + readonly status: number; + readonly type: ApiKeyErrorType; + readonly retryAfter?: number; + + constructor(status: number, type: ApiKeyErrorType, message: string, retryAfter?: number) { + super(message); + this.name = 'ApiKeyError'; + this.status = status; + this.type = type; + this.retryAfter = retryAfter; + } +} + +export interface ApiKeyErrorBody { + error: { + type: ApiKeyErrorType; + status: number; + message: string; + }; +} + +export function toErrorBody(error: ApiKeyError): ApiKeyErrorBody { + return { error: { type: error.type, status: error.status, message: error.message } }; +} + +export function invalidApiKey(message = 'Invalid API key'): ApiKeyError { + return new ApiKeyError(401, 'invalid_api_key', message); +} + +export function forestIdentityNotAllowed(message = 'Forest identity not allowed'): ApiKeyError { + return new ApiKeyError(403, 'forest_identity_not_allowed', message); +} + +export function invalidRequest(message = 'Invalid request'): ApiKeyError { + return new ApiKeyError(400, 'invalid_request', message); +} + +export function keyResolutionUnavailable( + retryAfter: number, + message = 'Key resolution unavailable', +): ApiKeyError { + return new ApiKeyError(503, 'key_resolution_unavailable', message, retryAfter); +} diff --git a/packages/agent-bff/src/api-key/api-key-middleware.ts b/packages/agent-bff/src/api-key/api-key-middleware.ts new file mode 100644 index 0000000000..640b4ed46a --- /dev/null +++ b/packages/agent-bff/src/api-key/api-key-middleware.ts @@ -0,0 +1,69 @@ +import type { ApiKeyAuthenticator, AuthenticatedApiKey } from './api-key-authenticator'; +import type { Logger } from '../ports/logger-port'; +import type { Context, Middleware } from 'koa'; + +import { fingerprintApiKey } from './api-key'; +import { ApiKeyError, toErrorBody } from './api-key-error'; + +export const BFF_KEY_HEADER = 'X-Forest-Bff-Key'; + +export interface ApiKeyMiddlewareOptions { + authenticator: ApiKeyAuthenticator; + logger: Logger; +} + +function writeError(ctx: Context, error: unknown, rawKey: string, logger: Logger): void { + if (error instanceof ApiKeyError) { + if (error.retryAfter !== undefined) ctx.set('Retry-After', String(error.retryAfter)); + ctx.status = error.status; + ctx.body = toErrorBody(error); + logger('Warn', 'BFF API key rejected', { + keyHash: fingerprintApiKey(rawKey), + type: error.type, + }); + + return; + } + + logger('Error', 'BFF API key middleware failure', { + keyHash: fingerprintApiKey(rawKey), + cause: error instanceof Error ? `${error.name}: ${error.message}` : String(error), + }); + ctx.status = 500; + ctx.body = { error: { type: 'server_error', status: 500, message: 'API key processing failed' } }; +} + +export default function createApiKeyMiddleware({ + authenticator, + logger, +}: ApiKeyMiddlewareOptions): Middleware { + return async function apiKeyMiddleware(ctx, next) { + const rawKey = ctx.get(BFF_KEY_HEADER); + + if (!rawKey) { + await next(); + + return; + } + + let authenticated: AuthenticatedApiKey; + + try { + authenticated = await authenticator.authenticate(rawKey); + } catch (error) { + writeError(ctx, error, rawKey, logger); + + return; + } + + ctx.state.agentToken = authenticated.agentToken; + ctx.state.apiKeyIdentity = authenticated.identity; + ctx.set('Cache-Control', 'no-store'); + logger('Info', 'Resolved BFF API key', { + keyHash: fingerprintApiKey(rawKey), + renderingId: authenticated.identity.renderingId, + }); + + await next(); + }; +} diff --git a/packages/agent-bff/src/api-key/api-key-resolve-error.ts b/packages/agent-bff/src/api-key/api-key-resolve-error.ts new file mode 100644 index 0000000000..93be13d4f4 --- /dev/null +++ b/packages/agent-bff/src/api-key/api-key-resolve-error.ts @@ -0,0 +1,25 @@ +export interface ApiKeyResolveErrorParams { + status?: number; + code?: string; + name?: string; + retryAfter?: number; + unreachable?: boolean; +} + +export class ApiKeyResolveError extends Error { + readonly status?: number; + readonly code?: string; + readonly saasName?: string; + readonly retryAfter?: number; + readonly unreachable: boolean; + + constructor(params: ApiKeyResolveErrorParams) { + super(`BFF API key resolve failed${params.status ? ` (${params.status})` : ''}`); + this.name = 'ApiKeyResolveError'; + this.status = params.status; + this.code = params.code; + this.saasName = params.name; + this.retryAfter = params.retryAfter; + this.unreachable = params.unreachable ?? false; + } +} diff --git a/packages/agent-bff/src/api-key/api-key.ts b/packages/agent-bff/src/api-key/api-key.ts new file mode 100644 index 0000000000..1d3e45db31 --- /dev/null +++ b/packages/agent-bff/src/api-key/api-key.ts @@ -0,0 +1,24 @@ +import crypto from 'crypto'; + +const API_KEY_PATTERN = /^fbff_([0-9a-f]{16})_([0-9a-f]{64})$/; + +export interface ParsedApiKey { + keyId: string; + secret: string; +} + +export function parseApiKey(raw: string): ParsedApiKey | null { + const match = API_KEY_PATTERN.exec(raw); + + if (!match) return null; + + return { keyId: match[1], secret: match[2] }; +} + +export function hashApiKey(keyId: string, secret: string): string { + return crypto.createHash('sha256').update(`${keyId}:${secret}`).digest('hex'); +} + +export function fingerprintApiKey(raw: string): string { + return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 12); +} diff --git a/packages/agent-bff/src/api-key/resolve-cache.ts b/packages/agent-bff/src/api-key/resolve-cache.ts new file mode 100644 index 0000000000..6c891ec6a2 --- /dev/null +++ b/packages/agent-bff/src/api-key/resolve-cache.ts @@ -0,0 +1,104 @@ +import type { ResolvedApiKeyIdentity } from './api-key-client'; +import type { ApiKeyError } from './api-key-error'; + +export interface ResolveCache { + getPositive(hash: string): ResolvedApiKeyIdentity | undefined; + getNegative(hash: string): ApiKeyError | undefined; + setPositive(hash: string, identity: ResolvedApiKeyIdentity): void; + setNegative(hash: string, error: ApiKeyError): void; + size(): number; +} + +export interface ResolveCacheOptions { + now: () => number; + positiveTtlSeconds?: number; + negativeTtlSeconds?: number; + maxEntries?: number; +} + +interface PositiveEntry { + kind: 'positive'; + identity: ResolvedApiKeyIdentity; + expiresAt: number; +} + +interface NegativeEntry { + kind: 'negative'; + error: ApiKeyError; + expiresAt: number; +} + +type CacheEntry = PositiveEntry | NegativeEntry; + +const DEFAULT_POSITIVE_TTL_SECONDS = 60; +const DEFAULT_NEGATIVE_TTL_SECONDS = 10; +const DEFAULT_MAX_ENTRIES = 10_000; + +export default function createResolveCache({ + now, + positiveTtlSeconds = DEFAULT_POSITIVE_TTL_SECONDS, + negativeTtlSeconds = DEFAULT_NEGATIVE_TTL_SECONDS, + maxEntries = DEFAULT_MAX_ENTRIES, +}: ResolveCacheOptions): ResolveCache { + const entries = new Map(); + + function purgeExpired(): void { + const current = now(); + + for (const [hash, entry] of entries) { + if (current >= entry.expiresAt) entries.delete(hash); + } + } + + function liveEntry(hash: string): CacheEntry | undefined { + const entry = entries.get(hash); + if (!entry) return undefined; + + if (now() >= entry.expiresAt) { + entries.delete(hash); + + return undefined; + } + + return entry; + } + + function store(hash: string, entry: CacheEntry): void { + purgeExpired(); + + if (!entries.has(hash) && entries.size >= maxEntries) { + const oldest = entries.keys().next().value; + if (oldest !== undefined) entries.delete(oldest); + } + + entries.set(hash, entry); + } + + return { + getPositive(hash) { + const entry = liveEntry(hash); + + return entry?.kind === 'positive' ? entry.identity : undefined; + }, + + getNegative(hash) { + const entry = liveEntry(hash); + + return entry?.kind === 'negative' ? entry.error : undefined; + }, + + setPositive(hash, identity) { + store(hash, { kind: 'positive', identity, expiresAt: now() + positiveTtlSeconds * 1000 }); + }, + + setNegative(hash, error) { + store(hash, { kind: 'negative', error, expiresAt: now() + negativeTtlSeconds * 1000 }); + }, + + size() { + purgeExpired(); + + return entries.size; + }, + }; +} diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 59a8844f36..633453b535 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -5,6 +5,10 @@ import type { Middleware } from 'koa'; import { bodyParser } from '@koa/bodyparser'; import createConsoleLogger from './adapters/console-logger'; +import createApiKeyAuthenticator from './api-key/api-key-authenticator'; +import ApiKeyClient from './api-key/api-key-client'; +import createApiKeyMiddleware from './api-key/api-key-middleware'; +import createResolveCache from './api-key/resolve-cache'; import { parseConfig } from './config/env-config'; import { extractErrorMessage } from './errors'; import BFFHttpServer from './http/bff-http-server'; @@ -75,12 +79,53 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise return [bodyParser({ jsonLimit: BODY_LIMIT }), oauthRoutes]; } +interface ResolvedApiKeyConfig { + forestServerUrl: string; + forestEnvSecret: string; + forestAuthSecret: string; +} + +function resolveApiKeyConfig(config: BFFConfig): ResolvedApiKeyConfig | undefined { + const { forestServerUrl, forestEnvSecret, forestAuthSecret } = config; + + if (forestServerUrl && forestEnvSecret && forestAuthSecret) { + return { forestServerUrl, forestEnvSecret, forestAuthSecret }; + } + + return undefined; +} + +function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | undefined { + const apiKeyConfig = resolveApiKeyConfig(config); + + if (!apiKeyConfig) { + logger('Warn', 'API key auth disabled: required configuration is missing'); + + return undefined; + } + + const client = new ApiKeyClient({ + forestServerUrl: apiKeyConfig.forestServerUrl, + envSecret: apiKeyConfig.forestEnvSecret, + }); + const cache = createResolveCache({ now: () => Date.now() }); + const authenticator = createApiKeyAuthenticator({ + client, + cache, + authSecret: apiKeyConfig.forestAuthSecret, + }); + + return createApiKeyMiddleware({ authenticator, logger }); +} + export default async function runCli( env: NodeJS.ProcessEnv, logger: Logger = createConsoleLogger(), ): Promise { const config = parseConfig(env); - const middlewares = await buildOAuthMiddlewares(config, logger); + const oauthMiddlewares = await buildOAuthMiddlewares(config, logger); + const apiKeyMiddleware = buildApiKeyMiddleware(config, logger); + const middlewares = [...oauthMiddlewares, ...(apiKeyMiddleware ? [apiKeyMiddleware] : [])]; const server = new BFFHttpServer({ port: config.httpPort, version, diff --git a/packages/agent-bff/src/index.ts b/packages/agent-bff/src/index.ts index 1d4dd7d355..91cff18bde 100644 --- a/packages/agent-bff/src/index.ts +++ b/packages/agent-bff/src/index.ts @@ -22,3 +22,30 @@ export { issueBffAccessToken, BFF_ACCESS_TOKEN_TYPE } from './oauth/bff-token'; export type { BffAccessTokenPayload } from './oauth/bff-token'; export { createPkcePair } from './oauth/pkce'; export { OAuthRequestError } from './oauth/oauth-error'; +export { parseApiKey, hashApiKey, fingerprintApiKey } from './api-key/api-key'; +export type { ParsedApiKey } from './api-key/api-key'; +export { issueAgentToken, AGENT_TOKEN_EXPIRES_IN } from './api-key/agent-token'; +export { default as ApiKeyClient, ApiKeyResolveError } from './api-key/api-key-client'; +export type { + ResolvedApiKeyIdentity, + ApiKeyIdentityUser, + ApiKeyClientOptions, +} from './api-key/api-key-client'; +export { default as createResolveCache } from './api-key/resolve-cache'; +export type { ResolveCache, ResolveCacheOptions } from './api-key/resolve-cache'; +export { default as createApiKeyAuthenticator } from './api-key/api-key-authenticator'; +export type { + ApiKeyAuthenticator, + AuthenticatedApiKey, + ApiKeyAuthenticatorOptions, +} from './api-key/api-key-authenticator'; +export { default as createApiKeyMiddleware, BFF_KEY_HEADER } from './api-key/api-key-middleware'; +export { + ApiKeyError, + invalidApiKey, + forestIdentityNotAllowed, + invalidRequest, + keyResolutionUnavailable, + toErrorBody as toApiKeyErrorBody, +} from './api-key/api-key-error'; +export type { ApiKeyErrorType, ApiKeyErrorBody } from './api-key/api-key-error'; diff --git a/packages/agent-bff/test/api-key/agent-token.test.ts b/packages/agent-bff/test/api-key/agent-token.test.ts new file mode 100644 index 0000000000..24da3d9622 --- /dev/null +++ b/packages/agent-bff/test/api-key/agent-token.test.ts @@ -0,0 +1,76 @@ +import type { ResolvedApiKeyIdentity } from '../../src/api-key/api-key-client'; + +import jsonwebtoken from 'jsonwebtoken'; + +import { issueAgentToken } from '../../src/api-key/agent-token'; + +const AUTH_SECRET = 'auth-secret'; + +const IDENTITY: ResolvedApiKeyIdentity = { + user: { + id: 42, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Support', + tags: [{ key: 'region', value: 'eu' }], + permissionLevel: 'admin', + }, + renderingId: 17, + allowedOrigins: [], +}; + +describe('issueAgentToken', () => { + it('should mint a JWT verifiable with the auth secret', () => { + const token = issueAgentToken({ identity: IDENTITY, authSecret: AUTH_SECRET }); + + expect(() => jsonwebtoken.verify(token, AUTH_SECRET)).not.toThrow(); + }); + + it('should expire 5 minutes after issuance', () => { + const token = issueAgentToken({ identity: IDENTITY, authSecret: AUTH_SECRET }); + const decoded = jsonwebtoken.decode(token) as { iat: number; exp: number }; + + expect(decoded.exp - decoded.iat).toBe(300); + }); + + it('should include camelCase Caller claims from the resolved identity', () => { + const token = issueAgentToken({ identity: IDENTITY, authSecret: AUTH_SECRET }); + + expect(jsonwebtoken.verify(token, AUTH_SECRET)).toMatchObject({ + id: 42, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Support', + renderingId: 17, + permissionLevel: 'admin', + tags: { region: 'eu' }, + }); + }); + + it('should include snake_case aliases for non-Node agents', () => { + const token = issueAgentToken({ identity: IDENTITY, authSecret: AUTH_SECRET }); + + expect(jsonwebtoken.verify(token, AUTH_SECRET)).toMatchObject({ + first_name: 'Ada', + last_name: 'Lovelace', + rendering_id: 17, + permission_level: 'admin', + }); + }); + + it('should coerce null first/last name to empty strings', () => { + const token = issueAgentToken({ + identity: { ...IDENTITY, user: { ...IDENTITY.user, firstName: null, lastName: null } }, + authSecret: AUTH_SECRET, + }); + + expect(jsonwebtoken.verify(token, AUTH_SECRET)).toMatchObject({ + firstName: '', + lastName: '', + first_name: '', + last_name: '', + }); + }); +}); diff --git a/packages/agent-bff/test/api-key/api-key-authenticator.test.ts b/packages/agent-bff/test/api-key/api-key-authenticator.test.ts new file mode 100644 index 0000000000..55311521a1 --- /dev/null +++ b/packages/agent-bff/test/api-key/api-key-authenticator.test.ts @@ -0,0 +1,245 @@ +import type { ResolvedApiKeyIdentity } from '../../src/api-key/api-key-client'; +import type ApiKeyClient from '../../src/api-key/api-key-client'; + +import { issueAgentToken } from '../../src/api-key/agent-token'; +import createApiKeyAuthenticator from '../../src/api-key/api-key-authenticator'; +import { ApiKeyResolveError } from '../../src/api-key/api-key-client'; +import createResolveCache from '../../src/api-key/resolve-cache'; + +jest.mock('../../src/api-key/agent-token', () => ({ + AGENT_TOKEN_EXPIRES_IN: '5m', + issueAgentToken: jest.fn(() => 'minted-token'), +})); + +const mintMock = issueAgentToken as jest.Mock; + +const AUTH_SECRET = 'auth-secret'; +const KEY_ID = 'a'.repeat(16); +const SECRET = 'b'.repeat(64); +const RAW = `fbff_${KEY_ID}_${SECRET}`; + +const IDENTITY: ResolvedApiKeyIdentity = { + user: { + id: 42, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Support', + tags: [{ key: 'region', value: 'eu' }], + permissionLevel: 'admin', + }, + renderingId: 17, + allowedOrigins: [], +}; + +function buildAuthenticator(resolve: jest.Mock, nowRef: { ms: number }) { + const client = { resolveApiKey: resolve } as unknown as ApiKeyClient; + const cache = createResolveCache({ now: () => nowRef.ms }); + + return createApiKeyAuthenticator({ client, cache, authSecret: AUTH_SECRET }); +} + +describe('api key authenticator', () => { + const nowRef = { ms: 1_000_000 }; + + beforeEach(() => { + nowRef.ms = 1_000_000; + mintMock.mockClear(); + }); + + describe('valid key', () => { + it('should resolve the key and mint an agent token from the identity', async () => { + const resolve = jest.fn(async () => IDENTITY); + const authenticator = buildAuthenticator(resolve, nowRef); + + const result = await authenticator.authenticate(RAW); + + expect(result).toEqual({ agentToken: 'minted-token', identity: IDENTITY }); + expect(mintMock).toHaveBeenCalledWith({ identity: IDENTITY, authSecret: AUTH_SECRET }); + }); + + it('should serve from cache without re-calling the SaaS within 60s', async () => { + const resolve = jest.fn(async () => IDENTITY); + const authenticator = buildAuthenticator(resolve, nowRef); + + await authenticator.authenticate(RAW); + nowRef.ms += 59_000; + await authenticator.authenticate(RAW); + + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('should re-resolve after the 60s positive window', async () => { + const resolve = jest.fn(async () => IDENTITY); + const authenticator = buildAuthenticator(resolve, nowRef); + + await authenticator.authenticate(RAW); + nowRef.ms += 60_000; + await authenticator.authenticate(RAW); + + expect(resolve).toHaveBeenCalledTimes(2); + }); + + it('should mint a fresh token on every request even on a cache hit', async () => { + const resolve = jest.fn(async () => IDENTITY); + const authenticator = buildAuthenticator(resolve, nowRef); + + await authenticator.authenticate(RAW); + await authenticator.authenticate(RAW); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(mintMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('malformed key', () => { + it('should reject with invalid_api_key without calling the SaaS', async () => { + const resolve = jest.fn(async () => IDENTITY); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate('not-a-key')).rejects.toMatchObject({ + type: 'invalid_api_key', + status: 401, + }); + expect(resolve).not.toHaveBeenCalled(); + expect(mintMock).not.toHaveBeenCalled(); + }); + }); + + describe('invalid / revoked / expired / wrong-environment key', () => { + it('should map a SaaS 401 to invalid_api_key and negatively cache it', async () => { + const resolve = jest.fn(async () => { + throw new ApiKeyResolveError({ status: 401 }); + }); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'invalid_api_key', + status: 401, + }); + + nowRef.ms += 9_000; + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'invalid_api_key', + }); + expect(resolve).toHaveBeenCalledTimes(1); + }); + }); + + describe('right keyId with wrong secret', () => { + it('should not hit the positive cache entry of a previously valid key', async () => { + const resolve = jest.fn(async () => IDENTITY); + const authenticator = buildAuthenticator(resolve, nowRef); + const wrongSecretKey = `fbff_${KEY_ID}_${'c'.repeat(64)}`; + + await authenticator.authenticate(RAW); + await authenticator.authenticate(wrongSecretKey); + + expect(resolve).toHaveBeenCalledTimes(2); + expect(resolve).toHaveBeenLastCalledWith({ keyId: KEY_ID, secret: 'c'.repeat(64) }); + }); + }); + + describe('user lost rendering access', () => { + it('should map a SaaS 403 to forest_identity_not_allowed and negatively cache it', async () => { + const resolve = jest.fn(async () => { + throw new ApiKeyResolveError({ status: 403 }); + }); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'forest_identity_not_allowed', + status: 403, + }); + + nowRef.ms += 9_000; + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'forest_identity_not_allowed', + }); + expect(resolve).toHaveBeenCalledTimes(1); + }); + }); + + describe('SaaS unavailable', () => { + it('should map an unreachable resolver to a non-cached 503 with Retry-After 5', async () => { + const resolve = jest.fn(async () => { + throw new ApiKeyResolveError({ unreachable: true }); + }); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'key_resolution_unavailable', + status: 503, + retryAfter: 5, + }); + + await authenticator.authenticate(RAW).catch(() => undefined); + expect(resolve).toHaveBeenCalledTimes(2); + }); + }); + + describe('SaaS rate-limited', () => { + it('should map a SaaS 429 to a non-cached 503 propagating Retry-After', async () => { + const resolve = jest.fn(async () => { + throw new ApiKeyResolveError({ status: 429, retryAfter: 12 }); + }); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'key_resolution_unavailable', + status: 503, + retryAfter: 12, + }); + + await authenticator.authenticate(RAW).catch(() => undefined); + expect(resolve).toHaveBeenCalledTimes(2); + }); + }); + + describe('malformed BFF call (SaaS 400)', () => { + it('should map a SaaS 400 to invalid_request without negatively caching it', async () => { + const resolve = jest.fn(async () => { + throw new ApiKeyResolveError({ status: 400 }); + }); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'invalid_request', + status: 400, + }); + + await authenticator.authenticate(RAW).catch(() => undefined); + expect(resolve).toHaveBeenCalledTimes(2); + }); + }); + + describe('unexpected SaaS status', () => { + it('should map an out-of-taxonomy status to a non-cached 503', async () => { + const resolve = jest.fn(async () => { + throw new ApiKeyResolveError({ status: 500 }); + }); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate(RAW)).rejects.toMatchObject({ + type: 'key_resolution_unavailable', + status: 503, + retryAfter: 5, + }); + + await authenticator.authenticate(RAW).catch(() => undefined); + expect(resolve).toHaveBeenCalledTimes(2); + }); + }); + + describe('non-resolver error', () => { + it('should rethrow an error that is not an ApiKeyResolveError unchanged', async () => { + const boom = new Error('unexpected'); + const resolve = jest.fn(async () => { + throw boom; + }); + const authenticator = buildAuthenticator(resolve, nowRef); + + await expect(authenticator.authenticate(RAW)).rejects.toBe(boom); + }); + }); +}); diff --git a/packages/agent-bff/test/api-key/api-key-client.test.ts b/packages/agent-bff/test/api-key/api-key-client.test.ts new file mode 100644 index 0000000000..dd7074dcf4 --- /dev/null +++ b/packages/agent-bff/test/api-key/api-key-client.test.ts @@ -0,0 +1,161 @@ +import ApiKeyClient from '../../src/api-key/api-key-client'; + +const OPTS = { forestServerUrl: 'https://api.forestadmin.com', envSecret: 'env-secret' }; +const PARSED = { keyId: 'a'.repeat(16), secret: 'b'.repeat(64) }; + +const IDENTITY = { + user: { + id: 1, + email: 'a@b.c', + firstName: 'A', + lastName: 'B', + team: 'T', + tags: [], + permissionLevel: 'admin', + }, + renderingId: 17, + allowedOrigins: ['https://x.com'], +}; + +function fakeResponse( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (key: string) => headers[key.toLowerCase()] ?? null }, + json: async () => body, + } as unknown as Response; +} + +const originalFetch = global.fetch; + +function mockFetch(impl: jest.Mock): void { + global.fetch = impl as unknown as typeof fetch; +} + +describe('ApiKeyClient.resolveApiKey', () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should POST to the resolve endpoint with the env secret header and key body', async () => { + const fetchMock = jest.fn(async () => fakeResponse(200, IDENTITY)); + mockFetch(fetchMock); + + await new ApiKeyClient(OPTS).resolveApiKey(PARSED); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.forestadmin.com/liana/v1/bff-api-keys/resolve', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'forest-secret-key': 'env-secret' }), + body: JSON.stringify({ keyId: PARSED.keyId, secret: PARSED.secret }), + }), + ); + }); + + it('should return the parsed identity on 200', async () => { + mockFetch(jest.fn(async () => fakeResponse(200, IDENTITY))); + + await expect(new ApiKeyClient(OPTS).resolveApiKey(PARSED)).resolves.toEqual(IDENTITY); + }); + + it.each([ + ['missing user', { renderingId: 17, allowedOrigins: [] }], + ['missing renderingId', { user: IDENTITY.user, allowedOrigins: [] }], + ['missing allowedOrigins', { user: IDENTITY.user, renderingId: 17 }], + [ + 'user missing tags array', + { user: { ...IDENTITY.user, tags: undefined }, renderingId: 17, allowedOrigins: [] }, + ], + [ + 'user missing email', + { user: { ...IDENTITY.user, email: undefined }, renderingId: 17, allowedOrigins: [] }, + ], + ['empty object', {}], + ])( + 'should mark the error unreachable when a 200 body is incomplete (%s)', + async (_label, body) => { + mockFetch(jest.fn(async () => fakeResponse(200, body))); + + await expect(new ApiKeyClient(OPTS).resolveApiKey(PARSED)).rejects.toMatchObject({ + name: 'ApiKeyResolveError', + unreachable: true, + }); + }, + ); + + it.each([401, 403, 400])( + 'should throw ApiKeyResolveError carrying the SaaS status %s', + async status => { + mockFetch( + jest.fn(async () => + fakeResponse(status, { errors: [{ name: 'X', meta: { code: 'some_code' } }] }), + ), + ); + + await expect(new ApiKeyClient(OPTS).resolveApiKey(PARSED)).rejects.toMatchObject({ + name: 'ApiKeyResolveError', + status, + code: 'some_code', + }); + }, + ); + + it('should capture the Retry-After header on 429', async () => { + mockFetch(jest.fn(async () => fakeResponse(429, { errors: [] }, { 'retry-after': '7' }))); + + await expect(new ApiKeyClient(OPTS).resolveApiKey(PARSED)).rejects.toMatchObject({ + status: 429, + retryAfter: 7, + }); + }); + + it.each([ + ['HTTP-date form', 'Wed, 21 Oct 2015 07:28:00 GMT'], + ['negative', '-5'], + ['fractional', '3.7'], + ['zero', '0'], + ])('should drop a non-usable Retry-After (%s) rather than emit it', async (_label, header) => { + mockFetch(jest.fn(async () => fakeResponse(429, { errors: [] }, { 'retry-after': header }))); + + await expect(new ApiKeyClient(OPTS).resolveApiKey(PARSED)).rejects.toMatchObject({ + status: 429, + retryAfter: undefined, + }); + }); + + it('should mark the error unreachable when a 200 body is not valid JSON', async () => { + mockFetch( + jest.fn(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + })), + ); + + await expect(new ApiKeyClient(OPTS).resolveApiKey(PARSED)).rejects.toMatchObject({ + name: 'ApiKeyResolveError', + unreachable: true, + }); + }); + + it('should mark the error unreachable when fetch throws', async () => { + mockFetch( + jest.fn(async () => { + throw new Error('network down'); + }), + ); + + await expect(new ApiKeyClient(OPTS).resolveApiKey(PARSED)).rejects.toMatchObject({ + name: 'ApiKeyResolveError', + unreachable: true, + }); + }); +}); diff --git a/packages/agent-bff/test/api-key/api-key-middleware.test.ts b/packages/agent-bff/test/api-key/api-key-middleware.test.ts new file mode 100644 index 0000000000..89c402f09a --- /dev/null +++ b/packages/agent-bff/test/api-key/api-key-middleware.test.ts @@ -0,0 +1,178 @@ +import type { ApiKeyAuthenticator } from '../../src/api-key/api-key-authenticator'; +import type { LoggerLevel } from '../../src/ports/logger-port'; + +import Koa from 'koa'; +import request from 'supertest'; + +import { invalidApiKey, keyResolutionUnavailable } from '../../src/api-key/api-key-error'; +import createApiKeyMiddleware, { BFF_KEY_HEADER } from '../../src/api-key/api-key-middleware'; + +const KEY_ID = 'a'.repeat(16); +const SECRET = 'b'.repeat(64); +const RAW = `fbff_${KEY_ID}_${SECRET}`; + +const IDENTITY = { + user: { + id: 42, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Support', + tags: [{ key: 'region', value: 'eu' }], + permissionLevel: 'admin', + }, + renderingId: 17, + allowedOrigins: ['https://app.example.com'], +}; + +interface LogLine { + level: LoggerLevel; + message: string; + context?: Record; +} + +function buildApp(authenticate: ApiKeyAuthenticator['authenticate']) { + const logs: LogLine[] = []; + + const logger = (level: LoggerLevel, message: string, context?: Record) => { + logs.push({ level, message, context }); + }; + + const app = new Koa(); + app.use(createApiKeyMiddleware({ authenticator: { authenticate }, logger })); + app.use(async ctx => { + ctx.status = 200; + ctx.body = { + agentToken: ctx.state.agentToken ?? null, + identity: ctx.state.apiKeyIdentity ?? null, + }; + }); + + return { app, logs }; +} + +describe('api key middleware', () => { + describe('when the key resolves', () => { + it('should attach the minted token and identity to ctx.state with no-store', async () => { + const authenticate = jest.fn(async () => ({ + agentToken: 'minted-token', + identity: IDENTITY, + })); + const { app } = buildApp(authenticate); + + const response = await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); + + expect(authenticate).toHaveBeenCalledWith(RAW); + expect(response.status).toBe(200); + expect(response.body).toEqual({ agentToken: 'minted-token', identity: IDENTITY }); + expect(response.headers['cache-control']).toBe('no-store'); + }); + + it('should log the rendering id and a key fingerprint, never the raw secret', async () => { + const authenticate = jest.fn(async () => ({ + agentToken: 'minted-token', + identity: IDENTITY, + })); + const { app, logs } = buildApp(authenticate); + + await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); + + expect(logs).toContainEqual( + expect.objectContaining({ + level: 'Info', + context: expect.objectContaining({ renderingId: 17 }), + }), + ); + expect(JSON.stringify(logs)).not.toContain(SECRET); + }); + }); + + describe('when the authenticator rejects', () => { + it('should return 401 invalid_api_key in the nested error shape', async () => { + const authenticate = jest.fn(async () => { + throw invalidApiKey(); + }); + const { app } = buildApp(authenticate); + + const response = await request(app.callback()).get('/').set(BFF_KEY_HEADER, 'bad-key'); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ + error: { type: 'invalid_api_key', status: 401, message: 'Invalid API key' }, + }); + }); + + it('should set Retry-After on a 503 key_resolution_unavailable', async () => { + const authenticate = jest.fn(async () => { + throw keyResolutionUnavailable(5); + }); + const { app } = buildApp(authenticate); + + const response = await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); + + expect(response.status).toBe(503); + expect(response.body.error.type).toBe('key_resolution_unavailable'); + expect(response.headers['retry-after']).toBe('5'); + }); + + it('should return a 500 server_error for an unexpected non-ApiKeyError', async () => { + const authenticate = jest.fn(async () => { + throw new Error('unexpected boom'); + }); + const { app, logs } = buildApp(authenticate); + + const response = await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: { type: 'server_error', status: 500, message: 'API key processing failed' }, + }); + expect(logs).toContainEqual( + expect.objectContaining({ level: 'Error', message: 'BFF API key middleware failure' }), + ); + expect(JSON.stringify(logs)).not.toContain(SECRET); + }); + }); + + describe('when a downstream handler throws after the key is accepted', () => { + it('should let the error propagate instead of rewriting it as an API key failure', async () => { + const authenticate = jest.fn(async () => ({ + agentToken: 'minted-token', + identity: IDENTITY, + })); + const logs: LogLine[] = []; + + const logger = (level: LoggerLevel, message: string, context?: Record) => { + logs.push({ level, message, context }); + }; + + const app = new Koa(); + app.silent = true; + app.use(createApiKeyMiddleware({ authenticator: { authenticate }, logger })); + app.use(async () => { + throw new Error('downstream boom'); + }); + + const response = await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); + + expect(response.status).toBe(500); + expect(response.body).not.toHaveProperty('error.type', 'server_error'); + expect(logs).not.toContainEqual( + expect.objectContaining({ message: 'BFF API key middleware failure' }), + ); + }); + }); + + describe('when no key header is present', () => { + it('should pass through to the next middleware without authenticating', async () => { + const authenticate = jest.fn(); + const { app } = buildApp(authenticate); + + const response = await request(app.callback()).get('/'); + + expect(authenticate).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(response.body).toEqual({ agentToken: null, identity: null }); + }); + }); +}); diff --git a/packages/agent-bff/test/api-key/api-key.test.ts b/packages/agent-bff/test/api-key/api-key.test.ts new file mode 100644 index 0000000000..182d7d2ff6 --- /dev/null +++ b/packages/agent-bff/test/api-key/api-key.test.ts @@ -0,0 +1,50 @@ +import { fingerprintApiKey, hashApiKey, parseApiKey } from '../../src/api-key/api-key'; + +const KEY_ID = 'a'.repeat(16); +const SECRET = 'b'.repeat(64); +const VALID = `fbff_${KEY_ID}_${SECRET}`; + +describe('parseApiKey', () => { + describe('when the key matches fbff__', () => { + it('should split into keyId and secret', () => { + expect(parseApiKey(VALID)).toEqual({ keyId: KEY_ID, secret: SECRET }); + }); + }); + + describe('when the key is malformed', () => { + it.each([ + ['missing prefix', `${KEY_ID}_${SECRET}`], + ['wrong prefix', `fbf_${KEY_ID}_${SECRET}`], + ['keyId too short', `fbff_${'a'.repeat(15)}_${SECRET}`], + ['secret too short', `fbff_${KEY_ID}_${'b'.repeat(63)}`], + ['uppercase hex', `fbff_${'A'.repeat(16)}_${SECRET}`], + ['empty string', ''], + ['extra segment', `${VALID}_extra`], + ])('should return null (%s)', (_label, raw) => { + expect(parseApiKey(raw)).toBeNull(); + }); + }); +}); + +describe('hashApiKey', () => { + it('should be stable for the same keyId and secret', () => { + expect(hashApiKey(KEY_ID, SECRET)).toBe(hashApiKey(KEY_ID, SECRET)); + }); + + it('should differ when the secret changes for the same keyId', () => { + expect(hashApiKey(KEY_ID, SECRET)).not.toBe(hashApiKey(KEY_ID, 'c'.repeat(64))); + }); + + it('should not contain the raw secret', () => { + expect(hashApiKey(KEY_ID, SECRET)).not.toContain(SECRET); + }); +}); + +describe('fingerprintApiKey', () => { + it('should return a 12-char hex fingerprint that excludes the raw secret', () => { + const fingerprint = fingerprintApiKey(VALID); + + expect(fingerprint).toMatch(/^[0-9a-f]{12}$/); + expect(fingerprint).not.toContain(SECRET); + }); +}); diff --git a/packages/agent-bff/test/api-key/resolve-cache.test.ts b/packages/agent-bff/test/api-key/resolve-cache.test.ts new file mode 100644 index 0000000000..cc63670d11 --- /dev/null +++ b/packages/agent-bff/test/api-key/resolve-cache.test.ts @@ -0,0 +1,96 @@ +import type { ResolvedApiKeyIdentity } from '../../src/api-key/api-key-client'; + +import { invalidApiKey } from '../../src/api-key/api-key-error'; +import createResolveCache from '../../src/api-key/resolve-cache'; + +const IDENTITY: ResolvedApiKeyIdentity = { + user: { + id: 42, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Support', + tags: [{ key: 'region', value: 'eu' }], + permissionLevel: 'admin', + }, + renderingId: 17, + allowedOrigins: [], +}; + +describe('resolve cache', () => { + let nowMs: number; + const now = () => nowMs; + + beforeEach(() => { + nowMs = 1_000_000; + }); + + describe('positive entries', () => { + it('should return the identity within the positive TTL', () => { + const cache = createResolveCache({ now, positiveTtlSeconds: 60 }); + cache.setPositive('hash', IDENTITY); + nowMs += 59_000; + + expect(cache.getPositive('hash')).toEqual(IDENTITY); + }); + + it('should expire exactly at the positive TTL', () => { + const cache = createResolveCache({ now, positiveTtlSeconds: 60 }); + cache.setPositive('hash', IDENTITY); + nowMs += 60_000; + + expect(cache.getPositive('hash')).toBeUndefined(); + }); + }); + + describe('negative entries', () => { + it('should return the cached error within the negative TTL', () => { + const cache = createResolveCache({ now, negativeTtlSeconds: 10 }); + const error = invalidApiKey(); + cache.setNegative('hash', error); + nowMs += 9_000; + + expect(cache.getNegative('hash')).toBe(error); + }); + + it('should expire exactly at the negative TTL', () => { + const cache = createResolveCache({ now, negativeTtlSeconds: 10 }); + cache.setNegative('hash', invalidApiKey()); + nowMs += 10_000; + + expect(cache.getNegative('hash')).toBeUndefined(); + }); + }); + + describe('kind isolation', () => { + it('should not expose a positive entry through getNegative', () => { + const cache = createResolveCache({ now }); + cache.setPositive('hash', IDENTITY); + + expect(cache.getNegative('hash')).toBeUndefined(); + }); + }); + + describe('memory bound', () => { + it('should evict the oldest entry once maxEntries is reached', () => { + const cache = createResolveCache({ now, maxEntries: 2 }); + cache.setPositive('a', IDENTITY); + cache.setPositive('b', IDENTITY); + cache.setPositive('c', IDENTITY); + + expect(cache.size()).toBe(2); + expect(cache.getPositive('a')).toBeUndefined(); + expect(cache.getPositive('b')).toEqual(IDENTITY); + expect(cache.getPositive('c')).toEqual(IDENTITY); + }); + + it('should still overwrite an existing key when full', () => { + const cache = createResolveCache({ now, maxEntries: 1 }); + cache.setPositive('a', IDENTITY); + cache.setNegative('a', invalidApiKey()); + + expect(cache.getNegative('a')).toBeDefined(); + expect(cache.getPositive('a')).toBeUndefined(); + }); + }); +});