Public: anyone can read this; the crew can change it.

The terminal's resilience screen takes a snapshot now with `n` and brings the mirror level with `l`, after a confirm, as MCP `snapshot_now` and `mirror_now` do #7358

Merged opened this

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

Merged

mason/forge:mason/7318-the-terminal-s-resilience-screen-takes-a → main 4 commits fork 5 threads · 5 unresolved

  • Merged. mason/7318-the-terminal-s-resilience-screen-takes-a was merged into main as 24aff76.

Review

Sign in to review this change.
7 files changed +410 −90
docs/TOUR.md +2
@@ -205,6 +205,8 @@ A writer of the app's repository redeploys production from the terminal's **Depl
205205
206206 The app's admin also sees its own domains on that screen, one line with each domain's state (`live` or `waiting for DNS`), and presses `n` for the domains screen: `+` adds one, `c` checks the selected one's DNS proof now and says the record still needed, `-` removes it after `remove <host>? y/n`. They are the settings page's and MCP `app_domains`' actions (`src/actions/domains.ts`), answered in the page's words (`src/actions/domain_words.ts`); anyone `listAppDomains` refuses sees neither the domains nor `n`.
207207
208 A forge admin takes a snapshot from the terminal's **Resilience** dialog (`ctrl+k`, Resilience) with `n` and brings the off-box mirror level with `l`, each after a `y/n` asked on the dialog's first line. They are the Backups page's buttons' and MCP `snapshot_now`'s and `mirror_now`'s actions (`src/actions/backups_now.ts`), so all three doors say the same words; the dialog lists the three newest snapshots, newest first (or that they could not be read), and re-reads them after each. While one runs, neither key is offered. Nothing names a key or a secret. Anyone else has no Resilience command, and the actions refuse them with `not_found`.
209
208210 The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker.
209211
210212 `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added.
src/actions/backups_now.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The Backups page's two buttons as actions: take a snapshot now, and bring

The page still has its own copy of both handlers (src/web/pages/backups.ts backupsNow / backupsMirrorNow). Route them through these actions so the words live in one place; they already differ on svc vs svc.store.

3 * the off-box mirror level now. A forge admin's only; anyone else is told
4 * `not_found`. Each answer carries `says`, worded as the page's flash, so
5 * MCP `snapshot_now` / `mirror_now` and the terminal say the same thing.
6 * Nothing here ever names a key or a secret: only the store's name.
7 */
8
9 import type { App } from "../app.ts";
10 import type { User } from "../core/users.ts";
11 import { err, ok, type Result } from "../kernel/result.ts";
12 import { snapshotsOf } from "../snapshot/service.ts";
13 import { fileSize } from "../web/ui.ts";
14
15 const isAdmin = (actor: User | undefined): boolean => actor?.isAdmin === true;
16
17 export interface SnapshotTaken {
18 readonly name: string;
19 readonly repos: number;
20 readonly apps: number;
21 readonly bytes: number;
22 readonly says: string;
23 }
24
25 export type SnapshotRefusal =
26 | { readonly code: "not_found"; readonly says: string }
27 | { readonly code: "no_store"; readonly says: string }
28 | { readonly code: "failed"; readonly says: string };
29
30 /** Take a snapshot now; waits for any in flight. */
31 export async function snapshotNow(
32 app: App,
33 actor: User | undefined,
34 ): Promise<Result<SnapshotTaken, SnapshotRefusal>> {
35 if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin takes a snapshot." });
36 const svc = snapshotsOf(app);
37 if (svc?.store === undefined)
38 return err({ code: "no_store", says: "This forge has nowhere to put a snapshot." });
39 try {
40 const m = await svc.now();
41 return ok({
42 name: m.name,
43 repos: m.repos,
44 apps: m.apps,
45 bytes: m.bytes,
46 says: `Snapshot ${m.name} taken: ${m.repos} repositories, ${m.apps} apps, ${fileSize(m.bytes)}.`,
47 });
48 } catch (error) {
49 return err({ code: "failed", says: `The snapshot failed: ${String(error)}` });
50 }
51 }
52
53 export interface MirrorLevel {
54 readonly level: boolean;
55 readonly newest?: string;
56 readonly error?: string;
57 readonly says: string;
58 }
59
60 /** Bring the mirror level now; says in words whether it reached the mirror. */
61 export async function mirrorNow(
62 app: App,
63 actor: User | undefined,
64 ): Promise<Result<MirrorLevel, { readonly code: "not_found"; readonly says: string }>> {
65 if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin brings the mirror level." });
66 const state = await snapshotsOf(app)?.mirrorNow();
67 if (state === undefined) return ok({ level: false, says: "This forge has no mirror to bring level." });
68 if (state.sourceError !== undefined)
69 return ok({
70 level: false,
71 error: state.sourceError,
72 says: `The snapshots could not be read from the destination, so the mirror was not touched: ${state.sourceError}`,
73 });
74 if (state.failingSince === undefined)
75 return ok({
76 level: true,
77 ...(state.newest ? { newest: state.newest } : {}),
78 says: `The mirror is level${state.newest ? ` at ${state.newest}` : ""}.`,
79 });
80 return ok({
81 level: false,
82 ...(state.newest ? { newest: state.newest } : {}),
83 error: state.error ?? "unknown reason",
84 says: `The mirror is not level: ${state.error ?? "unknown reason"}`,
85 });
86 }
87
88 /**
89 * The newest snapshots first, one line each: `name · size`; empty when there
90 * is no store. A store that cannot be listed is `unreadable`, never an empty
91 * list: a broken store must not read as one with nothing kept.
92 */
93 export async function recentSnapshots(
94 app: App,
95 actor: User | undefined,
96 limit: number,
97 ): Promise<Result<string[], "not_found" | "unreadable">> {
98 if (!isAdmin(actor)) return err("not_found");
outdated

This swallows a list failure as [], so the dialog says "none kept" when the store is actually unreadable. On a resilience screen that hides exactly what it should show. Return an error and render "could not be read"; add a test with a store whose list() throws.

99 try {
100 const kept = (await snapshotsOf(app)?.list()) ?? [];
101 return ok(kept.slice(0, limit).map((m) => `${m.name} · ${fileSize(m.bytes)}`));
102 } catch {
103 return err("unreadable");
104 }
105 }
src/http/mcp/tools/mirror_now.ts +5 −24
@@ -1,5 +1,5 @@
1 import { snapshotsOf } from "../../../snapshot/service.ts";
2 import { good, type Tool } from "../shared.ts";
1 import { mirrorNow } from "../../../actions/backups_now.ts";
2 import { bad, good, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "mirror_now",
@@ -9,27 +9,8 @@ export const tool: Tool = {
99 writes: true,
1010 adminOnly: true,
1111 needsAdminScope: true,
12 async run(ctx) {
13 const state = await snapshotsOf(ctx.app)?.mirrorNow();
14 if (state === undefined)
15 return good({ level: false, says: "This forge has no mirror to bring level." });
16 if (state.sourceError !== undefined)
17 return good({
18 level: false,
19 error: state.sourceError,
20 says: `The snapshots could not be read from the destination, so the mirror was not touched: ${state.sourceError}`,
21 });
22 if (state.failingSince === undefined)
23 return good({
24 level: true,
25 ...(state.newest ? { newest: state.newest } : {}),
26 says: `The mirror is level${state.newest ? ` at ${state.newest}` : ""}.`,
27 });
28 return good({
29 level: false,
30 ...(state.newest ? { newest: state.newest } : {}),
31 error: state.error ?? "unknown reason",
32 says: `The mirror is not level: ${state.error ?? "unknown reason"}`,
33 });
12 async run(ctx, user) {
13 const done = await mirrorNow(ctx.app, user);
14 return done.ok ? good(done.value) : bad("not_found", "no such tool here");
3415 },
3516 };
src/http/mcp/tools/snapshot_now.ts +3 −17
@@ -1,6 +1,5 @@
1 import { snapshotsOf } from "../../../snapshot/service.ts";
1 import { snapshotNow } from "../../../actions/backups_now.ts";
22 import { scopeAllows } from "../../../core/tokens.ts";
3 import { fileSize } from "../../../web/ui.ts";
43 import { good, bad, type Tool } from "../shared.ts";
54
65 export const tool: Tool = {
@@ -13,20 +12,7 @@ export const tool: Tool = {
1312 if (!user.isAdmin) return bad("not_found", "no such tool here");
1413 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
1514 return bad("forbidden", "a snapshot needs an admin-scope token");
16 const svc = snapshotsOf(ctx.app);
17 if (svc?.store === undefined)
18 return bad("no_store", "This forge has nowhere to put a snapshot.");
19 try {
20 const m = await svc.now();
21 return good({
22 name: m.name,
23 repos: m.repos,
24 apps: m.apps,
25 bytes: m.bytes,
26 says: `Snapshot ${m.name} taken: ${m.repos} repositories, ${m.apps} apps, ${fileSize(m.bytes)}.`,
27 });
28 } catch (error) {
29 return bad("failed", `The snapshot failed: ${String(error)}`);
30 }
15 const taken = await snapshotNow(ctx.app, user);
16 return taken.ok ? good(taken.value) : bad(taken.error.code, taken.error.says);
3117 },
3218 };
src/tui/screens/resilience.ts +100 −21
@@ -1,30 +1,43 @@
11 /**
22 * The Resilience command's dialog, a forge admin's: one line per THREATS.md
33 * protection with its standing in the last gate, as /resilience says it.
4 * Enter, Esc or q closes it. A forge admin who is a person also sees where the
5 * off-box snapshot mirror is, `M` seals it (five prompts, the secret masked,
6 * a blank keeps what was sealed) and `F` forgets it after a confirm; the
7 * rules are `actions/mirror.ts`, the Backups page's and MCP `mirror_set`'s.
8 * No key is ever drawn or read back.
4 * Enter, Esc or q closes it.
5 *
6 * Above the protections, the newest snapshots, newest first. A forge admin
7 * takes one now with `n` and brings the mirror level with `l`, each after a
8 * `y/n` asked on the dialog's first line, through `actions/backups_now.ts` —
9 * MCP `snapshot_now`'s and `mirror_now`'s actions — and the answer is said
10 * there in the page's words. While one runs, neither key is offered. The
11 * command is a forge admin's only (`tui/forge.ts`), and the actions refuse
12 * anyone else again.
13 *
14 * A forge admin who is a person also sees where the off-box snapshot mirror
15 * is, `M` seals it (five prompts, the secret masked, a blank keeps what was
16 * sealed) and `F` forgets it after a confirm; the rules are
17 * `actions/mirror.ts`, the Backups page's and MCP `mirror_set`'s. No key is
18 * ever drawn or read back.
919 */
1020
21 import { mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts";
1122 import { forgetMirror, saveMirror } from "../../actions/mirror.ts";
1223 import type { DestinationInput } from "../../actions/destination.ts";
1324 import { snapshotsOf } from "../../snapshot/service.ts";
14 import type { Session } from "../session.ts";
15 import { confirmScreen, later, promptScreen } from "./common.ts";
1625 import { readResilience } from "../../checks/resilience.ts";
1726 import type { Ctx, Screen } from "../app/app.ts";
1827 import { binding } from "../app/keys.ts";
28 import type { Session } from "../session.ts";
1929 import { pad, wrap } from "../term/width.ts";
2030 import { dialog } from "../ui/widgets.ts";
31 import { confirmScreen, later, promptScreen, runAsync } from "./common.ts";
2132
2233 const WIDTH = 72;
34 const INNER = WIDTH - 6;
35 /** How many of the newest snapshots the dialog lists. */
36 const SNAPSHOTS = 3;
2337
2438 export function resilienceLines(dataDir: string): string[] {
2539 const rows = readResilience(dataDir);
26 const inner = WIDTH - 6;
27 return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, inner));
40 return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, INNER));
outdated

The one caller always passes the session and is admin-gated. Make it required (session: Session) and drop the f === undefined guards.

2841 }
2942
3043 /** Where the mirror is, or that there is none; never a key. */
@@ -41,25 +54,82 @@ const FIELDS = [
4154 ["secretKey", "secret key", true],
4255 ] as const;
outdated

Nothing stops a second n→y while the first runs, so it queues another snapshot (or mirror push). Add a busy flag that ignores n/l and leaves them out of the footer until the action settles.

4356
57 type Asking = "snapshot" | "mirror";
58
59 const QUESTION: Record<Asking, string> = {
60 snapshot: "take a snapshot now? y/n",
61 mirror: "bring the mirror level now? y/n",
62 };
63
4464 export function resilienceScreen(f: Session, ctx: Ctx): Screen {
4565 const mayMirror = f.user?.isAdmin === true && f.user.kind === "user";
66 /** The newest snapshots, `unreadable` when the store could not be listed; undefined until read. */
67 let snapshots: string[] | "unreadable" | undefined;
68 let asking: Asking | undefined;
69 // While an action runs, `n` and `l` are ignored: a second would queue behind it.
70 let busy = false;
71 let said = "";
outdated

Unreachable: a non-admin never gets this screen (forge.ts only offers the command to isAdmin). Remove the non-admin branch, which also removes the hardcoded mirror refusal on line 63.

4672 let note = "";
73
74 const read = async (): Promise<void> => {
75 const listed = await recentSnapshots(f.app, f.user, SNAPSHOTS);
76 snapshots = listed.ok ? listed.value : "unreadable";
77 ctx.invalidate();
78 };
79
80 const act = (what: Asking): void => {
81 busy = true;
82 said = what === "snapshot" ? "Taking a snapshot…" : "Bringing the mirror level…";
83 ctx.invalidate();
84 runAsync(ctx, async () => {
85 try {
86 if (what === "snapshot") {
87 const taken = await snapshotNow(f.app, f.user);
88 said = taken.ok ? taken.value.says : taken.error.says;
89 } else {
90 const level = await mirrorNow(f.app, f.user);
91 said = level.ok ? level.value.says : level.error.says;
92 }
93 await read();
94 } finally {
95 busy = false;
96 ctx.invalidate();
97 }
98 });
99 };
100
101 const body = (): string[] => {
102 // The question and the answer come first: a long list must not clip them.
103 const out: string[] = [];
104 if (asking !== undefined) out.push(QUESTION[asking], "");
105 else if (said !== "") out.push(...wrap(said, INNER), "");
106 out.push(...wrap(mirrorLine(f), INNER));
107 if (mayMirror) out.push("M sets the mirror, F forgets it.");
108 if (note !== "") out.push(...wrap(note, INNER));
109 out.push("", "Snapshots, newest first:");
110 if (snapshots === undefined) out.push(" reading…");
111 else if (snapshots === "unreadable") out.push(" the snapshots could not be read");
112 else if (snapshots.length === 0) out.push(" none kept");
113 else out.push(...snapshots.map((s) => ` ${s}`));
114 out.push("", ...resilienceLines(f.app.config.dataDir));
115 return out;
116 };
117
47118 const screen: Screen = {
48119 title: "Resilience",
49 keys: [
50 ...(mayMirror ? [binding(["m"], "set the mirror"), binding(["f"], "forget the mirror")] : []),
51 binding(["enter", "esc", "q"], "close"),
52 ],
120 get keys() {
121 if (asking !== undefined) return [binding(["y"], "yes"), binding(["n", "esc"], "no")];
122 return [
123 ...(busy ? [] : [binding(["n"], "take a snapshot now"), binding(["l"], "bring the mirror level now")]),
124 ...(mayMirror ? [binding(["m"], "set the mirror"), binding(["f"], "forget the mirror")] : []),
125 binding(["enter", "esc", "q"], "close"),
126 ];
127 },
128 load: read,
53129 render(area, s) {
54130 const buttons = dialog(s, area, ctx.theme.c, {
55131 title: "Resilience",
56 body: [
57 ...wrap(mirrorLine(f), WIDTH - 6),
58 ...(mayMirror ? ["M sets the mirror, F forgets it."] : []),
59 ...(note === "" ? [] : wrap(note, WIDTH - 6)),
60 "",
61 ...resilienceLines(f.app.config.dataDir),
62 ],
132 body: body(),
63133 actions: [{ label: "Close" }],
64134 selected: 0,
65135 width: WIDTH,
@@ -68,7 +138,16 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
68138 },
69139 key(e) {
70140 if (e.ctrl || e.alt) return true;
71 if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
141 if (asking !== undefined) {
142 const was = asking;
143 asking = undefined;
144 if (e.key === "y") act(was);
145 return true;
146 }
147 if ((e.key === "n" || e.key === "l") && !busy) {
148 asking = e.key === "n" ? "snapshot" : "mirror";
149 said = "";
150 } else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
72151 else if (mayMirror && e.key === "m") ctx.push(ask(0, {}));
73152 else if (mayMirror && e.key === "f")
74153 ctx.push(
src/web/pages/backups.ts +13 −28
@@ -28,6 +28,7 @@ import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../s
2828 import { lastDrill } from "../../snapshot/drills.ts";
2929 import type { Store } from "../../snapshot/store.ts";
3030 import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts";
31 import { mirrorNow, snapshotNow } from "../../actions/backups_now.ts";
3132 import { lastSnapshotOutcome } from "../../snapshot/outcome.ts";
3233 import type { Ctx } from "../../http/context.ts";
3334 import {
@@ -626,23 +627,12 @@ export async function backupsNow(ctx: Ctx): Promise<Response> {
626627 if (denied) return denied;
627628 const form = await readForm(ctx, "admin");
628629 if (form instanceof Response) return form;
629 const svc = snapshotsOf(ctx.app);
630 if (svc === undefined)
631 return flashTo(ctx, "/settings/backups", {
632 tone: "danger",
633 message: "This forge has nowhere to put a snapshot.",
634 });
635 try {
636 const m = await svc.now();
637 return flashTo(ctx, "/settings/backups", {
638 message: `Snapshot ${m.name} taken: ${m.repos} repositories, ${m.apps} apps, ${fileSize(m.bytes)}.`,
639 });
640 } catch (error) {
641 return flashTo(ctx, "/settings/backups", {
642 tone: "danger",
643 message: `The snapshot failed: ${String(error)}`,
644 });
645 }
630 const taken = await snapshotNow(ctx.app, ctx.auth.user);
631 return flashTo(
632 ctx,
633 "/settings/backups",
634 taken.ok ? { message: taken.value.says } : { tone: "danger", message: taken.error.says },
635 );
646636 }
647637
648638 /** POST /settings/backups/drill — restore the newest snapshot, keep what came of it. */
@@ -708,19 +698,14 @@ export async function backupsMirrorNow(ctx: Ctx): Promise<Response> {
708698 if (denied) return denied;
709699 const form = await readForm(ctx, "admin");
710700 if (form instanceof Response) return form;
711 const state = await snapshotsOf(ctx.app)?.mirrorNow();
712 if (state === undefined)
713 return flashTo(ctx, "/settings/backups", { tone: "danger", message: "This forge has no mirror to bring level." });
714 if (state.sourceError !== undefined)
715 return flashTo(ctx, "/settings/backups", {
716 tone: "danger",
717 message: `The snapshots could not be read from the destination, so the mirror was not touched: ${state.sourceError}`,
718 });
701 const level = await mirrorNow(ctx.app, ctx.auth.user);
719702 return flashTo(
720703 ctx,
721704 "/settings/backups",
722 state.failingSince === undefined
723 ? { message: `The mirror is level${state.newest ? ` at ${state.newest}` : ""}.` }
724 : { tone: "danger", message: `The mirror is not level: ${state.error ?? "unknown reason"}` },
705 !level.ok
706 ? { tone: "danger", message: level.error.says }
707 : level.value.level
708 ? { message: level.value.says }
709 : { tone: "danger", message: level.value.says },
725710 );
726711 }
test/tui-resilience-now.test.ts added +182
@@ -0,0 +1,182 @@
1 /**
2 * The terminal's Resilience dialog takes a snapshot with `n` and brings the
3 * mirror level with `l`, each after a y/n (#7318), through the actions MCP
4 * `snapshot_now` and `mirror_now` use; a non-admin is refused by them.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App as Forge } from "../src/app.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { mirrorNow, snapshotNow } from "../src/actions/backups_now.ts";
13 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
14 import { dirStore, type Store } from "../src/snapshot/store.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import type { TuiIdentity } from "../src/tui/session.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 describe("snapshot and mirror from the terminal's Resilience dialog", () => {
24 let dataDir: string;
25 let forge: Forge;
26 let svc: SnapshotsHandle;
27
28 function boot(username: string) {
29 const terminal = headlessTerminal(140, 40);
30 const user = getUserByName(forge.db, username)!;
31 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
32 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
33 app.start();
34 const send = (k: string, ctrl = false) =>
35 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
36 const press = async (...keys: string[]) => {
37 for (const k of keys) send(k);
38 await settleApp(app);
39 };
40 const open = async () => {
41 send("k", true);
42 await settleApp(app);
43 for (const ch of "Resilience") send(ch);
44 await settleApp(app);
45 const listed = app.screen.toText();
46 await press("enter");
47 return listed;
48 };
49 // Keys without waiting for what they start: to see a screen mid-action.
50 const sendOnly = (...keys: string[]) => {
51 for (const k of keys) send(k);
52 app.frame();
53 };
54 return { text: () => app.screen.toText(), press, sendOnly, open, stop: () => app.stop() };
55 }
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-resilience-now-"));
59 forge = createApp({ dataDir, port: 0 });
60 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
61 const made = await createUser(forge.db, {
62 origin: "seed",
63 trusted: true,
64 username,
65 email: `${username}@forge.local`,
66 password: "a-good-passphrase",
67 adminIfFirst,
68 });
69 if (!made.ok) throw new Error(made.error);
70 }
71 svc = startSnapshots(forge, dirStore(join(dataDir, "store")), { version: "t", nightly: false });
72 }, 30_000);
73
74 afterAll(async () => {
75 await svc.stop();
76 forge.close();
77 await rm(dataDir, { recursive: true, force: true });
78 });
79
80 test("an admin is offered n and l, and n then y adds a snapshot shown on top", async () => {
81 const h = boot("alice");
82 await h.open();
83 expect(h.text()).toContain("Snapshots, newest first:");
84 expect(h.text()).toContain("none kept");
85 expect(h.text()).toContain("n take a snapshot now");
86 expect(h.text()).toContain("l bring the mirror level now");
87
88 await h.press("n");
89 expect(h.text()).toContain("take a snapshot now? y/n");
90 await h.press("x");
91 expect(h.text()).not.toContain("take a snapshot now? y/n");
92 expect(await svc.list()).toEqual([]);
93
94 await h.press("n", "y");
95 for (let i = 0; i < 100 && !h.text().includes(" taken: "); i++) {
96 await Bun.sleep(20);
97 await h.press();
98 }
99 const kept = await svc.list();
100 expect(kept.length).toBe(1);
101 const lines = h.text().split("\n");
102 const head = lines.findIndex((l) => l.includes("Snapshots, newest first:"));
103 expect(lines[head + 1]).toContain(kept[0]!.name);
104 expect(h.text()).toContain(`Snapshot ${kept[0]!.name} taken: `);
105 h.stop();
106 });
107
108 test("l then y says in words that there is no mirror, and draws no secret", async () => {
109 const h = boot("alice");
110 await h.open();
111 await h.press("l");
112 expect(h.text()).toContain("bring the mirror level now? y/n");
113 await h.press("y");
114 for (let i = 0; i < 100 && !h.text().includes("no mirror"); i++) {
115 await Bun.sleep(20);
116 await h.press();
117 }
118 expect(h.text()).toContain("This forge has no mirror to bring level.");
119 expect(h.text()).not.toContain("SNAPSHOT_MIRROR_S3");
120 h.stop();
121 });
122
123 test("a non-admin has no Resilience command, and both actions refuse them", async () => {
124 const h = boot("bob");
125 expect(await h.open()).not.toContain("each protection green, red or untested");
126 expect(h.text()).not.toContain("Snapshots, newest first:");
127 h.stop();
128 const bob = getUserByName(forge.db, "bob")!;
129 const before = (await svc.list()).length;
130 const taken = await snapshotNow(forge, bob);
131 expect(taken.ok).toBe(false);
132 if (!taken.ok) expect(taken.error.code).toBe("not_found");
133 expect((await mirrorNow(forge, bob)).ok).toBe(false);
134 expect((await svc.list()).length).toBe(before);
135 });
136
137 test("n then y pressed twice while the first runs takes one snapshot, and the keys leave the footer", async () => {
138 const h = boot("alice");
139 await h.open();
140 // Two snapshots in one second share a name, so count the asks, not the store.
141 let asked = 0;
142 const take = svc.now;
143 (svc as { now: SnapshotsHandle["now"] }).now = () => {
144 asked++;
145 return take();
146 };
147 h.sendOnly("n", "y", "n", "y", "l", "y");
148 expect(h.text()).toContain("Taking a snapshot…");
149 expect(h.text()).not.toContain("take a snapshot now? y/n");
150 expect(h.text()).not.toContain("n take a snapshot now");
151 for (let i = 0; i < 100 && !h.text().includes(" taken: "); i++) {
152 await Bun.sleep(20);
153 await h.press();
154 }
155 (svc as { now: SnapshotsHandle["now"] }).now = take;
156 expect(asked).toBe(1);
157 expect(h.text()).toContain("n take a snapshot now");
158 h.stop();
159 });
160
161 test("a store that cannot be listed says so, never none kept", async () => {
162 await svc.stop();
163 const inner = dirStore(join(dataDir, "broken"));
164 const broken: Store = {
165 ...inner,
166 list: async () => {
167 throw new Error("list refused");
168 },
169 names: async () => {
170 throw new Error("names refused");
171 },
172 };
173 svc = startSnapshots(forge, broken, { version: "t", nightly: false });
174 const h = boot("alice");
175 await h.open();
176 expect(h.text()).toContain("the snapshots could not be read");
177 expect(h.text()).not.toContain("none kept");
178 expect(h.text()).not.toContain("list refused");
179 expect(h.text()).not.toContain("names refused");
180 h.stop();
181 });
182 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs