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

A test pins MCP `signup`: reading the mode is free, changing it plans first and needs an admin token, an unknown mode is refused #7156

Merged opened this

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

Merged

scout/forge:scout/7147-a-test-pins-mcp-signup-reading-the-mode → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/7147-a-test-pins-mcp-signup-reading-the-mode was merged into main as bbb6f43.

Review

Sign in to review this change.
1 file changed +137 −0
test/mcp-signup.test.ts added +137
@@ -0,0 +1,137 @@
1 /**
2 * The `signup` tool (#7147): reading the mode is free for a site admin,
3 * changing it plans first and applies only with an admin-scope token, an
4 * unknown mode is `invalid_signup`, and a refused change leaves the mode.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createAgent } from "../src/actions/crew.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser, getUserByName, type User } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { getSignup, setSignup } from "../src/settings/signup.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 describe("MCP signup", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let adminToken = "";
25 let rwToken = "";
26 let cyToken = "";
27 let botToken = "";
28 let id = 1;
29
30 const call = async (token: string, args: Record<string, unknown>) => {
31 const res = await fetch(`${servers.url}/mcp`, {
32 method: "POST",
33 headers: {
34 "content-type": "application/json",
35 accept: "application/json, text/event-stream",
36 authorization: `Bearer ${token}`,
37 },
38 body: JSON.stringify({
39 jsonrpc: "2.0",
40 id: id++,
41 method: "tools/call",
42 params: { name: "signup", arguments: args },
43 }),
44 });
45 const body = (await res.json()) as any;
46 if (body.error) return { text: body.error.message as string, error: true };
47 return { text: body.result.content[0].text as string, error: body.result.isError === true };
48 };
49 const join_ = (username: string) =>
50 fetch(`${servers.url}/api/v1/users`, {
51 method: "POST",
52 headers: { "content-type": "application/json", origin: servers.url },
53 body: JSON.stringify({ username, password: "a-long-passphrase" }),
54 });
55
56 beforeAll(async () => {
57 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-signup-"));
58 app = createApp({ dataDir, port: 0, host: "forge.test" });
59 servers = await serve(app);
60 const make = async (username: string, adminIfFirst = false): Promise<User> => {
61 const made = await createUser(app.db, {
62 origin: "seed",
63 trusted: true,
64 username,
65 email: `${username}@forge.local`,
66 password: "a-good-passphrase",
67 adminIfFirst,
68 });
69 if (!made.ok) throw new Error(made.error);
70 return made.value;
71 };
72 const ada = await make("ada", true);
73 const cy = await make("cy");
74 adminToken = createToken(app.db, ada.id, "admin", "admin").token;
75 rwToken = createToken(app.db, ada.id, "rw", "rw").token;
76 cyToken = createToken(app.db, cy.id, "admin", "admin").token;
77 const bot = await createAgent(app, ada, { username: "botty", brief: "b", model: "m" });
78 if (!bot.ok) throw new Error(String(bot.error));
79 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(bot.value.user.id);
80 botToken = createToken(app.db, bot.value.user.id, "admin", "admin").token;
81 setSignup(app.db, "open", "system");
82 }, 60_000);
83
84 afterAll(async () => {
85 servers?.stop();
86 app?.close();
87 if (dataDir) await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("without a mode, an admin reads the current mode", async () => {
91 const read = await call(adminToken, {});
92 expect(read.error).toBe(false);
93 expect(JSON.parse(read.text).signup).toBe("open");
94 });
95
96 test("a mode plans by default and changes nothing; dryRun false applies and closes the door", async () => {
97 const plan = await call(adminToken, { mode: "closed" });
98 expect(plan.error).toBe(false);
99 expect(JSON.parse(plan.text).says).toContain("Would set signup from open to closed");
100 expect(getSignup(app.db)).toBe("open");
101
102 const planRw = await call(rwToken, { mode: "closed", dryRun: true });
103 expect(planRw.error).toBe(false);
104 expect(getSignup(app.db)).toBe("open");

setSignup(open) at the end of the test is skipped if an earlier expect fails; move it to afterEach.

105
106 const applied = await call(adminToken, { mode: "closed", dryRun: false });
107 expect(applied.error).toBe(false);
108 expect(JSON.parse(applied.text).signup).toBe("closed");
109 expect(getSignup(app.db)).toBe("closed");
110
111 const refused = await join_("newbie");
112 expect(refused.status).toBe(403);
113 expect(((await refused.json()) as { error: string }).error).toBe("signup_closed");
114 expect(getUserByName(app.db, "newbie")).toBeUndefined();
115 setSignup(app.db, "open", "system");
116 });
117
118 test("an unknown mode is invalid_signup", async () => {
119 for (const mode of ["public", 3, ""]) {
120 const res = await call(adminToken, { mode, dryRun: false });
121 expect(res.error).toBe(true);
122 expect(res.text).toContain("invalid_signup");
123 }
124 expect(getSignup(app.db)).toBe("open");
125 });
126
127 test("an rw token, a non-admin and an agent cannot apply; the mode is unchanged", async () => {
128 const rw = await call(rwToken, { mode: "closed", dryRun: false });
129 expect(rw.error).toBe(true);
130 expect(rw.text).toContain("forbidden");
131 for (const token of [cyToken, botToken]) {

Only error===true is asserted for the non-admin and the agent. Assert res.text contains not_found so the test pins the reason, not just any failure.

132 const res = await call(token, { mode: "closed", dryRun: false });
133 expect(res.error).toBe(true);
134 }
135 expect(getSignup(app.db)).toBe("open");
136 });
137 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs