#!/usr/bin/env bun /** * End-to-end check for notification coverage against a running dev server whose email goes to the dev * mail sink (EMAIL_DEV_SINK=kv, or any localhost PUBLIC_URL without a verified sender). Run it from the * worktree whose `vite dev` is under test, since it seeds users and sessions in that server's local D1/KV. * * BASE_URL=http://localhost:3000 bun scripts/e2e-notify.ts * BASE_URL=http://localhost:3017 bun scripts/e2e-notify.ts ci security # only some cases * * The "digest" case needs NOTIFY_DIGEST_DELAY_MS=0 (dev forges only) so the account alarm flushes it at once. * * Each case triggers a real event over HTTP and asserts both the web notification (through the * listNotifications server function) and the captured email (through /__dev/mail), plus the * unsubscribe headers, redaction of secret values and send-time access checks. */ import { createHash, randomBytes } from "node:crypto"; import { d1Rows, scopedSqlRows, sh as run } from "../e2e/scoped-sql"; import { toJSONAsync } from "seroval"; const BASE = (process.env.BASE_URL ?? "http://localhost:3000").replace(/\/$/, ""); const ROOT = new URL("..", import.meta.url).pathname; const SESSION_COOKIE = "__Host-gitfor_session"; const POLL_MS = 1000; const TIMEOUT_MS = Number(process.env.NOTIFY_TIMEOUT_MS ?? 90_000); const LFS_LIMIT = 10 * 1024 * 1024 * 1024; const LFS_NEAR_LIMIT = Math.floor(LFS_LIMIT * 0.9); const STALE_RUNNER_MS = 15 * 60 * 1000; const HTTP_OK = 200; const HTTP_CREATED = 201; const HTTP_NOT_FOUND = 404; const FAKE_AWS_KEY = `AKIA${"QX7Z".repeat(4)}`; type User = { id: string; name: string; email: string; token: string; cookie: string }; type Mail = { id: string; to: string; subject: string; text: string; html: string; headers?: Record }; type WebNotification = { reason: string; title: string; url: string }; const sh = (cmd: string[]) => run(ROOT, cmd); const d1 = (sql: string) => d1Rows(ROOT, sql); const scoped = (scope: "repo" | "account", id: string, sql: string) => scopedSqlRows(BASE, scope, id, sql); const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex"); const suffix = randomBytes(4).toString("hex"); function seedUser(label: string): User { const id = `nt${label}${suffix}`; const name = `notify-${label}-${suffix}`; const email = `${name}@example.test`; const token = `gitfor_pat_${randomBytes(16).toString("hex")}`; const session = `sess_${randomBytes(20).toString("hex")}`; const now = Date.now(); d1( `INSERT INTO accounts (id, name, kind, display_name, created_at) VALUES (${quote(id)}, ${quote(name)}, 'user', ${quote(name)}, ${now}); INSERT INTO users (account_id, email, is_admin, email_verified_at) VALUES (${quote(id)}, ${quote(email)}, 0, ${now}); INSERT INTO scope_locators (kind, key, scope_id) VALUES ('access_token', ${quote(sha256Hex(token))}, ${quote(id)}), ('access_token_id', ${quote(`${id}tok`)}, ${quote(id)});`, ); scoped( "account", id, `INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, permissions, all_repos, expires_at, created_at) VALUES (${quote(`${id}tok`)}, ${quote(id)}, 'e2e', ${quote(sha256Hex(token))}, ${quote(token.slice(0, 16))}, 'repo', '{"contents":"write","issues":"write","pulls":"write","actions":"write","admin":"write"}', 1, NULL, ${now})`, ); sh([ "bunx", "wrangler", "kv", "key", "put", "--local", "--binding", "KV", `session:${sha256Hex(session)}`, JSON.stringify({ userId: id, createdAt: now }), ]); return { id, name, email, token, cookie: `${SESSION_COOKIE}=${session}` }; } async function api(user: User, method: string, path: string, body?: unknown, ok = [200, 201, 202, 204]): Promise { const response = await fetch(`${BASE}/api/v1${path}`, { method, headers: { authorization: `Bearer ${user.token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); if (!ok.includes(response.status)) throw new Error(`${method} ${path} -> ${response.status}: ${text}`); return (text ? JSON.parse(text) : null) as T; } /** Calls a TanStack server function the way the client RPC does (dev ids are base64url file/export pairs). */ async function serverFn(user: User, file: string, name: string, method: "GET" | "POST", data: unknown): Promise { const id = Buffer.from( JSON.stringify({ file: `/src/fns/${file}.ts?tss-serverfn-split`, export: `${name}_createServerFn_handler` }), ).toString("base64url"); const payload = JSON.stringify(await toJSONAsync({ data })); const url = method === "GET" ? `${BASE}/_serverFn/${id}?payload=${encodeURIComponent(payload)}` : `${BASE}/_serverFn/${id}`; const response = await fetch(url, { method, headers: { cookie: user.cookie, origin: BASE, "sec-fetch-site": "same-origin", "x-tsr-serverFn": "true", accept: "application/json", ...(method === "POST" ? { "content-type": "application/json" } : {}), }, body: method === "POST" ? payload : undefined, }); const text = await response.text(); if (!response.ok) throw new Error(`${file}.${name} -> ${response.status}: ${text.slice(0, 300)}`); return parseServerFnResult(text); } function parseServerFnResult(text: string): T { const envelope = revive(JSON.parse(text)) as { result?: unknown; error?: unknown }; if (envelope.error) throw new Error(`server function error: ${JSON.stringify(envelope.error).slice(0, 300)}`); return envelope.result as T; } const SEROVAL_CONSTANTS = [null, undefined, true, false]; /** Minimal seroval JSON reader for the plain objects, arrays and primitives our server functions return. */ function revive(node: unknown): unknown { if (node === null || typeof node !== "object") return node; const value = node as { t?: number; s?: unknown; a?: unknown[]; p?: { k: string[]; v: unknown[] } }; switch (value.t) { case 0: case 1: return value.s; case 2: return SEROVAL_CONSTANTS[value.s as number] ?? null; case 9: return (value.a ?? []).map(revive); case 10: case 11: { const out: Record = {}; value.p?.k.forEach((key, index) => (out[key] = revive(value.p?.v[index]))); return out; } default: return value.s ?? null; } } async function webNotifications(user: User): Promise { return serverFn(user, "account", "listNotifications", "GET", { all: true }); } async function mails(to: string): Promise { const response = await fetch(`${BASE}/__dev/mail/messages?to=${encodeURIComponent(to)}`); if (!response.ok) throw new Error(`/__dev/mail -> ${response.status}`); return (await response.json()) as Mail[]; } async function poll(what: string, probe: () => Promise, timeoutMs = TIMEOUT_MS): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const value = await probe(); if (value) return value; await Bun.sleep(POLL_MS); } throw new Error(`Timed out waiting for ${what}`); } function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(`Assertion failed: ${message}`); } /** Waits for one web notification of `reason` matching `title`, and the matching captured email. */ async function expectNotified(user: User, reason: string, title: RegExp): Promise { await poll(`${reason} web notification for ${user.name}`, async () => (await webNotifications(user)).find((row) => row.reason === reason && title.test(row.title)), ); const mail = await poll(`${reason} email for ${user.name}`, async () => (await mails(user.email)).find((row) => title.test(row.subject)), ); assert(mail.headers?.["List-Unsubscribe"]?.includes("/email/unsubscribe?"), "List-Unsubscribe header"); assert(mail.headers?.["List-Unsubscribe-Post"] === "List-Unsubscribe=One-Click", "one-click unsubscribe"); assert(mail.text.includes("/settings/notifications"), "manage-prefs link in text part"); assert(mail.html.includes("Unsubscribe"), "unsubscribe link in HTML part"); return mail; } async function expectNothing(user: User, reason: string, title: RegExp, settleMs = 5000): Promise { await Bun.sleep(settleMs); const web = (await webNotifications(user)).filter((row) => row.reason === reason && title.test(row.title)); const email = (await mails(user.email)).filter((row) => title.test(row.subject)); assert( web.length === 0 && email.length === 0, `${user.name} must not get ${reason} (${web.length} web, ${email.length} email)`, ); } async function createRepo(user: User, name: string, isPrivate: boolean): Promise { await api(user, "POST", "/repos", { name, private: isPrivate }); const row = await poll(`repo ${name} ready`, async () => { const rows = d1( `SELECT r.id, r.status FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.name = ${quote(user.name)} AND r.name = ${quote(name)}`, ); return rows[0]?.status === "ready" ? rows[0] : null; }); return String(row.id); } async function putFile(user: User, repo: string, path: string, content: string, message: string): Promise { await api(user, "PUT", `/repos/${user.name}/${repo}/contents/${path}`, { branch: "main", message, content: Buffer.from(content).toString("base64"), }); } async function runnerCall(token: string, path: string, body?: unknown): Promise<{ status: number; data: T | null }> { const response = await fetch(`${BASE}/api/runner/${path}`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); return { status: response.status, data: text ? (JSON.parse(text) as T) : null }; } // --------------------------------------------------------------------------------------------------------------- const owner = seedUser("own"); const reporter = seedUser("rep"); const reader = seedUser("read"); const outsider = seedUser("out"); const privateRepo = `notify-private-${suffix}`; const publicRepo = `notify-public-${suffix}`; let privateRepoId = ""; let publicRepoId = ""; let runnerToken = ""; const runnerLabel = `notify-${suffix}`; async function setup(): Promise { const mailCheck = await fetch(`${BASE}/__dev/mail/messages`); assert(mailCheck.ok, "/__dev/mail must be reachable on a dev server"); [privateRepoId, publicRepoId] = await Promise.all([ createRepo(owner, privateRepo, true), createRepo(owner, publicRepo, false), ]); const now = Date.now(); d1( `INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES (${quote(privateRepoId)}, ${quote(reader.id)}, 'read', ${now}); INSERT INTO watches (user_id, repo_id, created_at, level) VALUES (${quote(reader.id)}, ${quote(privateRepoId)}, ${now}, 'all'); INSERT INTO watches (user_id, repo_id, created_at, level) VALUES (${quote(outsider.id)}, ${quote(privateRepoId)}, ${now}, 'all');`, ); for (const user of [reader, outsider]) { scoped( "account", user.id, `INSERT INTO notification_category_prefs (user_id, category, web, email, digest, updated_at) VALUES (${quote(user.id)}, 'ci_watching', 1, 1, 'off', ${now})`, ); } } const cases: Record Promise> = { async security() { await putFile(owner, privateRepo, "config/aws.env", `AWS_ACCESS_KEY_ID=${FAKE_AWS_KEY}\n`, "Add config"); const mail = await expectNotified(owner, "security_alert", /secret detected/i); assert(!mail.text.includes(FAKE_AWS_KEY) && !mail.html.includes(FAKE_AWS_KEY), "secret value never in email"); assert(mail.text.includes("config/aws.env"), "email names the file"); await expectNothing(reader, "security_alert", /secret detected/i, 0); }, async advisory() { scoped( "repo", publicRepoId, `INSERT INTO repo_security_settings (repo_id, secret_scanning, push_protection, private_reporting, dependency_graph, updated_at) VALUES (${quote(publicRepoId)}, 1, 0, 1, 1, ${Date.now()}) ON CONFLICT (repo_id) DO UPDATE SET private_reporting = 1`, ); const { ghsaId } = await serverFn<{ ghsaId: string }>(reporter, "advisories", "submitAdvisory", "POST", { owner: owner.name, repo: publicRepo, mode: "report", advisory: { title: `Notify e2e report ${suffix}`, description: "Steps to reproduce.", severity: "high", cveId: null, cweIds: null, ecosystem: null, packageName: null, affectedVersions: null, patchedVersions: null, }, }); await expectNotified(owner, "vulnerability_report", new RegExp(`${ghsaId} reported`)); await serverFn(owner, "advisories", "changeAdvisoryState", "POST", { owner: owner.name, repo: publicRepo, ghsaId, state: "draft", }); await expectNotified(reporter, "vulnerability_report", new RegExp(`${ghsaId} moved to draft`)); }, async ci() { const registration = await api<{ token: string }>( owner, "POST", `/repos/${owner.name}/${privateRepo}/actions/runners/registration-token`, { labels: [runnerLabel] }, ); const registered = await runnerCall<{ token: string }>("", "register", { token: registration.token, name: `notify-runner-${suffix}`, labels: [runnerLabel], os: "linux", arch: "x64", isolation: "none", }); assert(registered.status === HTTP_CREATED && registered.data, `runner registration -> ${registered.status}`); runnerToken = registered.data.token; const workflow = (command: string) => `name: Notify CI\non: push\njobs:\n build:\n runs-on: [self-hosted, ${runnerLabel}]\n steps:\n - run: ${command}\n`; async function runOnce(exitCode: number): Promise { const claimed = await poll("runner claim", async () => { const result = await runnerCall<{ job: { id: string } }>(runnerToken, "claim?wait=2000"); return result.status === HTTP_OK ? result.data?.job : null; }); const done = await runnerCall(runnerToken, `jobs/${claimed.id}/complete`, { exitCode }); assert(done.status === HTTP_OK, `job complete -> ${done.status}`); } await putFile(owner, privateRepo, ".gitfor/workflows/notify.yml", workflow("exit 1"), "Add failing workflow"); await runOnce(1); await expectNotified(owner, "ci", /Notify CI failed on main/); await expectNotified(reader, "ci_watching", /Notify CI failed on main/); await expectNothing(outsider, "ci_watching", /Notify CI/, 0); await putFile(owner, privateRepo, ".gitfor/workflows/notify.yml", workflow("true"), "Fix workflow"); await runOnce(0); await expectNotified(owner, "ci", /Notify CI fixed on main/); }, async runner() { if (!runnerToken) await cases.ci(); scoped( "account", owner.id, `UPDATE runners SET last_seen_at = ${Date.now() - STALE_RUNNER_MS} WHERE name = ${quote(`notify-runner-${suffix}`)}`, ); // Writing last_seen_at arms the owner's AccountState alarm, which reports the runner; no cron involved. await expectNotified(owner, "runner", new RegExp(`notify-runner-${suffix} is offline`)); }, async deploy() { await api(owner, "PUT", `/repos/${owner.name}/${publicRepo}/site`, { branch: "main", folder: "missing-folder" }); await putFile(owner, publicRepo, "index.md", "# hello\n", "Trigger site deploy"); await expectNotified(owner, "deploy", /Sites deploy failed/); }, /** Needs NOTIFY_DIGEST_DELAY_MS=0 on the dev server; runs after "deploy", which configures a site. */ async digest() { scoped( "account", owner.id, `INSERT INTO notification_category_prefs (user_id, category, web, email, digest, updated_at) VALUES (${quote(owner.id)}, 'deploy', 1, 1, 'daily', ${Date.now()}) ON CONFLICT (user_id, category) DO UPDATE SET email = 1, digest = 'daily'`, ); const before = (await mails(owner.email)).filter((row) => /Sites deploy failed/.test(row.subject)).length; await putFile(owner, publicRepo, "index.md", "# digest\n", "Trigger site deploy for digest"); await poll("second deploy web notification", async () => { const rows = (await webNotifications(owner)).filter((row) => row.reason === "deploy"); return rows.length >= 2 ? rows : null; }); const queued = await poll("digest item queued", async () => { const rows = scoped( "account", owner.id, `SELECT id FROM notification_digest_items WHERE user_id = ${quote(owner.id)} AND category = 'deploy'`, ); return rows.length > 0 ? rows : null; }); assert(queued.length === 1, "one digest item"); const after = (await mails(owner.email)).filter((row) => /Sites deploy failed/.test(row.subject)).length; assert(after === before, "daily digest suppresses the per-event email"); // The account's housekeeping alarm flushes the digest once its item is due; no cron involved. const mail = await poll("digest email", async () => (await mails(owner.email)).find((row) => /Deployments: 1 new notification/.test(row.subject)), ); assert(mail.text.includes("Sites deploy failed"), "digest lists the item"); assert(mail.headers?.["List-Unsubscribe"], "digest has List-Unsubscribe"); }, async import() { const name = `notify-import-${suffix}`; await api(owner, "POST", `/repos/${owner.name}/${name}/import`, { url: `https://example.invalid/${suffix}/missing.git`, }).catch((error: unknown) => console.warn(" import request:", (error as Error).message)); await expectNotified(owner, "import", new RegExp(`Import of ${owner.name}/${name} failed`)); }, async quota() { scoped( "account", owner.id, `INSERT INTO lfs_usage (owner_id, storage_bytes) VALUES (${quote(owner.id)}, ${LFS_NEAR_LIMIT}) ON CONFLICT (owner_id) DO UPDATE SET storage_bytes = ${LFS_NEAR_LIMIT}`, ); const oid = sha256Hex(`lfs-${suffix}`); const response = await fetch(`${BASE}/${owner.name}/${publicRepo}.git/info/lfs/objects/batch`, { method: "POST", headers: { authorization: `Basic ${Buffer.from(`${owner.name}:${owner.token}`).toString("base64")}`, accept: "application/vnd.git-lfs+json", "content-type": "application/vnd.git-lfs+json", }, body: JSON.stringify({ operation: "upload", transfers: ["basic"], objects: [{ oid, size: 1024 }] }), }); assert(response.ok, `LFS batch -> ${response.status}`); await expectNotified(owner, "quota", /Git LFS storage at 80% of limit/); }, async account() { await serverFn(owner, "tokens", "createFineGrainedToken", "POST", { name: `notify-${suffix}`, expiresInDays: 7, allRepos: true, repos: [], permissions: { contents: "read" }, }); const mail = await poll("token-created security email", async () => (await mails(owner.email)).find((row) => row.text.includes(`token named "notify-${suffix}" was created`)), ); assert(!mail.text.includes("gitfor_pat_"), "token value never in email"); }, async unsubscribe() { const mail = await poll("any categorised email", async () => (await mails(owner.email)).find((row) => row.headers?.["List-Unsubscribe"]), ); const link = /<([^>]+)>/.exec(mail.headers?.["List-Unsubscribe"] ?? "")?.[1]; assert(link, "List-Unsubscribe URL"); const target = new URL(link); const local = `${BASE}${target.pathname}${target.search}`; const confirm = await fetch(local); assert(confirm.ok && (await confirm.text()).includes(" ${oneClick.status}`); const after = scoped( "account", owner.id, `SELECT email FROM notification_category_prefs WHERE user_id = ${quote(owner.id)} AND category = ${quote(category)}`, ); const legacy = category === "participating" || category === "watching"; assert(legacy || after[0]?.email === 0, `${category} email turned off`); const bad = await fetch(`${BASE}/email/unsubscribe?token=nope&category=ci`, { method: "POST" }); assert(bad.status === HTTP_NOT_FOUND, "invalid token rejected"); }, }; const ORDER = ["security", "advisory", "ci", "runner", "deploy", "digest", "import", "quota", "account", "unsubscribe"]; const selected = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ORDER; console.log(`notify e2e against ${BASE} (suffix ${suffix})`); await setup(); const failures: string[] = []; for (const name of selected) { const run = cases[name]; if (!run) throw new Error(`Unknown case ${name}; known: ${ORDER.join(", ")}`); const started = Date.now(); try { await run(); console.log(`ok ${name} (${Date.now() - started} ms)`); } catch (error) { failures.push(name); console.log(`FAIL ${name}: ${(error as Error).message}`); } } if (failures.length > 0) { console.log(`${failures.length} failed: ${failures.join(", ")}`); process.exit(1); } console.log("all notification cases passed");