d3ro-voice/server/supabase/functions/realtime-token/handler.test.ts

234 lines
9.2 KiB
TypeScript

import {
type ClientSecretResult,
createRealtimeTokenHandler,
type RealtimeProviderPort,
type RealtimeQuotaConsumeResult,
type RealtimeQuotaPort,
type RealtimeQuotaSnapshot,
} from './handler.ts'
import type { RealtimeSessionConfig, RealtimeTier } from './policy.ts'
import { createOpenAiRealtimeProvider } from './openai-provider.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function assertEquals(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual)
const e = JSON.stringify(expected)
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
}
const LEAKY_BODY = JSON.stringify({
error: {
message: 'Rate limit reached for org-SECRETORG123 on requests. Incorrect API key provided: sk-proj-****abcd',
},
})
interface Harness {
handler: (req: Request) => Promise<Response>
consumeCalls: Array<{ userId: string; tier: RealtimeTier }>
mintCalls: RealtimeSessionConfig[]
logs: Array<{ message: string; meta: Record<string, unknown> }>
}
interface HarnessOptions {
tier?: RealtimeTier
checkAllowed?: boolean
consumeAllowed?: boolean
configured?: boolean
mint?: () => Promise<ClientSecretResult>
provider?: RealtimeProviderPort
consumeThrows?: Error
}
function harness(options: HarnessOptions = {}): Harness {
const consumeCalls: Harness['consumeCalls'] = []
const mintCalls: RealtimeSessionConfig[] = []
const logs: Harness['logs'] = []
const tier = options.tier ?? 'pro'
const quota: RealtimeQuotaPort = {
check(): Promise<RealtimeQuotaSnapshot> {
return Promise.resolve({
allowed: options.checkAllowed ?? true,
current: 3,
limit: 30,
period: 'daily',
tier,
overageCredits: 0,
})
},
consume(userId: string, consumeTier: RealtimeTier): Promise<RealtimeQuotaConsumeResult> {
consumeCalls.push({ userId, tier: consumeTier })
if (options.consumeThrows) return Promise.reject(options.consumeThrows)
return Promise.resolve({
allowed: options.consumeAllowed ?? true,
current: 4,
limit: 30,
overageCredits: 0,
})
},
}
const provider: RealtimeProviderPort = options.provider ?? {
isConfigured: () => options.configured ?? true,
mintClientSecret(_userId: string, session: RealtimeSessionConfig): Promise<ClientSecretResult> {
mintCalls.push(session)
return options.mint?.() ??
Promise.resolve({ ok: true, data: { value: 'ek_test', expires_at: 123 } })
},
}
const handler = createRealtimeTokenHandler({
authenticate: () => Promise.resolve({ ok: true, user: { id: 'user-1' } }),
quota,
provider,
logger: { error: (message, meta) => logs.push({ message, meta }) },
})
return { handler, consumeCalls, mintCalls, logs }
}
function post(body: unknown = {}): Request {
return new Request('http://localhost/realtime-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
Deno.test('missing provider key returns 503 without consuming the session quota', async () => {
const h = harness({ configured: false })
const res = await h.handler(post())
assertEquals(res.status, 503, 'status')
assertEquals((await res.json()).error, 'not_configured', 'error code')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
assertEquals(h.mintCalls.length, 0, 'provider must not be called')
})
Deno.test('upstream 4xx/5xx returns a fixed code, keeps quota, and never leaks the provider body', async () => {
for (const status of [401, 429, 500, 503]) {
const fetchImpl: typeof fetch = () => Promise.resolve(new Response(LEAKY_BODY, { status }))
const h = harness({
provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }),
})
const res = await h.handler(post())
const text = await res.text()
assertEquals(res.status, 502, `status for upstream ${status}`)
assertEquals(JSON.parse(text), { error: 'provider_request_failed' }, 'fixed error body')
assert(!text.includes('org-SECRETORG123'), 'org id leaked')
assert(!text.includes('sk-proj'), 'key hint leaked')
assertEquals(h.consumeCalls.length, 0, `quota consumed on upstream ${status}`)
assertEquals(h.logs[0]?.meta, { reason: 'upstream_status', status }, 'status is logged server-side')
assert(!JSON.stringify(h.logs).includes('SECRETORG'), 'provider body must not reach logs either')
}
})
Deno.test('network failure returns 502 without consuming quota', async () => {
const fetchImpl: typeof fetch = () => Promise.reject(new TypeError('dns failure for api.openai.com'))
const h = harness({
provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }),
})
const res = await h.handler(post())
assertEquals(res.status, 502, 'status')
assertEquals(await res.json(), { error: 'provider_request_failed' }, 'body')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
})
Deno.test('non-object upstream success body returns provider_invalid_response without consuming quota', async () => {
const fetchImpl: typeof fetch = () => Promise.resolve(new Response('not json', { status: 200 }))
const h = harness({
provider: createOpenAiRealtimeProvider({ readApiKey: () => 'sk-test', fetchImpl }),
})
const res = await h.handler(post())
assertEquals(res.status, 502, 'status')
assertEquals(await res.json(), { error: 'provider_invalid_response' }, 'body')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
})
Deno.test('successful mint consumes exactly one session and returns the secret with model and tier', async () => {
const h = harness({ tier: 'pro' })
const res = await h.handler(post({ voice: 'cedar', instructions: 'be brief' }))
assertEquals(res.status, 200, 'status')
assertEquals(
await res.json(),
{ value: 'ek_test', expires_at: 123, model: 'gpt-realtime-2.1-mini', tier: 'pro' },
'body',
)
assertEquals(h.consumeCalls, [{ userId: 'user-1', tier: 'pro' }], 'consume once')
assertEquals(h.mintCalls[0], {
type: 'realtime',
model: 'gpt-realtime-2.1-mini',
instructions: 'be brief',
audio: { output: { voice: 'cedar' } },
}, 'session config')
})
Deno.test('a consume race lost after minting returns 429 and discards the secret', async () => {
const h = harness({ consumeAllowed: false })
const res = await h.handler(post())
const text = await res.text()
assertEquals(res.status, 429, 'status')
assertEquals(JSON.parse(text).error, 'quota_exceeded', 'error code')
assert(!text.includes('ek_test'), 'minted secret must not be returned when quota is denied')
})
Deno.test('tier and quota denials happen before the provider is called', async () => {
const free = harness({ tier: 'free' })
const freeRes = await free.handler(post())
assertEquals(freeRes.status, 403, 'free status')
assertEquals((await freeRes.json()).error, 'tier_not_allowed', 'free error')
const wrongModel = harness({ tier: 'pro' })
const wrongRes = await wrongModel.handler(post({ model: 'gpt-realtime-2.1' }))
assertEquals(wrongRes.status, 403, 'model status')
assertEquals(await wrongRes.json(), {
error: 'model_not_allowed',
tier: 'pro',
requested: 'gpt-realtime-2.1',
allowed: ['gpt-realtime-2.1-mini'],
}, 'model body')
const exhausted = harness({ checkAllowed: false })
const exhaustedRes = await exhausted.handler(post())
assertEquals(exhaustedRes.status, 429, 'exhausted status')
await exhaustedRes.body?.cancel()
for (const h of [free, wrongModel, exhausted]) {
assertEquals(h.mintCalls.length, 0, 'provider must not be called')
assertEquals(h.consumeCalls.length, 0, 'quota must not be consumed')
}
})
Deno.test('unexpected errors return a generic internal_error without the exception message', async () => {
const h = harness({ consumeThrows: new Error('Failed to consume quota: relation daily_usage secret detail') })
const res = await h.handler(post())
const text = await res.text()
assertEquals(res.status, 500, 'status')
assertEquals(JSON.parse(text), { error: 'internal_error' }, 'body')
assert(!text.includes('secret detail'), 'exception message leaked')
})
Deno.test('auth rejection response is returned as-is and non-POST is 405', async () => {
const handler = createRealtimeTokenHandler({
authenticate: () =>
Promise.resolve({ ok: false, response: new Response('{"error":"Invalid auth token"}', { status: 401 }) }),
quota: {
check: () => Promise.reject(new Error('must not be called')),
consume: () => Promise.reject(new Error('must not be called')),
},
provider: { isConfigured: () => true, mintClientSecret: () => Promise.reject(new Error('unused')) },
logger: { error: () => undefined },
})
const res = await handler(post())
assertEquals(res.status, 401, 'auth status')
await res.body?.cancel()
const getRes = await handler(new Request('http://localhost/realtime-token', { method: 'GET' }))
assertEquals(getRes.status, 405, 'method status')
await getRes.body?.cancel()
const preflight = await handler(new Request('http://localhost/realtime-token', { method: 'OPTIONS' }))
assertEquals(preflight.status, 200, 'preflight status')
await preflight.body?.cancel()
})