Public: anyone can read this; the crew can change it.
The terminal lists an app's variables, sets one and unsets one, never showing a value, as MCP `app_env` does #7277
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7232-the-terminal-lists-an-app-s-variables-se → main 2 commits fork
6 files
changed +375
−22
docs/APPS.md +6
| @@ -711,6 +711,12 @@ again — the page, the API and the event outbox carry the name and when it | ||
| 711 | 711 | was set, nothing more. At rest each value is sealed with the data |
| 712 | 712 | directory's key (`kernel/secret.ts`), bound to the app and the name. |
| 713 | 713 | |
| 714 | The same list is in the terminal: an admin's repository screen has | |
| 715 | *Variables* in its `:` palette, which lists names and scopes, sets one with | |
| 716 | `a` (a name, a value typed masked, a scope) and unsets one with `d` after a | |
| 717 | confirm, refused in the page's words (`actions/app_env_words.ts`). MCP | |
| 718 | `app_env` does the same for an agent. | |
| 719 | ||
| 714 | 720 | A variable has a **scope**: everywhere (`all`, the default), `production` |
| 715 | 721 | only, or `preview` only — the select beside the value, or `"scope"` in the |
| 716 | 722 | `PUT` body and `?scope=` on the `DELETE`. The same name may be set once in |
src/actions/app_env_words.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The words a person reads about an app's variables: where a scope sends | |
| 3 | * one, and why `setAppEnvVar` or `deleteAppEnvVar` refused. The settings | |
| 4 | * page and the terminal both say them, so they are said from here. | |
| 5 | */ | |
| 6 | ||
| 7 | import { NAME_MAX, type AppEnvScope } from "../core/app_env.ts"; | |
| 8 | ||
| 9 | /** Where a variable in a scope goes. */ | |
| 10 | export const SCOPE_WORDS: Record<AppEnvScope, string> = { | |
| 11 | all: "everywhere", | |
| 12 | production: "production", | |
| 13 | preview: "previews", | |
| 14 | }; | |
| 15 | ||
| 16 | /** `setAppEnvVar`'s refusals, by code. */ | |
| 17 | export const APP_ENV_ERRORS: Record<string, string> = { | |
| 18 | invalid_name: `A name is capital letters, digits and underscores, starting with a letter — ADMIN_PASSWORD, STRIPE_SECRET_KEY — and at most ${NAME_MAX} characters.`, | |
| 19 | reserved_name: | |
| 20 | "That name is the forge's own (PORT, HOST, PATH, FORGE_…); the app is already told it.", | |
| 21 | invalid_value: "A value is one line or many, never empty and under 64 KiB.", | |
| 22 | invalid_scope: "A variable goes everywhere, to production, or to previews.", | |
| 23 | }; | |
| 24 | ||
| 25 | /** `setAppEnvVar` refused with a code the table does not name. */ | |
| 26 | export const APP_ENV_NOT_SET = "That variable could not be set."; | |
| 27 | ||
| 28 | /** `deleteAppEnvVar` refused as `no_such_var`. */ | |
| 29 | export const APP_ENV_NO_SUCH = "No variable by that name."; |
src/tui/screens/appenv.ts added +169
| @@ -0,0 +1,169 @@ | ||
| 1 | /** | |
| 2 | * An app's variables in the terminal, an admin's (#7232): the names and | |
| 3 | * scopes the settings page lists, from a repository's `:` palette. `a` adds | |
| 4 | * or replaces one — a name, a value typed masked, a scope — and `d` unsets | |
| 5 | * the one under the cursor after a confirm, through the same actions as the | |
| 6 | * page and MCP `app_env`, refused in the same words. A value is write-only: | |
| 7 | * it is held in a closure from the masked field to the action and is never | |
| 8 | * drawn, toasted or logged. | |
| 9 | */ | |
| 10 | ||
| 11 | import { deleteAppEnvVar, listAppEnv, setAppEnvVar } from "../../actions/apps.ts"; | |
| 12 | import { | |
| 13 | APP_ENV_ERRORS, | |
| 14 | APP_ENV_NO_SUCH, | |
| 15 | APP_ENV_NOT_SET, | |
| 16 | SCOPE_WORDS, | |
| 17 | } from "../../actions/app_env_words.ts"; | |
| 18 | import { APP_ENV_SCOPES, type AppEnvName } from "../../core/app_env.ts"; | |
| 19 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 20 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 21 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 22 | import { binding } from "../app/keys.ts"; | |
| 23 | import type { Command } from "../app/palette.ts"; | |
| 24 | import { style } from "../term/style.ts"; | |
| 25 | import { wrap } from "../term/width.ts"; | |
| 26 | import { fill, len, split } from "../ui/layout.ts"; | |
| 27 | import type { Session } from "../session.ts"; | |
| 28 | import { | |
| 29 | ListState, | |
| 30 | confirmScreen, | |
| 31 | drawList, | |
| 32 | later, | |
| 33 | pickerScreen, | |
| 34 | promptScreen, | |
| 35 | runAsync, | |
| 36 | type ListRow, | |
| 37 | } from "./common.ts"; | |
| 38 | ||
| 39 | /** The palette's "Variables", for an admin of a repository that is an app. */ | |
| 40 | export function appEnvCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 41 | if (repo === undefined || f.user === undefined) return []; | |
| 42 | if (getAppByRepo(f.app.db, repo.id) === undefined || !canAdmin(f.app.db, repo, f.user)) return []; | |
| 43 | return [ | |
| 44 | { | |
| 45 | id: "app-env", | |
| 46 | title: `${repo.ownerName}/${repo.name} — Variables`, | |
| 47 | hint: "the app's environment: names and scopes, never a value", | |
| 48 | run: () => ctx.push(appEnvScreen(f, ctx, repo)), | |
| 49 | }, | |
| 50 | ]; | |
| 51 | } | |
| 52 | ||
| 53 | export function appEnvScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 54 | const state = new ListState(); | |
| 55 | let rows: AppEnvName[] = []; | |
| 56 | // Whether the action lists for this viewer; it decides, not the screen. | |
| 57 | let canChange = false; | |
| 58 | let note = ""; | |
| 59 | const reload = (): void => { | |
| 60 | const listed = listAppEnv(f.app, f.user, repo); | |
| 61 | canChange = listed.ok; | |
| 62 | rows = listed.ok ? listed.value : []; | |
| 63 | state.clamp(rows.length); | |
| 64 | ctx.invalidate(); | |
| 65 | }; | |
| 66 | reload(); | |
| 67 | const say = (text: string): void => { | |
| 68 | note = text; | |
| 69 | reload(); | |
| 70 | }; | |
| 71 | const where = (v: { scope: AppEnvName["scope"] }): string => | |
| 72 | v.scope === "all" ? "everywhere" : `for ${SCOPE_WORDS[v.scope]}`; | |
| 73 | ||
| 74 | const add = (): Screen => | |
| 75 | promptScreen(ctx, screen, { | |
| 76 | title: "Set a variable", | |
| 77 | label: "name, like STRIPE_SECRET_KEY", | |
| 78 | placeholder: "ADMIN_PASSWORD", | |
| 79 | onSubmit: (name) => { | |
| 80 | if (name === "") return "A variable needs a name."; | |
| 81 | return later(ctx, () => | |
| 82 | promptScreen(ctx, screen, { | |
| 83 | title: name, | |
| 84 | label: "the value (sealed, never shown again)", | |
| 85 | mask: true, | |
| 86 | onSubmit: (value) => | |
| 87 | later(ctx, () => | |
| 88 | pickerScreen(ctx, screen, { | |
| 89 | title: `Where ${name} goes`, | |
| 90 | items: APP_ENV_SCOPES.map((s) => ({ id: s, label: SCOPE_WORDS[s] })), | |
| 91 | onPick: ([scope]) => | |
| 92 | runAsync(ctx, async () => { | |
| 93 | const set = await setAppEnvVar(f.app, f.user, repo, name, value, scope); | |
| 94 | say( | |
| 95 | set.ok | |
| 96 | ? `${set.value.name} is set ${where(set.value)}.` | |
| 97 | : (APP_ENV_ERRORS[set.error] ?? APP_ENV_NOT_SET), | |
| 98 | ); | |
| 99 | }), | |
| 100 | }), | |
| 101 | ), | |
| 102 | }), | |
| 103 | ); | |
| 104 | }, | |
| 105 | }); | |
| 106 | ||
| 107 | const unset = (v: AppEnvName): Screen => | |
| 108 | confirmScreen(ctx, screen, { | |
| 109 | title: "Unset this variable", | |
| 110 | body: [`${v.name}, ${SCOPE_WORDS[v.scope]}`, "The app restarts without it."], | |
| 111 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Unset", variant: "danger" }], | |
| 112 | onChoose: (i) => { | |
| 113 | if (i !== 1) return; | |
| 114 | runAsync(ctx, async () => { | |
| 115 | const removed = await deleteAppEnvVar(f.app, f.user, repo, v.name, v.scope); | |
| 116 | say(removed.ok ? `${v.name} is unset ${where(v)}.` : APP_ENV_NO_SUCH); | |
| 117 | }); | |
| 118 | }, | |
| 119 | }); | |
| 120 | ||
| 121 | const lines = (): ListRow[] => | |
| 122 | rows.map((v) => ({ | |
| 123 | title: v.name, | |
| 124 | meta: `${SCOPE_WORDS[v.scope]} · set ${v.updatedAt.slice(0, 16).replace("T", " ")} UTC`, | |
| 125 | })); | |
| 126 | ||
| 127 | const screen: Screen = { | |
| 128 | title: "Variables", | |
| 129 | get keys() { | |
| 130 | return [ | |
| 131 | ...(canChange ? [binding(["a"], "set one"), binding(["d"], "unset")] : []), | |
| 132 | binding(["j", "k"], "move cursor"), | |
| 133 | ]; | |
| 134 | }, | |
| 135 | render(area, s) { | |
| 136 | const c = ctx.theme.c; | |
| 137 | const text = | |
| 138 | note !== "" | |
| 139 | ? note | |
| 140 | : !canChange | |
| 141 | ? "An app's variables are its admin's." | |
| 142 | : rows.length === 0 | |
| 143 | ? `${repo.ownerName}/${repo.name} sets no variable. Press a to set one.` | |
| 144 | : `${rows.length} set for ${repo.ownerName}/${repo.name}; a value is never shown.`; | |
| 145 | // A refusal is the page's sentence, whole: it wraps rather than cuts. | |
| 146 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); | |
| 147 | const [head, list] = split(area, "v", [len(said.length), fill(1)]); | |
| 148 | said.forEach((line, i) => | |
| 149 | s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w), | |
| 150 | ); | |
| 151 | drawList(s, list!, c, state, lines()); | |
| 152 | }, | |
| 153 | key(e) { | |
| 154 | if (state.key(e, rows.length)) return true; | |
| 155 | if (e.ctrl || e.alt || !canChange) return false; | |
| 156 | if (e.key === "a") { | |
| 157 | ctx.push(add()); | |
| 158 | return true; | |
| 159 | } | |
| 160 | const v = rows[state.index]; | |
| 161 | if (e.key === "d" && v !== undefined) { | |
| 162 | ctx.push(unset(v)); | |
| 163 | return true; | |
| 164 | } | |
| 165 | return false; | |
| 166 | }, | |
| 167 | }; | |
| 168 | return screen; | |
| 169 | } |
src/tui/screens/workspace.ts +2
| @@ -121,6 +121,7 @@ import { reportCommands } from "./report.ts"; | ||
| 121 | 121 | import { tellCommands } from "./tell.ts"; |
| 122 | 122 | import { workingCommands } from "./working.ts"; |
| 123 | 123 | import { yoursCommands } from "./yours.ts"; |
| 124 | import { appEnvCommands } from "./appenv.ts"; | |
| 124 | 125 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 125 | 126 | import { finderScreen } from "./finder.ts"; |
| 126 | 127 | import { issuesScreen } from "./issues.ts"; |
| @@ -1283,6 +1284,7 @@ export function workspaceScreen( | ||
| 1283 | 1284 | ...reportCommands(f, ctx, () => screen, repo), |
| 1284 | 1285 | ...noteCommands(f, ctx, () => screen, repo), |
| 1285 | 1286 | ...yoursCommands(f, ctx, repo), |
| 1287 | ...appEnvCommands(f, ctx, repo), | |
| 1286 | 1288 | ], |
| 1287 | 1289 | get title() { |
| 1288 | 1290 | return sourceName(src); |
src/web/pages/settings.ts +13 −22
| @@ -36,8 +36,13 @@ import { | ||
| 36 | 36 | listAppEnvNames, |
| 37 | 37 | NAME_MAX, |
| 38 | 38 | type AppEnvName, |
| 39 | type AppEnvScope, | |
| 40 | 39 | } from "../../core/app_env.ts"; |
| 40 | import { | |
| 41 | APP_ENV_ERRORS, | |
| 42 | APP_ENV_NO_SUCH, | |
| 43 | APP_ENV_NOT_SET, | |
| 44 | SCOPE_WORDS, | |
| 45 | } from "../../actions/app_env_words.ts"; | |
| 41 | 46 | import { isAutonomy, isDecisionsBy } from "../../core/repos.ts"; |
| 42 | 47 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 43 | 48 | import { appsRuntime } from "../../apps/runtime.ts"; |
| @@ -734,17 +739,6 @@ function appSettingsView( | ||
| 734 | 739 | </div>`; |
| 735 | 740 | } |
| 736 | 741 | |
| 737 | /** | |
| 738 | * The app's environment: the names that are set, and a form for one more. | |
| 739 | * Values are write-only — the page shows a name and when it was set, and | |
| 740 | * the value input forgets what was typed the moment the form is sent. | |
| 741 | */ | |
| 742 | const SCOPE_WORDS: Record<AppEnvScope, string> = { | |
| 743 | all: "everywhere", | |
| 744 | production: "production", | |
| 745 | preview: "previews", | |
| 746 | }; | |
| 747 | ||
| 748 | 742 | /** Production's visitors and the speed they saw, from the app's own beacon. */ |
| 749 | 743 | export function appVisitorsView(v: AppVitals): Html { |
| 750 | 744 | const known = v.loadDays.filter((n): n is number => n !== null); |
| @@ -775,6 +769,11 @@ export function appVisitorsView(v: AppVitals): Html { | ||
| 775 | 769 | ); |
| 776 | 770 | } |
| 777 | 771 | |
| 772 | /** | |
| 773 | * The app's environment: the names that are set, and a form for one more. | |
| 774 | * Values are write-only — the page shows a name and when it was set, and | |
| 775 | * the value input forgets what was typed the moment the form is sent. | |
| 776 | */ | |
| 778 | 777 | function appEnvView(base: string, names: AppEnvName[]): Html { |
| 779 | 778 | const rows = names.map((v) => |
| 780 | 779 | row({ |
| @@ -1160,14 +1159,6 @@ export async function appDisable(ctx: Ctx): Promise<Response> { | ||
| 1160 | 1159 | ); |
| 1161 | 1160 | } |
| 1162 | 1161 | |
| 1163 | const APP_ENV_ERRORS: Record<string, string> = { | |
| 1164 | invalid_name: `A name is capital letters, digits and underscores, starting with a letter — ADMIN_PASSWORD, STRIPE_SECRET_KEY — and at most ${NAME_MAX} characters.`, | |
| 1165 | reserved_name: | |
| 1166 | "That name is the forge's own (PORT, HOST, PATH, FORGE_…); the app is already told it.", | |
| 1167 | invalid_value: "A value is one line or many, never empty and under 64 KiB.", | |
| 1168 | invalid_scope: "A variable goes everywhere, to production, or to previews.", | |
| 1169 | }; | |
| 1170 | ||
| 1171 | 1162 | // POST /:owner/:name/settings/app/env — set one variable. |
| 1172 | 1163 | export async function appEnvSet(ctx: Ctx): Promise<Response> { |
| 1173 | 1164 | const gate = await adminForm(ctx); |
| @@ -1193,7 +1184,7 @@ export async function appEnvSet(ctx: Ctx): Promise<Response> { | ||
| 1193 | 1184 | : { |
| 1194 | 1185 | tone: "danger", |
| 1195 | 1186 | message: |
| 1196 | APP_ENV_ERRORS[set.error] ?? "That variable could not be set.", | |
| 1187 | APP_ENV_ERRORS[set.error] ?? APP_ENV_NOT_SET, | |
| 1197 | 1188 | }, |
| 1198 | 1189 | ); |
| 1199 | 1190 | } |
| @@ -1218,7 +1209,7 @@ export async function appEnvDelete(ctx: Ctx): Promise<Response> { | ||
| 1218 | 1209 | `${repoBase(repo)}/settings/app`, |
| 1219 | 1210 | removed.ok |
| 1220 | 1211 | ? { message: `${name} is removed.${redeploying(ctx, "without it")}` } |
| 1221 | : { tone: "danger", message: "No variable by that name." }, | |
| 1212 | : { tone: "danger", message: APP_ENV_NO_SUCH }, | |
| 1222 | 1213 | ); |
| 1223 | 1214 | } |
| 1224 | 1215 |
test/tui-app-env.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** | |
| 2 | * An app's variables in the terminal (#7232): an admin opens "Variables" | |
| 3 | * from the repository's `:` palette, sets one through a masked field and a | |
| 4 | * scope, sees its name and scope listed and never its value, and unsets it | |
| 5 | * after a confirm. A refusal reads in the settings page's words. A writer | |
| 6 | * who is not an admin is offered no entry. Every frame drawn on the way is | |
| 7 | * kept, and none of them holds the value. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { APP_ENV_ERRORS } from "../src/actions/app_env_words.ts"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { listAppEnvNames } from "../src/core/app_env.ts"; | |
| 17 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 18 | import { listEvents } from "../src/core/events.ts"; | |
| 19 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 20 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import { repoScreen } from "../src/tui/screens/repo.ts"; | |
| 24 | import { local } from "../src/tui/screens/where.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | import { proveAll } from "./helpers/verified.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | const SECRET = "sk_live_b4n4n4s"; | |
| 33 | const RIGHT = "\x1b[C"; | |
| 34 | const DOWN = "\x1b[B"; | |
| 35 | ||
| 36 | let dir: string; | |
| 37 | let app: App; | |
| 38 | let alice: User; // admin of acme, and so of the app | |
| 39 | let bob: User; // writes to it through a team, administers nothing | |
| 40 | let shop: Repo; | |
| 41 | /** Every frame any session in this file drew, after every key. */ | |
| 42 | const frames: string[] = []; | |
| 43 | ||
| 44 | const vars = () => listAppEnvNames(app.db, getAppByRepo(app.db, shop.id)!.id); | |
| 45 | ||
| 46 | /** Open the repository as `user`, feed `keys` one chunk at a time, keep each frame. */ | |
| 47 | async function drive(user: User, keys: string[]): Promise<string> { | |
| 48 | const terminal = headlessTerminal(120, 40); | |
| 49 | const { app: tui, session } = createForgeApp( | |
| 50 | app, | |
| 51 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 52 | { terminal, exitHooks: false }, | |
| 53 | ); | |
| 54 | tui.start(); | |
| 55 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, shop))); | |
| 56 | await settleApp(tui); | |
| 57 | frames.push(tui.screen.toText()); | |
| 58 | for (const k of keys) { | |
| 59 | terminal.feed(k); | |
| 60 | await settleApp(tui); | |
| 61 | frames.push(tui.screen.toText()); | |
| 62 | } | |
| 63 | const text = tui.screen.toText(); | |
| 64 | tui.stop?.(); | |
| 65 | return text; | |
| 66 | } | |
| 67 | ||
| 68 | const open = [":", "Variables", "\r"]; | |
| 69 | ||
| 70 | beforeAll(async () => { | |
| 71 | dir = await mkdtemp(join(tmpdir(), "forge-tui-app-env-")); | |
| 72 | app = createApp({ dataDir: dir, port: 0 }); | |
| 73 | const mk = async (username: string, first = false) => { | |
| 74 | const r = await createUser(app.db, { | |
| 75 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 76 | password: "a-good-passphrase", adminIfFirst: first, | |
| 77 | }); | |
| 78 | if (!r.ok) throw new Error(r.error); | |
| 79 | return r.value; | |
| 80 | }; | |
| 81 | alice = await mk("alice", true); | |
| 82 | bob = await mk("bob"); | |
| 83 | proveAll(app); | |
| 84 | const org = createOrg(app.db, "acme", alice.id); | |
| 85 | if (!org.ok) throw new Error("org"); | |
| 86 | const team = createTeam(app.db, org.value.id, "writers", "write"); | |
| 87 | if (!team.ok) throw new Error("team"); | |
| 88 | addTeamMember(app.db, team.value, bob.id); | |
| 89 | const made = await newApp(app, alice, { name: "shop", owner: "acme", visibility: "public" }); | |
| 90 | if (!made.ok) throw new Error(String(made.error)); | |
| 91 | shop = getRepo(app.db, "acme", "shop")!; | |
| 92 | }); | |
| 93 | afterAll(async () => { | |
| 94 | app.db.close(); | |
| 95 | await rm(dir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | describe("an app's variables, in the terminal", () => { | |
| 99 | test("an admin sets one: masked as it is typed, then listed by name and scope", async () => { | |
| 100 | const text = await drive(alice, [...open, "a", "STRIPE_KEY", "\r", SECRET, "\r", DOWN, "\r"]); | |
| 101 | expect(vars().map((v) => [v.name, v.scope])).toEqual([["STRIPE_KEY", "production"]]); | |
| 102 | expect(text).toContain("STRIPE_KEY is set for production."); | |
| 103 | expect(text).toContain("STRIPE_KEY"); | |
| 104 | // The masked field drew one dot per character the moment it held the value. | |
| 105 | expect(frames.some((f) => f.includes("•".repeat(SECRET.length)))).toBe(true); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the list shows the name and scope, never the value", async () => { | |
| 109 | const text = await drive(alice, open); | |
| 110 | expect(text).toContain("Variables"); | |
| 111 | expect(text).toContain("STRIPE_KEY"); | |
| 112 | expect(text).toContain("production · set"); | |
| 113 | expect(text).not.toContain(SECRET); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a bad name is refused in the page's words, and nothing is set", async () => { | |
| 117 | const text = await drive(alice, [...open, "a", "lower", "\r", SECRET, "\r", "\r"]); | |
| 118 | // The page's sentence wraps across the head; its start is on the screen. | |
| 119 | expect(text).toContain("A name is capital letters, digits and underscores"); | |
| 120 | expect(APP_ENV_ERRORS["invalid_name"]).toStartWith("A name is capital letters, digits and underscores"); | |
| 121 | expect(vars().map((v) => v.name)).toEqual(["STRIPE_KEY"]); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a value too long is refused in the page's words", async () => { | |
| 125 | const long = `\x1b[200~${"x".repeat(64 * 1024 + 1)}\x1b[201~`; | |
| 126 | const text = await drive(alice, [...open, "a", "TOO_LONG", "\r", long, "\r", "\r"]); | |
| 127 | expect(text).toContain(APP_ENV_ERRORS["invalid_value"]!); | |
| 128 | expect(vars().map((v) => v.name)).toEqual(["STRIPE_KEY"]); | |
| 129 | }); | |
| 130 | ||
| 131 | test("unset asks first: keeping it changes nothing, confirming removes it", async () => { | |
| 132 | const kept = await drive(alice, [...open, "d", "\r"]); | |
| 133 | expect(kept).toContain("STRIPE_KEY"); | |
| 134 | expect(vars().length).toBe(1); | |
| 135 | const asked = await drive(alice, [...open, "d"]); | |
| 136 | expect(asked).toContain("Unset this variable"); | |
| 137 | const text = await drive(alice, [...open, "d", RIGHT, "\r"]); | |
| 138 | expect(vars()).toEqual([]); | |
| 139 | expect(text).toContain("STRIPE_KEY is unset for production."); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a writer who is not an admin has no entry, and can set nothing", async () => { | |
| 143 | expect(await drive(bob, [":", "Variables"])).not.toContain("acme/shop — Variables"); | |
| 144 | expect(await drive(alice, [":", "Variables"])).toContain("acme/shop — Variables"); | |
| 145 | await drive(bob, [...open, "a", "BOB_KEY", "\r", SECRET, "\r", "\r"]); | |
| 146 | expect(vars()).toEqual([]); | |
| 147 | }); | |
| 148 | ||
| 149 | test("no frame drawn, and no event written, ever held the value", () => { | |
| 150 | expect(frames.length).toBeGreaterThan(20); | |
| 151 | for (const f of frames) expect(f).not.toContain(SECRET); | |
| 152 | const events = listEvents(app.db, { repoId: shop.id }).filter((e) => e.kind.startsWith("app.env_")); | |
| 153 | expect(events.map((e) => e.kind).sort()).toEqual(["app.env_deleted", "app.env_set"]); | |
| 154 | expect(JSON.stringify(events)).not.toContain(SECRET); | |
| 155 | }); | |
| 156 | }); |
new above
offline