tests/assistant-proxy.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import type { Context, Next } from 'hono'
import type { MiddlewareKeyVariables } from '../supabase/functions/_backend/utils/hono.ts'
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../supabase/functions/_backend/utils/hono_middleware.ts', () => ({
middlewareAuth: () => async (c: Context<MiddlewareKeyVariables>, next: Next) => {
const authorization = c.req.header('authorization')
if (!['Bearer console-session', 'Bearer api-key', 'Bearer service-session'].includes(authorization ?? ''))
return c.json({ error: 'invalid_jwt' }, 401)
c.set('auth', {
userId: '00000000-0000-4000-8000-000000000001',
authType: authorization === 'Bearer api-key' ? 'apikey' : 'jwt',
jwt: null,
apikey: null,
claims: { sub: '00000000-0000-4000-8000-000000000001', role: authorization === 'Bearer service-session' ? 'service_role' : 'authenticated' },
})
await next()
},
}))
const { app: assistant } = await import('../supabase/functions/_backend/private/assistant.ts')
const app = new Hono<MiddlewareKeyVariables>()
app.route('/private/assistant', assistant)
app.onError((error, c) => {
if (error instanceof HTTPException)
return c.json(error.cause ?? { error: error.message }, error.status)
throw error
})
const headers = { authorization: 'Bearer console-session', 'x-chatgpt-token': 'chatgpt-access-token', 'content-type': 'application/json', origin: 'https://console.capgo.app' }
const payload = { model: 'test-model', input: [{ role: 'user', content: 'Hello' }], store: false, stream: true }
const fetchMock = vi.fn<typeof fetch>()
describe('authenticated assistant proxy', () => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => vi.unstubAllGlobals())
it.each([undefined, 'Bearer invalid', 'Bearer api-key', 'Bearer service-session'])('rejects unauthorized caller %s before contacting OpenAI', async (authorization) => {
const requestHeaders = new Headers(headers)
if (authorization)
requestHeaders.set('authorization', authorization)
else
requestHeaders.delete('authorization')
const response = await app.request('/private/assistant/models', { headers: requestHeaders })
expect(response.status).toBe(401)
expect(fetchMock).not.toHaveBeenCalled()
})
it.each(['', 'has spaces', 'x'.repeat(16_385)])('rejects missing or invalid ChatGPT credentials', async (token) => {
const response = await app.request('/private/assistant/models', { headers: { ...headers, 'x-chatgpt-token': token } })
expect(response.status).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})
it('only forwards the ChatGPT credential to the fixed OpenAI models URL', async () => {
fetchMock.mockResolvedValueOnce(Response.json({ models: [{ slug: 'test-model' }] }, {
headers: { 'set-cookie': 'upstream=secret', 'cache-control': 'public, max-age=3600', 'access-control-allow-origin': 'https://other.example' },
}))
const response = await app.request('/private/assistant/models?url=https://other.example', {
headers: { ...headers, cookie: 'private-cookie', 'x-api-key': 'private-capgo-key' },
})
expect(fetchMock).toHaveBeenCalledWith('https://api.openai.com/v1/models', expect.objectContaining({
method: 'GET', headers: { authorization: 'Bearer chatgpt-access-token', accept: 'application/json' }, redirect: 'manual',
}))
await expect(response.json()).resolves.toEqual({ models: [{ slug: 'test-model' }] })
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('set-cookie')).toBeNull()
expect(response.headers.get('access-control-allow-origin')).toBe('https://console.capgo.app')
})
it('streams response chunks without waiting for completion', async () => {
let controller!: ReadableStreamDefaultController<Uint8Array>
const upstream = new ReadableStream<Uint8Array>({ start(streamController) { controller = streamController } })
fetchMock.mockResolvedValueOnce(new Response(upstream, { headers: { 'content-type': 'text/event-stream' } }))
const response = await app.request('/private/assistant/responses', { method: 'POST', headers, body: JSON.stringify(payload) })
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('text/event-stream')
expect(fetchMock).toHaveBeenCalledWith('https://api.openai.com/v1/responses', expect.objectContaining({
method: 'POST', body: JSON.stringify(payload), redirect: 'manual',
headers: { authorization: 'Bearer chatgpt-access-token', accept: 'text/event-stream', 'content-type': 'application/json' },
}))
const reader = response.body!.getReader()
const chunk = 'data: {"type":"response.created"}\n\n'
controller.enqueue(new TextEncoder().encode(chunk))
const first = await reader.read()
expect(new TextDecoder().decode(first.value)).toBe(chunk)
expect(first.done).toBe(false)
controller.close()
expect((await reader.read()).done).toBe(true)
})
it.each([401, 403, 429, 500])('preserves upstream error status %s and retry headers', async (status) => {
const error = { error: { code: status === 429 ? 'subscription_sharing_usage_limit_exceeded' : 'upstream_error' } }
fetchMock.mockResolvedValueOnce(Response.json(error, { status, headers: { 'retry-after': '30' } }))
const response = await app.request('/private/assistant/responses', { method: 'POST', headers, body: JSON.stringify(payload) })
expect(response.status).toBe(status)
expect(response.headers.get('retry-after')).toBe('30')
expect(response.headers.get('cache-control')).toBe('no-store')
await expect(response.json()).resolves.toEqual(error)
})
it.each([
{ body: '{', status: 400 },
{ body: JSON.stringify({ ...payload, store: true }), status: 400 },
{ body: JSON.stringify({ ...payload, stream: false }), status: 400 },
{ body: 'null', status: 400 },
{ body: '[]', status: 400 },
{ body: 'x'.repeat(1024 * 1024 + 1), status: 413 },
])('rejects invalid or oversized response payloads ($status)', async ({ body, status }) => {
const response = await app.request('/private/assistant/responses', { method: 'POST', headers, body })
expect(response.status).toBe(status)
expect(fetchMock).not.toHaveBeenCalled()
})
it.each(['text/plain', 'application/jsonp', 'application/json-seq', 'application/json-patch+json'])('rejects non-JSON request bodies (%s)', async (contentType) => {
const response = await app.request('/private/assistant/responses', {
method: 'POST', headers: { ...headers, 'content-type': contentType }, body: JSON.stringify(payload),
})
expect(response.status).toBe(415)
expect(fetchMock).not.toHaveBeenCalled()
})
it.each(['application/json; charset=utf-8', 'Application/JSON ; charset=UTF-8'])('accepts JSON media types with parameters (%s)', async (contentType) => {
fetchMock.mockResolvedValueOnce(new Response('data: {}\n\n', { headers: { 'content-type': 'text/event-stream' } }))
const response = await app.request('/private/assistant/responses', {
method: 'POST', headers: { ...headers, 'content-type': contentType }, body: JSON.stringify(payload),
})
expect(response.status).toBe(200)
expect(fetchMock).toHaveBeenCalledOnce()
})
it.each([
{ path: '/private/assistant/models', method: 'POST' },
{ path: '/private/assistant/responses', method: 'GET' },
{ path: '/private/assistant/token', method: 'POST' },
{ path: '/private/assistant/responses/extra', method: 'POST' },
])('does not proxy unsupported routes ($method $path)', async ({ path, method }) => {
const response = await app.request(path, { method, headers })
expect(response.status).toBe(404)
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not follow redirects or expose upstream cookies', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: 'https://other.example', 'set-cookie': 'secret' } }))
const response = await app.request('/private/assistant/models', { headers })
expect(response.status).toBe(502)
expect(fetchMock).toHaveBeenCalledOnce()
expect(response.headers.get('location')).toBeNull()
expect(response.headers.get('set-cookie')).toBeNull()
})
it('returns a sanitized error when the upstream connection fails', async () => {
fetchMock.mockRejectedValueOnce(new Error('contains-chatgpt-access-token'))
const response = await app.request('/private/assistant/models', { headers })
expect(response.status).toBe(502)
expect(await response.text()).not.toContain('chatgpt-access-token')
})
it('propagates client cancellation to the upstream request', async () => {
fetchMock.mockResolvedValueOnce(Response.json({ models: [] }))
const controller = new AbortController()
const request = new Request('https://api.capgo.test/private/assistant/models', { headers, signal: controller.signal })
await app.request(request)
const signal = fetchMock.mock.calls[0][1]?.signal
expect(signal?.aborted).toBe(false)
controller.abort()
expect(signal?.aborted).toBe(true)
})
})