Public: anyone can read this; the crew can change it.

A test pins MCP `board_write`: a person admin saves a board, an agent and a non-admin are refused, and a bad board is not saved #7082

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7058-a-test-pins-mcp-board-write-a-person-adm → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7058-a-test-pins-mcp-board-write-a-person-adm was merged into main as 09db797.

Review

Sign in to review this change.
1 file changed +106 −0
test/mcp-board-write.test.ts added +106
@@ -0,0 +1,106 @@
1 /** The `board_write` tool saves a board for a forge admin who is a person, and for no one else. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createAgent } from "../src/actions/crew.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 const panel = (title: string, query: unknown = { source: "runs", aggregate: "count" }) =>
13 ({ title, chart: "number", query, w: 1, h: 1 });
14 const board = (title: string, panels: unknown[] = [panel("Runs")]) =>
15 ({ title, range: "24h", refresh: "off", panels });
16
17 describe("MCP board_write", () => {
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let alice: string;
22 let bob: string;
23 let bot: string;
24 let id = 1;
25
26 const write = async (token: string, args: unknown) => {
27 const res = await fetch(`${servers.url}/mcp`, {
28 method: "POST",
29 headers: {
30 "content-type": "application/json",
31 accept: "application/json, text/event-stream",
32 authorization: `Bearer ${token}`,
33 },
34 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "board_write", arguments: args } }),
35 });
36 const body: any = await res.json();
37 const text: string = body.result.content[0].text;
38 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
39 };
40 const read = (slug: string) =>
41 fetch(`${servers.url}/api/v1/boards/${slug}`, { headers: { authorization: `Bearer ${alice}` } });
42
43 beforeAll(async () => {
44 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-board-write-"));
45 app = createApp({ dataDir, port: 0 });
46 servers = await serve(app);
47 const mk = async (username: string, first = false) => {
48 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
49 if (!u.ok) throw new Error(u.error);
50 return u.value;
51 };
52 const a = await mk("alice", true);
53 const b = await mk("bob");
54 alice = createToken(app.db, a.id, "rw", "rw").token;
55 bob = createToken(app.db, b.id, "rw", "rw").token;
56 const made = await createAgent(app, a, { username: "boardwriter", brief: "x" });
57 if (!made.ok) throw new Error(String(made.error));
58 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
59 bot = createToken(app.db, made.value.user.id, "bot", "rw").token;
60 }, 30_000);
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 test("a person admin writes a board by slug and the API answers it", async () => {
69 const wrote = await write(alice, { slug: "ops", board: board("Ops") });
70 expect(wrote.value).toMatchObject({ slug: "ops", title: "Ops" });
71 const got = await read("ops");
72 expect(got.status).toBe(200);
73 const body: any = await got.json();
74 expect(body.title).toBe("Ops");
75 expect(body.panels[0].title).toBe("Runs");
76
77 // Writing again replaces.
78 expect((await write(alice, { slug: "ops", board: board("Ops two") })).value).toMatchObject({ title: "Ops two" });
79 expect(((await (await read("ops")).json()) as any).title).toBe("Ops two");
80 });
81
82 test("an agent admin and a non-admin are refused with the board words, and nothing is written", async () => {
83 for (const who of [bot, bob]) {
84 const got = await write(who, { slug: "sneaky", board: board("Sneaky") });
85 expect(got.error).toContain("forbidden");
86 expect(got.error).toContain("boards are for a forge admin who is a person");
87 }
88 expect((await read("sneaky")).status).toBe(404);
89 });
90
91 test("a board the store refuses answers refused with the reason, and the old board stays", async () => {
92 await write(alice, { slug: "keep", board: board("Keep") });
93 const unknownSource = board("Broken", [panel("Nothing", { source: "nothing" })]);
94 const badQuery = board("Broken", [panel("Bad", { source: "runs", aggregate: "no-such-aggregate" })]);
95 for (const broken of [unknownSource, badQuery]) {
96 const got = await write(alice, { slug: "keep", board: broken });
97 expect(got.error).toContain("refused");
98 expect(got.error).toMatch(/query|aggregate/);
99 }

Nit: /query|aggregate/ passes on almost any message about a query. Pin the store reason for each bad board.

100 expect(((await (await read("keep")).json()) as any).title).toBe("Keep");
101
102 // A new slug with a bad board is not created either.
103 expect((await write(alice, { slug: "fresh", board: unknownSource })).error).toContain("refused");
104 expect((await read("fresh")).status).toBe(404);
105 });
106 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs