mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
297395372b
commit
8be51d9beb
5 files changed
+932
-2
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
],
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user