Public: anyone can read this; the crew can change it.
A test pins verifying a cloud account from Settings: a forge admin only, and a failing provider explained in words #7041
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7028-a-test-pins-verifying-a-cloud-account-fr → main 1 commit fork 2 threads · 2 unresolved
1 file
changed +110
−0
test/settings-cloud-verify.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * POST /settings/cloud/verify, posted the way the page does: a forge admin | |
| 3 | * verifies an account against a stubbed provider and is told the result; a | |
| 4 | * provider that answers 401 is explained in words, with no key and no raw | |
| 5 | * provider body; a non-admin and a cross-origin post are refused before any | |
| 6 | * provider call. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, 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 } from "../src/actions/cloud.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const TOKEN = "ghp_verify9Zq4TxLm7Kp2Vb8nWcR5"; | |
| 21 | const RAW = "raw-provider-body-bad-credentials-7731"; | |
| 22 | let mode: "ok" | "unauthorized" = "ok"; | |
| 23 | let calls = 0; | |
| 24 | net.fetch = async () => { | |
| 25 | calls++; | |
| 26 | return mode === "ok" | |
| 27 | ? new Response(JSON.stringify({ login: "octo" }), { status: 200 }) | |
| 28 | : new Response(JSON.stringify({ message: RAW }), { status: 401 }); | |
| 29 | }; | |
| 30 | ||
| 31 | describe("POST /settings/cloud/verify", () => { | |
| 32 | let dataDir = ""; | |
| 33 | let app: App; | |
| 34 | let servers: Servers; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-cloud-verify-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 41 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst }); | |
| 42 | if (!made.ok) throw new Error(made.error); | |
| 43 | } | |
| 44 | const added = await addAccount(app, getUserByName(app.db, "alice")!, "github", "main", TOKEN); | |
| 45 | if (!added.ok) throw new Error(added.error); | |
| 46 | }); | |
| 47 | afterAll(async () => { | |
| 48 | servers.stop(); | |
| 49 | app.close(); | |
| 50 | await rm(dataDir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | const login = async (username: string) => | |
| 54 | ((await fetch(`${servers.url}/signin`, { | |
| 55 | method: "POST", | |
| 56 | redirect: "manual", | |
| 57 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 58 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 59 | })).headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 60 | ||
| 61 | const verify = (cookie: string, origin: string = servers.url) => | |
| 62 | fetch(`${servers.url}/settings/cloud/verify`, { | |
| 63 | method: "POST", | |
| 64 | redirect: "manual", | |
| 65 | headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin }, | |
| 66 | body: new URLSearchParams({ provider: "github", name: "main" }), | |
| 67 | }); | |
| 68 | ||
| 69 | /** Follow the redirect with the flash cookie, as a browser does. */ | |
| 70 | const landing = async (cookie: string, res: Response): Promise<string> => { | |
| 71 | expect(res.status).toBe(303); | |
| 72 | expect(res.headers.get("location")).toBe("/settings/cloud"); | |
| 73 | const flash = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 74 | return await (await fetch(`${servers.url}/settings/cloud`, { headers: { cookie: `${cookie}; ${flash}` } })).text(); | |
| 75 | }; | |
| 76 | ||
| 77 | test("an admin verifies an account that works and is told so", async () => { | |
| 78 | mode = "ok"; | |
| 79 | const cookie = await login("alice"); | |
| 80 | const before = calls; | |
| 81 | const page = await landing(cookie, await verify(cookie)); | |
| 82 | expect(calls).toBeGreaterThan(before); | |
| 83 | expect(page).toContain("GitHub token belongs to octo"); | |
| 84 | expect(page).not.toContain(TOKEN); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a provider that answers 401 is explained in words, without key or raw body", async () => { | |
| 88 | mode = "unauthorized"; | |
| 89 | const cookie = await login("alice"); | |
| 90 | const res = await verify(cookie); | |
| 91 | expect(res.headers.get("set-cookie") ?? "").not.toContain(TOKEN); | |
| 92 | const page = await landing(cookie, res); | |
| 93 | expect(page).toContain("The provider did not accept that credential."); | |
| 94 | expect(page).not.toContain(TOKEN); | |
| 95 | expect(page).not.toContain(RAW); | |
| 96 | expect(page).not.toContain("answered 401"); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a non-admin and a cross-origin post are refused, and no provider call is made", async () => { | |
| 100 | mode = "ok"; | |
| 101 | const bob = await login("bob"); | |
| 102 | const alice = await login("alice"); | |
| 103 | const before = calls; | |
| 104 | expect((await verify(bob)).status).toBe(404); | |
| 105 | expect((await verify(alice, "https://evil.example")).status).toBe(403); | |
| 106 | const anon = await verify(""); | |
| 107 | expect(anon.headers.get("location") ?? "").not.toBe("/settings/cloud"); | |
|
||
| 108 | expect(calls).toBe(before); | |
| 109 | }); | |
| 110 | }); |
new above
offline
net.fetch is replaced at module scope and never restored. Save the original and put it back in afterAll so other files in the same bun run keep the real network seam.