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

A test pins "Test" and "Bring level now" on Settings → Backups: a forge without a mirror says so, and only an admin may press them #7033

Merged opened this

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

Merged

scout/forge:scout/7022-a-test-pins-test-and-bring-level-now-on → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/7022-a-test-pins-test-and-bring-level-now-on was merged into main as 0dcfdb8.

Review

Sign in to review this change.
1 file changed +123 −0
test/settings-backups-buttons.test.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * Settings → Backups, "Test" and "Bring level now": posted the way the page
3 * posts them (session cookie, same-origin Origin), a forge with no mirror
4 * says so, an unreachable destination fails in words without leaking its
5 * credentials, and only an admin on the forge's own origin may press them.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let root = "";
19 let app: App;
20 let servers: Servers;
21 let alice = "";
22 let bob = "";
23
24 const login = async (username: string, password: string) => {
25 const r = await fetch(`${servers.url}/signin`, {
26 method: "POST",
27 headers: { origin: servers.url },
28 body: new URLSearchParams({ username, password }),
29 redirect: "manual",
30 });
31 return (r.headers.get("set-cookie") ?? "").split(";")[0]!;
32 };
33
34 const post = (path: string, cookie: string, fields: Record<string, string> = {}, origin = servers.url) =>
35 fetch(`${servers.url}${path}`, {
36 method: "POST",
37 headers: { cookie, origin },
38 body: new URLSearchParams(fields),
39 redirect: "manual",
40 });
41
42 /** The message the redirect carries in its flash cookie. */
43 const flash = (r: Response): { tone?: string; message: string } => {
44 const raw = r.headers.getSetCookie().find((c) => c.startsWith("forge_flash=") || /flash/.test(c.split("=")[0]!));
45 expect(raw).toBeDefined();
46 return JSON.parse(decodeURIComponent(raw!.split(";")[0]!.split("=").slice(1).join("=")));

Match the cookie by the exported FLASH_COOKIE rather than the loose /flash/ regex fallback, so the helper cannot pick up an unrelated cookie.

47 };
48
49 beforeAll(async () => {
50 root = mkdtempSync(join(tmpdir(), "forge-backups-buttons-"));
51 app = createApp({ dataDir: root, port: 0 });
52 for (const [username, password, adminIfFirst] of [
53 ["alice", "a-good-passphrase", true],
54 ["bob", "another-good-passphrase", false],
55 ] as const) {
56 const u = await createUser(app.db, {
57 origin: "seed",
58 trusted: true,
59 username,
60 email: `${username}@forge.local`,
61 password,
62 adminIfFirst,
63 });
64 if (!u.ok) throw new Error(u.error);
65 }
66 servers = await serve(app);
67 alice = await login("alice", "a-good-passphrase");
68 bob = await login("bob", "another-good-passphrase");
69 });
70 afterAll(() => {
71 servers.stop();
72 app.close();
73 rmSync(root, { recursive: true, force: true });
74 });
75
76 test("a forge with no mirror says so when brought level", async () => {
77 const r = await post("/settings/backups/mirror/now", alice);
78 expect(r.status).toBe(303);
79 expect(r.headers.get("location")).toBe("/settings/backups");
80 expect(flash(r)).toEqual({ tone: "danger", message: "This forge has no mirror to bring level." });
81 });
82
83 test("Test against a destination it cannot reach fails in words, with no stack and no credential", async () => {
84 const secret = "SECRETKEYVALUE123";
85 const access = "ACCESSKEYVALUE123";
86 const r = await post("/settings/backups/test", alice, {
87 endpoint: "http://store.invalid",
88 bucket: "nowhere-bucket",
89 region: "us-east-1",
90 access_key: access,
91 secret_key: secret,

This case spends about 8s on retry backoff, plus a 90s timeout. Prefer a fast-fail seam so the suite stays fast.

92 });
93 expect(r.status).toBe(303);
94 const f = flash(r);
95 expect(f.tone).toBe("danger");
96 expect(f.message).toMatch(/^Could not list /);
97 for (const leak of [secret, access, " at ", "node_modules"]) expect(f.message).not.toContain(leak);
98 const page = await (await fetch(`${servers.url}/settings/backups`, { headers: { cookie: alice } })).text();
99 expect(page).not.toContain(secret);
100 expect(page).not.toContain(access);
101 }, 90_000);
102
103 test("Test with nothing typed and nothing sealed says there is nothing to test", async () => {
104 const f = flash(await post("/settings/backups/test", alice));
105 expect(f.tone).toBe("danger");
106 expect(f.message.length).toBeGreaterThan(0);
107 });
108
109 for (const path of ["/settings/backups/test", "/settings/backups/mirror/now"]) {
110 test(`${path}: a non-admin, an anonymous visitor and a cross-origin post are refused`, async () => {
111 const asBob = await post(path, bob);
112 expect(asBob.status).toBe(404);
113 expect(asBob.headers.getSetCookie().join(";")).not.toMatch(/flash/);
114
115 const anon = await post(path, "");
116 expect(anon.status).not.toBe(200);
117 expect(anon.headers.getSetCookie().join(";")).not.toMatch(/flash/);
118
119 const cross = await post(path, alice, {}, "https://evil.example");
120 expect(cross.status).toBe(403);
121 expect(cross.headers.getSetCookie().join(";")).not.toMatch(/flash/);
122 });
123 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs