fix(push): send the test push to every device, in the user's language

The Web Push test went to the newest subscription only. With a phone
and a laptop subscribed, Test pressed on the laptop buzzed the phone
and the laptop looked broken. The text was English in every locale,
and the push was dialled through the library's own transport, past the
pinned safeFetch that real sends use.

The route now calls sendViaWebPush, so the test reaches every
subscription, reaps endpoints the push service reports gone, and goes
out through the same dial as a reminder. Title and body come from the
account's locale. The response keeps ok and latencyMs and gains reason;
sent and perEndpoint are gone, nothing read them.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:52:36 +01:00
1 parent 7f8ba0ae98
commit af623d12db
14 files changed
+210 -247

No files matched your search

+2 -3
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5475,
"count": 522,
"total": 5466,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -219,7 +219,6 @@
"app/api/notifications/action/[token]/__tests__/route.test.ts": 5,
"app/api/notifications/status/__tests__/route.test.ts": 4,
"app/api/notifications/web-push/__tests__/route.test.ts": 5,
"app/api/notifications/web-push/test/__tests__/route.test.ts": 9,
"app/api/nutrients/__tests__/route.test.ts": 5,
"app/api/nutrients/batch/__tests__/route.test.ts": 11,
"app/api/nutrients/daily/__tests__/route.test.ts": 7,
@@ -1,176 +1,186 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/**
* The Web Push test reaches every subscribed browser of the account, in the
* account's language, through the sender real reminders use.
*
* It used to send to the newest subscription only: with a phone and a laptop
* subscribed, Test pressed on the laptop buzzed the phone and the laptop
* looked broken. The text was English in every locale, and the push went out
* through the library's own transport instead of the pinned `safeFetch` dial.
*
* The real sender runs here; the push library's signing, the dial and the
* database are stubbed.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PushSubscription } from "@/generated/prisma/client";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
findMany: vi.fn(),
deleteMany: vi.fn(),
getVapidConfig: vi.fn(),
safeFetch: vi.fn(),
generateRequestDetails: vi.fn(),
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T>(fn: T) => fn,
requireAuth: mocks.requireAuth,
}));
vi.mock("@/lib/db", () => ({
prisma: {
pushSubscription: {
findFirst: vi.fn(),
findMany: mocks.findMany,
deleteMany: mocks.deleteMany,
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => null,
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((x: string) => x),
}));
vi.mock("@/lib/crypto", () => ({ decrypt: (v: string) => v }));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: vi.fn(async () => ({
subject: "mailto:test@example.com",
publicKey:
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
privateKey: "private-key-stub",
})),
getVapidConfig: mocks.getVapidConfig,
}));
const sendNotification = vi.fn();
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: mocks.safeFetch }));
vi.mock("web-push", () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: (...args: unknown[]) => sendNotification(...args),
},
setVapidDetails: vi.fn(),
sendNotification: (...args: unknown[]) => sendNotification(...args),
generateRequestDetails: mocks.generateRequestDetails,
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
interface ApiErrorEnvelope {
data: null;
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
type Handler = (request: Request) => Promise<Response>;
const post = () =>
(POST as unknown as Handler)(
new Request("http://localhost/api/notifications/web-push/test", {
method: "POST",
}),
);
function subscription(id: string, endpoint: string): PushSubscription {
return {
id,
userId: "u-1",
endpoint,
p256dh: "p256",
auth: "auth",
userAgent: null,
createdAt: new Date(0),
};
}
const originalFetch = global.fetch;
const PHONE = subscription(
"s-phone",
"https://fcm.googleapis.com/fcm/send/phone-secret",
);
const LAPTOP = subscription(
"s-laptop",
"https://updates.push.services.mozilla.com/wpush/v2/laptop-secret",
);
/** The JSON payload the sender encrypted for each subscription. */
function payloadsSent(): Array<{ title: string; body: string; tag: string }> {
return mocks.generateRequestDetails.mock.calls.map(([, payload]) =>
JSON.parse(String(payload)),
);
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.pushSubscription.findFirst).mockReset();
sendNotification.mockReset();
global.fetch = vi.fn() as never;
});
afterEach(() => {
global.fetch = originalFetch;
});
function emptyRequest(): Request {
return new Request("http://localhost/api/notifications/web-push/test", {
method: "POST",
headers: { "content-length": "0" },
vi.clearAllMocks();
mocks.requireAuth.mockResolvedValue({ user: { id: "u-1", locale: "de" } });
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
mocks.getVapidConfig.mockResolvedValue({
subject: "mailto:ops@example.com",
publicKey: "pub",
privateKey: "priv",
});
}
mocks.findMany.mockResolvedValue([PHONE, LAPTOP]);
mocks.deleteMany.mockResolvedValue({ count: 0 });
mocks.generateRequestDetails.mockImplementation(
(sub: { endpoint: string }) => ({
endpoint: sub.endpoint,
method: "POST",
headers: { TTL: "60" },
body: null,
}),
);
mocks.safeFetch.mockResolvedValue(new Response(null, { status: 201 }));
});
describe("POST /api/notifications/web-push/test", () => {
it("happy path returns ok and sends to one subscription", async () => {
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
id: "p-1",
userId: "u-1",
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
p256dh: "p256",
auth: "auth",
} as never);
sendNotification.mockResolvedValueOnce({ statusCode: 201 });
it("sends to every subscribed browser, in the account's language", async () => {
const res = await post();
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiSuccessEnvelope<{
ok: boolean;
sent: number;
latencyMs: number;
perEndpoint: Array<{ host: string; status: number | null }>;
}>;
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { ok: boolean; latencyMs: number };
};
expect(body.data.ok).toBe(true);
expect(body.data.sent).toBe(1);
expect(body.data.perEndpoint).toHaveLength(1);
expect(body.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
// never the full endpoint URL (the routing token is the only thing
// protecting that subscription from anyone who learns it).
const text = JSON.stringify(body);
expect(text).not.toMatch(/abc123token/);
expect(typeof body.data.latencyMs).toBe("number");
// Spec: only the most-recent subscription gets the test push, not all.
expect(prisma.pushSubscription.findFirst).toHaveBeenCalledWith(
expect(mocks.findMany).toHaveBeenCalledWith({ where: { userId: "u-1" } });
expect(mocks.safeFetch.mock.calls.map(([url]) => url)).toEqual([
PHONE.endpoint,
LAPTOP.endpoint,
]);
for (const call of mocks.safeFetch.mock.calls) {
expect(call[2]).toEqual({ requirePublicHost: true });
}
expect(payloadsSent()).toEqual([
expect.objectContaining({
where: { userId: "u-1" },
orderBy: { createdAt: "desc" },
title: "HealthLog Test-Benachrichtigung",
body: "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an.",
tag: "self-test",
}),
);
expect(sendNotification).toHaveBeenCalledTimes(1);
expect.objectContaining({ tag: "self-test" }),
]);
});
it("rate-limit denial returns 429 with no upstream call", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(sendNotification).not.toHaveBeenCalled();
it("answers 422 not_configured when no browser is subscribed", async () => {
mocks.findMany.mockResolvedValue([]);
const res = await post();
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("not_configured");
expect(mocks.safeFetch).not.toHaveBeenCalled();
});
it("returns 422 when no subscriptions registered", async () => {
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/no push subscriptions/i);
expect(sendNotification).not.toHaveBeenCalled();
it("answers 422 vapid_not_configured without VAPID keys", async () => {
mocks.getVapidConfig.mockResolvedValue(null);
const res = await post();
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("vapid_not_configured");
expect(mocks.safeFetch).not.toHaveBeenCalled();
});
it("does not leak Bearer/sk- or full endpoint URL when upstream rejects", async () => {
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
id: "p-1",
userId: "u-1",
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
p256dh: "p256",
auth: "auth",
} as never);
sendNotification.mockRejectedValueOnce(
Object.assign(
new Error(
"401 Unauthorized: Authorization: Bearer sk-secret from https://fcm.googleapis.com/fcm/send/abc123token",
),
{ statusCode: 401 },
),
);
const response = await POST(emptyRequest() as never);
const text = await response.text();
expect(response.status).toBe(200);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/abc123token/);
const parsed = JSON.parse(text) as ApiSuccessEnvelope<{
ok: boolean;
sent: number;
perEndpoint: Array<{ host: string; status: number | null }>;
}>;
expect(parsed.data.ok).toBe(false);
expect(parsed.data.sent).toBe(0);
expect(parsed.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
expect(parsed.data.perEndpoint[0].status).toBe(401);
it("refuses over the rate limit without sending", async () => {
mocks.checkRateLimit.mockResolvedValue({ allowed: false });
const res = await post();
expect(res.status).toBe(429);
expect(mocks.safeFetch).not.toHaveBeenCalled();
});
it("reports a rejected push without echoing an endpoint", async () => {
mocks.safeFetch.mockResolvedValue(new Response(null, { status: 401 }));
const res = await post();
const text = await res.text();
expect(res.status).toBe(200);
expect(JSON.parse(text).data.ok).toBe(false);
expect(text).not.toMatch(/phone-secret|laptop-secret/);
});
});
@@ -3,28 +3,20 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { coerceLocale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
export const dynamic = "force-dynamic";
function hostFromEndpoint(endpoint: string): string {
try {
return new URL(endpoint).host;
} catch {
return "unknown";
}
}
// Strip any URL from a stringified web-push library error. Push providers (FCM,
// Mozilla autopush, etc.) embed the full subscription endpoint into 410/404
// error messages — that endpoint token is the routing secret, so it must never
// land in our Wide Events.
function redactPushError(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
}
/**
* POST /api/notifications/web-push/test
*
* Sends a test push to every browser subscribed for the account, through the
* sender real reminders use, in the account's language. Pressing Test on one
* device must reach that device; the newest subscription alone did not when
* a person had more than one.
*/
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
@@ -37,68 +29,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
// Spec: only the most-recent subscription receives the test push. A user
// with phone + tablet + desktop should not get three buzzes per click.
const subscription = await prisma.pushSubscription.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
const { t } = getServerTranslator(coerceLocale(user.locale));
const start = performance.now();
const result = await sendViaWebPush(user.id, {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: t("notifications.admin.testNotificationTitle"),
message: t("notifications.user.webPushTestBody"),
metadata: { webPushTag: "self-test" },
});
const latencyMs = Math.round(performance.now() - start);
if (!subscription) {
if (result.reason === "web_push_no_subscriptions") {
return apiError("No push subscriptions registered", 422, {
errorCode: "not_configured",
});
}
const config = await getVapidConfig();
if (!config) {
if (result.reason === "web_push_vapid_not_configured") {
return apiError("VAPID keys not configured", 422, {
errorCode: "vapid_not_configured",
});
}
const webpush = await import("web-push");
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
const pushPayload = JSON.stringify({
title: "HealthLog",
body: "This is a test notification from HealthLog",
tag: "self-test",
annotate({
meta: { web_push_test_ok: result.ok, web_push_test_reason: result.reason },
});
const host = hostFromEndpoint(subscription.endpoint);
const start = performance.now();
try {
const p256dh = decrypt(subscription.p256dh);
const auth = decrypt(subscription.auth);
const result = await webpush.sendNotification(
{ endpoint: subscription.endpoint, keys: { p256dh, auth } },
pushPayload,
);
const latencyMs = Math.round(performance.now() - start);
return apiSuccess({
ok: true,
sent: 1,
latencyMs,
perEndpoint: [{ host, status: result.statusCode ?? 201 }],
});
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode ?? null;
const message = redactPushError((err as Error).message ?? "").slice(0, 200);
annotate({
meta: {
web_push_test_status: status,
web_push_test_host: host,
web_push_test_error: message,
},
});
return apiSuccess({
ok: false,
sent: 0,
perEndpoint: [{ host, status }],
});
}
return apiSuccess(
result.ok ? { ok: true, latencyMs } : { ok: false, reason: result.reason },
);
});
@@ -66,34 +66,22 @@ const apnsTestResponse = z
const webPushTestResponse = z
.object({
ok: z.boolean(),
sent: z
.number()
.int()
.describe("1 on success, 0 on failure. Never more than one."),
ok: z
.boolean()
.describe(
"Whether at least one of the account's subscriptions accepted the push.",
),
latencyMs: z
.number()
.int()
.optional()
.describe("Round-trip to the push service. Present on success only."),
perEndpoint: z
.array(
z.object({
host: z
.string()
.describe(
"Host of the push service the test went to — the endpoint path is a routing secret and is never returned.",
),
status: z
.number()
.int()
.nullable()
.describe(
"The push service's HTTP status, or null when the attempt threw before one existed.",
),
}),
)
.describe("One entry: only the newest subscription is tested."),
.describe("Time to send to every subscription. Present on success only."),
reason: z
.string()
.optional()
.describe(
"Why it was not, when `ok` is false. Absent on success. The sender's own classification, the same code a failed reminder records (`web_push_410_gone` when every endpoint is gone, a status-derived code otherwise).",
),
})
.meta({ id: "WebPushTestResponse" });
@@ -200,10 +188,10 @@ export const notificationTransportPaths: NonNullable<
"/api/notifications/web-push/test": {
post: {
tags: ["Notifications"],
summary: "Send a test push to the newest Web Push subscription",
summary: "Send a test push to every Web Push subscription",
description:
"Fires one notification to the caller's MOST RECENT subscription only — a person with a phone, a tablet and a desktop registered should not get three buzzes per click. Takes no body. Rate-limited 5 per minute per user.\n\n" +
"Read `ok`, not the status. A push that the service rejects still answers 200, with `ok: false` and the service's own status in `perEndpoint`. Only the endpoint's HOST is reported: the full endpoint is the routing secret for that subscription, and push services embed it in their own error messages, so it is stripped before anything is logged or returned.",
"Fires one notification to each of the caller's subscriptions, through the same sender real reminders use, with the text in the account's language. The device the test was pressed on gets it whichever subscription is newest. Endpoints the push service reports gone (404/410) are removed, as on a real send. Takes no body. Rate-limited 5 per minute per user.\n\n" +
"Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false` and a `reason`. No endpoint is returned: the full endpoint is the routing secret for that subscription.",
responses: {
"200": {
description: