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

A test pins rotating, renaming and removing a cloud account from Settings: a forge admin only, never echoing the key #7039

Merged opened this

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

Merged

scout/forge:scout/7020-a-test-pins-rotating-renaming-and-removi → main 2 commits fork

  • Merged. scout/7020-a-test-pins-rotating-renaming-and-removi was merged into main as 2973709.

Review

Sign in to review this change.
1 file changed +164 −0
test/settings-cloud-forms.test.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * Rotating, renaming and removing a cloud account from Settings (#7020):
3 * the forms post the way the page does, a forge admin only, same-origin
4 * only; each answer says what happened and the list shows it; neither the
5 * old key nor the new one shows in any page, flash or event; an empty or
6 * taken name is refused with the action's words.
7 */
8 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { addAccount, net, removeAccount, reverify } from "../src/actions/cloud.ts";
14 import { listCloudAccounts } from "../src/core/cloud_account.ts";
15 import { listEvents } from "../src/core/events.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const T1 = "ghp_first9Zq4TxLm7Kp2Vb8nWcR5";
23 const T2 = "ghp_second7Hs3DfGj6Lm1Qw0Zx8";
24 const calls: string[] = [];
25 net.fetch = async (_url, init) => {
26 const auth = String((init?.headers as Record<string, string> | undefined)?.authorization ?? "");
27 calls.push(auth);
28 return new Response(JSON.stringify({ login: "octo" }), {
29 status: auth.includes(T1) || auth.includes(T2) ? 200 : 401,
30 });
31 };
32
33 describe("cloud account forms in Settings", () => {
34 let dataDir = "";
35 let app: App;
36 let servers: Servers;
37 let admin = "";
38 let member = "";
39 const bodies: string[] = [];
40
41 const login = async (username: string) =>
42 ((await fetch(`${servers.url}/signin`, {
43 method: "POST",
44 redirect: "manual",
45 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
46 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
47 })).headers.get("set-cookie") ?? "").split(";")[0] ?? "";
48
49 /** Post a form as the page does; `follow` reads the page the redirect lands on, flash included. */
50 const post = async (path: string, cookie: string, form: Record<string, string>, origin = servers.url) => {
51 const res = await fetch(`${servers.url}/settings/cloud/${path}`, {
52 method: "POST",
53 redirect: "manual",
54 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin },
55 body: new URLSearchParams(form),
56 });
57 const flashCookie = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
58 let page = "";
59 if (res.status === 303) {
60 page = await (await fetch(`${servers.url}${res.headers.get("location")}`, {
61 headers: { cookie: [cookie, flashCookie].filter(Boolean).join("; ") },
62 })).text();
63 bodies.push(page);
64 }
65 return { status: res.status, page };
66 };
67 const names = () => listCloudAccounts(app.db).map((a) => a.name);
68
69 beforeAll(async () => {
70 dataDir = await mkdtemp(join(tmpdir(), "forge-cloud-forms-"));
71 app = createApp({ dataDir, port: 0 });
72 servers = await serve(app);
73 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
74 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst });
75 if (!made.ok) throw new Error(made.error);
76 }
77 admin = await login("alice");
78 member = await login("bob");
79 });
80 /** Every test starts from the same two accounts, both holding T1. */
81 beforeEach(async () => {
82 for (const a of listCloudAccounts(app.db)) removeAccount(app, aliceUser(), a.provider, a.name);
83 for (const name of ["main", "spare"]) expect((await addAccount(app, aliceUser(), "github", name, T1)).ok).toBe(true);
84 calls.length = 0;
85 });
86 /** The key an account actually holds: verify it and see which key the vendor was shown. */
87 const heldKey = async (name: string) => {
88 calls.length = 0;
89 expect((await reverify(app, aliceUser(), "github", name)).ok).toBe(true);
90 return calls.at(-1)!;
91 };
92 afterAll(async () => {
93 servers.stop();
94 app.close();
95 await rm(dataDir, { recursive: true, force: true });
96 });
97 const aliceUser = () => getUserByName(app.db, "alice")!;
98
99 const form = (path: string) =>
100 path === "rotate" ? { provider: "github", name: "main", value: T2 }
101 : path === "rename" ? { provider: "github", name: "main", to: "renamed" }
102 : { provider: "github", name: "main" };
103
104 test("a non-admin and a cross-origin post are refused for each, and the account stays", async () => {
105 const before = JSON.stringify(listCloudAccounts(app.db));
106 for (const path of ["rotate", "rename", "remove"]) {
107 expect((await post(path, member, form(path))).status).toBe(404);
108 expect((await post(path, admin, form(path), "https://evil.example")).status).toBe(403);
109 }
110 expect(JSON.stringify(listCloudAccounts(app.db))).toBe(before);
111 });
112
113 test("an admin rotates, renames, then removes; each answer says so and the list shows it", async () => {
114 const rotated = await post("rotate", admin, form("rotate"));
115 expect(rotated.status).toBe(303);
116 expect(rotated.page).toContain("Rotated.");
117 expect(await heldKey("main")).toContain(T2);
118
119 const renamed = await post("rename", admin, form("rename"));
120 expect(renamed.status).toBe(303);
121 expect(renamed.page).toContain("Renamed.");
122 expect(names()).toContain("renamed");
123 expect(names()).not.toContain("main");
124 expect(renamed.page).toContain("renamed");
125 expect(await heldKey("renamed")).toContain(T2);
126
127 const removed = await post("remove", admin, { provider: "github", name: "renamed" });
128 expect(removed.status).toBe(303);
129 expect(removed.page).toContain("Removed.");
130 expect(names()).not.toContain("renamed");
131 expect(removed.page).not.toContain("aria-label=\"Rotate renamed\"");
132 });
133
134 test("rotate refuses a key the provider rejects, and an unknown account", async () => {
135 const bad = await post("rotate", admin, { provider: "github", name: "spare", value: "ghp_notvalid0000000000000000" });
136 expect(bad.page).not.toContain("Rotated.");
137 expect(bad.page).toContain('data-tone="danger"');
138 expect(bad.page).toContain("The provider did not accept that credential.");
139 expect(await heldKey("spare")).toContain(T1);
140 const none = await post("rotate", admin, { provider: "github", name: "nope", value: T2 });
141 expect(none.page).toContain("No such credential.");
142 expect(names().sort()).toEqual(["main", "spare"]);
143 });
144
145 test("renaming to an empty name or a taken one is refused with the action's words", async () => {
146 const empty = await post("rename", admin, { provider: "github", name: "spare", to: " " });
147 expect(empty.status).toBe(303);
148 expect(empty.page).not.toContain("Renamed.");
149 expect(empty.page).toContain('data-tone="danger"');
150 expect(names().sort()).toEqual(["main", "spare"]);
151
152 const taken = await post("rename", admin, { provider: "github", name: "spare", to: "main" });
153 expect(taken.page).toContain("An account has that name already.");
154 expect(taken.page).not.toContain("Renamed.");
155 expect(names().sort()).toEqual(["main", "spare"]);
156 });
157
158 test("neither key shows in any page, flash or event", () => {
159 const everything = JSON.stringify(listEvents(app.db, {})) + bodies.join("\n");
160 expect(bodies.length).toBeGreaterThan(5);
161 expect(everything).not.toContain(T1);
162 expect(everything).not.toContain(T2);
163 });
164 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs