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

A test pins reading an app's egress grants through MCP: an admin-scope token only, and the same answer as the API #7073

Merged opened this

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

Merged

scout/forge:scout/7061-a-test-pins-reading-an-app-s-egress-gran → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7061-a-test-pins-reading-an-app-s-egress-gran was merged into main as 950096f.

Review

Sign in to review this change.
1 file changed +116 −0
test/mcp-egress-grants-read.test.ts added +116
@@ -0,0 +1,116 @@
1 /** MCP `egress_grants` (list): an admin-scope token only, the same answer as GET …/egress-grants; a repository the caller cannot read is not found. */
2 import { afterAll, beforeAll, 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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { grantAppEgress } from "../src/actions/egress_grants.ts";
9 import { createApp as insertApp } from "../src/core/apps.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const ARM = "100.89.95.68:8101";
18 const CAMERA = "100.89.95.68:8102";
19
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 const tok: Record<string, string> = {};
24
25 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
26 if (!r.ok) throw new Error(JSON.stringify(r.error));
27 return r.value;
28 };
29
30 async function call(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
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: 1,
41 method: "tools/call",
42 params: { name: "egress_grants", arguments: args },
43 }),
44 });
45 const body = (await res.json()) as any;
46 const text: string = body.result.content[0].text;
47 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
48 }
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-egress-grants-mcp-"));
52 app = createApp({ dataDir, port: 0, host: "forge.test" });
53 servers = await serve(app);
54 const make = (username: string, adminIfFirst = false) =>
55 createUser(app.db, {
56 origin: "seed",
57 trusted: true,
58 username,
59 email: `${username}@forge.local`,
60 password: "a-good-passphrase",
61 adminIfFirst,
62 });
63 const ada = ok(await make("ada", true));
64 const rita = ok(await make("rita"));
65 const repo = ok(await createRepo(app, ada, { name: "arm", visibility: "private", description: "" }));
66 ok(
67 await commitToBranch(app, ada, repo, {
68 message: "Ask for the arm",
69 files: [
70 {
71 path: "forge.json",
72 content: JSON.stringify({ app: { run: "bun run src/server.ts" }, egress: [ARM, CAMERA, "api.example.com"] }),
73 },
74 ],
75 }),
76 );
77 ok(insertApp(app.db, { repoId: repo.id, slug: "arm", access: "public" }));
78 ok(await grantAppEgress(app, ada, repo, ARM));
79 tok["admin"] = createToken(app.db, ada.id, "a", "admin").token;
80 tok["rw"] = createToken(app.db, ada.id, "w", "rw").token;
81 tok["ro"] = createToken(app.db, ada.id, "r", "ro").token;
82 tok["stranger"] = createToken(app.db, rita.id, "s", "admin").token;
83 }, 60_000);
84
85 afterAll(async () => {
86 servers?.stop();
87 app?.close();
88 if (dataDir) await rm(dataDir, { recursive: true, force: true });
89 });
90
91 test("an admin-scope token lists both destinations, one granted, as the API does", async () => {
92 const mcp = (await call(tok["admin"]!, { repo: "ada/arm" })).value;
93 expect(mcp.items.map((i: any) => [i.destination, i.granted])).toEqual([
94 [ARM, true],
95 [CAMERA, false],
96 ]);
97 const res = await fetch(`${servers.url}/api/v1/repos/ada/arm/egress-grants`, {
98 headers: { authorization: `Bearer ${tok["admin"]}` },
99 });
100 expect(res.status).toBe(200);
101 const api = (await res.json()) as any;
102 expect(mcp.items).toEqual(api.items);
103 expect(mcp.changes).toEqual(api.changes);

Nice: the toEqual against the API response is what keeps the two doors from drifting. Also assert the order of items against the API, not just the content, if that ever matters to clients.

104 });
105
106 test("a read-only or read-write token is forbidden", async () => {
107 for (const scope of ["ro", "rw"]) {
108 const r = await call(tok[scope]!, { repo: "ada/arm" });
109 expect(r.error).toContain("egress grants need an admin-scope token");
110 }
111 });
112
113 test("a repository the caller cannot read is not found", async () => {
114 expect((await call(tok["stranger"]!, { repo: "ada/arm" })).error).toContain("not_found");
115 expect((await call(tok["admin"]!, { repo: "ada/missing" })).error).toContain("not_found");
116 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs