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

A test pins MCP `snapshot_now`: a forge with nowhere to keep a snapshot says so, and only an admin-scope admin may take one #7162

Merged opened this

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

Merged

scout/forge:scout/7148-a-test-pins-mcp-snapshot-now-a-forge-wit → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7148-a-test-pins-mcp-snapshot-now-a-forge-wit was merged into main as 6cd5b1d.

Review

Sign in to review this change.
1 file changed +72 −37
test/mcp-snapshot-now.test.ts +72 −37
@@ -1,12 +1,11 @@
1 /** MCP `snapshot_now`: the Backups page's "take one now", for a forge admin. */
2
1 /** MCP `snapshot_now` is the Backups page's button: it says when there is nowhere to keep one, and only an admin-scope admin takes one. */
32 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtempSync, rmSync } from "node:fs";
3 import { mkdtemp, rm } from "node:fs/promises";
54 import { tmpdir } from "node:os";
65 import { join } from "node:path";
76 import { createApp, type App } from "../src/app.ts";
87 import { createToken } from "../src/core/tokens.ts";
9 import { createUser, getUserByName } from "../src/core/users.ts";
8 import { createUser } from "../src/core/users.ts";
109 import { serve, type Servers } from "../src/http/server.ts";
1110 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
1211 import { dirStore } from "../src/snapshot/store.ts";
@@ -15,62 +14,98 @@ import { quietLogs } from "./helpers/globals.ts";
1514 quietLogs();
1615
1716 describe("MCP snapshot_now", () => {
18 let root = "";
17 let dataDir: string;
1918 let app: App;
2019 let servers: Servers;
2120 let svc: SnapshotsHandle | undefined;
21 let admin = "";
22 let adminRw = "";
23 let other = "";
24 let id = 1;
2225
23 const token = (name: string, scope: "admin" | "rw" = "admin") =>
24 createToken(app.db, getUserByName(app.db, name)!.id, "t", scope).token;
25 const call = async (tok: string) => {
26 const call = async (token: string) => {
2627 const res = await fetch(`${servers.url}/mcp`, {
2728 method: "POST",
28 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` },
29 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "snapshot_now", arguments: {} } }),
29 headers: {
30 "content-type": "application/json",
31 accept: "application/json, text/event-stream",
32 authorization: `Bearer ${token}`,
33 },
34 body: JSON.stringify({
35 jsonrpc: "2.0",
36 id: id++,
37 method: "tools/call",
38 params: { name: "snapshot_now", arguments: {} },
39 }),
3040 });
3141 const body = (await res.json()) as any;
32 return { error: body.result.isError === true, text: body.result.content[0].text as string };
42 if (body.error) return { text: JSON.stringify(body.error), isError: true };
43 return { text: body.result.content[0].text as string, isError: body.result.isError === true };
3344 };
3445
3546 beforeAll(async () => {
36 root = mkdtempSync(join(tmpdir(), "forge-mcp-snap-"));
37 app = createApp({ dataDir: root, port: 0 });
38 for (const [username, isAdmin] of [["alice", true], ["bob", false]] as const) {
39 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-snapshot-passphrase", isAdmin });
40 if (!made.ok) throw new Error(username);
41 }
47 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-snapshot-now-"));
48 app = createApp({ dataDir, port: 0 });
4249 servers = await serve(app);
50 const a = await createUser(app.db, { origin: "seed", trusted: true,
51 username: "alice",
52 email: "[email protected]",
53 password: "a-good-passphrase",
54 adminIfFirst: true,
55 });
56 if (!a.ok) throw new Error(a.error);
57 const b = await createUser(app.db, { origin: "seed", trusted: true,
58 username: "bob",
59 email: "[email protected]",
60 password: "a-good-passphrase",
61 });
62 if (!b.ok) throw new Error(b.error);
63 admin = createToken(app.db, a.value.id, "admin", "admin").token;
64 adminRw = createToken(app.db, a.value.id, "rw", "rw").token;
65 other = createToken(app.db, b.value.id, "admin", "admin").token;
4366 }, 30_000);
4467
4568 afterAll(async () => {
46 servers.stop();
4769 await svc?.stop();
70 servers.stop();
4871 app.close();
49 rmSync(root, { recursive: true, force: true });
72 await rm(dataDir, { recursive: true, force: true });
5073 });
5174
52 test("with no store, it says the page's sentence", async () => {
53 const r = await call(token("alice"));
75 test("a forge with no snapshot store says so", async () => {
76 svc = startSnapshots(app, () => undefined, { version: "t", nightly: false });
77 const r = await call(admin);
78 expect(r.isError).toBe(true);
79 expect(r.text).toContain("no_store");
5480 expect(r.text).toContain("This forge has nowhere to put a snapshot.");
81 await svc.stop();
82 svc = undefined;
5583 });
5684
57 test("a non-admin is refused and nothing is taken", async () => {
58 const r = await call(token("bob"));
59 expect(r.error).toBe(true);
60 expect(r.text).toContain("no such tool here");
61 });
62
63 test("an admin with a local store takes one, and it is listed after", async () => {
64 svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false });
65 const weak = await call(token("alice", "rw"));
66 expect(weak.error).toBe(true);
67 expect(weak.text).toContain("admin-scope token");
85 test("a non-admin, and an admin's rw token, are refused and nothing is taken", async () => {
86 const store = dirStore(join(dataDir, "refused"));
87 svc = startSnapshots(app, store, { version: "t", nightly: false });
88 const plain = await call(other);

Assert plain.text too (the old test checked "no such tool here"); isError alone passes for any failure.

89 const rw = await call(adminRw);
90 expect(plain.isError).toBe(true);
91 expect(rw.isError).toBe(true);
92 expect(rw.text).toContain("forbidden");
6893 expect(await svc.list()).toEqual([]);
69 const r = await call(token("alice"));
70 expect(r.error).toBe(false);
94 expect(svc.last().manifest).toBeUndefined();
95 await svc.stop();
96 svc = undefined;
97 });
98
99 test("an admin-scope admin takes one, and it is in the store", async () => {
100 const store = dirStore(join(dataDir, "store"));
101 svc = startSnapshots(app, store, { version: "t", nightly: false });
102 const r = await call(admin);
103 expect(r.isError).toBe(false);
71104 const out = JSON.parse(r.text);
72 expect(out.says).toContain(`Snapshot ${out.name} taken`);
73 expect(typeof out.bytes).toBe("number");
74 expect((await svc.list()).map((m) => m.name)).toContain(out.name);
105 expect(Object.keys(out).sort()).toEqual(["apps", "bytes", "name", "repos", "says"]);
106 expect(out.says).toStartWith(`Snapshot ${out.name} taken: `);
107 const kept = await svc.list();
108 expect(kept.map((m) => m.name)).toEqual([out.name]);
109 expect((await store.list(`snapshots/${out.name}/`)).length).toBeGreaterThan(0);
75110 });
76111 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs