diff --git a/e2e/notification-dispatch-journey.spec.ts b/e2e/notification-dispatch-journey.spec.ts new file mode 100644 index 000000000..aceff5319 --- /dev/null +++ b/e2e/notification-dispatch-journey.spec.ts @@ -0,0 +1,617 @@ +/** + * Notification preferences → dispatch decision, the web arm — one journey. + * + * The flow this file is checked against, in one script: + * + * 1. an account configures two delivery channels on the settings surface — + * email switched ON with a recipient, ntfy saved with a public relay and + * left OFF; + * 2. it pairs a phone, so the APNs arm exists in the cascade; + * 3. a medication whose dose window closed hours ago is created through the + * page's own fetch, and the reminder sweep the admin panel drives runs; + * 4. the dispatch decision is read back off the account's own delivery + * ledger: one email attempt, delivered; nothing for the channel that is + * switched off; the APNs arm attempted and skipped with its reason; + * 5. the account then opts into client-managed medication reminders — the + * iOS contract — and the same sweep skips the APNs arm for THAT reason + * while email still delivers, which is the whole claim of the opt-in. + * + * Refusing controls: a channel switched off is never attempted and its own + * test control is inert; and an ntfy relay URL inside a private range is + * refused by the save, leaves the stored config untouched, and writes no + * delivery attempt. + * + * Why email is the channel that gets exercised for real, and why nothing else + * could be: every account-supplied delivery target crosses the SSRF floor + * (`isPublicUrl` at save time, `safeFetch({ requirePublicHost: true })` with a + * connect-time address pin at send time), so ntfy, the generic webhook and Web + * Push all refuse a local stub by design; Telegram and APNs address hard-coded + * third parties. Email's transport is OPERATOR configuration, so it never + * crosses that floor — see `e2e/setup/smtp-stub.ts`. The APNs arm therefore + * cannot deliver here, and the journey does not pretend it can: what it proves + * about APNs is the DECISION, read from the reason the ledger carries. + * + * Every assertion addresses a stable attribute — `data-slot`, `data-state`, + * a form-control id — or the account's own API responses, never rendered copy: + * the labels are i18n-driven and this account renders them in English only by + * cookie. + */ +import type { Page } from "@playwright/test"; + +import { NOTIFY_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { resetNotificationFixture } from "./setup/notification-fixture"; +import { startSmtpStub, type SmtpStub } from "./setup/smtp-stub"; +import { expect, test } from "./setup/test"; + +/** The panel has finished its read and the channel cards are mounted. */ +const CHANNELS_PANEL = '[data-slot="notification-channels-panel"]'; + +/** Where the account's email notifications are addressed. */ +const EMAIL_RECIPIENT = "notification-journey@healthlog.test"; + +/** + * A public relay hostname for the channel that stays switched OFF. It is + * never dialled — the dispatcher drops a disabled channel before any I/O, and + * that is precisely the verdict this journey wants — but it has to pass + * `isPublicUrl` for the save to succeed at all. + */ +const NTFY_SERVER = "https://ntfy.example.com"; +const NTFY_TOPIC = "healthlog-e2e-notify"; + +/** RFC1918. The save refuses it; the refusal is the point. */ +const PRIVATE_NTFY_SERVER = "http://192.168.31.7"; + +/** + * A 64-character hex APNs token. Hex only — the registration route refuses + * anything else — and this spec's alone, because a token belongs to exactly + * one account and re-registering it under another is a 409. + */ +const APNS_TOKEN = `e2e0d15c0de${"0".repeat(53)}`; + +/** The medication whose window has closed, so the sweep has something to send. */ +const MEDICATION_NAME = "E2E Notification Journey"; + +interface PushAttempt { + channel: string; + eventType: string; + result: string; + reason: string | null; +} + +interface DiagnosticPayload { + notificationChannels: Array<{ + type: string; + enabled: boolean; + configPresent: boolean; + }>; + recentPushAttempts: PushAttempt[]; +} + +/** + * Every fetch below rides the PAGE's own `fetch` rather than a shared + * Playwright request context: a pooled context can hand a POST to a keep-alive + * socket the server has already closed, and the retry lands as a second write. + * It also means the cookie, the origin and the CSRF posture are the browser's, + * which is the arm this journey is about. + */ +async function fromPage( + page: Page, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise<{ status: number; data: T | null }> { + return page.evaluate( + async (call: { path: string; method: string; body: string | null }) => { + const res = await fetch(call.path, { + method: call.method, + credentials: "include", + ...(call.body === null + ? {} + : { + headers: { "Content-Type": "application/json" }, + body: call.body, + }), + }); + const envelope = (await res.json()) as { data: unknown }; + return { status: res.status, data: envelope.data }; + }, + { + path, + method: init.method ?? "GET", + body: init.body === undefined ? null : JSON.stringify(init.body), + }, + ) as Promise<{ status: number; data: T | null }>; +} + +/** The admin notification diagnostic — channels and the trailing ledger. */ +async function readDiagnostic(page: Page): Promise { + const res = await fromPage( + page, + "/api/admin/notifications/diagnostic", + ); + expect(res.status, "the diagnostic answered the admin cookie").toBe(200); + if (!res.data) throw new Error("diagnostic returned no data"); + return res.data; +} + +/** + * The delivery attempts written since `before` rows existed. + * + * The ledger only ever grows and the diagnostic returns it newest-first, so + * the head of the list is exactly what this dispatch produced. The wait is a + * poll rather than a sleep because the ledger write is fire-and-forget: the + * trigger's response can return before the row lands. + * + * `expected` is asserted, not merely awaited — an EXTRA attempt is the failure + * this journey exists to catch (a channel that was switched off delivering + * anyway), and a poll that stops at "at least" would never see it. + */ +async function attemptsSince( + page: Page, + before: number, + expected: number, +): Promise { + await expect + .poll(async () => (await readDiagnostic(page)).recentPushAttempts.length, { + message: `the dispatch wrote ${expected} delivery attempt(s)`, + timeout: 20_000, + }) + .toBe(before + expected); + + const all = (await readDiagnostic(page)).recentPushAttempts; + expect( + all.length, + "no further attempt arrived after the ledger settled", + ).toBe(before + expected); + return all.slice(0, expected); +} + +/** How many attempts the ledger already holds. */ +async function attemptCount(page: Page): Promise { + return (await readDiagnostic(page)).recentPushAttempts.length; +} + +/** + * Open the delivery-channels surface and wait until each card has taken its + * seed from the server. + * + * The wait is not cosmetic. Both cards mount BEFORE their query resolves and + * then copy the server's value into the input once it arrives — the React + * sync-from-server shape, no effect involved. A value typed into the input + * before that copy runs is silently replaced by the server's, so the save that + * follows persists an empty field and the next request is refused for a reason + * that points nowhere near the test. Waiting for the two reads is what makes + * the copy already done rather than pending. + */ +async function openChannels(page: Page): Promise { + const seeded = ["/api/settings/email", "/api/settings/ntfy"].map((endpoint) => + page.waitForResponse( + (res) => + new URL(res.url()).pathname === endpoint && + res.request().method() === "GET", + ), + ); + await page.goto("/settings/integrations", { waitUntil: "domcontentloaded" }); + await expect(page.locator(CHANNELS_PANEL)).toBeVisible({ timeout: 30_000 }); + await Promise.all(seeded); +} + +/** + * Type into a channel card's field and confirm the field kept it. The + * confirmation is the second half of the seed race described on + * `openChannels`: the read has landed, so nothing is left to overwrite it. + */ +async function fillField( + page: Page, + selector: string, + value: string, +): Promise { + await page.locator(selector).fill(value); + await expect(page.locator(selector)).toHaveValue(value); +} + +/** + * Save one channel card's form and hand back the status the settings route + * answered with. Gated on the card's own PUT rather than on a repaint, so a + * refusal is read from the wire instead of inferred from what did not change. + */ +async function saveCard( + page: Page, + anchor: string, + endpoint: string, +): Promise { + const settled = page.waitForResponse( + (res) => + new URL(res.url()).pathname === endpoint && + res.request().method() === "PUT", + ); + await page + .locator( + `${anchor} [data-slot="settings-card-actions"] button[type=submit]`, + ) + .click(); + return (await settled).status(); +} + +/** Flip a channel card's enable switch and wait for the route to answer. */ +async function toggleCard( + page: Page, + switchId: string, + endpoint: string, +): Promise { + const settled = page.waitForResponse( + (res) => + new URL(res.url()).pathname === endpoint && + res.request().method() === "PUT", + ); + await page.locator(switchId).click(); + return (await settled).status(); +} + +/** + * Configure both channels the way a person would: email on with a recipient, + * ntfy saved and left off. Everything here happens on the settings surface. + */ +async function configureChannels(page: Page): Promise { + await openChannels(page); + + await fillField(page, "#email-recipient", EMAIL_RECIPIENT); + expect(await saveCard(page, "#email", "/api/settings/email")).toBe(200); + expect(await toggleCard(page, "#email-toggle", "/api/settings/email")).toBe( + 200, + ); + await expect(page.locator("#email-toggle")).toHaveAttribute( + "data-state", + "checked", + ); + + await fillField(page, "#ntfy-server", NTFY_SERVER); + await fillField(page, "#ntfy-topic", NTFY_TOPIC); + expect(await saveCard(page, "#ntfy", "/api/settings/ntfy")).toBe(200); + await expect(page.locator("#ntfy-toggle")).toHaveAttribute( + "data-state", + "unchecked", + ); +} + +/** + * Pair a phone through the device-registration route the native client uses. + * That registration — and nothing else in the product — is what puts an APNS + * row in the cascade, so the arm the client-managed opt-in suppresses only + * exists once this has run. + */ +async function pairPhone(page: Page): Promise { + const res = await fromPage(page, "/api/devices", { + method: "POST", + body: { + token: APNS_TOKEN, + bundleId: "dev.healthlog.e2e", + apnsToken: APNS_TOKEN, + apnsEnvironment: "sandbox", + }, + }); + expect(res.status, "the phone registered").toBe(201); +} + +/** + * Pin the account's timezone so its local clock reads midday. + * + * The sweep classifies a dose window against the account's OWN wall clock, so + * a window written as a fixed pair of times is overdue for most of the day and + * not yet open around midnight. Rather than leave a spec that is honest 1439 + * minutes out of 1440, the account sets the one preference that decides which + * clock is consulted — through its own profile route, the same PATCH the + * profile form sends — to a whole-hour zone where "now" is early afternoon. + * The morning window below is then unambiguously closed, every run. + */ +async function pinAccountClockToMidday(page: Page): Promise { + const wrapped = (12 - new Date().getUTCHours() + 24) % 24; + const offsetHours = wrapped > 12 ? wrapped - 24 : wrapped; + // `Etc/GMT-5` is UTC+5 — the POSIX sign convention is inverted, which is + // why the zone name is built rather than looked up. + const zone = + offsetHours === 0 + ? "Etc/GMT" + : offsetHours > 0 + ? `Etc/GMT-${offsetHours}` + : `Etc/GMT+${-offsetHours}`; + + const res = await fromPage<{ timezone: string }>(page, "/api/user/profile", { + method: "PATCH", + body: { timezone: zone }, + }); + expect(res.status, "the profile accepted the timezone").toBe(200); + expect(res.data?.timezone).toBe(zone); +} + +/** + * A medication with a morning window and reminders on. With the account's + * clock pinned to early afternoon the window is hours closed, which is the + * state the sweep dispatches for. + */ +async function createOverdueMedication(page: Page): Promise { + const res = await fromPage<{ id: string }>(page, "/api/medications", { + method: "POST", + body: { + name: MEDICATION_NAME, + dose: "1 tablet", + notificationsEnabled: true, + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }, + }); + expect(res.status, "the medication was created").toBe(201); + const id = res.data?.id; + expect(id, "the create returned an id").toBeTruthy(); + return id as string; +} + +interface SweepResult { + medications: Array<{ + name: string; + schedules: Array<{ notificationSent: boolean }>; + }>; +} + +/** + * Run the reminder sweep the admin notifications panel drives. This is the + * product's own trigger for the medication-reminder dispatch, and it goes + * through `dispatchNotification` — the cascade, the per-channel preference + * read and the client-managed gate — rather than calling a sender directly. + */ +async function runReminderSweep(page: Page): Promise { + const res = await fromPage( + page, + "/api/admin/notifications/reminder-check", + { method: "POST" }, + ); + expect(res.status, "the sweep ran").toBe(200); + const mine = res.data?.medications.find((m) => m.name === MEDICATION_NAME); + expect(mine, "the sweep saw this account's overdue medication").toBeTruthy(); + expect( + mine?.schedules.some((s) => s.notificationSent), + "the sweep dispatched for the closed window", + ).toBe(true); +} + +/** Look one channel's attempt out of a dispatch's rows. */ +function attemptFor( + attempts: PushAttempt[], + channel: string, +): PushAttempt | undefined { + return attempts.find((a) => a.channel === channel); +} + +test.describe("notification preferences drive the dispatch decision", () => { + test.use({ storageState: NOTIFY_STORAGE_STATE_PATH }); + + /** + * Serial, because every test configures the one account's channels and then + * counts rows in its ledger. Two of these in flight would be counting each + * other's deliveries. + */ + test.describe.configure({ mode: "serial" }); + + /** + * The sweep walks every active medication on the instance and the ledger + * write that follows is fire-and-forget, so the budget covers a loaded + * runner rather than the 30 s default. + */ + test.setTimeout(120_000); + + let smtp: SmtpStub; + + test.beforeAll(async () => { + smtp = await startSmtpStub(); + }); + + test.afterAll(async () => { + await smtp.close(); + }); + + test.beforeEach(async () => { + await resetNotificationFixture(); + smtp.reset(); + }); + + test("the card's own test control delivers on the enabled channel only", async ({ + page, + }) => { + await configureChannels(page); + + // The diagnostic agrees with the surface about which channel is on. Both + // reads are the account's own; neither is the switch it just clicked. + const channels = (await readDiagnostic(page)).notificationChannels; + expect( + channels.find((c) => c.type === "EMAIL"), + "the email channel is configured and on", + ).toMatchObject({ enabled: true, configPresent: true }); + expect( + channels.find((c) => c.type === "NTFY"), + "the ntfy channel is configured and off", + ).toMatchObject({ enabled: false, configPresent: true }); + + // A channel that is off offers no way to probe it — the refusal is on the + // surface, before any request is made. + await expect( + page.locator( + '#ntfy [data-slot="settings-card-actions"] button[type=button]', + ), + ).toBeDisabled(); + + const before = await attemptCount(page); + const probe = page.waitForResponse( + (res) => new URL(res.url()).pathname === "/api/settings/email/test", + ); + await page + .locator('#email [data-slot="settings-card-actions"] button[type=button]') + .click(); + expect((await probe).status(), "the probe reached the transport").toBe(200); + + const attempts = await attemptsSince(page, before, 1); + expect(attempts[0]).toMatchObject({ + channel: "EMAIL", + result: "ok", + reason: null, + }); + + // And the transport is a real one: the message left the process and was + // accepted for the address the settings surface saved. + expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]); + }); + + test("a due reminder delivers on the enabled channel and skips the rest", async ({ + page, + }) => { + await configureChannels(page); + await pairPhone(page); + await pinAccountClockToMidday(page); + await createOverdueMedication(page); + + const before = await attemptCount(page); + await runReminderSweep(page); + + // Two arms, and exactly two: the paired phone and the enabled email + // channel. A third row would mean the switched-off relay was dialled. + const attempts = await attemptsSince(page, before, 2); + expect( + attempts.map((a) => a.channel).sort(), + "only the channels in the cascade were attempted", + ).toEqual(["APNS", "EMAIL"]); + + expect(attemptFor(attempts, "EMAIL")).toMatchObject({ + eventType: "MEDICATION_REMINDER", + result: "ok", + reason: null, + }); + expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]); + + // The APNs arm was reached and refused by the TRANSPORT, not by a + // preference — this instance has no APNs credentials. That distinction is + // the baseline the next test moves. + expect(attemptFor(attempts, "APNS")).toMatchObject({ + eventType: "MEDICATION_REMINDER", + result: "skipped", + reason: "apns_not_configured", + }); + }); + + test("the client-managed opt-in suppresses the APNs arm and nothing else", async ({ + page, + }) => { + await configureChannels(page); + await pairPhone(page); + await pinAccountClockToMidday(page); + await createOverdueMedication(page); + + // The opt-in the native client sets when it owns the local reminder. + const patched = await fromPage<{ medication: { clientManaged: boolean } }>( + page, + "/api/auth/me/notification-prefs", + { method: "PATCH", body: { medication: { clientManaged: true } } }, + ); + expect(patched.status, "the preference was accepted").toBe(200); + expect(patched.data?.medication.clientManaged).toBe(true); + + // Read back through the account's own resolved preferences, not through + // the value just echoed: the deep-merge is what the dispatcher consults. + const resolved = await fromPage<{ medication: { clientManaged: boolean } }>( + page, + "/api/auth/me/notification-prefs", + ); + expect(resolved.data?.medication.clientManaged).toBe(true); + + const before = await attemptCount(page); + await runReminderSweep(page); + + const attempts = await attemptsSince(page, before, 2); + + // Suppressed BEFORE the sender: the reason is the gate's, not the + // transport's. Same instance, same missing credentials as the previous + // test — only the preference moved, and the ledger says so. + expect(attemptFor(attempts, "APNS")).toMatchObject({ + result: "skipped", + reason: "client_managed", + }); + + // "The APNs leg only" is the published contract, so email still has to + // deliver. An opt-in that silenced the whole dispatch would pass every + // APNs assertion above and be the bug. + expect(attemptFor(attempts, "EMAIL")).toMatchObject({ + eventType: "MEDICATION_REMINDER", + result: "ok", + reason: null, + }); + expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]); + }); + + test("the client-managed opt-in is visible on the medication it silences", async ({ + page, + }) => { + test.skip( + true, + "The chip exists and is unreachable. " + + "`src/components/medications/sections/notifications-section.tsx` " + + "decides between the reminder switch and the client-managed chip on " + + "`notificationPrefs.medication.clientManaged` read out of " + + "`/api/auth/me` — a field that payload does not carry (the prefs live " + + "on `/api/auth/me/notification-prefs`). So the flag reads as false for " + + "every account, the chip never renders, and a person whose phone owns " + + "the reminders is shown a server-side switch that no longer decides " + + "anything. The write path and the dispatch decision are correct — the " + + "two tests above prove both — only the web reflection is missing its " + + "end. The account-payload guard cannot see this: it asks that every " + + "field in the payload has a reader, and this is a reader with no " + + "field. Kept at full strength rather than relaxed to match today's " + + "render.", + ); + + await configureChannels(page); + await pinAccountClockToMidday(page); + const medicationId = await createOverdueMedication(page); + + const patched = await fromPage(page, "/api/auth/me/notification-prefs", { + method: "PATCH", + body: { medication: { clientManaged: true } }, + }); + expect(patched.status).toBe(200); + + await page.goto(`/medications/${medicationId}`, { + waitUntil: "domcontentloaded", + }); + await page.locator('[data-slot="medication-tab-zeitplan"]').click(); + await expect( + page.locator('[data-slot="notifications-client-managed-chip"]'), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.locator('[data-slot="notifications-switch-row"]'), + ).toHaveCount(0); + }); + + test("a relay URL inside a private range is refused and delivers nothing", async ({ + page, + }) => { + await configureChannels(page); + + const before = await attemptCount(page); + await fillField(page, "#ntfy-server", PRIVATE_NTFY_SERVER); + expect( + await saveCard(page, "#ntfy", "/api/settings/ntfy"), + "the save refused the private host", + ).toBe(422); + + // A refusal that still wrote something would be worse than no refusal: + // the stored relay is the one the account saved before. + const stored = await fromPage<{ serverUrl: string; enabled: boolean }>( + page, + "/api/settings/ntfy", + ); + expect(stored.status).toBe(200); + expect(stored.data).toMatchObject({ + serverUrl: NTFY_SERVER, + enabled: false, + }); + + // And nothing was dispatched on the way to being refused. + expect(await attemptCount(page)).toBe(before); + expect(smtp.accepted()).toEqual([]); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 9ee0f42ec..bd0f24e99 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -93,6 +93,31 @@ export const E2E_GUARDIAN = { role: "USER", } as const; +/** + * The notification-dispatch journey's own account. + * + * It has one, rather than borrowing the shared fixture, because the journey's + * verdicts are counts over ONE account's `push_attempts` ledger — "one email + * attempt, no ntfy attempt, the APNs arm skipped and for which reason". Any + * other spec that happened to trigger a delivery for the shared account would + * land inside that window and flip a count that is not about it. + * + * ADMIN because the two surfaces the journey reads the dispatch decision + * through — the reminder trigger and the notification diagnostic — are + * `requireAdmin()`, which is cookie-only by construction. + */ +export const E2E_NOTIFY = { + email: "e2e-notify@healthlog.test", + username: "e2e-notify", + password: "Xd6!Ct3pW9qBz5Fh", + role: "ADMIN", +} as const; + +export const NOTIFY_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateNotify.json", +); + /** A separate delegate session for the eight scoped-record browser journeys. */ export const E2E_SCOPE_DELEGATE = { email: "e2e-scope-delegate@healthlog.test", @@ -408,6 +433,33 @@ export default async function globalSetup(config: FullConfig): Promise { [E2E_GUARDIAN.username], ); + // The notification-dispatch journey's account. Seeded like the others; + // its channels, devices, ledger rows and preferences are reset by + // `e2e/setup/notification-fixture.ts` before every test, because the + // journey has to be re-runnable and `--repeat-each` is exactly the case a + // once-per-suite reset does not cover. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, $5, $6, $6, $6, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + [ + cuid(), + E2E_NOTIFY.username, + E2E_NOTIFY.email, + await hashPassword(E2E_NOTIFY.password), + E2E_NOTIFY.role, + now, + ], + ); + // The scoped-record journey uses a dedicated delegate so its preseeded // grants cannot affect the invitation lifecycle exercised by the sharing // journey above. Its own module preferences are explicitly off: a target @@ -647,7 +699,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in TEN times now + // The login bucket, for the same reason. This setup signs in ELEVEN times now // (the shared jar, the owner, and one jar apiece for every spec that moves // a session's record selector), and the ceiling is five attempts per IP per // quarter-hour — so two local runs in a row would otherwise end with a 429 @@ -727,7 +779,7 @@ export default async function globalSetup(config: FullConfig): Promise { * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now - * signs in ten times, so clearing once before the batch is no longer + * signs in eleven times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. @@ -753,6 +805,10 @@ export default async function globalSetup(config: FullConfig): Promise { // for what happens when the journey switches the shared row instead. await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH); + // The notification-dispatch journey's jar. Its own account, so its own + // login — see `E2E_NOTIFY` for why the ledger counts need one. + await capture(E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/e2e/setup/notification-fixture.ts b/e2e/setup/notification-fixture.ts new file mode 100644 index 000000000..23363b05a --- /dev/null +++ b/e2e/setup/notification-fixture.ts @@ -0,0 +1,102 @@ +/** + * Account hygiene for the notification-dispatch journey. + * + * Every verdict in that journey is a COUNT over one account's delivery ledger + * inside a window the test opens itself — one email attempt, no ntfy attempt, + * one APNs skip carrying a named reason. A channel row, a paired device, a + * medication or a preference blob left behind by an earlier run would put a + * second dispatch decision inside that window, and the count would be reading + * the previous run rather than this one. `--repeat-each` is the case that + * makes it certain rather than likely. + * + * So the account is put back to "nothing configured" before every test: + * no channels, no devices, no ledger, no medications, default preferences and + * the default timezone. Deletes rather than disables — a disabled channel row + * is itself one of the journey's subjects, so leaving one behind would seed + * the answer to a question the test asks. + * + * Reaching for Postgres directly is the channel `global-setup.ts` already + * uses to seed and to clear buckets, and every statement here is parameter + * bound on the account's own id. + */ +import pg from "pg"; + +import { E2E_NOTIFY } from "./global-setup"; + +async function withPool(fn: (pool: pg.Pool) => Promise): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[notification-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + return await fn(pool); + } finally { + await pool.end(); + } +} + +/** The notification account's id, or a loud failure if the seed never ran. */ +export async function notificationAccountId(): Promise { + return withPool(async (pool) => { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_NOTIFY.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[notification-fixture] e2e-notify is not seeded — global-setup must run first", + ); + } + return id; + }); +} + +/** + * Put the notification account back to a freshly-registered state. + * + * The rate-limit buckets go too. The card's own test control allows five + * probes per five minutes and device registration twenty per quarter-hour — + * production numbers, and both below what three repeats of this journey + * spend. A run that ended on the fixture's 429 would say nothing about the + * dispatcher. + */ +export async function resetNotificationFixture(): Promise { + await withPool(async (pool) => { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_NOTIFY.username], + ); + const userId = res.rows[0]?.id; + if (!userId) { + throw new Error( + "[notification-fixture] e2e-notify is not seeded — global-setup must run first", + ); + } + + await pool.query("DELETE FROM notification_channels WHERE user_id = $1", [ + userId, + ]); + await pool.query("DELETE FROM devices WHERE user_id = $1", [userId]); + await pool.query( + `DELETE FROM push_attempts + WHERE user_id = $1 OR recipient_user_id = $1 OR record_user_id = $1`, + [userId], + ); + await pool.query("DELETE FROM medications WHERE user_id = $1", [userId]); + await pool.query( + `UPDATE users + SET notification_prefs = NULL, + timezone = DEFAULT + WHERE id = $1`, + [userId], + ); + await pool.query("DELETE FROM rate_limits WHERE key = ANY($1)", [ + [ + `email-test:${userId}`, + `ntfy-test:${userId}`, + `devices:register:${userId}`, + `notification-prefs:patch:${userId}`, + ], + ]); + }); +} diff --git a/e2e/setup/smtp-stub.ts b/e2e/setup/smtp-stub.ts new file mode 100644 index 000000000..d640cc492 --- /dev/null +++ b/e2e/setup/smtp-stub.ts @@ -0,0 +1,133 @@ +/** + * A local SMTP responder, and the reason the notification journey needs one. + * + * Every user-configurable DELIVERY channel in this product refuses a local + * destination on purpose. ntfy and the generic webhook take their URL from the + * account, so the URL passes `isPublicUrl()` at save time and the send goes + * through `safeFetch({ requirePublicHost: true })`, which pins the resolved + * address at connect time; loopback, RFC1918 and every alternate notation are + * refused at both ends. Web Push is the same shape one layer down — the + * subscription endpoint is checked before the dial. Telegram and APNs address + * hard-coded third parties. So none of them can be pointed at a stub, and + * faking one would prove the fixture rather than the dispatcher. + * + * Email is the exception, and not by oversight: its transport is OPERATOR + * configuration (`SMTP_HOST` / `SMTP_PORT` / `SMTP_FROM`), never account + * input, so it never crosses the SSRF floor that guards the account-supplied + * hosts. That makes it the one channel a self-hoster — and this suite — can + * exercise end to end on one machine, through the real sender, the real + * transport and the real `push_attempts` ledger write. + * + * The responder below speaks the minimum of RFC 5321 that nodemailer needs: + * a greeting, EHLO with no advertised extensions (so nodemailer neither + * pipelines nor tries STARTTLS), the envelope commands, and DATA. It stores + * nothing but the envelope recipients, which is all the journey reads back. + */ +import { createServer, type Server, type Socket } from "node:net"; + +/** + * Port the stub listens on, and the port `playwright.config.ts` hands the app + * server as `SMTP_PORT`. Named here so the config and the spec cannot drift. + */ +export const SMTP_STUB_PORT = 3925; + +export const SMTP_STUB_HOST = "127.0.0.1"; + +/** The envelope sender the app is configured with (`SMTP_FROM`). */ +export const SMTP_STUB_FROM = "healthlog-e2e@healthlog.test"; + +export interface SmtpStub { + /** Envelope recipients of every message the stub accepted, oldest first. */ + accepted(): readonly string[]; + /** Forget everything accepted so far. */ + reset(): void; + close(): Promise; +} + +const CRLF = "\r\n"; + +/** Start the responder and resolve once it is accepting connections. */ +export async function startSmtpStub(): Promise { + const accepted: string[] = []; + + const server: Server = createServer((socket: Socket) => { + let buffer = ""; + let inData = false; + let envelope: string[] = []; + + const say = (line: string): void => { + socket.write(`${line}${CRLF}`); + }; + + socket.setEncoding("utf8"); + say("220 healthlog-e2e-smtp ready"); + + socket.on("data", (chunk: string) => { + buffer += chunk; + let index = buffer.indexOf(CRLF); + while (index !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + CRLF.length); + + if (inData) { + // A bare "." on its own line ends the message body. + if (line === ".") { + inData = false; + accepted.push(...envelope); + envelope = []; + say("250 2.0.0 Ok: queued"); + } + } else { + const verb = line.slice(0, 4).toUpperCase(); + if (verb === "EHLO" || verb === "HELO") { + // Single-line, no extensions: nodemailer then sends one command + // at a time and never attempts STARTTLS or AUTH. + say("250 healthlog-e2e-smtp"); + } else if (verb === "MAIL") { + say("250 2.1.0 Ok"); + } else if (verb === "RCPT") { + const match = /<([^>]*)>/.exec(line); + if (match) envelope.push(match[1]); + say("250 2.1.5 Ok"); + } else if (verb === "DATA") { + inData = true; + say("354 End data with ."); + } else if (verb === "QUIT") { + say("221 2.0.0 Bye"); + socket.end(); + } else if (verb === "RSET") { + envelope = []; + say("250 2.0.0 Ok"); + } else { + say("250 2.0.0 Ok"); + } + } + + index = buffer.indexOf(CRLF); + } + }); + + // A client that vanishes mid-exchange is not this stub's problem, and an + // unhandled 'error' on a socket would take the whole runner down. + socket.on("error", () => socket.destroy()); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(SMTP_STUB_PORT, SMTP_STUB_HOST, () => { + server.removeListener("error", reject); + resolve(); + }); + }); + + return { + accepted: () => accepted, + reset: () => { + accepted.length = 0; + }, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} diff --git a/playwright.config.ts b/playwright.config.ts index 4bd5961d6..b2c689661 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,6 +4,11 @@ import { SSR_PREFETCH_BASE_URL, SSR_PREFETCH_PORT, } from "./e2e/setup/ssr-prefetch-server"; +import { + SMTP_STUB_FROM, + SMTP_STUB_HOST, + SMTP_STUB_PORT, +} from "./e2e/setup/smtp-stub"; /** * Playwright configuration for the HealthLog E2E suite. @@ -171,6 +176,14 @@ export default defineConfig({ // through stable data attributes, not a mobile layout, so it runs in // one project. "apple-health-import.spec.ts", + // The notification-dispatch journey binds a local SMTP responder on a + // fixed port and reads GLOBAL verdicts off one account's delivery + // ledger — "one email attempt, no ntfy attempt, the APNs arm skipped + // for this reason". A second project would fight it for the port and + // add attempts to the window it counts. It proves a dispatch decision + // through stable attributes and the account's own API, not a mobile + // layout, so it runs in one project. + "notification-dispatch-journey.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs @@ -210,6 +223,15 @@ export default defineConfig({ // Disable the prefetch for the e2e server — the suite keeps the // deterministic client-fetch path. DASHBOARD_SSR_PREFETCH: "false", + // The one delivery channel a notification journey can exercise + // for real on one machine. Its transport is operator config, not + // account input, so it never crosses the SSRF floor that refuses + // a local host for ntfy / webhook / Web Push. The responder these + // point at is started by the journey itself; see + // `e2e/setup/smtp-stub.ts` for why no other channel can be. + SMTP_HOST: SMTP_STUB_HOST, + SMTP_PORT: String(SMTP_STUB_PORT), + SMTP_FROM: SMTP_STUB_FROM, }, }, // The SHIPPED configuration, on its own port.