/** * 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. * * What isolates this file, and what does not. `test.describe.configure({ mode: * "serial" })` orders the tests INSIDE it; the suite runs `fullyParallel` with * two workers, so every other spec file is live against the same database * throughout. Three things carry the isolation instead: the account is this * file's own (`E2E_NOTIFY`), the server is this file's own (mail configured, no * reminder scheduler — `e2e/setup/notification-server.ts`), and the reminder * sweep is told which account to walk, so the one trigger here that could * dispatch for the whole instance no longer does. * * What is left standing is a precondition rather than a mechanism: nothing else * delivers email in this run, because no other e2e account configures an EMAIL * channel and no other server is pointed at the responder. `smtp.accepted()` is * asserted by exact equality on the strength of that, and a spec that seeds an * email channel for another account has to say so here. * * `reason: "apns_not_configured"` and `reason: "client_managed"` are internal * sender / dispatcher constants rather than published contract, and they are * pinned on purpose: the ledger reason is the only place the DECISION is * legible, and the whole claim of the client-managed opt-in is that the reason * changes while everything else does not. A rename that reaches here should * read as a decision to make, not as a mystery break. */ import type { Page } from "@playwright/test"; import { E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH } from "./setup/global-setup"; import { NOTIFICATION_BASE_URL } from "./setup/notification-server"; import { notificationAccountId, 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. * * The diagnostic returns the trailing TWENTY rows and no more * (`src/app/api/admin/notifications/diagnostic/route.ts`). The fixture resets * the ledger to zero before every test and the largest cascade here is two, so * the ceiling is far away — but a poll that ran past it could never converge * and would report "the dispatch wrote N delivery attempt(s)" while the real * answer was "the diagnostic stopped counting". The ceiling is asserted up * front so that failure names itself. */ async function attemptsSince( page: Page, before: number, expected: number, ): Promise { expect( before + expected, "the diagnostic returns at most 20 attempts, so a larger window could never converge", ).toBeLessThanOrEqual(20); 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 { scoped: boolean; medications: Array<{ name: string; user: string; schedules: Array<{ notificationSent: boolean }>; }>; } /** * Run the reminder sweep the admin notifications panel drives, NAMING this * account. 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. * * The selector is not a convenience. Without it the sweep walks every active * medication on the instance and dispatches for each overdue slot, which makes * this file a writer into every other account's delivery ledger — the exact * inverse of the interference it is trying to avoid, and one that would start * sending real mail the moment another account had a channel and an overdue * dose. Scoped, the run reaches one account: the one it then counts. */ async function runReminderSweep(page: Page, userId: string): Promise { const res = await fromPage( page, "/api/admin/notifications/reminder-check", { method: "POST", body: { userId } }, ); expect(res.status, "the sweep ran").toBe(200); expect( res.data?.scoped, "the sweep walked one account, not the instance", ).toBe(true); expect( [...new Set(res.data?.medications.map((m) => m.user))], "every medication the sweep touched belongs to this account", ).toEqual([E2E_NOTIFY.username]); 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", () => { /** * Its own server as well as its own account. That process is the one carrying * the mail configuration, and the one with no reminder scheduler inside it — * `e2e/setup/notification-server.ts` says why each matters and what the * shared server keeping the scheduler still leaves standing. */ test.use({ storageState: NOTIFY_STORAGE_STATE_PATH, baseURL: NOTIFICATION_BASE_URL, }); /** * 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 ledger write that follows a dispatch is fire-and-forget and the tests * poll for it, so the budget covers a loaded runner rather than the 30 s * default. */ test.setTimeout(120_000); let smtp: SmtpStub; let accountId: string; test.beforeAll(async ({ playwright }) => { smtp = await startSmtpStub(); accountId = await notificationAccountId(); // The one precondition this file cannot arrange for itself: the server it // talks to has to have been started with `SMTP_*`. `playwright.config.ts` // hands them to the server it starts, but `reuseExistingServer` (the local // default) and `E2E_SKIP_WEB_SERVER=1` both attach to a server started // without them — and the symptom is a missing `#email-recipient` field // thirty lines into the first test, which points nowhere near the cause. const api = await playwright.request.newContext({ baseURL: NOTIFICATION_BASE_URL, storageState: NOTIFY_STORAGE_STATE_PATH, }); try { const res = await api.get("/api/settings/email"); const body = (await res.json()) as { data: { smtpConfigured: boolean } }; expect( body.data?.smtpConfigured, `the server on ${NOTIFICATION_BASE_URL} was started without SMTP_* — a reused ` + `dev server or E2E_SKIP_WEB_SERVER=1 does not carry them. Start it with ` + `SMTP_HOST=127.0.0.1 SMTP_PORT=${smtp.port} SMTP_FROM=.`, ).toBe(true); } finally { await api.dispose(); } }); test.afterAll(async () => { // Optional-chained: a `beforeAll` that threw before the stub resolved // leaves this undefined, and a teardown TypeError buries the real cause // under its own. 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, accountId); // 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, accountId); 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]); }); /** * The web reflection of the opt-in, and the reason it is a test rather than a * screenshot: the section reads `notificationPrefs.medication.clientManaged` * off `/api/auth/me`, and that payload did not publish it until this branch. * The flag was `undefined` for every account, so the chip never rendered and * a person whose phone owned the reminders was shown a server-side switch * that decided nothing. Both halves are asserted — the chip appears AND the * switch row is gone — because either one alone passes on the wrong render. */ test("the client-managed opt-in is visible on the medication it silences", async ({ page, }) => { 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([]); }); });