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

The terminal's notify screen sends a test push with `t` and says whether it arrived #8085

Merged opened this

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

Merged

scout/forge:scout/8081-the-terminal-s-notify-screen-sends-a-tes → main 4 commits fork

  • Merged. scout/8081-the-terminal-s-notify-screen-sends-a-tes was merged into main as 08e6435.

Review

Sign in to review this change.
3 files changed +159 −6
src/crew/notify.ts +30
@@ -169,6 +169,36 @@ export const post: Sender = async (url, body, headers) => {
169169 }
170170 };
171171
172 export const TEST_LINE = "Test from the forge — notifications reach you.";
173 const TEST_GAP_MS = 30_000;
174 const lastTest = new Map<string, number>();
175
176 /** Whether this person may send a test now: one per `TEST_GAP_MS`. Claims the slot when it says yes. */
177 export function claimTest(userId: string, now = Date.now()): boolean {
178 for (const [id, at] of lastTest) if (now - at >= TEST_GAP_MS) lastTest.delete(id);
179 if (lastTest.has(userId)) return false;
180 lastTest.set(userId, now);
181 return true;
182 }
183
184 /**
185 * One short line to the saved URL, to prove it works. Never throws; the
186 * URL's path (it can hold a secret) is never logged.
187 */
188 export async function sendTest(
189 db: Database,
190 userId: string,
191 sender: Sender = post,
192 ): Promise<"sent" | "refused" | "off"> {
193 const url = notifyUrlOf(db, userId);
194 if (url === undefined) return "off";
195 try {
196 return (await sender(url, TEST_LINE, { title: "forge" })) ? "sent" : "refused";
197 } catch {
198 return "refused";
199 }
200 }
201
172202 /**
173203 * The forge's admin hears of a red restore drill, or none in eight days,
174204 * once per cause: the id is the drill's own key, so a repeat tick is not news.
src/tui/screens/notify.ts +31 −6
@@ -1,12 +1,13 @@
11 /**
22 * Your phone notify URL in the terminal: the host notifications go to, or
3 * that they are off. `s` asks for a URL and saves it, `x` turns it off after
4 * a confirm. The URL may carry a secret in its path (an ntfy topic is a
5 * password), so only its host is ever drawn; the rules and the words are
6 * `crew/notify.ts`'s, as Settings and MCP `notify` use them.
3 * that they are off. `s` asks for a URL and saves it, `t` sends a test
4 * line, `x` turns it off after a confirm. The URL may carry a secret in
5 * its path (an ntfy topic is a password), so only its host is ever drawn;
6 * the rules and the words are `crew/notify.ts`'s, as Settings and MCP
7 * `notify` use them.
78 */
89
9 import { NOTIFY_ERRORS, notifyHost, notifyUrlOf, setNotifyUrl } from "../../crew/notify.ts";
10 import { NOTIFY_ERRORS, claimTest, notifyHost, notifyUrlOf, post, sendTest, setNotifyUrl, type Sender } from "../../crew/notify.ts";
1011 import type { Ctx, Screen } from "../app/app.ts";
1112 import { binding } from "../app/keys.ts";
1213 import { style } from "../term/style.ts";
@@ -14,6 +15,9 @@ import { truncate } from "../term/width.ts";
1415 import type { Session } from "../session.ts";
1516 import { confirmScreen, promptScreen } from "./common.ts";
1617
18 /** The sender `t` uses; a test swaps it for a fake. */
19 export const testSender: { current: Sender } = { current: post };
20
1721 export function notifyScreen(f: Session, ctx: Ctx): Screen {
1822 let note = "";
1923 const host = () =>
@@ -29,7 +33,13 @@ export function notifyScreen(f: Session, ctx: Ctx): Screen {
2933
3034 const screen: Screen = {
3135 title: "Phone notifications",
32 keys: [binding(["s"], "set the URL"), binding(["x"], "turn off")],
36 get keys() {
37 return [
38 binding(["s"], "set the URL"),
39 ...(host() === undefined ? [] : [binding(["t"], "send a test")]),
40 binding(["x"], "turn off"),
41 ];
42 },
3343 render(area, s) {
3444 const c = ctx.theme.c;
3545 const h = host();
@@ -58,6 +68,21 @@ export function notifyScreen(f: Session, ctx: Ctx): Screen {
5868 );
5969 return true;
6070 }
71 if (e.key === "t") {
72 const h = host();
73 if (h === undefined || f.user === undefined) return true;
74 if (!claimTest(f.user.id)) {
75 ctx.toast("just sent — wait a moment");
76 return true;
77 }
78 const id = f.user.id;
79 void sendTest(f.app.db, id, testSender.current).then((r) => {
80 if (r === "sent") ctx.toast(`sent to ${h}`);
81 else ctx.toast(`${h} did not take it`, "danger");
82 ctx.invalidate();
83 });
84 return true;
85 }
6186 if (e.key === "x") {
6287 if (host() === undefined) {
6388 note = "Already off.";
test/notify-test-send.test.ts added +98
@@ -0,0 +1,98 @@
1 /** The notify screen's `t`: a test line to the saved URL, and whether it arrived (#8081). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { TEST_LINE, claimTest, sendTest, setNotifyUrl, type Sender } from "../src/crew/notify.ts";
8 import { createUser, getUserByName } from "../src/core/users.ts";
9 import { createForgeApp } from "../src/tui/forge.ts";
10 import { testSender } from "../src/tui/screens/notify.ts";
11 import { headlessTerminal } from "../src/tui/term/terminal.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { settleApp } from "./helpers/tui.ts";
14
15 quietLogs();
16
17 const URL_ = "https://ntfy.example.com/secret-topic-123";
18
19 describe("send a test push", () => {
20 let dataDir: string;
21 let app: App;
22 let id: string;
23
24 beforeAll(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-notify-test-"));
26 app = createApp({ dataDir, port: 0, host: "forge.test" });
27 const made = await createUser(app.db, {
28 origin: "seed",
29 trusted: true,
30 username: "alice",
31 email: "[email protected]",
32 password: "a-good-passphrase",
33 adminIfFirst: true,
34 });
35 if (!made.ok) throw new Error(made.error);
36 id = getUserByName(app.db, "alice")!.id;
37 });
38 afterAll(async () => {
39 await rm(dataDir, { recursive: true, force: true });
40 });
41
42 test("off, sent, refused, and a throwing sender", async () => {
43 const calls: string[] = [];
44 const ok: Sender = async (u, body) => (calls.push(u, body), true);
45 expect(await sendTest(app.db, id, ok)).toBe("off");
46 expect(calls).toEqual([]);
47 setNotifyUrl(app.db, id, URL_);
48 expect(await sendTest(app.db, id, ok)).toBe("sent");
49 expect(calls).toEqual([URL_, TEST_LINE]);
50 expect(await sendTest(app.db, id, async () => false)).toBe("refused");
51 expect(
52 await sendTest(app.db, id, async () => {
53 throw new Error("boom");
54 }),
55 ).toBe("refused");
56 });
57
58 test("one per user per 30 seconds", () => {
59 expect(claimTest("u1", 1_000)).toBe(true);
60 expect(claimTest("u1", 20_000)).toBe(false);
61 expect(claimTest("u2", 20_000)).toBe(true);
62 expect(claimTest("u1", 31_001)).toBe(true);
63 });
64
65 test("t in the terminal toasts the result, then the wait", async () => {
66 setNotifyUrl(app.db, id, URL_);
67 const terminal = headlessTerminal(140, 32);
68 const { app: tui } = createForgeApp(
69 app,
70 { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, "alice")! },
71 { terminal, exitHooks: false },
72 );
73 tui.start();
74 const key = async (k: string, ctrl = false) => {
75 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
76 await settleApp(tui);
77 };
78 const sent: string[] = [];
79 const saved = testSender.current;
80 testSender.current = async (u) => (sent.push(u), false);
81 try {
82 await key("k", true);
83 for (const ch of "Phone notifications") await key(ch);
84 await key("enter");
85 expect(tui.screen.toText()).toContain("send a test");
86 await key("t");
87 expect(sent).toEqual([URL_]);
88 expect(tui.screen.toText()).toContain("ntfy.example.com did not take it");
89 expect(tui.screen.toText()).not.toContain("secret-topic");
90 await key("t");
91 expect(sent.length).toBe(1);
92 expect(tui.screen.toText()).toContain("just sent — wait a moment");
93 } finally {
94 testSender.current = saved;
95 tui.stop();
96 }
97 });
98 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs