Merge the notification dispatch end-to-end journey, the account-scoped reminder sweep and the published notification preferences

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 06:37:56 +02:00
commit 8b1ca7ee5a
15 files changed
+1742 -14

No files matched your search

+185
View File
@@ -9750,6 +9750,50 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/admin/notifications/reminder-check:
post:
tags:
- Admin
summary: Run the medication-reminder sweep
description: >-
The admin panel's manual reminder sweep: it classifies every active medication's dose windows against the OWNING
account's wall clock and dispatches a MEDICATION_REMINDER for each overdue slot that has no intake event today.
Cookie auth only — `requireAdmin()` refuses every Bearer caller, wildcard scope included.
Scope is the thing to get right. With no body the sweep is INSTANCE-WIDE and dispatches into every account that
has an overdue dose and a configured channel; that is the operator button's behaviour and it is deliberate. A
caller that means to sweep one account sends `userId` and stops being a second writer for every other one. The
selector can only narrow: it feeds the Prisma `where`, never the authorisation.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
userId:
type: string
minLength: 1
maxLength: 64
pattern: ^[A-Za-z0-9_-]+$
additionalProperties: false
responses:
"200":
description: The sweep's findings and how many reminders it sent.
content:
application/json:
schema:
$ref: "#/components/schemas/AdminReminderCheckResponse"
"401": *a1
"403":
description: Caller is not an admin.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/admin/notifications/diagnostic:
get:
tags:
@@ -49138,6 +49182,132 @@ components:
- data
- error
additionalProperties: false
AdminReminderCheckResponse:
type: object
properties:
data:
$ref: "#/components/schemas/AdminReminderCheckResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AdminReminderCheckResult:
type: object
properties:
timestamp:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
missedThresholdMinutes:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
scoped:
type: boolean
description: True when the request named a `userId` and the sweep therefore walked one account only.
medications:
type: array
items:
$ref: "#/components/schemas/AdminReminderMedication"
notificationsSent:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
message:
type: string
required:
- timestamp
- missedThresholdMinutes
- scoped
- medications
- notificationsSent
- message
additionalProperties: false
description: What the sweep found and what it sent. `notificationsSent` counts DISPATCHES, not deliveries — each one
enters the channel cascade and the per-channel verdict lands in the `push_attempts` ledger the notification
diagnostic surfaces.
AdminReminderMedication:
type: object
properties:
name:
type: string
dose:
type: string
user:
type: string
timezone:
type: string
localTime:
type: string
dayOfWeek:
type: string
notificationsEnabled:
type: boolean
schedules:
type: array
items:
$ref: "#/components/schemas/AdminReminderScheduleStatus"
eventsToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- name
- dose
- user
- timezone
- localTime
- dayOfWeek
- notificationsEnabled
- schedules
- eventsToday
additionalProperties: false
description: One active medication the sweep walked, with the account it belongs to and that account's local clock — the
sweep classifies every window against the owner's timezone, never the operator's.
AdminReminderScheduleStatus:
type: object
properties:
window:
type: string
days:
type: string
status:
type: string
enum:
- open
- late
- threshold
- missed
- skipped
label:
type: string
minutesPastEnd:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
notificationSent:
type: boolean
required:
- window
- days
- status
- label
- minutesPastEnd
- notificationSent
additionalProperties: false
description: One dose window as the sweep classified it against the OWNING account's wall clock, and whether this run
dispatched for it. `notificationSent` is false for a window that is still open, for a day the schedule does not
cover, and for a slot that already has an intake event today.
AdminDiagnosticResponse:
type: object
properties:
@@ -66178,12 +66348,27 @@ components:
anyOf:
- $ref: "#/components/schemas/RecordSessionState"
- type: "null"
notificationPrefs:
type: object
properties:
medication:
type: object
properties:
clientManaged:
type: boolean
required:
- clientManaged
additionalProperties: {}
required:
- medication
additionalProperties: {}
required:
- id
- username
- accountAccess
- moduleAccess
- recordSession
- notificationPrefs
additionalProperties: {}
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the
+701
View File
@@ -0,0 +1,701 @@
/**
* 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<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.
*
* 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<PushAttempt[]> {
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<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 {
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<void> {
const res = await fromPage<SweepResult>(
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=<addr>.`,
).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([]);
});
});
+64 -2
View File
@@ -145,6 +145,37 @@ export const E2E_MEDICATION = {
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".
*
* The direction that matters is the one that is easy to state backwards. The
* exposure is not mainly that another spec writes into this account's window;
* it is that this journey's own trigger — the admin reminder sweep — dispatches
* for whatever accounts it is pointed at, and pointed at the instance it
* reaches all of them. A dedicated account gives the sweep something to name,
* so the run stays a writer for exactly one ledger and a reader of exactly the
* same one.
*
* 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",
@@ -556,6 +587,33 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// 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 doctor-report journey's account. Seeded like the shared one — a
// record with a height, a date of birth and a gender, so the report has a
// patient header to print — and with the two report columns cleared, which
@@ -845,7 +903,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 SIXTEEN times now
// The login bucket, for the same reason. This setup signs in SEVENTEEN 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
@@ -989,7 +1047,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 sixteen times, so clearing once before the batch is no longer
* signs in seventeen 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.
@@ -1019,6 +1077,10 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// nothing it clears is visible to the shared one.
await capture(E2E_MEDICATION, MEDICATION_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);
+116
View File
@@ -0,0 +1,116 @@
/**
* 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]);
// `user_id` and `recipient_user_id`, and deliberately NOT
// `record_user_id`: that column is the health-record SUBJECT, so a
// delivery ABOUT this account sent TO a guardian carries it while the
// recipient is somebody else. Deleting on it would take a row out of
// another account's ledger. The diagnostic this journey reads filters on
// the recipient, so the two agree.
await pool.query(
`DELETE FROM push_attempts
WHERE user_id = $1 OR recipient_user_id = $1`,
[userId],
);
// The medication delete cascades to its intake events, but a stale event
// is what makes the sweep decide a dose was already taken and skip its
// dispatch, so the statement is written out rather than inherited from a
// foreign key a future fixture might stop relying on.
await pool.query(
"DELETE FROM medication_intake_events WHERE 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}`,
],
]);
});
}
+43
View File
@@ -0,0 +1,43 @@
/**
* The third E2E server: the one configured for mail, and the one with no
* scheduler.
*
* Two things the notification-dispatch journey needs are wrong for every other
* spec, so it gets its own process rather than bending the shared one.
*
* **Mail.** `SMTP_HOST` / `SMTP_PORT` / `SMTP_FROM` are operator config, and
* `isEmailConfigured()` is what decides whether the Email card renders at all
* (`src/components/settings/email-card.tsx`). Putting them on the shared server
* would put that card on `/settings/integrations` for every spec that opens the
* route — the axe scan and the card-anatomy geometry sweep included — and make
* this branch responsible for a surface it does not touch. Here, the shipped
* "no SMTP, no card" state stays what every other spec observes.
*
* **No scheduler.** Unset, `HEALTHLOG_PROCESS_TYPE` reads "all" and
* `src/instrumentation.ts` starts the pg-boss reminder worker inside the
* process serving requests, which schedules `medication-reminder-check` every
* fifteen minutes. The journey keeps a medication overdue for the length of a
* test, and a tick inside that window either mints the missed-dose row that
* makes the sweep skip its own dispatch or dispatches beside it and adds a
* ledger row the count was not expecting. `web` is the shipped, documented
* value for HTTP-without-scheduler (docs/self-hosting/scaling.md); the pg-boss
* PRODUCER still starts, so a route that enqueues work keeps working, and the
* journey's own trigger is a route rather than a job.
*
* The shared server keeps "all" deliberately, and the reason is measured
* rather than assumed: `getWorkerStatus()` is per-process in-memory, so a `web`
* process reports the worker stopped, `/admin` renders that row
* `text-destructive`, and axe fails it at 3.97:1 against the muted tile. That
* is a real contrast defect, visible to any operator running the documented
* web/worker split, and it is not this branch's to fix. What it means here is
* that the scheduler still exists on the suite's shared process: it is off in
* the process the journey drives, not off in the run.
*
* With `E2E_SKIP_WEB_SERVER=1` no server is started for you and all three have
* to be running — the same contract the other two already carry.
*/
export const NOTIFICATION_PORT = 3200;
export const NOTIFICATION_BASE_URL =
process.env.E2E_NOTIFICATION_BASE_URL ??
`http://localhost:${NOTIFICATION_PORT}`;
+184
View File
@@ -0,0 +1,184 @@
/**
* 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`.
*
* It cannot be an OS-assigned port-0 binding: the config has to put `SMTP_PORT`
* into the web server's environment before any worker process exists, and the
* stub itself does not start until the spec's `beforeAll` — so by the time a
* kernel-assigned port would be known, the server it has to reach is already
* running. The port is therefore CHOSEN once per run, at config load, from the
* IANA dynamic range, and published through `SMTP_STUB_PORT` in the
* environment so the config, the server and the spec all read the one value.
* Two suites on one machine draw different numbers instead of colliding on a
* constant, and a collision that does happen fails by name in `listen` below
* rather than as a missing form field thirty lines into a test.
*
* An operator pinning it by hand (`SMTP_STUB_PORT=3925 pnpm e2e`) wins, which
* is what makes an externally started server — `E2E_SKIP_WEB_SERVER=1` — able
* to carry the same value.
*/
export function resolveSmtpStubPort(): number {
const pinned = Number(process.env.SMTP_STUB_PORT);
if (Number.isInteger(pinned) && pinned > 0 && pinned < 65_536) return pinned;
// 49152–65535, the dynamic/private range no registered service claims.
const chosen = 49_152 + Math.floor(Math.random() * (65_536 - 49_152));
process.env.SMTP_STUB_PORT = String(chosen);
return chosen;
}
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 {
/** The port this responder bound, which is the one the app was handed. */
readonly port: number;
/** 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 port = resolveSmtpStubPort();
// Every established socket, so `close()` can end them. `server.close()`
// resolves only once the last connection is gone and never ends one itself,
// so a socket abandoned mid-DATA by a timed-out test would otherwise hold
// teardown open until Playwright's own timeout fires over it.
const open = new Set<Socket>();
const server: Server = createServer((socket: Socket) => {
open.add(socket);
socket.on("close", () => open.delete(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", (err: NodeJS.ErrnoException) => {
// The one failure worth naming. A crashed prior run, a second suite, or
// a hand-pinned port already in use all arrive here, and the bare
// `EADDRINUSE` that used to propagate took the whole `beforeAll` down
// with a message that named neither the port nor the stub.
reject(
err.code === "EADDRINUSE"
? new Error(
`[smtp-stub] port ${port} on ${SMTP_STUB_HOST} is already in use — ` +
"another run's responder is still listening, or SMTP_STUB_PORT " +
"was pinned to a port something else holds. Free it, or unset " +
"SMTP_STUB_PORT to draw a fresh one.",
{ cause: err },
)
: err,
);
});
server.listen(port, SMTP_STUB_HOST, () => {
server.removeAllListeners("error");
resolve();
});
});
return {
port,
accepted: () => accepted,
reset: () => {
accepted.length = 0;
},
close: () =>
new Promise<void>((resolve) => {
for (const socket of open) socket.destroy();
open.clear();
server.close(() => resolve());
}),
};
}
+58
View File
@@ -1,9 +1,18 @@
import { defineConfig, devices } from "@playwright/test";
import {
NOTIFICATION_BASE_URL,
NOTIFICATION_PORT,
} from "./e2e/setup/notification-server";
import {
SSR_PREFETCH_BASE_URL,
SSR_PREFETCH_PORT,
} from "./e2e/setup/ssr-prefetch-server";
import {
resolveSmtpStubPort,
SMTP_STUB_FROM,
SMTP_STUB_HOST,
} from "./e2e/setup/smtp-stub";
/**
* Playwright configuration for the HealthLog E2E suite.
@@ -19,6 +28,14 @@ import {
* To run locally: `pnpm dlx playwright install --with-deps chromium`
* once, then `pnpm e2e`.
*/
/**
* Drawn once here, before any server or worker exists, and published through
* `process.env.SMTP_STUB_PORT` — which the web server inherits and which every
* worker process re-reads when it loads this config, so all three agree on one
* number without a constant to collide on.
*/
const smtpStubPort = resolveSmtpStubPort();
export default defineConfig({
testDir: "./e2e",
testIgnore: ["setup/**"],
@@ -196,6 +213,15 @@ export default defineConfig({
// project would be two browsers rewriting one record's tables in
// parallel, and its last act switches a session into that record.
"backup-restore-journey.spec.ts",
// The notification-dispatch journey binds a local SMTP responder and
// reads verdicts off one account's delivery ledger — "one email
// attempt, no ntfy attempt, the APNs arm skipped for this reason". A
// second project would run the file in a second worker process, whose
// stub would find the port already bound, and would add attempts to
// the window the first one 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
@@ -265,5 +291,37 @@ export default defineConfig({
DASHBOARD_SSR_PREFETCH: "true",
},
},
// The mail-configured, scheduler-free server, on its own port.
//
// Only `notification-dispatch-journey.spec.ts` talks to it, and
// `e2e/setup/notification-server.ts` carries the whole reasoning: why
// the SMTP env must not reach the shared server (it would render the
// Email card for every spec that opens `/settings/integrations`), why
// this process must not run the reminder scheduler (a tick inside the
// journey's window is a writer no spec can see), and what the shared
// server keeping "all" still leaves standing.
{
command: `PORT=${NOTIFICATION_PORT} HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
url: `${NOTIFICATION_BASE_URL}/api/version`,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
env: {
...process.env,
NATIVE_CANVAS: "off",
DASHBOARD_SSR_PREFETCH: "false",
HEALTHLOG_PROCESS_TYPE: "web",
// 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(smtpStubPort),
SMTP_FROM: SMTP_STUB_FROM,
},
},
],
});
@@ -1,5 +1,6 @@
/**
* v1.32.36 — pair guard over the account payload (`GET /api/auth/me`).
* v1.32.36 — pair guard over the account payload (`GET /api/auth/me`), in both
* directions.
*
* The recurring failure this exists to prevent: a two-ended feature whose ends
* ship in different releases. The column lands with the schema change, the
@@ -33,12 +34,39 @@
* be allowlisted with that reason written down, and the reason is an
* assumption about another repository until an audit crosses over.
*
* ## The other direction: a reader with no field
*
* Everything above asks that a published field has a reader. It cannot see the
* inverse — a client that reads a field the payload never published — and that
* blind spot cost a shipped feature: the medication surface decided between
* the server-side reminder switch and the client-managed chip on
* `notificationPrefs.medication.clientManaged`, read off this payload, which
* did not carry it. The flag was `undefined` for every account, so the chip
* never rendered and a person whose phone owned the reminders was shown a
* switch that decided nothing. Every guard was green.
*
* The last case closes it, and the shape it takes is deliberate. A typed guard
* — one `AccountPayload` interface, `tsc` refusing an absent property — was the
* first idea and does not work here: the consumers declare their own local
* response interfaces (`interface UserPrefsResponse { … }`), so `tsc` checks
* each file against its own private claim about the payload and agrees with
* every one of them. So the check reads those local claims instead, out of the
* file that makes them, and holds them against what the route publishes. It
* asserts a non-zero match count, because a textual guard that silently
* matches nothing is the failure mode this repository already has on record.
*
* Its limit, written down: it sees the response type a `/api/auth/me` read
* names, so a consumer that types the read as `unknown` and indexes into it, or
* imports its response type from another module, is invisible to it.
*
* Mutation check: append a field to the response literal in
* `src/app/api/auth/me/route.ts` and this goes red; delete the practice-name
* read in `health-record-export-panel.tsx` and it goes red too.
* read in `health-record-export-panel.tsx` and it goes red too; delete
* `notificationPrefs` from the response literal and the last case goes red
* naming the medication notification section.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { join, relative } from "node:path";
import { describe, it, expect } from "vitest";
@@ -189,6 +217,31 @@ function readsField(text: string, field: string): boolean {
return re.test(text);
}
/**
* Top-level member names of a locally declared `interface Name { … }`, or null
* when the file does not declare one under that name (the type is imported, or
* is a shared transport type this file only references).
*
* Members are read at one indent level, the same way the route's response
* literal is read above — the tree is prettier-formatted, so a nested object's
* keys sit deeper and are not mistaken for top-level fields.
*/
function declaredMembers(text: string, name: string): string[] | null {
const open = new RegExp(`\\binterface\\s+${name}\\s*\\{`).exec(text);
if (!open) return null;
const start = open.index + open[0].length;
const end = text.indexOf("\n}", start);
if (end === -1) return null;
const body = text.slice(start, end);
return [
...new Set(
[...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*)\??\s*[?:]/gm)].map(
(m) => m[1],
),
),
];
}
describe("account payload consumer guard", () => {
const source = readFileSync(ME_ROUTE, "utf8");
const fields = payloadFields(source);
@@ -294,4 +347,40 @@ describe("account payload consumer guard", () => {
).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
}
});
it("every field a client types onto the account payload is published", () => {
const published = new Set(fields);
const offenders: string[] = [];
let claimsChecked = 0;
for (const file of consumerFiles) {
const text = stripComments(readFileSync(file, "utf8"));
// The type argument of a read of THIS endpoint. `/api/auth/me/...`
// siblings are different payloads, so the literal is anchored closed.
for (const call of text.matchAll(
/<\s*([A-Za-z_$][\w$]*)\s*>\s*\(\s*"\/api\/auth\/me"\s*\)/g,
)) {
const declared = declaredMembers(text, call[1]);
if (declared === null) continue; // an imported / shared type
claimsChecked += 1;
for (const member of declared) {
if (!published.has(member)) {
offenders.push(
`${relative(process.cwd(), file)} reads \`${member}\``,
);
}
}
}
}
expect(
claimsChecked,
"no client names a response type for GET /api/auth/me, so this case matched nothing rather than proving anything",
).toBeGreaterThan(0);
expect(
offenders,
"these clients type a field onto the account payload that the route does not publish, so it is `undefined` for every account and whatever it decides never happens",
).toEqual([]);
});
});
@@ -256,10 +256,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
},
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/reminder-check": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
@@ -0,0 +1,120 @@
/**
* The admin reminder sweep's SCOPE, which is the half of this route that is
* easy to state backwards.
*
* The route dispatches. Every overdue slot it finds enters the channel cascade
* and reaches whoever owns the medication, so an unscoped run is a writer into
* every account on the instance at once — not a reader that somebody else might
* disturb. These cases pin both arms: no body means the long-standing
* instance-wide sweep, and a named account means a `where` that cannot reach
* anyone else.
*
* Mutation check: drop the `userId` spread from the route's `where` and the
* second case fails on the filter it no longer carries.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { count: vi.fn() },
},
}));
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(async () => undefined),
}));
import { POST } from "../route";
import { requireAdmin } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
const URL_ = "http://localhost/api/admin/notifications/reminder-check";
/** The admin console's own call: no body, no content-type. */
function bare(): NextRequest {
return new NextRequest(URL_, { method: "POST" });
}
function withBody(body: unknown): NextRequest {
return new NextRequest(URL_, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function whereOfLastFindMany(): Record<string, unknown> {
const call = vi.mocked(prisma.medication.findMany).mock.calls.at(-1);
return (call?.[0] as { where: Record<string, unknown> }).where;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
reminderMissedMinutes: 240,
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0 as never);
});
describe("POST /api/admin/notifications/reminder-check", () => {
it("sweeps the whole instance when no account is named", async () => {
const res = await POST(bare());
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(false);
expect(whereOfLastFindMany()).toEqual({ active: true });
});
it("narrows the sweep to the named account", async () => {
const res = await POST(withBody({ userId: "cku0mock0account0id" }));
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(true);
expect(whereOfLastFindMany()).toEqual({
active: true,
userId: "cku0mock0account0id",
});
});
it("refuses an unknown body key rather than sweeping everything", async () => {
const res = await POST(withBody({ userid: "cku0mock0account0id" }));
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
it("refuses an account selector that is not an id", async () => {
const res = await POST(withBody({ userId: "../../etc/passwd" }));
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,10 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
@@ -40,13 +43,50 @@ interface MedicationResult {
}
/**
* POST: Execute the reminder check — analyzes all medications AND sends
* POST: Execute the reminder check — analyses medications AND sends
* notifications for overdue schedules (late + missed). Returns detailed
* results for display in the admin panel.
*
* ## Scope
*
* The sweep is instance-wide by default: no body, every active medication on
* the instance, one dispatch per overdue slot. That is the operator button's
* long-standing behaviour and it stays.
*
* An optional `userId` in the body narrows it to ONE account. The direction
* that matters is easy to state backwards, so it is written here: the hazard a
* caller of this route creates is not that somebody else writes into its
* window, it is that THIS call dispatches into everybody else's — a run reaches
* every account that has an overdue dose and a configured channel. A caller
* that only means to sweep one account names it and stops being a second
* writer for every other one.
*
* Naming an account cannot widen anything. `requireAdmin()` is cookie-only by
* construction, so a Bearer token — wildcard scope included — never reaches
* this handler at all, and the field only ever removes rows from the `where`.
*/
export const POST = apiHandler(async () => {
export const POST = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.notifications.reminder-check" } });
// A bodyless POST is the admin console's own call (`apiPost(path)` sends no
// body and no content-type), so the parse only runs when a JSON body is
// actually presented. An empty object is the same instance-wide sweep.
let input: unknown = {};
if (
(request.headers.get("content-type") ?? "").includes("application/json")
) {
const { data, error } = await safeJson(request, { maxBytes: 4 * 1024 });
if (error) return error;
input = data ?? {};
}
const parsed = adminReminderCheckSchema.safeParse(input);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const scopedUserId = parsed.data.userId ?? null;
annotate({
action: { name: "admin.notifications.reminder-check" },
meta: { scope: scopedUserId ? "account" : "instance" },
});
const now = new Date();
@@ -57,7 +97,11 @@ export const POST = apiHandler(async () => {
const missedMinutes = appSettings?.reminderMissedMinutes ?? 240;
const medications = await prisma.medication.findMany({
where: { active: true },
where: {
active: true,
// Absent selector ⇒ no `userId` key ⇒ the instance-wide sweep.
...(scopedUserId ? { userId: scopedUserId } : {}),
},
include: {
schedules: true,
user: { select: { id: true, username: true, timezone: true } },
@@ -211,6 +255,7 @@ export const POST = apiHandler(async () => {
return apiSuccess({
timestamp: now.toISOString(),
missedThresholdMinutes: missedMinutes,
scoped: scopedUserId !== null,
medications: results,
notificationsSent,
message:
+11
View File
@@ -55,6 +55,7 @@ import {
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
import {
buildModuleDisclosure,
@@ -224,6 +225,16 @@ export const GET = apiHandler(async () => {
// the account has never saved one, which is what makes the panel show the
// named template on the first run rather than a silent server default.
reportSelection: user.reportSelectionJson ?? null,
// The account's resolved notification preferences — the same resolver
// `GET /api/auth/me/notification-prefs` answers with, so the two surfaces
// cannot disagree. Published here because the medication detail's
// notification section decides between the server-side reminder switch and
// the client-managed chip on `medication.clientManaged`, and it reads that
// decision off THIS payload: the dedicated endpoint is not on the app-boot
// path. An account that has never opted in resolves to the documented
// defaults rather than to `undefined`, so the switch is what renders.
// No extra query — the session already loads the row this column is on.
notificationPrefs: parseNotificationPrefs(user.notificationPrefs ?? null),
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0078 commentary). Every Coach mount point on the client checks
+11
View File
@@ -451,6 +451,17 @@ const accountPayload = z
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
recordSession: recordSessionState.nullable(),
// Enumerated because a client DECIDES on it rather than displays it: the
// medication surface hides the server-side reminder switch when
// `medication.clientManaged` is true, because the phone owns those
// reminders and a switch that no longer decides anything is worse than no
// switch. Always fully resolved — an account that never opted in reads
// `false`, not absent. `PATCH /api/auth/me/notification-prefs` writes it;
// the categories this object does not name are the rest of the open
// preference envelope.
notificationPrefs: z.looseObject({
medication: z.looseObject({ clientManaged: z.boolean() }),
}),
})
.meta({
id: "AccountPayload",
+85
View File
@@ -8,6 +8,7 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { inviteCreateSchema } from "@/lib/validations/invite";
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
@@ -190,7 +191,91 @@ export const adminInvitePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
};
const adminReminderSchedule = z
.object({
window: z.string(),
days: z.string(),
status: z.enum(["open", "late", "threshold", "missed", "skipped"]),
label: z.string(),
minutesPastEnd: z.number().int().nullable(),
notificationSent: z.boolean(),
})
.meta({
id: "AdminReminderScheduleStatus",
description:
"One dose window as the sweep classified it against the OWNING account's wall clock, and whether this run dispatched for it. `notificationSent` is false for a window that is still open, for a day the schedule does not cover, and for a slot that already has an intake event today.",
});
const adminReminderMedication = z
.object({
name: z.string(),
dose: z.string(),
user: z.string(),
timezone: z.string(),
localTime: z.string(),
dayOfWeek: z.string(),
notificationsEnabled: z.boolean(),
schedules: z.array(adminReminderSchedule),
eventsToday: z.number().int(),
})
.meta({
id: "AdminReminderMedication",
description:
"One active medication the sweep walked, with the account it belongs to and that account's local clock — the sweep classifies every window against the owner's timezone, never the operator's.",
});
const adminReminderCheckResult = z
.object({
timestamp: z.iso.datetime({ offset: true }),
missedThresholdMinutes: z.number().int(),
scoped: z
.boolean()
.describe(
"True when the request named a `userId` and the sweep therefore walked one account only.",
),
medications: z.array(adminReminderMedication),
notificationsSent: z.number().int(),
message: z.string(),
})
.meta({
id: "AdminReminderCheckResult",
description:
"What the sweep found and what it sent. `notificationsSent` counts DISPATCHES, not deliveries — each one enters the channel cascade and the per-channel verdict lands in the `push_attempts` ledger the notification diagnostic surfaces.",
});
export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/admin/notifications/reminder-check": {
post: {
tags: ["Admin"],
summary: "Run the medication-reminder sweep",
description:
"The admin panel's manual reminder sweep: it classifies every active medication's dose windows against the OWNING account's wall clock and dispatches a MEDICATION_REMINDER for each overdue slot that has no intake event today. Cookie auth only — `requireAdmin()` refuses every Bearer caller, wildcard scope included.\n\nScope is the thing to get right. With no body the sweep is INSTANCE-WIDE and dispatches into every account that has an overdue dose and a configured channel; that is the operator button's behaviour and it is deliberate. A caller that means to sweep one account sends `userId` and stops being a second writer for every other one. The selector can only narrow: it feeds the Prisma `where`, never the authorisation.",
requestBody: {
required: false,
content: {
"application/json": { schema: adminReminderCheckSchema },
},
},
responses: {
"200": {
description: "The sweep's findings and how many reminders it sent.",
content: {
"application/json": {
schema: dataEnvelope(
adminReminderCheckResult,
"AdminReminderCheckResponse",
),
},
},
},
"403": {
description: "Caller is not an admin.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/admin/notifications/diagnostic": {
get: {
tags: ["Admin"],
+22
View File
@@ -347,3 +347,25 @@ export const webPushSubscriptionSchema = z.object({
auth: z.string().min(1),
}),
});
/**
* Body of `POST /api/admin/notifications/reminder-check` — the admin panel's
* manual reminder sweep.
*
* `userId` narrows the sweep to one account. Omitted (and a bodyless POST is
* the admin console's own call) the sweep stays instance-wide, which is what
* the operator button has always done. The field is an ACCOUNT SELECTOR, not
* an identity: the route is `requireAdmin()`, cookie-only by construction, so
* naming an account here can never widen who is allowed to run the sweep — it
* only narrows whose overdue doses it dispatches for.
*
* Strict, so a typo'd key is a 422 rather than a silently instance-wide run.
*/
export const adminReminderCheckSchema = z.strictObject({
userId: z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/, "Ungültige Account-ID")
.optional(),
});