mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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);
|
||||
});
|
||||
Reference in new issue
Block a user