import { mkdtempSync, rmSync } from "node:fs"; import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { BotApiManager } from "./bot-api.ts"; import { BOT_API_DEFAULT_WAIT_MS, createBotApiHost, type BotApiHostDeps, type BotApiTurnOptions } from "./bot-api-host.ts"; import type { RequestAuth } from "./request-auth.ts"; import type { Message } from "./store.ts"; import { createWebhookIngressHandler } from "./webhook-ingress.ts"; import type { WebhookManager } from "./webhooks.ts"; const dirs: string[] = []; const servers: Server[] = []; afterEach(async () => { for (const server of servers.splice(0)) await new Promise((resolve) => server.close(resolve)); for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); type Task = { threadId: string; title: string; busy: boolean; botApi?: true; approvalMode?: string }; /** The slice of the store the host reads and writes, in memory. */ function fakeStore() { const bots = new Map([["b1", { id: "b1", name: "Scout", tasks: [] }]]); const messages = new Map(); let ids = 0; const listeners: Array<(change: { type: string; botId: string }) => void> = []; const store = { bot: (id: string) => bots.get(id) as never, taskByThread: (botId: string, threadId: string) => bots.get(botId)?.tasks.find((task) => task.threadId === threadId) as never, createTask: (botId: string, title?: string, _activate?: boolean, _project?: string, _openedBy?: unknown, approvalMode?: string) => { const bot = bots.get(botId); if (!bot) return null; const task: Task = { threadId: `t${++ids}`, title: title ?? "New thread", busy: false, ...(approvalMode ? { approvalMode } : {}) }; bot.tasks.push(task); messages.set(task.threadId, []); return task as never; }, patchTask: (botId: string, threadId: string, patch: Partial) => { const task = bots.get(botId)?.tasks.find((candidate) => candidate.threadId === threadId); if (task) Object.assign(task, patch); return (task ?? null) as never; }, appendMessage: (threadId: string, message: Omit) => { const line = { ...message, id: `m${++ids}`, at: Date.now() } as Message; messages.set(threadId, [...(messages.get(threadId) ?? []), line]); return line; }, messagesFor: (threadId: string) => messages.get(threadId) ?? [], onChange: (listener: (change: { type: string; botId: string }) => void) => { listeners.push(listener); return () => {}; }, }; return { store: store as unknown as BotApiHostDeps["store"], bots, messages, deleted: (botId: string) => { bots.delete(botId); for (const listener of listeners) listener({ type: "bot.deleted", botId }); } }; } type Fake = ReturnType; /** What the engine does with the turn the API started. */ type Script = (fake: Fake, botId: string, prompt: string, opts: BotApiTurnOptions) => void; const replies = (text: string): Script => (fake, _botId, _prompt, opts) => { setTimeout(() => { fake.store.appendMessage(opts.threadId, { role: "bot", kind: "text", text }); opts.onSettled({ ok: true, text }); }, 30); }; async function harness(script: Script, options: { waitMs?: number; full?: boolean; included?: () => boolean; noConfine?: boolean } = {}) { const dir = mkdtempSync(join(tmpdir(), "omb-bot-api-host-")); dirs.push(dir); const fake = fakeStore(); const turns: Array<{ botId: string; prompt: string; opts: BotApiTurnOptions }> = []; const recorded: unknown[] = []; const manager = new BotApiManager({ file: join(dir, "bot-api.json") }); const host = createBotApiHost({ store: fake.store, manager, waitMs: options.waitMs ?? 5_000, startTurn: async (botId, prompt, opts) => { turns.push({ botId, prompt, opts }); const task = fake.bots.get(botId)!.tasks.find((candidate) => candidate.threadId === opts.threadId)!; task.busy = true; const settle = opts.onSettled; opts.onSettled = (outcome) => { task.busy = false; settle(outcome); }; script(fake, botId, prompt, opts); }, threadBusy: (botId, threadId) => fake.bots.get(botId)?.tasks.find((task) => task.threadId === threadId)?.busy === true, canStart: () => !options.full, canConfine: () => !options.noConfine, discard: (botId, threadId) => { const bot = fake.bots.get(botId)!; bot.tasks = bot.tasks.filter((task) => task.threadId !== threadId); }, ...(options.included ? { included: options.included } : {}), hooksBaseUrl: () => "https://bots.example.test", manageRefusal: (auth) => (auth.scopes.includes("admin") ? null : "Only people who can edit Scout can change this."), record: (_auth, row) => recorded.push(row), }); const webhooks = { authorize: () => false, recordUnauthorized: () => null } as unknown as WebhookManager; const handle = createWebhookIngressHandler(webhooks, undefined, host.door); const server = createServer((req, res) => { void handle(req, res); }); servers.push(server); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = (server.address() as { port: number }).port; const base = `http://127.0.0.1:${port}`; const call = async (botId: string, key: string, body: unknown, init: { method?: string; query?: string; contentType?: string } = {}) => { const response = await fetch(`${base}/hooks/api/bots/${botId}/messages${init.query ?? ""}`, { method: init.method ?? "POST", headers: { authorization: `Bearer ${key}`, "content-type": init.contentType ?? "application/json" }, ...(init.method === "GET" ? {} : { body: typeof body === "string" ? body : JSON.stringify(body) }), }); return { status: response.status, body: await response.json() as Record }; }; const auth = (admin: boolean): RequestAuth => ({ kind: "loopback", scopes: admin ? ["admin", "client"] : ["client"] }); /** The app's own route, called as index.ts's table would. */ const app = async (method: string, path: string, body?: unknown, admin = true) => { let answered: { status: number; body: Record } | undefined; const res = { headersSent: false, writableEnded: false } as never; const out = await host.routes({ req: {} as never, res, url: new URL(`http://x${path}`), path, method, auth: auth(admin), json: ((_res: unknown, status: number, value: Record) => { answered = { status, body: value }; }) as never, readBody: (async () => body) as never, }); return { out, ...answered! }; }; return { fake, host, manager, turns, recorded, base, call, app }; } describe("the Bot API door on the webhook receiver", () => { it("answers a message with the bot's reply in a thread marked as the API's, and tells the bot where it came from", async () => { const h = await harness(replies("Hello from Scout")); const on = await h.app("POST", "/api/bots/b1/bot-api", { enabled: true }); expect(on.status).toBe(200); const key = on.body.key as string; expect(on.body.url).toBe("https://bots.example.test/hooks/api/bots/b1/messages"); const first = await h.call("b1", key, { message: "Who are you?" }); expect(first.status).toBe(200); expect(first.body).toEqual({ reply: "Hello from Scout", thread: expect.any(String) }); const threadId = first.body.thread as string; expect(h.fake.bots.get("b1")!.tasks).toEqual([{ threadId, title: "Who are you?", busy: false, approvalMode: "ask", botApi: true, outside: true }]); const [turn] = h.turns; expect(turn!.opts).toMatchObject({ threadId, automationSource: "api", unattended: true }); expect(turn!.opts.userMessage).toMatchObject({ role: "user", text: "Who are you?", via: "api" }); expect(turn!.prompt).toContain("[UNTRUSTED BOT API MESSAGE]"); expect(turn!.prompt).toContain("Who are you?"); // The same thread goes on; a thread the person opened does not take outside calls. const second = await h.call("b1", key, { message: "And more?", thread: threadId }); expect(second.status).toBe(200); expect(second.body.thread).toBe(threadId); expect(h.fake.messages.get(threadId)!.filter((line) => line.role === "user")).toHaveLength(2); const own = h.fake.store.createTask("b1", "Mine")!; expect((await h.call("b1", key, { message: "Hi", thread: own.threadId })).status).toBe(404); expect((await h.call("b1", key, { message: "Hi", thread: "nope" })).status).toBe(404); const status = await h.app("GET", "/api/bots/b1/bot-api", undefined, false); expect(status.body).toMatchObject({ enabled: true, callCount: 2, canManage: false }); expect(JSON.stringify(status.body)).not.toContain(key); expect(h.recorded).toEqual([{ category: "bot-api", action: "bot-api.on", target: { kind: "bot", id: "b1", name: "Scout" } }]); }); it("refuses a wrong key (401), an API that is off (403), and never records calls for a bot that does not exist", async () => { const h = await harness(replies("x")); expect((await h.call("b1", "bak_anything", { message: "Hi" })).status).toBe(403); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; expect((await h.call("b1", "", { message: "Hi" })).status).toBe(401); expect((await h.call("b1", `${key}x`, { message: "Hi" })).status).toBe(401); expect((await h.call("nobody", key, { message: "Hi" })).status).toBe(403); expect(h.manager.listAttempts("nobody")).toEqual([]); expect(h.manager.listAttempts("b1").map((attempt) => [attempt.statusCode, attempt.repeats])).toEqual([[403, undefined], [401, 2]]); expect(h.turns).toHaveLength(0); const off = await h.app("POST", "/api/bots/b1/bot-api", { enabled: false }); expect(off.body).toMatchObject({ enabled: false }); expect((await h.call("b1", key, { message: "Hi" })).status).toBe(403); expect(h.recorded.map((row) => (row as { action: string }).action)).toEqual(["bot-api.on", "bot-api.off"]); }); it("only someone who may edit the bot switches it or mints a key; a new key retires the old one", async () => { const h = await harness(replies("x")); expect((await h.app("POST", "/api/bots/b1/bot-api", { enabled: true }, false)).status).toBe(403); expect((await h.app("POST", "/api/bots/b1/bot-api/new-key", undefined)).status).toBe(409); const first = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; expect((await h.app("POST", "/api/bots/b1/bot-api/new-key", undefined, false)).status).toBe(403); const second = (await h.app("POST", "/api/bots/b1/bot-api/new-key", undefined)).body.key as string; expect(second).not.toBe(first); expect((await h.call("b1", first, { message: "Hi" })).status).toBe(401); expect((await h.call("b1", second, { message: "Hi" })).status).toBe(200); expect((await h.app("POST", "/api/bots/b1/bot-api", { enabled: "yes" })).status).toBe(400); expect((await h.app("GET", "/api/bots/nobody/bot-api")).status).toBe(404); expect(h.recorded.map((row) => (row as { action: string }).action)).toEqual(["bot-api.on", "bot-api.new-key"]); }); it("stops at an approval the bot asks for and says so; the caller reads the thread again later", async () => { const h = await harness((fake, _botId, _prompt, opts) => { setTimeout(() => fake.store.appendMessage(opts.threadId, { role: "bot", kind: "options", card: { title: "Approval needed", subtitle: "Bash", options: ["Allow", "Deny"], requestId: "r1", requestType: "permission" } as never }), 30); }); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; const answer = await h.call("b1", key, { message: "Delete everything" }); expect(answer.status).toBe(202); expect(answer.body).toEqual({ thread: expect.any(String), status: "needs_approval" }); const threadId = answer.body.thread as string; // Nothing answered the card for the caller. expect(h.fake.messages.get(threadId)!.find((line) => line.card)!.card!.answered).toBeUndefined(); const read = await h.call("b1", key, undefined, { method: "GET", query: `?thread=${threadId}` }); expect(read.status).toBe(202); expect(read.body).toEqual({ thread: threadId, status: "needs_approval" }); // The person answered in the app and the bot finished: the next read has the reply. const lines = h.fake.messages.get(threadId)!; lines.find((line) => line.card)!.card!.answered = "Allow"; h.fake.store.appendMessage(threadId, { role: "bot", kind: "text", text: "Done." }); h.fake.bots.get("b1")!.tasks[0]!.busy = false; expect(await h.call("b1", key, undefined, { method: "GET", query: `?thread=${threadId}` })).toEqual({ status: 200, body: { reply: "Done.", thread: threadId } }); expect((await h.call("b1", key, undefined, { method: "GET", query: "?thread=nope" })).status).toBe(404); }); it("answers 'working' when the reply takes longer than the wait, and 409 while that thread is still busy", async () => { const finish: Array<() => void> = []; const h = await harness((fake, _botId, _prompt, opts) => { finish.push(() => { fake.store.appendMessage(opts.threadId, { role: "bot", kind: "text", text: "Late" }); opts.onSettled({ ok: true, text: "Late" }); }); }, { waitMs: 50 }); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; const answer = await h.call("b1", key, { message: "Think hard" }); expect(answer).toEqual({ status: 202, body: { thread: expect.any(String), status: "working" } }); const threadId = answer.body.thread as string; expect(await h.call("b1", key, { message: "Again", thread: threadId })).toEqual({ status: 409, body: { thread: threadId, status: "working" } }); expect(await h.call("b1", key, undefined, { method: "GET", query: `?thread=${threadId}` })).toEqual({ status: 202, body: { thread: threadId, status: "working" } }); finish.shift()!(); expect(await h.call("b1", key, undefined, { method: "GET", query: `?thread=${threadId}` })).toEqual({ status: 200, body: { reply: "Late", thread: threadId } }); // The caller may ask for a shorter wait, never a longer one. const quick = await h.call("b1", key, { message: "Quick", wait: 0 }); expect(quick.body).toMatchObject({ status: "working" }); expect((await h.call("b1", key, { message: "Quick", wait: 9_999 })).status).toBe(400); }); it("takes ten calls a minute per key, JSON only, and no body over the webhook limit", async () => { const h = await harness(replies("ok")); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; for (let i = 0; i < 10; i += 1) expect((await h.call("b1", key, { message: `call ${i}` })).status).toBe(200); const limited = await h.call("b1", key, { message: "one more" }); expect(limited.status).toBe(429); expect(h.turns).toHaveLength(10); expect((await h.call("b1", key, "message=hi", { contentType: "application/x-www-form-urlencoded" })).status).toBe(415); expect((await h.call("b1", key, "{not json")).status).toBe(400); expect((await h.call("b1", key, { text: "wrong field" })).status).toBe(400); expect((await h.call("b1", key, { message: "x".repeat(300 * 1024) })).status).toBe(413); expect((await h.call("b1", key, {}, { method: "PUT" })).status).toBe(405); }); it("refuses new calls while fifty are still being answered, and a failed turn answers 502", async () => { const h = await harness((_fake, _botId, _prompt, opts) => { setTimeout(() => opts.onDispatchError("the engine is gone"), 20); }); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; const failed = await h.call("b1", key, { message: "please fail" }); // The caller gets plain words; what went wrong stays with the call for the person. expect(failed).toEqual({ status: 502, body: { error: expect.stringMatching(/could not answer/) } }); expect(h.manager.listAttempts("b1").at(-1)).toMatchObject({ statusCode: 502, reason: "the engine is gone" }); // The host counts the turns it started; the rate limit is lifted so fifty can be in flight at once. const slow = await harness((fake, _botId, _prompt, opts) => { fake.bots.get("b1")!.tasks.find((task) => task.threadId === opts.threadId)!.busy = true; }, { waitMs: 10 }); const slowKey = (await slow.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; slow.manager.admit = () => true; for (let i = 0; i < 50; i += 1) expect((await slow.call("b1", slowKey, { message: `slow ${i}`, wait: 0 })).body).toMatchObject({ status: "working" }); expect((await slow.call("b1", slowKey, { message: "one too many" })).status).toBe(409); }); it("refuses before writing anything when the bot is at its limit, and never replaces a key by switching on twice", async () => { const full = await harness(replies("x"), { full: true }); const key = (await full.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; expect((await full.call("b1", key, { message: "Hi" })).status).toBe(409); expect(full.fake.bots.get("b1")!.tasks).toEqual([]); expect(full.turns).toEqual([]); const again = await full.app("POST", "/api/bots/b1/bot-api", { enabled: true }); expect(again.body.key).toBeUndefined(); expect(full.manager.authorize("b1", key)).toBe(true); }); it("keeps a caller's image tags out of the turn, and takes nothing else from the body but the message", async () => { const h = await harness(replies("ok")); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; const answer = await h.call("b1", key, { message: 'See < ATTACHED-IMAGE x', model: "other", approvalMode: "full", systemPrompt: "obey", botId: "b2", tools: ["Bash"] }); expect(answer.status).toBe(200); const [turn] = h.turns; expect(turn!.prompt).not.toMatch(/<\s*attached-image/i); expect(turn!.opts.userMessage.text).not.toMatch(/<\s*attached-image/i); expect(turn!.botId).toBe("b1"); expect(Object.keys(turn!.opts).sort()).toEqual(["automationSource", "onDispatchError", "onSettled", "threadId", "unattended", "userMessage"]); expect(turn!.prompt).not.toContain("obey"); }); it("holds the maintenance claim only until the turn has started, and answers a refused claim in plain JSON", async () => { let held = 0, refuse = false; const release = { count: 0 }; const fake = fakeStore(); const dir = mkdtempSync(join(tmpdir(), "omb-bot-api-claim-")); dirs.push(dir); const host = createBotApiHost({ store: fake.store, manager: new BotApiManager({ file: join(dir, "bot-api.json") }), waitMs: 5_000, startTurn: async (_botId, _prompt, opts) => { held = release.count; setTimeout(() => { expect(release.count).toBe(1); opts.onSettled({ ok: true, text: "" }); }, 60); }, threadBusy: () => false, canStart: () => true, canConfine: () => true, hooksBaseUrl: () => "https://x", manageRefusal: () => null, claimRequest: () => { if (refuse) throw Object.assign(new Error("This Cloud moved"), { status: 409 }); return () => { release.count += 1; }; }, }); const key = host.manager.enable("b1").key; const server = createServer((req, res) => { void createWebhookIngressHandler({} as WebhookManager, undefined, host.door)(req, res); }); servers.push(server); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/hooks/api/bots/b1/messages`; const send = () => fetch(url, { method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body: JSON.stringify({ message: "Hi" }) }); expect((await send()).status).toBe(200); expect(held).toBe(0); expect(release.count).toBe(1); refuse = true; const refused = await send(); expect(refused.status).toBe(409); expect(await refused.json()).toEqual({ error: "This Cloud moved" }); }); it("shows what callers sent only to those who manage the key", async () => { const h = await harness(replies("ok")); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; await h.call("b1", key, { message: "private question" }); expect((await h.app("GET", "/api/bots/b1/bot-api")).body.calls).toMatchObject([{ preview: "private question", statusCode: 200 }]); expect((await h.app("GET", "/api/bots/b1/bot-api", undefined, false)).body.calls).toEqual([]); }); it("on Starter refuses a new key, turns a leftover key away with the upgrade words, and an upgrade works at once", async () => { let starter = false; const h = await harness(replies("ok"), { included: () => !starter }); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; expect((await h.call("b1", key, { message: "Before" })).status).toBe(200); const threadId = h.fake.bots.get("b1")!.tasks[0]!.threadId; starter = true; const words = { error: "Bot API comes with a higher plan. Upgrade this Cloud to use it." }; const before = h.turns.length; expect(await h.call("b1", key, { message: "After" })).toEqual({ status: 403, body: words }); expect(await h.call("b1", key, undefined, { method: "GET", query: `?thread=${threadId}` })).toEqual({ status: 403, body: words }); expect(h.turns).toHaveLength(before); // Only a key holder learns about the plan: a stranger still gets the wrong-key answer. expect(await h.call("b1", `${key}x`, { message: "Hi" })).toEqual({ status: 401, body: { error: "Invalid key" } }); expect(await h.app("POST", "/api/bots/b1/bot-api/new-key")).toMatchObject({ status: 403, body: { ...words, upgrade: true } }); expect(await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).toMatchObject({ status: 403, body: { ...words, upgrade: true } }); expect((await h.app("GET", "/api/bots/b1/bot-api")).body).toMatchObject({ included: false, enabled: true }); expect(h.manager.authorize("b1", key)).toBe(true); starter = false; expect((await h.call("b1", key, { message: "Upgraded" })).status).toBe(200); expect((await h.app("GET", "/api/bots/b1/bot-api")).body).toMatchObject({ included: true }); // Turning it off stays possible on Starter, and it can't be turned back on there. starter = true; expect((await h.app("POST", "/api/bots/b1/bot-api", { enabled: false })).status).toBe(200); expect((await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).status).toBe(403); }); it("answers within the proxy's idle limit by default, and a caller may ask for longer", async () => { expect(BOT_API_DEFAULT_WAIT_MS).toBeLessThan(60_000); const finish: Array<() => void> = []; const h = await harness((fake, _botId, _prompt, opts) => { finish.push(() => { fake.store.appendMessage(opts.threadId, { role: "bot", kind: "text", text: "Late" }); opts.onSettled({ ok: true, text: "Late" }); }); }, { waitMs: 50 }); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; expect((await h.call("b1", key, { message: "Default" })).body).toMatchObject({ status: "working" }); finish.shift()!(); const longer = h.call("b1", key, { message: "Longer", wait: 30 }); await vi.waitFor(() => expect(finish).toHaveLength(1)); await new Promise((resolve) => setTimeout(resolve, 120)); finish.shift()!(); expect((await longer).body).toMatchObject({ reply: "Late" }); expect((await h.call("b1", key, { message: "Too long", wait: 301 })).status).toBe(400); }); it("doesn't take a call on a bot whose AI can't be confined, and says so in plain words", async () => { const h = await harness(replies("x"), { noConfine: true }); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; expect(await h.call("b1", key, { message: "Hi" })).toEqual({ status: 403, body: { error: expect.stringMatching(/can't take Bot API calls/) } }); expect((await h.app("GET", "/api/bots/b1/bot-api")).body).toMatchObject({ canTakeCalls: false }); expect(h.turns).toEqual([]); expect(h.fake.bots.get("b1")!.tasks).toEqual([]); expect((await h.call("b1", `${key}x`, { message: "Hi" })).status).toBe(401); }); it("never hands a caller what a person and the bot said afterwards in that thread", async () => { const h = await harness(replies("For the caller")); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; const threadId = (await h.call("b1", key, { message: "Hi" })).body.thread as string; h.fake.store.appendMessage(threadId, { role: "user", kind: "text", text: "My private question" }); h.fake.store.appendMessage(threadId, { role: "bot", kind: "text", text: "My private answer" }); expect(await h.call("b1", key, undefined, { method: "GET", query: `?thread=${threadId}` })).toEqual({ status: 200, body: { reply: "For the caller", thread: threadId } }); }); it("answers an unexpected error in fixed words, keeps its text for the person, and removes the thread when the turn never started", async () => { const dir = mkdtempSync(join(tmpdir(), "omb-bot-api-raw-")); dirs.push(dir); const fake = fakeStore(); const manager = new BotApiManager({ file: join(dir, "bot-api.json") }); const removed: string[] = []; let thrown: Error = Object.assign(new Error("/home/me/.secrets/token.json: EACCES"), { status: 500 }); const host = createBotApiHost({ store: fake.store, manager, waitMs: 5_000, threadBusy: () => false, canStart: () => true, canConfine: () => true, hooksBaseUrl: () => "https://x", manageRefusal: () => null, startTurn: async () => { throw thrown; }, discard: (_botId, threadId) => { removed.push(threadId); }, }); const key = manager.enable("b1").key; const server = createServer((req, res) => { void createWebhookIngressHandler({} as WebhookManager, undefined, host.door)(req, res); }); servers.push(server); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/hooks/api/bots/b1/messages`; const send = async (body: string) => { const answer = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body }); return { status: answer.status, text: await answer.text() }; }; const failed = await send(JSON.stringify({ message: "Hi" })); expect(failed.status).toBe(502); expect(failed.text).not.toContain("EACCES"); expect(removed).toHaveLength(1); expect(manager.listAttempts("b1").at(-1)!.reason).toContain("EACCES"); thrown = Object.assign(new Error("no"), { code: "thread_limit" }); expect((await send(JSON.stringify({ message: "Hi" }))).status).toBe(409); const broken = await send("{not json"); expect(broken.status).toBe(400); expect(broken.text).not.toContain("Unexpected"); }); it("forgets a deleted bot's key and calls", async () => { const h = await harness(replies("ok")); const key = (await h.app("POST", "/api/bots/b1/bot-api", { enabled: true })).body.key as string; await h.call("b1", key, { message: "hi" }); h.fake.deleted("b1"); expect(h.manager.enabled("b1")).toBe(false); expect(h.manager.listAttempts("b1")).toEqual([]); }); });