From 267652e3b6f1cf6c2f1d4c934c2901c8356af5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 23 May 2026 10:05:55 +0200 Subject: [PATCH] feat(admin): add /api/admin/notifications/diagnostic endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces device APNs token presence (masked), notification channel config presence, and recent push attempts for the calling admin's account. Closes H-APNs-1 in v1.4.48 backlog — fills the DB-shell gap that bottlenecked the v1.4.47 APNs investigation. --- .../diagnostic/__tests__/route.test.ts | 331 ++++++++++++++++++ .../admin/notifications/diagnostic/route.ts | 197 +++++++++++ 2 files changed, 528 insertions(+) create mode 100644 src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts create mode 100644 src/app/api/admin/notifications/diagnostic/route.ts diff --git a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts new file mode 100644 index 000000000..3f91d3b52 --- /dev/null +++ b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts @@ -0,0 +1,331 @@ +/** + * v1.4.48 H-APNs-1 — coverage for the admin notification diagnostic + * endpoint. The route surfaces what the dispatcher would observe when + * targeting the calling admin's account so an operator can debug push + * delivery without DB-shell access; these tests pin: + * + * * 401 for an unauthenticated caller (no session cookie), + * * 403 for an authenticated non-admin (USER role), + * * 200 + the documented envelope shape for an admin with an empty + * device + channel set, + * * APNs token masking (prefix 8 + suffix 8 hex only, full token + * never returned), + * * per-channel `configPresent` reflecting the decrypted JSON blob + * for TELEGRAM / NTFY and the row-existence shortcut for APNS / + * WEB_PUSH (the latter two store delivery targets on sibling + * tables, not in the channel config blob). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + device: { + findMany: vi.fn(), + }, + notificationChannel: { + findMany: vi.fn(), + }, + }, +})); + +// The route imports requireAdmin from `@/lib/api-handler`. We keep +// apiHandler real so HttpError throws produce the proper 401 / 403 +// JSON envelope, but stub requireAdmin to drive the three auth cases. +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), + eventStorage: { + run: (_evt: unknown, fn: () => Promise) => fn(), + }, +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")), + encrypt: vi.fn((value: string) => `enc(${value})`), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; + +const ADMIN_USER_ID = "admin-1"; +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: ADMIN_USER_ID, + username: "admin", + role: "ADMIN", + }, +} as never; + +interface DiagnosticEnvelope { + data: { + devices: Array<{ + id: string; + platform: string; + hasApnsToken: boolean; + apnsTokenPrefix: string | null; + apnsTokenSuffix: string | null; + apnsEnvironment: string | null; + lastSeenAt: string; + }>; + notificationChannels: Array<{ + type: string; + enabled: boolean; + configPresent: boolean; + }>; + recentPushAttempts: Array<{ + eventType: string; + channel: string; + result: string; + reason: string | null; + at: string; + }>; + }; + error: string | null; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.device.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue( + [] as never, + ); +}); + +describe("GET /api/admin/notifications/diagnostic — auth gates", () => { + it("returns 401 when the caller has no session", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + + const res = await GET(); + expect(res.status).toBe(401); + const body = (await res.json()) as { data: null; error: string }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Not authenticated"); + }); + + it("returns 403 when the caller is authenticated but not ADMIN", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + + const res = await GET(); + expect(res.status).toBe(403); + const body = (await res.json()) as { data: null; error: string }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Admin access required"); + }); +}); + +describe("GET /api/admin/notifications/diagnostic — empty state", () => { + it("returns the documented envelope shape with empty arrays for an admin with no devices or channels", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(body.error).toBeNull(); + expect(body.data).toEqual({ + devices: [], + notificationChannels: [], + recentPushAttempts: [], + }); + }); +}); + +describe("GET /api/admin/notifications/diagnostic — device masking", () => { + it("returns hasApnsToken=true and 8/8-char prefix+suffix when device has an APNs token", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + const fullToken = + "abcdef1234567890aabbccddeeff0011223344556677889900112233445566778899aabb"; + // Trim to a typical 64-char APNs hex token to keep the assertion + // realistic. The masker doesn't care about length beyond `>= 16`. + const token64 = fullToken.slice(0, 64); + vi.mocked(prisma.device.findMany).mockResolvedValue([ + { + id: "dev-1", + platform: "ios", + apnsToken: token64, + apnsEnvironment: "production", + lastSeen: new Date("2026-05-22T10:00:00.000Z"), + }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(res.status).toBe(200); + expect(body.data.devices).toHaveLength(1); + const device = body.data.devices[0]; + expect(device.hasApnsToken).toBe(true); + expect(device.apnsTokenPrefix).toBe(token64.slice(0, 8)); + expect(device.apnsTokenSuffix).toBe(token64.slice(-8)); + expect(device.apnsEnvironment).toBe("production"); + expect(device.lastSeenAt).toBe("2026-05-22T10:00:00.000Z"); + + // Belt-and-braces: the full token must not appear anywhere in the + // serialised response. + const serialised = JSON.stringify(body); + expect(serialised).not.toContain(token64); + }); + + it("returns hasApnsToken=false and null prefix/suffix when the device row has no APNs token", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.device.findMany).mockResolvedValue([ + { + id: "dev-2", + platform: "ios", + apnsToken: null, + apnsEnvironment: null, + lastSeen: new Date("2026-05-22T11:00:00.000Z"), + }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(res.status).toBe(200); + const device = body.data.devices[0]; + expect(device.hasApnsToken).toBe(false); + expect(device.apnsTokenPrefix).toBeNull(); + expect(device.apnsTokenSuffix).toBeNull(); + }); +}); + +describe("GET /api/admin/notifications/diagnostic — channel configPresent", () => { + it("returns configPresent=true for TELEGRAM when the decrypted blob has both botToken and chatId", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + type: "TELEGRAM", + enabled: true, + config: 'enc({"botToken":"bot:12345","chatId":"678"})', + }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(res.status).toBe(200); + expect(body.data.notificationChannels).toEqual([ + { type: "TELEGRAM", enabled: true, configPresent: true }, + ]); + }); + + it("returns configPresent=false for TELEGRAM when chatId is empty", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + type: "TELEGRAM", + enabled: false, + config: 'enc({"botToken":"bot:12345","chatId":""})', + }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(body.data.notificationChannels).toEqual([ + { type: "TELEGRAM", enabled: false, configPresent: false }, + ]); + }); + + it("returns configPresent=true for NTFY when topic is non-empty", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + type: "NTFY", + enabled: true, + config: 'enc({"serverUrl":"https://ntfy.sh","topic":"hl-test"})', + }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(body.data.notificationChannels).toEqual([ + { type: "NTFY", enabled: true, configPresent: true }, + ]); + }); + + it("returns configPresent=false for NTFY when topic is missing", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + type: "NTFY", + enabled: true, + config: 'enc({"serverUrl":"https://ntfy.sh"})', + }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(body.data.notificationChannels).toEqual([ + { type: "NTFY", enabled: true, configPresent: false }, + ]); + }); + + it("treats APNS and WEB_PUSH rows as configPresent=true (delivery target lives on sibling tables)", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { type: "APNS", enabled: true, config: "enc({})" }, + { type: "WEB_PUSH", enabled: false, config: "enc({})" }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(body.data.notificationChannels).toEqual([ + { type: "APNS", enabled: true, configPresent: true }, + { type: "WEB_PUSH", enabled: false, configPresent: true }, + ]); + }); + + it("returns configPresent=false when the encrypted blob is malformed (decryption throws)", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + // Our crypto mock unwraps `enc(...)`; pass a value that yields + // non-JSON after the unwrap so JSON.parse throws and the helper's + // try/catch returns false. + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { type: "TELEGRAM", enabled: true, config: "not-encrypted-junk" }, + ] as never); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(res.status).toBe(200); + expect(body.data.notificationChannels).toEqual([ + { type: "TELEGRAM", enabled: true, configPresent: false }, + ]); + }); +}); + +describe("GET /api/admin/notifications/diagnostic — recentPushAttempts placeholder", () => { + it("returns recentPushAttempts as an empty array (no per-attempt push history table yet)", async () => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + + const res = await GET(); + const body = (await res.json()) as DiagnosticEnvelope; + + expect(body.data.recentPushAttempts).toEqual([]); + }); +}); diff --git a/src/app/api/admin/notifications/diagnostic/route.ts b/src/app/api/admin/notifications/diagnostic/route.ts new file mode 100644 index 000000000..10d95bf07 --- /dev/null +++ b/src/app/api/admin/notifications/diagnostic/route.ts @@ -0,0 +1,197 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import type { ChannelType } from "@/lib/notifications/types"; + +export const dynamic = "force-dynamic"; + +/** + * v1.4.48 H-APNs-1 — admin-only diagnostic endpoint for the + * notification subsystem. Surfaces what the dispatcher would see when + * targeting the calling admin's account so an operator can debug an + * iOS / Web Push / Telegram / ntfy issue without needing DB shell + * access. This fills the gap that bottlenecked the v1.4.47 APNs hunt + * — every "is the token there?" / "is the channel enabled?" question + * was a Coolify exec round-trip. + * + * Response shape: + * { + * devices: Device[], — APNs token presence + masked + * prefix/suffix only; never the + * full token. + * notificationChannels: Channel[] — per-channel enabled + whether + * the channel-specific config + * blob is present. + * recentPushAttempts: PushAttempt[] — last 20 push deliveries for + * the calling user. See + * comment below — HealthLog + * does not currently persist + * per-attempt push history, so + * this is `[]` until a + * dedicated table is added. + * } + * + * Security: admin-only (cookie auth, never Bearer). The route never + * returns the full APNs token — only an 8-char hex prefix + 8-char + * suffix so the operator can correlate with iOS-side diagnostics. + */ + +interface DiagnosticDevice { + id: string; + platform: string; + hasApnsToken: boolean; + apnsTokenPrefix: string | null; + apnsTokenSuffix: string | null; + apnsEnvironment: string | null; + lastSeenAt: string; +} + +interface DiagnosticChannel { + type: ChannelType | string; + enabled: boolean; + configPresent: boolean; +} + +interface DiagnosticPushAttempt { + eventType: string; + channel: string; + result: string; + reason: string | null; + at: string; +} + +interface DiagnosticPayload { + devices: DiagnosticDevice[]; + notificationChannels: DiagnosticChannel[]; + recentPushAttempts: DiagnosticPushAttempt[]; +} + +/** + * Map an APNs hex token to its 8-char prefix + 8-char suffix. APNs + * device tokens are 64-char hex strings; we surface 8 + 8 = 16 chars + * total (25 % of the token), which is enough for the operator to + * cross-check against an iOS-side log line without disclosing the + * delivery target. Tokens shorter than 16 chars are treated as opaque + * (prefix / suffix both null) — that shape never appears in + * production, but defensive against partial inserts during testing. + */ +function maskApnsToken( + token: string | null, +): { prefix: string | null; suffix: string | null } { + if (!token || token.length < 16) return { prefix: null, suffix: null }; + return { + prefix: token.slice(0, 8), + suffix: token.slice(-8), + }; +} + +/** + * Decide whether the channel's encrypted-JSON config blob carries the + * key the corresponding sender will actually read. We decrypt + parse + * inside a try/catch so a corrupted ciphertext row (or an upstream + * encryption-key rotation) cannot 500 the diagnostic endpoint — the + * worst case is `configPresent: false` and the operator investigates + * why the row is unreadable. + * + * The required field per channel type: + * - TELEGRAM → `chatId` + `botToken` + * - NTFY → `topic` + * - WEB_PUSH → no per-channel blob (subscriptions live in the + * `push_subscriptions` table); treat the row's mere + * existence as `configPresent: true`. + * - APNS → no per-channel blob (device tokens live on the + * `devices` table); treat the row's mere existence as + * `configPresent: true`. + */ +function isChannelConfigPresent( + type: string, + encryptedConfig: string, +): boolean { + if (type === "WEB_PUSH" || type === "APNS") { + // These channels store their delivery target on sibling tables + // (`push_subscriptions` / `devices`), not in the channel-row + // config blob. The row's existence is the signal. + return true; + } + try { + const parsed = JSON.parse(decrypt(encryptedConfig)) as Record< + string, + unknown + >; + if (type === "TELEGRAM") { + return ( + typeof parsed.chatId === "string" && + parsed.chatId.length > 0 && + typeof parsed.botToken === "string" && + parsed.botToken.length > 0 + ); + } + if (type === "NTFY") { + return typeof parsed.topic === "string" && parsed.topic.length > 0; + } + return false; + } catch { + return false; + } +} + +export const GET = apiHandler(async () => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.notifications.diagnostic" } }); + + const [devices, channels] = await Promise.all([ + prisma.device.findMany({ + where: { userId: user.id }, + select: { + id: true, + platform: true, + apnsToken: true, + apnsEnvironment: true, + lastSeen: true, + }, + orderBy: { lastSeen: "desc" }, + }), + prisma.notificationChannel.findMany({ + where: { userId: user.id }, + select: { type: true, enabled: true, config: true }, + }), + ]); + + const diagnosticDevices: DiagnosticDevice[] = devices.map((d) => { + const { prefix, suffix } = maskApnsToken(d.apnsToken); + return { + id: d.id, + platform: d.platform, + hasApnsToken: d.apnsToken !== null && d.apnsToken !== "", + apnsTokenPrefix: prefix, + apnsTokenSuffix: suffix, + apnsEnvironment: d.apnsEnvironment, + lastSeenAt: d.lastSeen.toISOString(), + }; + }); + + const diagnosticChannels: DiagnosticChannel[] = channels.map((c) => ({ + type: c.type, + enabled: c.enabled, + configPresent: isChannelConfigPresent(c.type, c.config), + })); + + // HealthLog does not currently persist per-attempt push delivery + // history (no `PushAttempt` / equivalent table — Wide Events emit to + // stdout via `emitIfSampled` and audit-log captures only + // channel-state transitions, not individual sends). Surfacing the + // last 20 attempts is a known v1.4.48+ follow-up; until then this + // field is intentionally an empty array so the response shape stays + // stable for iOS-side consumers. + const recentPushAttempts: DiagnosticPushAttempt[] = []; + + const payload: DiagnosticPayload = { + devices: diagnosticDevices, + notificationChannels: diagnosticChannels, + recentPushAttempts, + }; + + return apiSuccess(payload); +});