Public: anyone can read this; the crew can change it.
A test proves the terminal's app variables screen: an admin adds a masked value that is never drawn, toasted or logged, and unsets one after a confirm #7604
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7591-a-test-proves-the-terminal-s-app-variabl → main 1 commit fork
1 file
changed +125
−0
test/tui-appenv.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * What the terminal's variables screen (src/tui/screens/appenv.ts, #7232) | |
| 3 | * promises about a secret: the value typed into the masked field is in no | |
| 4 | * frame, no toast and no log line, and `d` unsets only after a confirm. The | |
| 5 | * flow itself is in tui-app-env.test.ts; this file watches the console and | |
| 6 | * every frame while an admin adds, declines, and confirms. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, spyOn, 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 { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { APP_ENV_ERRORS } from "../src/actions/app_env_words.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { listAppEnvNames } from "../src/core/app_env.ts"; | |
| 16 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 17 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { logLevel, setLogLevel, type LogLevel } from "../src/kernel/log.ts"; | |
| 20 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 21 | import { repoScreen } from "../src/tui/screens/repo.ts"; | |
| 22 | import { local } from "../src/tui/screens/where.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | import { proveAll } from "./helpers/verified.ts"; | |
| 26 | ||
| 27 | const SECRET = "hunter2_Zq9xW"; | |
| 28 | const RIGHT = "\x1b[C"; | |
| 29 | const DOWN = "\x1b[B"; | |
| 30 | const open = [":", "Variables", "\r"]; | |
| 31 | ||
| 32 | let dir: string; | |
| 33 | let app: App; | |
| 34 | let admin: User; | |
| 35 | let writer: User; | |
| 36 | let shop: Repo; | |
| 37 | let found: LogLevel; | |
| 38 | const frames: string[] = []; | |
| 39 | const logged: string[] = []; | |
| 40 | const spies: { mockRestore(): void }[] = []; | |
| 41 | ||
| 42 | const vars = () => listAppEnvNames(app.db, getAppByRepo(app.db, shop.id)!.id); | |
| 43 | ||
| 44 | async function drive(user: User, keys: string[]): Promise<string> { | |
| 45 | const terminal = headlessTerminal(120, 40); | |
| 46 | const { app: tui, session } = createForgeApp( | |
| 47 | app, | |
| 48 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 49 | { terminal, exitHooks: false }, | |
| 50 | ); | |
| 51 | tui.start(); | |
| 52 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, shop))); | |
| 53 | await settleApp(tui); | |
| 54 | for (const k of keys) { | |
| 55 | terminal.feed(k); | |
| 56 | await settleApp(tui); | |
| 57 | frames.push(tui.screen.toText()); | |
| 58 | } | |
| 59 | const text = tui.screen.toText(); | |
| 60 | tui.stop?.(); | |
| 61 | return text; | |
| 62 | } | |
| 63 | ||
| 64 | beforeAll(async () => { | |
| 65 | found = logLevel(); | |
| 66 | setLogLevel("debug"); | |
| 67 | for (const m of ["log", "info", "warn", "error", "debug"] as const) { | |
| 68 | spies.push(spyOn(console, m).mockImplementation((...a: unknown[]) => void logged.push(a.join(" ")))); | |
| 69 | } | |
| 70 | dir = await mkdtemp(join(tmpdir(), "forge-tui-appenv-")); | |
| 71 | app = createApp({ dataDir: dir, port: 0 }); | |
| 72 | const mk = async (username: string, first = false) => { | |
| 73 | const r = await createUser(app.db, { | |
| 74 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 75 | password: "a-good-passphrase", adminIfFirst: first, | |
| 76 | }); | |
| 77 | if (!r.ok) throw new Error(r.error); | |
| 78 | return r.value; | |
| 79 | }; | |
| 80 | admin = await mk("alice", true); | |
| 81 | writer = await mk("walt"); | |
| 82 | proveAll(app); | |
| 83 | const made = await newApp(app, admin, { name: "shop", visibility: "public" }); | |
| 84 | if (!made.ok) throw new Error(String(made.error)); | |
| 85 | shop = getRepo(app.db, admin.username, "shop")!; | |
| 86 | }); | |
| 87 | afterAll(async () => { | |
| 88 | for (const s of spies) s.mockRestore(); | |
| 89 | setLogLevel(found); | |
| 90 | app.db.close(); | |
| 91 | await rm(dir, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | describe("the terminal's variables screen", () => { | |
| 95 | test("an admin adds a masked value: name and scope listed, the value nowhere", async () => { | |
| 96 | const text = await drive(admin, [...open, "a", "API_TOKEN", "\r", SECRET, "\r", DOWN, "\r"]); | |
| 97 | expect(vars().map((v) => [v.name, v.scope])).toEqual([["API_TOKEN", "production"]]); | |
| 98 | expect(text).toContain("API_TOKEN is set for production."); | |
| 99 | expect(text).toContain("production · set"); | |
| 100 | expect(frames.some((f) => f.includes("•".repeat(SECRET.length)))).toBe(true); | |
| 101 | for (const f of frames) expect(f).not.toContain(SECRET); | |
| 102 | expect(logged.join("\n")).not.toContain(SECRET); | |
| 103 | }); | |
| 104 | ||
| 105 | test("d asks first; keeping it leaves it, confirming unsets it", async () => { | |
| 106 | const asked = await drive(admin, [...open, "d"]); | |
| 107 | expect(asked).toContain("Unset this variable"); | |
| 108 | expect(vars().length).toBe(1); | |
| 109 | await drive(admin, [...open, "d", "\r"]); | |
| 110 | expect(vars().length).toBe(1); | |
| 111 | const text = await drive(admin, [...open, "d", RIGHT, "\r"]); | |
| 112 | expect(vars()).toEqual([]); | |
| 113 | expect(text).toContain("API_TOKEN is unset for production."); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a non-admin is not offered the entry; a bad name is refused in MCP's words", async () => { | |
| 117 | expect(await drive(writer, [":", "Variables"])).not.toContain("— Variables"); | |
| 118 | expect(await drive(admin, [":", "Variables"])).toContain("— Variables"); | |
| 119 | const text = await drive(admin, [...open, "a", "bad name", "\r", SECRET, "\r", "\r"]); | |
| 120 | expect(text).toContain(APP_ENV_ERRORS["invalid_name"]!.slice(0, 50)); | |
| 121 | expect(vars()).toEqual([]); | |
| 122 | for (const f of frames) expect(f).not.toContain(SECRET); | |
| 123 | expect(logged.join("\n")).not.toContain(SECRET); | |
| 124 | }); | |
| 125 | }); |
new above
offline