Repository navigation
Expand file tree
/
Copy pathhttp.spec.ts
More file actions
98 lines (93 loc) · 6.71 KB
/
Copy pathhttp.spec.ts
File metadata and controls
98 lines (93 loc) · 6.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import { expect, it } from 'vitest'
import { z } from 'zod'
import { registerHttp } from '../src/app/http.ts'
import { PromptConflict } from '../src/app/prompts.ts'
import { SettingsConflict } from '../src/app/settings.ts'
import type { HttpHost } from '../src/app/http.ts'
import { SettingsSchema } from '../src/policy.ts'
import { SetupError } from '../src/setup.ts'
type Handler = (req: unknown, res: unknown) => Promise<void>
type Headers = Record<string, string | undefined>
/** Mount management HTTP routes on a fake web server and return a request helper that includes default panel headers. */
function serve(runtime: Partial<Record<keyof HttpHost, unknown>>, host = '127.0.0.1') {
let handler: Handler = async () => {}
const ctx = { webServer: { host, port: 3082, register: (route: { handler: Handler }) => { handler = route.handler; return () => {} } } }
registerHttp(ctx as never, { ready: Promise.resolve(), ...runtime } as unknown as HttpHost)
return async (method: string, path: string, body?: unknown, headers: Headers = {}) => {
let status = 0, text = ''
const chunks = body === undefined ? [] : [Buffer.from(typeof body === 'string' ? body : JSON.stringify(body))]
const sent = Object.fromEntries(Object.entries({ host: '127.0.0.1:3082', 'x-orchestrator-request': '1', ...headers }).filter(([,
value]) => value !== undefined))
const req = { method, url: `/orchestrator/api/${path}`, headers: sent, async *[Symbol.asyncIterator]() { yield* chunks } }
await handler(req, { writeHead: (code: number) => { status = code }, end: (data: string) => { text = data } })
return { status, body: JSON.parse(text) as Record<string, unknown> }
}
}
it('answers each kind of failure with its own status', async () => {
const saves: unknown[] = []
const call = serve({
store: { tickets: { get: () => undefined }, blockRevision: () => 0 },
sources: { instances: () => [{ id: 'github' }] },
settings: { save: async (...args: unknown[]) => {
saves.push(args)
if (args[1] === 'stale') throw new SettingsConflict('Settings changed. Reload before saving.')
if (args[1] === 'invalid') z.object({ maxWorkers: z.number() }).parse({})
if (args[1] === 'refused') throw new SetupError('agentCliUnset')
if (args[1] === 'broken') throw new Error('disk full')
return { saved: true, revision: 1, restarting: false }
} },
})
expect(await call('PUT', 'settings/executor', { value: 'stale', revision: 0 })).toMatchObject({ status: 409 })
expect(await call('PUT', 'settings/executor', { value: 'invalid', revision: 0 })).toMatchObject({ status: 400, body: { issues: [{ path: 'maxWorkers' }] } })
expect(await call('PUT', 'settings/source:github', { value: 'refused', revision: 0 })).toMatchObject({ status: 400, body: { setup: { code: 'agentCliUnset', place: 'github' } } })
expect(await call('PUT', 'settings/executor', { value: 'broken', revision: 0 })).toEqual({ status: 500, body: { error: 'disk full' } })
// Block names are validated before calling settings.save: reject unknown blocks or non-existent Sources.
expect(await call('PUT', 'settings/pause', { value: {}, revision: 0 })).toMatchObject({ status: 400 })
expect(await call('PUT', 'settings/source:feishu', { value: {}, revision: 0 })).toEqual({ status: 404, body: { error: 'Source not found' } })
expect(saves).toHaveLength(4)
expect(await call('POST', 'tickets/t9/retry')).toEqual({ status: 404, body: { error: 'Ticket not found' } })
expect(await call('GET', 'nothing/here')).toEqual({ status: 404, body: { error: 'Unknown operation' } })
expect(await call('POST', 'executor/pause', 'not json')).toEqual({ status: 400, body: { error: 'The request body is not JSON' } })
})
it('decodes path parameters and parses each route\'s query', async () => {
const seen: unknown[] = []
const call = serve({
store: { settings: () => SettingsSchema.parse({}),
read: { decisions: (filter: unknown, offset: number, limit: number) => { seen.push({ filter, offset, limit }); return { items: [], more: false } } } },
links: () => () => ({ url: '', label: { zh: '', en: '' } }),
engine: { unsubscribe: (key: string) => { seen.push(key); return true } },
sessions: { delivery: async () => 'injected' },
})
expect(await call('POST', `threads/${encodeURIComponent('feishu:oc_1:om/root')}/unsubscribe`)).toMatchObject({ status: 200, body: { context_delivery: 'injected' } })
// Invalid offsets default to 0, and limits above the route maximum are clamped.
expect(await call('GET', 'decisions?kind=accept%2Creopen&limit=500&offset=x&window=24h')).toEqual({ status: 200, body: { items: [], next_offset: null } })
expect(seen).toEqual(['feishu:oc_1:om/root', { filter: { kinds: ['accept', 'reopen'], since: expect.any(String) }, offset: 0, limit: 200 }])
})
it('answers only a same-origin request from the panel on the loopback address', async () => {
const call = serve({ sessions: { delivery: async () => 'injected' }, engine: { unsubscribe: () => true } })
const post = (headers: Headers) => call('POST', 'threads/github%3A1/unsubscribe', undefined, headers)
for (const headers of [{ 'x-orchestrator-request': undefined }, { host: 'evil.example:3082' }, { host: '127.0.0.1:9999' },
{ origin: 'http://evil.example' }, { host: 'localhost:3082', origin: 'http://127.0.0.1:3082' }]) {
expect(await post(headers), JSON.stringify(headers)).toEqual({ status: 403,
body: { error: 'Same-origin management request required' } })
}
expect(await post({ host: 'localhost:3082', origin: 'http://localhost:3082' })).toMatchObject({ status: 200 })
// Management routes require a loopback Web server.
expect(() => serve({}, '0.0.0.0')).toThrow('Orchestrator management requires a loopback Web server')
})
it('answers a prompt save that lost a race with 409, and a settings body over 128 KiB with 400', async () => {
const saves: unknown[] = []
const call = serve({
machine: { promptMaxChars: { get: () => 100_000 } },
store: { atomic: (work: () => unknown) => work(), blockRevision: () => 0 },
prompts: { save: () => { throw new PromptConflict('Prompt version changed to 3; compare the latest version before saving') } },
sessions: { notify: () => {}, delivery: async () => 'injected' },
settings: { save: async (...args: unknown[]) => { saves.push(args); return { saved: true, revision: 1, restarting: false } } },
})
expect(await call('PUT', 'prompts', { config: {}, baseRevision: 2 }))
.toEqual({ status: 409, body: { error: 'Prompt version changed to 3; compare the latest version before saving' } })
const value = { prepare: 'x'.repeat(128 * 1024) }
expect(await call('PUT', 'settings/executor', { value, revision: 0 })).toEqual({ status: 400,
body: { error: `Request exceeds ${128 * 1024} bytes` } })
expect(saves).toEqual([])
})