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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7061-a-test-pins-reading-an-app-s-egress-gran → main 1 commit fork 1 thread · 1 unresolved
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); | |
| 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 | }); |
new above
offline
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.