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

Merged opened this

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

Merged

scout/forge:scout/8034-a-test-fails-when-a-terminal-screen-call → main 3 commits fork

  • Merged. scout/8034-a-test-fails-when-a-terminal-screen-call was merged into main as 30f7821.

Review

Sign in to review this change.
3 files changed +283 −2
src/tui/screens/egress.ts +16 −2
@@ -1,7 +1,8 @@
11 /**
22 * An app's private destinations in the terminal, a site admin's: the list the
33 * 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.
56 */
67
78 import {
@@ -91,7 +92,20 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
9192 return true;
9293 }
9394 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 );
95109 return true;
96110 }
97111 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", () => {
145145 expect(h.text()).toContain("granted by ada");
146146 expect(changes().map((e) => e.kind)).toEqual(["app.egress.granted"]);
147147 await h.press("x");
148 expect(h.text()).toContain(`Stop acme/arm reaching ${ARM}?`);
149 expect(grants()).toEqual([ARM]);
150 await h.press("y");
148151 expect(grants()).toEqual([]);
149152 expect(changes().map((e) => e.kind).sort()).toEqual(["app.egress.granted", "app.egress.revoked"]);
150153 h.stop();
151154 });
152155
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
153180 test("a site admin who presses g then n grants nothing", async () => {
154181 const before = changes().length;
155182 const h = boot(ada);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs