g1t/apps/status/src/subscribers.test.ts
| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { alertLetter, bindingSender, confirmLetter, memorySender, render, unsubscribeHeaders, updateLetter } from "./email.ts"; |
| 5 | import { atom, feedItems, jsonFeed } from "./feed.ts"; |
| 6 | import { chosenParts, hashToken, newToken, normalizeEmail, parseParts, readUnsubscribeToken, unsubscribeToken, wants } from "./subscribers.ts"; |
| 7 | |
| 8 | test("addresses are trimmed, lowercased and checked", () => { |
| 9 | assert.equal(normalizeEmail(" Ana@Example.COM "), "ana@example.com"); |
| 10 | assert.equal(normalizeEmail("nope"), null); |
| 11 | assert.equal(normalizeEmail("a@b"), null); |
| 12 | assert.equal(normalizeEmail("a b@example.com"), null); |
| 13 | assert.equal(normalizeEmail(42), null); |
| 14 | }); |
| 15 | |
| 16 | test("confirmation tokens: random, and only their hash is kept", async () => { |
| 17 | const a = newToken(); |
| 18 | assert.match(a, /^[A-Za-z0-9_-]{43}$/); |
| 19 | assert.notEqual(newToken(), a); |
| 20 | const hash = await hashToken(a); |
| 21 | assert.match(hash, /^[0-9a-f]{64}$/); |
| 22 | assert.equal(await hashToken(a), hash, "the same token hashes the same"); |
| 23 | assert.ok(!hash.includes(a)); |
| 24 | }); |
| 25 | |
| 26 | test("unsubscribe links are signed, not stored, and refuse tampering", async () => { |
| 27 | const token = await unsubscribeToken("secret", "sub-1"); |
| 28 | assert.ok(token.startsWith("sub-1.")); |
| 29 | assert.equal(await readUnsubscribeToken("secret", token), "sub-1"); |
| 30 | assert.equal(await readUnsubscribeToken("other", token), null, "another secret"); |
| 31 | assert.equal(await readUnsubscribeToken("secret", token.replace("sub-1", "sub-2")), null, "another subscriber"); |
| 32 | assert.equal(await readUnsubscribeToken("secret", `${token}x`), null); |
| 33 | assert.equal(await readUnsubscribeToken("secret", "garbage"), null); |
| 34 | }); |
| 35 | |
| 36 | test("per-part subscriptions", () => { |
| 37 | assert.equal(chosenParts([], ["git", "api"]), null, "none chosen is everything"); |
| 38 | assert.equal(chosenParts(["git", "api"], ["git", "api"]), null, "all chosen is everything"); |
| 39 | assert.deepEqual(chosenParts(["git", "git", "zzz"], ["git", "api"]), ["git"]); |
| 40 | assert.equal(parseParts(null), null); |
| 41 | assert.deepEqual(parseParts('["git"]'), ["git"]); |
| 42 | assert.equal(parseParts("not json"), null); |
| 43 | assert.equal(wants(null, ["api"]), true); |
| 44 | assert.equal(wants(["git"], ["api"]), false); |
| 45 | assert.equal(wants(["git"], ["api", "git"]), true); |
| 46 | assert.equal(wants(["git"], []), true, "news about no part in particular goes to everyone"); |
| 47 | }); |
| 48 | |
| 49 | test("letters: escaped HTML, plain text, and an unsubscribe link in every update", () => { |
| 50 | const { text, html } = render(updateLetter({ heading: "Identified: <Pushes>", text: "A bad deploy.", url: "https://status.g1t.sh/incidents/a", affects: ["Git"], unsubscribe: "https://status.g1t.sh/unsubscribe?token=x" })); |
| 51 | assert.ok(html.includes("Identified: <Pushes>") && !html.includes("<Pushes>")); |
| 52 | assert.ok(text.includes("Unsubscribe: https://status.g1t.sh/unsubscribe?token=x")); |
| 53 | assert.ok(text.includes("Affects: Git.")); |
| 54 | assert.equal(unsubscribeHeaders("https://u")["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click"); |
| 55 | assert.ok(render(confirmLetter("https://c", ["Git"])).text.includes("affecting Git")); |
| 56 | assert.ok(render(alertLetter({ title: "Detected: API not answering", lines: ["API has not answered on 3 checks in a row since 5 Oct 12:00 UTC."], link: "https://sudo.g1t.sh/incidents/x" })).text.includes("https://sudo.g1t.sh/incidents/x")); |
| 57 | }); |
| 58 | |
| 59 | test("senders: the binding when there is one, none otherwise", async () => { |
| 60 | assert.equal(bindingSender(undefined), null); |
| 61 | const calls: unknown[] = []; |
| 62 | const sender = bindingSender({ send: async (m) => void calls.push(m) }, "Status <s@g1t.sh>"); |
| 63 | await sender!.send({ to: "a@b.co", subject: "Hi", text: "t", html: "h" }); |
| 64 | assert.deepEqual(calls, [{ to: "a@b.co", from: "Status <s@g1t.sh>", subject: "Hi", text: "t", html: "h" }]); |
| 65 | const memory = memorySender(); |
| 66 | await memory.send({ to: "a@b.co", subject: "Hi", text: "t", html: "h" }); |
| 67 | assert.equal(memory.sent.length, 1); |
| 68 | }); |
| 69 | |
| 70 | test("feeds: every public update and maintenance notice, newest first, in Atom and JSON Feed", () => { |
| 71 | const incidents = [ |
| 72 | { |
| 73 | id: "a", |
| 74 | title: "Pushes & pulls failing", |
| 75 | impact: "down" as const, |
| 76 | status: "resolved" as const, |
| 77 | components: ["git"], |
| 78 | component_impacts: [{ key: "git", impact: "major_outage" as const }], |
| 79 | started_at: "2026-10-05T11:00:00Z", |
| 80 | resolved_at: "2026-10-05T12:00:00Z", |
| 81 | url: "https://status.g1t.sh/incidents/a", |
| 82 | postmortem_published_at: null, |
| 83 | updates: [ |
| 84 | { id: "u2", at: "2026-10-05T12:00:00Z", status: "resolved" as const, text: "Fixed." }, |
| 85 | { id: "u1", at: "2026-10-05T11:05:00Z", status: "investigating" as const, text: "Looking <now>." }, |
| 86 | ], |
| 87 | }, |
| 88 | ]; |
| 89 | const maintenance = [ |
| 90 | { id: "m", title: "Upgrade", message: "m", components: ["git"], starts_at: "2026-10-06T02:00:00Z", ends_at: "2026-10-06T03:00:00Z", state: "scheduled" as const, url: "https://status.g1t.sh/maintenance/m", updates: [{ id: "mu", at: "2026-10-05T11:30:00Z", text: "Scheduled." }] }, |
| 91 | ]; |
| 92 | const items = feedItems(incidents, maintenance); |
| 93 | assert.deepEqual(items.map((i) => i.title), ["Resolved: Pushes & pulls failing", "Maintenance: Upgrade", "Investigating: Pushes & pulls failing"]); |
| 94 | assert.equal(items[0]!.url, "https://status.g1t.sh/incidents/a#update-u2"); |
| 95 | const xml = atom(items, { origin: "https://status.g1t.sh", title: "g1t status", updated: "2026-10-05T13:00:00Z" }); |
| 96 | assert.ok(xml.startsWith('<?xml version="1.0" encoding="utf-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom">')); |
| 97 | assert.ok(xml.includes("<title>Resolved: Pushes & pulls failing</title>")); |
| 98 | assert.ok(xml.includes("Looking <now>.")); |
| 99 | assert.ok(xml.includes("<updated>2026-10-05T12:00:00Z</updated>")); |
| 100 | assert.equal((xml.match(/<entry>/g) ?? []).length, 3); |
| 101 | const json = jsonFeed(items, { origin: "https://status.g1t.sh", title: "g1t status", updated: "" }) as { version: string; items: { id: string; content_text: string }[] }; |
| 102 | assert.equal(json.version, "https://jsonfeed.org/version/1.1"); |
| 103 | assert.equal(json.items[2]!.content_text, "Looking <now>."); |
| 104 | assert.equal(feedItems(incidents, maintenance, 1).length, 1); |
| 105 | }); |