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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7020-a-test-pins-rotating-renaming-and-removi → main 2 commits fork
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 | }); |
new above
offline