72 lines
5.2 KiB
TypeScript
72 lines
5.2 KiB
TypeScript
/** @jest-environment node */
|
|
import { EventEmitter } from 'node:events'
|
|
import { lookup } from 'node:dns/promises'
|
|
import { request as httpsRequest } from 'node:https'
|
|
import { requestAiModelOperation, requestAiModelOutput } from '@/lib/ai-model-http'
|
|
|
|
jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }))
|
|
jest.mock('node:https', () => ({ request: jest.fn() }))
|
|
jest.mock('@/lib/ai-model-access', () => ({ AiModelError: class extends Error { constructor(message: string, public status = 400, public code = 'bad_request') { super(message) } } }))
|
|
let sent: { url: string; options: any }[]
|
|
beforeEach(() => {
|
|
jest.clearAllMocks(); sent = []
|
|
;(lookup as jest.Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
|
|
;(httpsRequest as jest.Mock).mockImplementation((url, options, callback) => {
|
|
sent.push({ url: String(url), options })
|
|
const req = new EventEmitter() as any
|
|
req.write = () => true; req.destroy = jest.fn()
|
|
req.end = () => queueMicrotask(() => { const response = new EventEmitter() as any; response.statusCode = 200; response.headers = {}; response.destroy = jest.fn(); callback(response); response.emit('data', Buffer.from('{}')); response.emit('end') })
|
|
return req
|
|
})
|
|
})
|
|
|
|
test('retains configured /v1 prefix for standard operations and roots the separate extension path', async () => {
|
|
await requestAiModelOperation('https://models.example.com/v1', 'synthetic-secret', '/v1/messages', '{}', 'request-1', { 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-1' })
|
|
await requestAiModelOperation('https://models.example.com/v1', 'synthetic-secret', '/extensions/v1/tasks/task-1/result', undefined, undefined, {})
|
|
expect(sent.map(item => item.url)).toEqual(['https://models.example.com/v1/messages', 'https://models.example.com/extensions/v1/tasks/task-1/result'])
|
|
expect(sent[0].options.headers).toMatchObject({ Authorization: 'Bearer synthetic-secret', 'Idempotency-Key': 'request-1', 'Anthropic-Version': '2023-06-01', 'X-Relay-Route-Option': 'route-1' })
|
|
expect(sent[1].options.method).toBe('GET')
|
|
expect(sent[1].options.headers['Idempotency-Key']).toBeUndefined()
|
|
})
|
|
|
|
test.each(['/v1/unknown', '//169.254.169.254/metadata', '/extensions/v1/tasks/../../secret/result', '/extensions/v1/tasks/task-1/result?url=private'])('rejects unsupported or injected operation %s before DNS', async path => {
|
|
await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', path, '{}', 'request-1', {})).rejects.toMatchObject({ code: 'protocol_unsupported' })
|
|
expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test.each([{ Authorization: 'Bearer attacker' }, { Host: 'localhost' }, { 'X-Relay-Route-Option': 'route\r\ninjected' }])('rejects arbitrary headers before transport', async headers => {
|
|
await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', '/v1/messages', '{}', 'request-1', headers as any)).rejects.toMatchObject({ code: 'protocol_invalid' })
|
|
expect(httpsRequest).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test.each(['https://attacker.example/api/external/assets/file', 'http://models.example.com/api/external/assets/file', 'https://models.example.com/admin', 'https://models.example.com/api/external/assets/../admin', 'https://user:pass@models.example.com/api/external/assets/file'])('rejects generated asset target %s without disclosing credentials', async url => {
|
|
await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', url)).rejects.toMatchObject({ code: 'output_invalid' })
|
|
expect(httpsRequest).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('same-origin assets keep fixed DNS and fresh authorization checks', async () => {
|
|
const guard = jest.fn()
|
|
await requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(216)}`, guard)
|
|
expect(guard).toHaveBeenCalledTimes(1)
|
|
expect(sent[0].options.headers.Authorization).toBe('Bearer secret')
|
|
const callback = jest.fn(); sent[0].options.lookup('models.example.com', {}, callback)
|
|
expect(callback).toHaveBeenCalledWith(null, '93.184.216.34', 4)
|
|
})
|
|
|
|
test('revoked user creates zero generation or asset sockets', async () => {
|
|
const guard = async () => { throw new Error('revoked') }
|
|
await expect(requestAiModelOperation('https://models.example.com/v1', 'secret', '/extensions/v1/tasks', '{}', 'one', {}, guard)).rejects.toThrow('revoked')
|
|
await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(32)}`, guard)).rejects.toThrow('revoked')
|
|
expect(httpsRequest).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test.each([32, 216, 8192])('accepts a same-origin encrypted relay asset token of length %i', async length => {
|
|
await requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(length)}`)
|
|
expect(httpsRequest).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test.each([31, 8193])('rejects relay asset token length %i outside its published contract', async length => {
|
|
await expect(requestAiModelOutput('https://models.example.com/v1', 'secret', `https://models.example.com/api/external/assets/${'a'.repeat(length)}`)).rejects.toMatchObject({ code: 'output_invalid' })
|
|
expect(lookup).not.toHaveBeenCalled(); expect(httpsRequest).not.toHaveBeenCalled()
|
|
})
|