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

A test pins MCP `cloud_account`: an unknown verb is refused, and every verb needs an admin with an admin-scope token #7157

Merged opened this

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

Merged

scout/forge:scout/7151-a-test-pins-mcp-cloud-account-an-unknown → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7151-a-test-pins-mcp-cloud-account-an-unknown was merged into main as a2441f4.

Review

Sign in to review this change.
1 file changed +131 −0
test/mcp-cloud-account.test.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * The `cloud_account` tool changes a cloud account only for a site admin with
3 * an admin-scope token (#7151): attach, rename and remove each answer one
4 * line and never show the token, "delete" is `bad_verb`, and a non-admin or an
5 * `rw` token is refused for every verb with the account unchanged.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { net } from "../src/actions/cloud.ts";
13 import { listCloudAccounts } from "../src/core/cloud_account.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const TOKEN = "do-live-9Zq4TxLm7Kp2Vb8nWcR5";
22 const realFetch = net.fetch;
23 const VERBS = ["attach", "verify", "rotate", "rename", "remove", "allow_writes", "audit"];
24
25 describe("MCP cloud_account", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let adminToken = "";
30 let adminRw = "";
31 let bobAdminScope = "";
32 let id = 1;
33
34 const call = async (token: string, args: Record<string, unknown>) => {
35 const res = await fetch(`${servers.url}/mcp`, {
36 method: "POST",
37 headers: {
38 "content-type": "application/json",
39 accept: "application/json, text/event-stream",
40 authorization: `Bearer ${token}`,
41 },
42 body: JSON.stringify({
43 jsonrpc: "2.0",
44 id: id++,
45 method: "tools/call",
46 params: { name: "cloud_account", arguments: { provider: "digitalocean", name: "main", ...args } },
47 }),
48 });
49 const body = (await res.json()) as any;
50 if (body.error) return { text: body.error.message as string, error: true };
51 return { text: body.result.content[0].text as string, error: body.result.isError === true };
52 };
53 const accounts = () => listCloudAccounts(app.db).map((a) => `${a.provider}/${a.name}`);
54
55 beforeAll(async () => {
56 net.fetch = async (url) => {
57 const u = new URL(url);
58 if (u.pathname === "/v2/account")
59 return new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }));
60 if (u.pathname === "/v2/projects") return new Response(JSON.stringify({ projects: [{}] }));
61 return new Response("{}", { status: 404 });
62 };
63 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-cloud-account-"));
64 app = createApp({ dataDir, port: 0, host: "forge.test" });
65 servers = await serve(app);
66 const make = async (username: string, adminIfFirst = false) => {
67 const made = await createUser(app.db, {
68 origin: "seed",
69 trusted: true,
70 username,
71 email: `${username}@forge.local`,
72 password: "a-good-passphrase",
73 adminIfFirst,
74 });
75 if (!made.ok) throw new Error(made.error);
76 return made.value;
77 };
78 const ada = await make("ada", true);
79 const bob = await make("bob");
80 adminToken = createToken(app.db, ada.id, "admin", "admin").token;
81 adminRw = createToken(app.db, ada.id, "rw", "rw").token;
82 bobAdminScope = createToken(app.db, bob.id, "admin", "admin").token;
83 }, 60_000);
84
85 afterAll(async () => {
86 net.fetch = realFetch;
87 servers?.stop();
88 app?.close();
89 if (dataDir) await rm(dataDir, { recursive: true, force: true });
90 });
91
92 test("an admin with an admin-scope token attaches, renames and removes; no answer shows the token", async () => {
93 const attached = await call(adminToken, { verb: "attach", value: TOKEN });
94 expect(attached.error).toBe(false);
95 expect(JSON.parse(attached.text).says).toStartWith("attach digitalocean/main: ");
96 expect(accounts()).toEqual(["digitalocean/main"]);
97 const renamed = await call(adminToken, { verb: "rename", to: "ops" });
98 expect(renamed.error).toBe(false);
99 expect(JSON.parse(renamed.text).says).toBe("rename digitalocean/main: done.");
100 expect(accounts()).toEqual(["digitalocean/ops"]);
101 const removed = await call(adminToken, { verb: "remove", name: "ops" });
102 expect(removed.error).toBe(false);
103 expect(JSON.parse(removed.text).says).toBe("remove digitalocean/ops: done.");
104 expect(accounts()).toEqual([]);
105 for (const r of [attached, renamed, removed]) {
106 expect(r.text).not.toContain(TOKEN);
107 expect(JSON.parse(r.text).says).not.toContain("\n");
108 }
109 });
110
111 test("verb delete is bad_verb", async () => {
112 const r = await call(adminToken, { verb: "delete" });
113 expect(r.error).toBe(true);
114 expect(r.text).toContain("bad_verb");
115 });
116
117 test("a non-admin and an rw token are refused for every verb, the account unchanged", async () => {
118 await call(adminToken, { verb: "attach", value: TOKEN });
119 expect(accounts()).toEqual(["digitalocean/main"]);
120 for (const token of [bobAdminScope, adminRw]) {

Only r.error is checked; also expect r.text toContain not_found for bobAdminScope and forbidden for adminRw, so the test fails if the wrong guard refuses.

121 for (const verb of VERBS) {
122 const r = await call(token, { verb, value: TOKEN, to: "other", allowed: true, repo: "ada/x" });
123 expect(r.error).toBe(true);
124 expect(r.text).not.toContain(TOKEN);
125 }
126 expect(accounts()).toEqual(["digitalocean/main"]);
127 }
128 const [row] = listCloudAccounts(app.db);
129 expect(row?.writeAllowed).toBe(false);
130 });
131 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs