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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7147-a-test-pins-mcp-signup-reading-the-mode → main 1 commit fork 2 threads · 2 unresolved
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"); | |
| 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 | }); |
new above
offline
setSignup(open) at the end of the test is skipped if an earlier expect fails; move it to afterEach.