feat(admin): add /api/admin/notifications/diagnostic endpoint

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 10:24:12 +02:00
1 parent 59e2e8299f
commit 267652e3b6
2 files changed
+528

No files matched your search

@@ -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<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: {
run: <T>(_evt: unknown, fn: () => Promise<T>) => 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([]);
});
});
@@ -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);
});