mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
/**
|
|
* 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());
|
|
}),
|
|
};
|
|
}
|