Public: anyone can read this; the crew can change it.
A test fails when a terminal screen calls a destructive action without asking first #8036
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8034-a-test-fails-when-a-terminal-screen-call → main 3 commits fork
3 files
changed +283
−2
src/tui/screens/egress.ts +16 −2
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * An app's private destinations in the terminal, a site admin's: the list the |
| 3 | 3 | * settings page shows, with `g` to grant the one under the cursor once `y` |
| 4 | * confirms it (a grant widens the boundary) and `x` to revoke it. The actions decide who may; anyone else is offered nothing. | |
| 4 | * confirms it (a grant widens the boundary) and `x` to revoke it once `y` | |
| 5 | * confirms that too. The actions decide who may; anyone else is offered nothing. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { |
| @@ -91,7 +92,20 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 91 | 92 | return true; |
| 92 | 93 | } |
| 93 | 94 | if (e.key === "x" && row !== undefined) { |
| 94 | change(revokeAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is revoked.`); | |
| 95 | const { destination } = row; | |
| 96 | ctx.push( | |
| 97 | confirmScreen(ctx, screen, { | |
| 98 | title: `Stop ${repo.ownerName}/${repo.name} reaching ${destination}?`, | |
| 99 | body: ["The app's outbound traffic to it is refused until g grants it again."], | |
| 100 | actions: [{ label: "Revoke", variant: "danger" }, { label: "Cancel", variant: "ghost" }], | |
| 101 | yesNo: true, | |
| 102 | width: 78, | |
| 103 | onChoose: (choice) => { | |
| 104 | if (choice !== 0) return; | |
| 105 | change(revokeAppEgress(f.app, f.user, repo, destination), `${destination} is revoked.`); | |
| 106 | }, | |
| 107 | }), | |
| 108 | ); | |
| 95 | 109 | return true; |
| 96 | 110 | } |
| 97 | 111 | return state.key(e, rows.length); |
test/tui-asks-first.test.ts added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | // Every terminal key that deletes, removes, revokes, stops or detaches asks | |
| 2 | // first, as test/danger-buttons.test.ts holds the web to. A call to one of the | |
| 3 | // destructive actions below passes when it sits inside an `onChoose` or | |
| 4 | // `onSubmit` callback (confirmScreen / promptScreen), or under a `y` key check | |
| 5 | // a few lines above it. The scan is plain text, kept narrow so it never fails | |
| 6 | // on correct code; anything else is named in `allowed` with its reason. | |
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { readdirSync, readFileSync } from "node:fs"; | |
| 9 | import { join, relative } from "node:path"; | |
| 10 | ||
| 11 | const root = join(import.meta.dir, ".."); | |
| 12 | const screens = join(root, "src", "tui", "screens"); | |
| 13 | ||
| 14 | /** The destructive actions from src/actions/ a terminal key must ask before calling. */ | |
| 15 | const destructive: readonly string[] = [ | |
| 16 | "deleteAgent", | |
| 17 | "deleteAppEnvVar", | |
| 18 | "deleteBoard", | |
| 19 | "deleteBranch", | |
| 20 | "deleteLabel", | |
| 21 | "deleteOriginApp", | |
| 22 | "deleteRepo", | |
| 23 | "deleteSshKey", | |
| 24 | "deleteTeam", | |
| 25 | "deleteToken", | |
| 26 | "deleteWebhook", | |
| 27 | "detachFromParent", | |
| 28 | "disableApp", | |
| 29 | "endAccountSession", | |
| 30 | "removeAccount", | |
| 31 | "removeAppDomain", | |
| 32 | "removeFromCrew", | |
| 33 | "removePromptSource", | |
| 34 | "removeSecret", | |
| 35 | "removeTeamMember", | |
| 36 | "revoke", | |
| 37 | "revokeAccountToken", | |
| 38 | "revokeAppEgress", | |
| 39 | "revokeProposal", | |
| 40 | "severForge", | |
| 41 | "stopEnv", | |
| 42 | "stopMirroring", | |
| 43 | "suspendAccount", | |
| 44 | ]; | |
| 45 | ||
| 46 | /** Today's calls that do not ask in a way the scan sees, one `file — action — reason` per line. */ | |
| 47 | const allowed: readonly { file: string; action: string; reason: string }[] = [ | |
| 48 | { file: "src/tui/screens/prompts.ts", action: "removePromptSource", reason: "in a named `choose` passed as onChoose" }, | |
| 49 | { file: "src/tui/screens/deploys.ts", action: "removeAppDomain", reason: "in a named `choose` passed to yesNo as onChoose" }, | |
| 50 | { file: "src/tui/screens/deploys.ts", action: "stopEnv", reason: "in `act`, called from `go` after the Stop confirm" }, | |
| 51 | { file: "src/tui/screens/boards.ts", action: "deleteBoard", reason: "in `remove`, called only on the y of the delete y/n" }, | |
| 52 | { file: "src/tui/screens/about.ts", action: "detachFromParent", reason: "the first call is the dry run; the real one is in onChoose" }, | |
| 53 | ]; | |
| 54 | ||
| 55 | /** How many lines above a call a `y` key check still guards it. */ | |
| 56 | const Y_REACH = 15; | |
| 57 | ||
| 58 | type Call = { file: string; line: number; action: string }; | |
| 59 | ||
| 60 | const callPattern = new RegExp(`(?<![\\w.$])(${destructive.join("|")})\\s*\\(`, "g"); | |
| 61 | ||
| 62 | /** A destructive action handed over as a value, `onChoose: deleteBoard` or `.then(stopEnv)`: the scan cannot see what calls it. */ | |
| 63 | const referencePattern = new RegExp(`(?<=[:,(=]\\s*)(${destructive.join("|")})(?=\\s*[),};])`, "g"); | |
| 64 | ||
| 65 | /** True when the call at `at` sits inside a block opened as an onChoose/onSubmit callback. */ | |
| 66 | function insideCallback(text: string, at: number): boolean { | |
| 67 | let depth = 0; | |
| 68 | for (let i = at - 1; i >= 0; i--) { | |
| 69 | const c = text[i]; | |
| 70 | if (c === "}" || c === ")") depth++; | |
| 71 | else if (c === "{" || c === "(") { | |
| 72 | if (depth > 0) { | |
| 73 | depth--; | |
| 74 | continue; | |
| 75 | } | |
| 76 | // An opener enclosing the call: is it `onChoose: (…) => {` or `onSubmit(…) {`? | |
| 77 | const before = text.slice(Math.max(0, i - 200), i + 1); | |
| 78 | if (/\b(onChoose|onSubmit)\s*(:\s*(async\s*)?(\([^()]*\)|\w+)\s*=>\s*[({]|\([^()]*\)\s*\{|:\s*(async\s*)?\()$/.test(before)) | |
| 79 | return true; | |
| 80 | } | |
| 81 | } | |
| 82 | return false; | |
| 83 | } | |
| 84 | ||
| 85 | const yCheck = /(===?\s*["']y["']|["']y["']\s*===?)/; | |
| 86 | ||
| 87 | /** A condition that holds only on `y`: it checks for y and offers no other way in. */ | |
| 88 | const onlyOnY = (condition: string): boolean => yCheck.test(condition) && !condition.includes("||"); | |
| 89 | ||
| 90 | /** | |
| 91 | * True when the call at `at` runs only on a `y` key: `if (… "y") call(…)` on its | |
| 92 | * own line, or inside the block of such an `if` opened within Y_REACH lines above. | |
| 93 | */ | |
| 94 | function yGuarded(text: string, at: number): boolean { | |
| 95 | const lineStart = text.lastIndexOf("\n", at) + 1; | |
| 96 | const sameLine = /\bif\s*\((.*)\)\s*(void\s+|await\s+|return\s+)?[\w.]*$/.exec(text.slice(lineStart, at)); | |
| 97 | if (sameLine && onlyOnY(sameLine[1] ?? "")) return true; | |
| 98 | let depth = 0; | |
| 99 | for (let i = at - 1; i >= 0; i--) { | |
| 100 | const c = text[i]; | |
| 101 | if (c === "}") depth++; | |
| 102 | else if (c === "{" && depth-- === 0) { | |
| 103 | const opened = /\bif\s*\(([^{}]*)\)\s*$/.exec(text.slice(Math.max(0, i - 300), i)); | |
| 104 | const lines = text.slice(i, at).split("\n").length - 1; | |
| 105 | return opened !== null && onlyOnY(opened[1] ?? "") && lines <= Y_REACH; | |
| 106 | } | |
| 107 | } | |
| 108 | return false; | |
| 109 | } | |
| 110 | ||
| 111 | /** Destructive calls in one screen file that neither sit in a callback nor under a y check. */ | |
| 112 | function unasked(file: string, text: string): Call[] { | |
| 113 | const found: Call[] = []; | |
| 114 | for (const m of text.matchAll(callPattern)) { | |
| 115 | const at = m.index; | |
| 116 | const lineStart = text.lastIndexOf("\n", at) + 1; | |
| 117 | const head = text.slice(lineStart, at); | |
| 118 | if (/^\s*(import\b|export\b|\/\/|\*)/.test(head) || /\bfunction\s+$/.test(head)) continue; | |
| 119 | const line = text.slice(0, at).split("\n").length; | |
| 120 | if (insideCallback(text, at) || yGuarded(text, at)) continue; | |
| 121 | found.push({ file, line, action: m[1] ?? "" }); | |
| 122 | } | |
| 123 | for (const m of text.matchAll(referencePattern)) { | |
| 124 | const lineStart = text.lastIndexOf("\n", m.index) + 1; | |
| 125 | if (/^\s*(import\b|export\b|\/\/|\*)/.test(text.slice(lineStart, m.index))) continue; | |
| 126 | // A name in an import list that spans lines: an `import` above with no `;` since. | |
| 127 | const imported = text.lastIndexOf("import ", m.index); | |
| 128 | if (imported >= 0 && !text.slice(imported, m.index).includes(";")) continue; | |
| 129 | found.push({ file, line: text.slice(0, m.index).split("\n").length, action: m[1] ?? "" }); | |
| 130 | } | |
| 131 | return found; | |
| 132 | } | |
| 133 | ||
| 134 | function sources(dir: string): string[] { | |
| 135 | return readdirSync(dir, { recursive: true, encoding: "utf8" }) | |
| 136 | .filter((f) => f.endsWith(".ts")) | |
| 137 | .map((f) => join(dir, f)); | |
| 138 | } | |
| 139 | ||
| 140 | describe("terminal asks first", () => { | |
| 141 | test("a bare destructive call on a key branch is found", () => { | |
| 142 | const screen = [ | |
| 143 | "onKey(key) {", | |
| 144 | ' if (key.name === "d") {', | |
| 145 | " void deleteBoard(f.app, f.user, slug);", | |
| 146 | " }", | |
| 147 | "}", | |
| 148 | ].join("\n"); | |
| 149 | expect(unasked("x.ts", screen)).toEqual([{ file: "x.ts", line: 3, action: "deleteBoard" }]); | |
| 150 | }); | |
| 151 | ||
| 152 | test("confirm callbacks, prompt submits and y checks are not", () => { | |
| 153 | const cases = [ | |
| 154 | "confirmScreen(ctx, screen, {\n onChoose: (i) => {\n if (i !== 1) return;\n deleteWebhook(f.app, f.user, repo, id);\n },\n});", | |
| 155 | "confirmScreen(ctx, screen, {\n onChoose: async (i) => {\n await deleteBoard(f.app, f.user, slug);\n },\n});", | |
| 156 | "promptScreen(ctx, screen, {\n onSubmit: (value) => {\n if (value === name) void deleteRepo(f.app, f.user, name);\n },\n});", | |
| 157 | 'promptScreen(ctx, screen, {\n onSubmit(value) {\n removeSecret(f.app, f.user, "repo", ref, value);\n },\n});', | |
| 158 | 'if (asking && key.name === "y") {\n asking = false;\n removeAppDomain(f.app, f.user, repo, host);\n}', | |
| 159 | 'if (e.key === "y") void deleteBoard(f.app, f.user, slug);', | |
| 160 | "promptScreen(ctx, screen, {\n onSubmit: async (value) => {\n if (value !== name) return;\n await deleteRepo(f.app, f.user, name);\n },\n});", | |
| 161 | 'import { deleteBoard } from "../../actions/boards.ts";', | |
| 162 | "// deleteBoard(f.app, f.user, slug) is asked first", | |
| 163 | "const undeleteBoard = (x) => restoreBoard(x);", | |
| 164 | "f.deleteBoard(x);", | |
| 165 | ]; | |
| 166 | for (const screen of cases) expect(unasked("x.ts", screen)).toEqual([]); | |
| 167 | }); | |
| 168 | ||
| 169 | test("a named function passed as onChoose is not followed, so its call is found and listed", () => { | |
| 170 | const screen = [ | |
| 171 | "const choose = (choice) => {", | |
| 172 | " if (choice !== 0) return;", | |
| 173 | " removeAppDomain(f.app, f.user, repo, host);", | |
| 174 | "};", | |
| 175 | "ctx.push(yesNo(ctx, screen, { onChoose: choose }));", | |
| 176 | ].join("\n"); | |
| 177 | expect(unasked("x.ts", screen)).toEqual([{ file: "x.ts", line: 3, action: "removeAppDomain" }]); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a destructive action passed by reference is found, since nothing shows who calls it", () => { | |
| 181 | const screen = [ | |
| 182 | "confirmScreen(ctx, screen, { onChoose: revokeAppEgress });", | |
| 183 | "void plan.then(stopEnv);", | |
| 184 | "const run = deleteBoard;", | |
| 185 | ].join("\n"); | |
| 186 | expect(unasked("x.ts", screen)).toEqual([ | |
| 187 | { file: "x.ts", line: 1, action: "revokeAppEgress" }, | |
| 188 | { file: "x.ts", line: 2, action: "stopEnv" }, | |
| 189 | { file: "x.ts", line: 3, action: "deleteBoard" }, | |
| 190 | ]); | |
| 191 | const names = ['import {\n deleteBoard,\n showBoard,\n} from "../../actions/boards.ts";', '// the action is `deleteBoard`, asked first', 'binding(["x"], "revoke")']; | |
| 192 | for (const text of names) expect(unasked("x.ts", text)).toEqual([]); | |
| 193 | }); | |
| 194 | ||
| 195 | test("a call outside the callback, after it closes, is found", () => { | |
| 196 | const screen = "confirmScreen(ctx, screen, {\n onChoose: (i) => {\n note = String(i);\n },\n});\nstopEnv(f.app, f.user, repo, env);"; | |
| 197 | expect(unasked("x.ts", screen)).toEqual([{ file: "x.ts", line: 6, action: "stopEnv" }]); | |
| 198 | }); | |
| 199 | ||
| 200 | test("a call beside a y/n check, not under its y, is found", () => { | |
| 201 | const screen = [ | |
| 202 | "if (asking) {", | |
| 203 | ' if (e.key === "y" || e.key === "n") {', | |
| 204 | ' if (e.key === "D") void deleteBoard(f.app, f.user, slug);', | |
| 205 | ' if (e.key === "y") remove(row);', | |
| 206 | " }", | |
| 207 | "}", | |
| 208 | ].join("\n"); | |
| 209 | expect(unasked("x.ts", screen)).toEqual([{ file: "x.ts", line: 3, action: "deleteBoard" }]); | |
| 210 | }); | |
| 211 | ||
| 212 | test("every destructive call in src/tui/screens asks first or is on the list", () => { | |
| 213 | const found = sources(screens).flatMap((path) => | |
| 214 | unasked(relative(root, path).replaceAll("\\", "/"), readFileSync(path, "utf8")), | |
| 215 | ); | |
| 216 | // Each listed case covers one call, so a second bare call in the same file still fails. | |
| 217 | const left = [...allowed]; | |
| 218 | const listed = (c: Call): boolean => { | |
| 219 | const i = left.findIndex((a) => a.file === c.file && a.action === c.action); | |
| 220 | if (i >= 0) left.splice(i, 1); | |
| 221 | return i >= 0; | |
| 222 | }; | |
| 223 | // A gone case only prints: the pull that fixes a listed screen should not have to | |
| 224 | // edit this file too, so pulls touching the list land in any order (#8034). | |
| 225 | const stale = allowed.filter((a) => !found.some((c) => c.file === a.file && c.action === a.action)); | |
| 226 | if (stale.length > 0) | |
| 227 | console.log( | |
| 228 | `tui-asks-first: these listed cases are gone; drop them from the list:\n${stale | |
| 229 | .map((a) => ` ${a.file} — ${a.action} — ${a.reason}`) | |
| 230 | .join("\n")}`, | |
| 231 | ); | |
| 232 | const bare = found | |
| 233 | .filter((c) => !listed(c)) | |
| 234 | .map( | |
| 235 | (c) => | |
| 236 | `${c.file}:${c.line} ${c.action} runs without asking: ask first: confirmScreen or a y/n, as src/tui/screens/hooks.ts does`, | |
| 237 | ); | |
| 238 | expect(bare).toEqual([]); | |
| 239 | }); | |
| 240 | }); |
test/tui-egress.test.ts +27
| @@ -145,11 +145,38 @@ describe("private destinations in the terminal", () => { | ||
| 145 | 145 | expect(h.text()).toContain("granted by ada"); |
| 146 | 146 | expect(changes().map((e) => e.kind)).toEqual(["app.egress.granted"]); |
| 147 | 147 | await h.press("x"); |
| 148 | expect(h.text()).toContain(`Stop acme/arm reaching ${ARM}?`); | |
| 149 | expect(grants()).toEqual([ARM]); | |
| 150 | await h.press("y"); | |
| 148 | 151 | expect(grants()).toEqual([]); |
| 149 | 152 | expect(changes().map((e) => e.kind).sort()).toEqual(["app.egress.granted", "app.egress.revoked"]); |
| 150 | 153 | h.stop(); |
| 151 | 154 | }); |
| 152 | 155 | |
| 156 | test("a site admin who presses x then n or esc revokes nothing", async () => { | |
| 157 | const h = boot(ada); | |
| 158 | await h.toAbout(); | |
| 159 | await h.press("e"); | |
| 160 | await h.press("g"); | |
| 161 | await h.press("y"); | |
| 162 | expect(grants()).toEqual([ARM]); | |
| 163 | const before = changes().length; | |
| 164 | await h.press("x"); | |
| 165 | expect(h.text()).toContain(`Stop acme/arm reaching ${ARM}?`); | |
| 166 | expect(h.text()).toContain("refused until g grants it again."); | |
| 167 | await h.press("n"); | |
| 168 | expect(h.text()).not.toContain(`Stop acme/arm reaching ${ARM}?`); | |
| 169 | await h.press("x"); | |
| 170 | await h.press("esc"); | |
| 171 | expect(h.text()).not.toContain(`Stop acme/arm reaching ${ARM}?`); | |
| 172 | expect(grants()).toEqual([ARM]); | |
| 173 | expect(changes().length).toBe(before); | |
| 174 | await h.press("x"); | |
| 175 | await h.press("y"); | |
| 176 | expect(grants()).toEqual([]); | |
| 177 | h.stop(); | |
| 178 | }); | |
| 179 | ||
| 153 | 180 | test("a site admin who presses g then n grants nothing", async () => { |
| 154 | 181 | const before = changes().length; |
| 155 | 182 | const h = boot(ada); |
new above
offline