Public: anyone can read this; the crew can change it.
A test pins proposal tokens in the terminal: shown once, minted only by who may, revoked for good #6992
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6979-a-test-pins-proposal-tokens-in-the-termi → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +146
−0
test/tui-proposal-tokens.test.ts added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | /** | |
| 2 | * Proposal tokens in the terminal (#6979): the secret shows once on its own | |
| 3 | * screen, only a writer mints, and a revoked token is refused by the API. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const NOT_VISIBLE = "no such resource here, or it is not visible to you"; | |
| 21 | const count = (app: App) => | |
| 22 | app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM proposal_token").get()!.n; | |
| 23 | ||
| 24 | describe("proposal tokens in the terminal", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | ||
| 29 | function boot(username: string) { | |
| 30 | const terminal = headlessTerminal(140, 32); | |
| 31 | const user = getUserByName(app.db, username)!; | |
| 32 | const { app: tui } = createForgeApp( | |
| 33 | app, | |
| 34 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 35 | { terminal, exitHooks: false }, | |
| 36 | ); | |
| 37 | tui.start(); | |
| 38 | const key = (k: string, ctrl = false) => | |
| 39 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 40 | return { | |
| 41 | text: () => tui.screen.toText(), | |
| 42 | press: async (...keys: string[]) => { | |
| 43 | keys.forEach((k) => key(k)); | |
| 44 | await settleApp(tui); | |
| 45 | }, | |
| 46 | type: async (t: string) => { | |
| 47 | for (const ch of t) key(ch); | |
| 48 | await settleApp(tui); | |
| 49 | }, | |
| 50 | open: async function () { | |
| 51 | key("k", true); | |
| 52 | await settleApp(tui); | |
| 53 | await this.type("Proposal tokens"); | |
| 54 | await this.press("enter"); | |
| 55 | await this.type("alice/lib"); | |
| 56 | await this.press("enter"); | |
| 57 | }, | |
| 58 | stop: () => tui.stop(), | |
| 59 | }; | |
| 60 | } | |
| 61 | ||
| 62 | const asProposal = (token: string) => | |
| 63 | fetch(`${servers.url}/api/v1/user`, { headers: { authorization: `Bearer ${token}` } }); | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-proposal-")); | |
| 67 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 68 | servers = await serve(app); | |
| 69 | let alice; | |
| 70 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 71 | const made = await createUser(app.db, { | |
| 72 | origin: "seed", | |
| 73 | trusted: true, | |
| 74 | username, | |
| 75 | email: `${username}@forge.local`, | |
| 76 | password: "a-good-passphrase", | |
| 77 | adminIfFirst: first, | |
| 78 | }); | |
| 79 | if (!made.ok) throw new Error(made.error); | |
| 80 | alice ??= made.value; | |
| 81 | } | |
| 82 | const repo = await createRepo(app, alice!, { name: "lib", visibility: "public" }); | |
| 83 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 84 | }); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | await servers.stop(); | |
| 88 | await rm(dataDir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("n mints; the secret shows once, then x revokes it and the API refuses it", async () => { | |
| 92 | const h = boot("alice"); | |
| 93 | await h.open(); | |
| 94 | expect(h.text()).toContain("None yet"); | |
| 95 | ||
| 96 | await h.press("n"); | |
| 97 | await h.type("kid-one"); | |
| 98 | await h.press("enter"); | |
| 99 | await h.type("https://kid.example.com/"); | |
| 100 | await h.press("enter"); | |
| 101 | ||
| 102 | const secret = /forge_[A-Za-z0-9_-]+/.exec(h.text())?.[0] ?? ""; | |
| 103 | expect(secret).not.toBe(""); | |
| 104 | expect(h.text()).toContain("Shown once"); | |
| 105 | expect(count(app)).toBe(1); | |
| 106 | // A live proposal token is known but limited (403); a revoked one is nobody (401). | |
| 107 | expect((await asProposal(secret)).status).toBe(403); | |
| 108 | ||
| 109 | await h.press("esc"); | |
| 110 | expect(h.text()).toContain("kid-one"); | |
| 111 | expect(h.text()).toContain("https://kid.example.com/"); | |
| 112 | expect(h.text()).not.toContain(secret); | |
| 113 | expect(h.text()).not.toContain("Shown once"); | |
| 114 | ||
| 115 | await h.press("x"); | |
| 116 | expect(h.text()).toContain("kid-one: revoked."); | |
| 117 | expect(h.text()).not.toContain(secret); | |
| 118 | expect(count(app)).toBe(0); | |
| 119 | expect((await asProposal(secret)).status).toBe(401); | |
| 120 | h.stop(); | |
| 121 | }); | |
| 122 | ||
| 123 | test("bad input mints nothing", async () => { | |
| 124 | const h = boot("alice"); | |
| 125 | await h.open(); | |
| 126 | await h.press("n"); | |
| 127 | await h.type("Bad Name"); | |
| 128 | await h.press("enter"); | |
| 129 | await h.type("https://kid.example.com/"); | |
| 130 | await h.press("enter"); | |
| 131 | expect(h.text()).toContain("lowercase words and hyphens"); | |
| 132 | expect(count(app)).toBe(0); | |
| 133 | h.stop(); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a person who may not write the repository sees the refusal and mints nothing", async () => { | |
| 137 | const h = boot("bob"); | |
| 138 | await h.open(); | |
| 139 | expect(h.text()).toContain(NOT_VISIBLE); | |
| 140 | expect(h.text()).not.toContain("Tokens you minted"); | |
| 141 | await h.press("n"); | |
| 142 | expect(h.text()).not.toContain("Mint a proposal token"); | |
| 143 | expect(count(app)).toBe(0); | |
| 144 | h.stop(); | |
| 145 | }); | |
| 146 | }); |
new above
offline
Minor, non-blocking: Bob's submit is refused, so he never reaches the list screen. The prompt stays open, and
nhere is typed into its text field. That means this assertion can't fail whatever the mint key does. The real guarantee is the refusal text pluscount(app) === 0, and both are checked. Next time this file is touched, drop thenpress or assert the prompt still shows the refusal.