test(e2e): cover notification preferences through to the dispatch decision

One browser journey for the web arm of notification delivery: an account
configures its channels on the settings surface, a due medication reminder
runs through the product's own sweep, and the dispatch decision is read back
off that account's `push_attempts` ledger via the admin diagnostic.

What it pins:
  - the enabled channel delivers (`result: ok`) and the message really leaves
    the process, asserted against a local SMTP responder rather than against
    the ledger row alone;
  - a channel that is configured and switched OFF is never attempted, and its
    own test control is inert on the surface;
  - the APNs arm is attempted and skipped, and the ledger's reason
    distinguishes the transport refusing (`apns_not_configured`) from the
    client-managed opt-in refusing (`client_managed`) — with email still
    delivering under the opt-in, which is the published "APNs leg only"
    contract;
  - an ntfy relay URL inside a private range is refused with 422, leaves the
    stored config untouched, and writes no attempt.

Email is the channel exercised for real because it is the only one that can
be: every account-supplied delivery target crosses the SSRF floor
(`isPublicUrl` plus `safeFetch({ requirePublicHost: true })` with a
connect-time address pin), so ntfy, the generic webhook and Web Push refuse a
local stub by design, and Telegram and APNs address hard-coded third parties.
Email's transport is operator configuration, so `e2e/setup/smtp-stub.ts` can
stand in for it end to end. The config points the e2e server at that stub.

The journey carries one skipped test at full strength: the medication detail
surface decides between the reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged` read out of `/api/auth/me`, and
that payload does not carry the field. The flag therefore reads as false for
every account and the chip never renders, so a person whose phone owns the
reminders is still shown a server-side switch that no longer decides anything.
The write path and the dispatch decision are both correct; only the web
reflection is missing its end.

The account and its jar are seeded in global setup, and its channels, devices,
ledger and preferences are reset before every test so the counts are about the
run in front of them.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 05:41:17 +02:00
1 parent 297395372b
commit 8be51d9beb
5 files changed
+932 -2

No files matched your search

+617
View File
@@ -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<T>(
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<DiagnosticPayload> {
const res = await fromPage<DiagnosticPayload>(
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<PushAttempt[]> {
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<number> {
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<void> {
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<void> {
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<number> {
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<number> {
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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
const res = await fromPage<SweepResult>(
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([]);
});
});
+58 -2
View File
@@ -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<void> {
[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<void> {
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<void> {
* 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<void> {
// 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);
+102
View File
@@ -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<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
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<string> {
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<void> {
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}`,
],
]);
});
}
+133
View File
@@ -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<void>;
}
const CRLF = "\r\n";
/** Start the responder and resolve once it is accepting connections. */
export async function startSmtpStub(): Promise<SmtpStub> {
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 <CR><LF>.<CR><LF>");
} 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<void>((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<void>((resolve) => {
server.close(() => resolve());
}),
};
}
+22
View File
@@ -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.