diff --git a/messages/de.json b/messages/de.json index 146f8a994..721e72a42 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Löschen des Kontos ist fehlgeschlagen. Es bleibt gesperrt und wird jede Stunde erneut versucht, nach den anderen fälligen Konten. Den Fehler zeigt die Karte der fehlgeschlagenen Jobs unter Administration." }, "user": { - "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv." + "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", + "webPushTestBody": "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an." }, "moodReminder": { "title": "Tägliche Stimmungs-Erinnerung", diff --git a/messages/en.json b/messages/en.json index 7341a1e66..5212ca494 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "The deletion window of {username} has ended, but erasing the account failed. It stays locked and is tried again every hour, after the other accounts due. The failing jobs card in Administration has the error." }, "user": { - "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active." + "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active.", + "webPushTestBody": "If you can see this, push notifications reach this device." }, "moodReminder": { "title": "Daily mood reminder", diff --git a/messages/es.json b/messages/es.json index b3f5203f9..61217c9e8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "El plazo de eliminación de {username} ha terminado, pero no se pudo borrar la cuenta. Sigue bloqueada y se vuelve a intentar cada hora, después de las demás cuentas pendientes. El error aparece en la tarjeta de tareas fallidas de Administration." }, "user": { - "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas." + "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas.", + "webPushTestBody": "Si ves esto, las notificaciones push llegan a este dispositivo." }, "moodReminder": { "title": "Recordatorio diario de ánimo", diff --git a/messages/fr.json b/messages/fr.json index ef5ea9d9c..3f728eda2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Le délai de suppression de {username} est écoulé, mais l’effacement du compte a échoué. Il reste verrouillé et une nouvelle tentative a lieu chaque heure, après les autres comptes en attente. L’erreur figure dans la carte des tâches en échec, dans Administration." }, "user": { - "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives." + "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives.", + "webPushTestBody": "Si vous voyez ceci, les notifications push arrivent sur cet appareil." }, "moodReminder": { "title": "Rappel quotidien d’humeur", diff --git a/messages/it.json b/messages/it.json index 9c695a0e4..bfe254835 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Il periodo di eliminazione di {username} è terminato, ma la cancellazione dell’account non è riuscita. Resta bloccato e viene ritentata ogni ora, dopo gli altri account in scadenza. L’errore è nella scheda dei job non riusciti in Administration." }, "user": { - "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive." + "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive.", + "webPushTestBody": "Se vedi questo messaggio, le notifiche push arrivano su questo dispositivo." }, "moodReminder": { "title": "Promemoria quotidiano dell’umore", diff --git a/messages/ko.json b/messages/ko.json index c5314d117..501d360a3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "{username} 계정의 삭제 유예 기간이 끝났지만 계정을 지우지 못했어요. 계정은 계속 잠겨 있고, 삭제할 다른 계정들 다음으로 매시간 다시 시도해요. 오류는 관리자 화면의 실패한 작업 카드에서 볼 수 있어요." }, "user": { - "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요." + "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요.", + "webPushTestBody": "이 알림이 보이면 이 기기에서 푸시 알림을 받을 수 있어요." }, "moodReminder": { "title": "매일 기분 알림", diff --git a/messages/nl.json b/messages/nl.json index 9398345ba..09fb725ea 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "De verwijdertermijn van {username} is afgelopen, maar het wissen van het account is mislukt. Het blijft vergrendeld en wordt elk uur opnieuw geprobeerd, na de andere accounts die aan de beurt zijn. De fout staat op de kaart met mislukte taken in Beheer." }, "user": { - "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief." + "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief.", + "webPushTestBody": "Zie je dit, dan komen pushmeldingen aan op dit apparaat." }, "moodReminder": { "title": "Dagelijkse stemmingsherinnering", diff --git a/messages/pl.json b/messages/pl.json index 655e74b46..699081867 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Okres usuwania konta {username} minął, ale usunięcie konta się nie powiodło. Konto pozostaje zablokowane, a próba jest ponawiana co godzinę, po innych kontach do usunięcia. Błąd widać na karcie nieudanych zadań w Administracja." }, "user": { - "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne." + "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne.", + "webPushTestBody": "Jeśli to widzisz, powiadomienia push docierają na to urządzenie." }, "moodReminder": { "title": "Codzienne przypomnienie o nastroju", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 941c24df1..3da35552e 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "O prazo de exclusão de {username} terminou, mas apagar a conta falhou. Ela continua bloqueada e uma nova tentativa é feita a cada hora, depois das outras contas pendentes. O erro aparece no cartão de tarefas com falha em Administração." }, "user": { - "telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas." + "telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas.", + "webPushTestBody": "Se você está vendo isto, as notificações push chegam a este dispositivo." }, "moodReminder": { "title": "Lembrete diário de humor", diff --git a/messages/ru.json b/messages/ru.json index ce87f9f0d..1703b52cb 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Срок удаления {username} истёк, но удалить аккаунт не удалось. Он остаётся заблокированным, и попытка повторяется каждый час после остальных аккаунтов с наступившим сроком. Ошибка указана в карточке сбоящих заданий в разделе Администрирование." }, "user": { - "telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны." + "telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны.", + "webPushTestBody": "Если вы видите это сообщение, push-уведомления приходят на это устройство." }, "moodReminder": { "title": "Ежедневное напоминание о настроении", diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index 20b560254..8806cc7df 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -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, diff --git a/src/app/api/notifications/web-push/test/__tests__/route.test.ts b/src/app/api/notifications/web-push/test/__tests__/route.test.ts index ba8532fe8..980830013 100644 --- a/src/app/api/notifications/web-push/test/__tests__/route.test.ts +++ b/src/app/api/notifications/web-push/test/__tests__/route.test.ts @@ -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: 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: (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 { - data: T; - error: null; +type Handler = (request: Request) => Promise; +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/); }); }); diff --git a/src/app/api/notifications/web-push/test/route.ts b/src/app/api/notifications/web-push/test/route.ts index 94e4c92f5..54e45d09f 100644 --- a/src/app/api/notifications/web-push/test/route.ts +++ b/src/app/api/notifications/web-push/test/route.ts @@ -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 }, + ); }); diff --git a/src/lib/openapi/routes/notifications-transport.ts b/src/lib/openapi/routes/notifications-transport.ts index 9eb27b5b4..1839477d9 100644 --- a/src/lib/openapi/routes/notifications-transport.ts +++ b/src/lib/openapi/routes/notifications-transport.ts @@ -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: