mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(coverage): real-behaviour tests for admin routes + glitchtip ingest (#141)
Adds vitest unit tests for five previously zero-coverage routes: - /api/admin/users (GET) — admin gate, safe select shape regression, no password-hash leak. - /api/admin/users/[id] (PUT) — Zod validation, 404 for unknown id, last-admin demotion guard, audit-log call shape. - /api/admin/settings (GET / PUT) — singleton row regression, encrypted blob never reaches the client, AES encrypt() called for VAPID + GitHub-issue tokens, clear flags wipe encrypted columns, every schema-allowed field round-trips through upsert. - /api/admin/status (GET) — integration health summary aggregates umami / glitchtip / web-push / bug-report flags, only non-revoked tokens count as active, encrypted blobs never appear in the envelope. - /api/monitoring/glitchtip (POST) — IP rate-limit gate, short-circuit when DSN is null, message/stack size caps (2000 / 20 000 chars), upstream 5xx mapped to 502, environment fallback to "production". All five files now land at >=80% line coverage: - admin/users/route.ts: 100% - admin/users/[id]/route.ts: 100% - admin/settings/route.ts: 100% - admin/status/route.ts: 84.21% - monitoring/glitchtip/route.ts: 100% Test count: 469 -> 523 (+54; my files contribute 46, the other 8 were unblocked by re-running prisma generate in the worktree). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
873f3eb8d0
commit
382b4a0f9f
5 files changed
+1125
No files matched your search
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/settings", () => {
|
||||
it("rejects with 401 when no session", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("reads the singleton row (regression: not the default-typo)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
await GET();
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.appSettings.findUnique).mock.calls[0]?.[0];
|
||||
// Must be "singleton" — the test-endpoints PR previously typo'd this as "default".
|
||||
expect(args).toEqual({ where: { id: "singleton" } });
|
||||
});
|
||||
|
||||
it("returns sane defaults when no settings row exists", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.registrationEnabled).toBe(true);
|
||||
expect(body.data.defaultLocale).toBe("de");
|
||||
expect(body.data.webPushVapidConfigured).toBe(false);
|
||||
expect(body.data.bugReportConfigured).toBe(false);
|
||||
expect(body.data.glitchtipEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("never leaks the encrypted private key fields", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The two configured-flags must be true …
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
expect(body.data.bugReportConfigured).toBe(true);
|
||||
// … but raw encrypted blobs must never reach the client.
|
||||
const serialized = JSON.stringify(body.data);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
|
||||
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/settings", () => {
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(PUT(jsonReq({}))).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 for an unknown field (strict schema)", async () => {
|
||||
const res = await PUT(jsonReq({ totallyBogus: true }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 for invalid Glitchtip DSN URL", async () => {
|
||||
const res = await PUT(jsonReq({ glitchtipDsn: "not a url" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when the body has no actionable fields", async () => {
|
||||
const res = await PUT(jsonReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("No valid fields");
|
||||
});
|
||||
|
||||
it("encrypts the VAPID private key before persisting", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(encrypt).toHaveBeenCalledWith("plain-private");
|
||||
|
||||
const upsertArgs = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsertArgs?.where).toEqual({ id: "singleton" });
|
||||
expect(upsertArgs?.update).toEqual({
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
});
|
||||
// Audit detail must NOT carry the plaintext private key.
|
||||
expect(auditLog).toHaveBeenCalled();
|
||||
const auditDetails = vi.mocked(auditLog).mock.calls[0]?.[1]
|
||||
?.details as Record<string, unknown>;
|
||||
expect(JSON.stringify(auditDetails)).not.toContain("plain-private");
|
||||
expect(auditDetails.webPushVapidPrivateKeyUpdated).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the encrypted private key when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true }));
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({ webPushVapidPrivateKeyEncrypted: null });
|
||||
});
|
||||
|
||||
it("normalises a Umami URL with no path to /script.js", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({ umamiScriptUrl: "https://analytics.example.com" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalises and persists every Zod-allowed field in a single PUT", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
moodLogGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKey: "secret",
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
bugReportRepo: "owner/repo",
|
||||
bugReportToken: "gh-token",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toMatchObject({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
apiGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
});
|
||||
expect(encrypt).toHaveBeenCalledWith("secret");
|
||||
expect(encrypt).toHaveBeenCalledWith("gh-token");
|
||||
});
|
||||
|
||||
it("clears nullable string fields when an empty string is submitted", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
await PUT(
|
||||
jsonReq({
|
||||
webPushVapidPublicKey: "",
|
||||
webPushVapidSubject: "",
|
||||
umamiScriptUrl: "",
|
||||
umamiWebsiteId: "",
|
||||
glitchtipDsn: "",
|
||||
glitchtipEnvironment: "",
|
||||
bugReportRepo: "",
|
||||
}),
|
||||
);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the encrypted bug-report token when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
await PUT(jsonReq({ clearBugReportToken: true }));
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r);
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
measurement: { count: vi.fn() },
|
||||
medication: { count: vi.fn() },
|
||||
medicationIntakeEvent: { count: vi.fn() },
|
||||
apiToken: { count: vi.fn() },
|
||||
session: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medication.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.session.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/status (integration health summary)", () => {
|
||||
it("rejects non-admin callers with 403", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns null integration entries when no app settings exist", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, unknown> };
|
||||
};
|
||||
expect(body.data.integrations).toEqual({
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
umamiEnabled: true,
|
||||
glitchtipDsn: "https://abc@x/123",
|
||||
glitchtipEnabled: false,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, Record<string, unknown> | null> };
|
||||
};
|
||||
expect(body.data.integrations.umami).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
});
|
||||
// glitchtip is configured but the global toggle is off
|
||||
expect(body.data.integrations.glitchtip).toEqual({
|
||||
configured: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
});
|
||||
|
||||
it("does not mark webPush configured when only the public key is present", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
webPushVapidSubject: null,
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: { webPush: unknown } };
|
||||
};
|
||||
expect(body.data.integrations.webPush).toBeNull();
|
||||
});
|
||||
|
||||
it("returns aggregate counts and the worker status block", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(7);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(123);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(2);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
counts: Record<string, number>;
|
||||
worker: { running: boolean };
|
||||
database: string;
|
||||
};
|
||||
};
|
||||
expect(body.data.counts).toEqual({
|
||||
users: 7,
|
||||
measurements: 123,
|
||||
medications: 0,
|
||||
intakeEvents: 0,
|
||||
activeTokens: 2,
|
||||
activeSessions: 0,
|
||||
});
|
||||
expect(body.data.worker.running).toBe(true);
|
||||
expect(body.data.database).toBe("connected");
|
||||
|
||||
// Regression: only NON-revoked tokens are counted as "active".
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function params(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/users/[id]", () => {
|
||||
it("rejects with 401 when unauthenticated", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails Zod validation (short username)", async () => {
|
||||
const res = await PUT(jsonReq({ username: "ab" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.user.findUnique)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when role is unknown", async () => {
|
||||
const res = await PUT(jsonReq({ role: "SUPERUSER" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids demoting the last remaining admin", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/last admin/i);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows demoting an admin while another admin exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "a@b.com",
|
||||
role: "USER",
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.where).toEqual({ id: "u1" });
|
||||
expect(updateArgs?.data).toEqual({ role: "USER" });
|
||||
});
|
||||
|
||||
it("persists username + email + role and writes an audit-log entry", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
const updated = {
|
||||
id: "u1",
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
};
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(updated as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
}),
|
||||
params("u1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.username).toBe("renamed");
|
||||
expect(body.data.role).toBe("ADMIN");
|
||||
expect(body.data).not.toHaveProperty("passwordHash");
|
||||
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.user.update",
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
details: expect.objectContaining({ targetUserId: "u1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports clearing email by passing null", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ email: null }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({ email: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r, params("u1"));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const mockUserFindMany = vi.mocked(prisma.user.findMany);
|
||||
const mockRequireAdmin = vi.mocked(requireAdmin);
|
||||
|
||||
describe("GET /api/admin/users", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireAdmin.mockResolvedValue({
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
});
|
||||
mockUserFindMany.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("rejects with 401 when no session", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(new HttpError(401, "Not authenticated"));
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns users sorted by createdAt ascending with passkey counts", async () => {
|
||||
const created = new Date("2024-01-01T00:00:00Z");
|
||||
mockUserFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 2 },
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 0 },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0]).toEqual({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created.toISOString(),
|
||||
passkeyCount: 2,
|
||||
});
|
||||
expect(body.data[1].passkeyCount).toBe(0);
|
||||
expect(body.data[1].email).toBeNull();
|
||||
});
|
||||
|
||||
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
|
||||
await GET();
|
||||
expect(mockUserFindMany).toHaveBeenCalledTimes(1);
|
||||
const args = mockUserFindMany.mock.calls[0]?.[0] as {
|
||||
select: Record<string, unknown>;
|
||||
orderBy: { createdAt: "asc" | "desc" };
|
||||
};
|
||||
// Regression: the route must NOT pull password hash / token columns.
|
||||
expect(args.select).toEqual({
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
expect(args.orderBy).toEqual({ createdAt: "asc" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring-settings", () => ({
|
||||
getGlitchtipSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring/glitchtip", () => ({
|
||||
sendGlitchtipEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ addWarning: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
|
||||
return new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
method: "POST",
|
||||
headers: { "content-type": contentType },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://pub@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "production",
|
||||
});
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValue({
|
||||
ok: true,
|
||||
method: "envelope",
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/monitoring/glitchtip", () => {
|
||||
it("returns 429 when rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(jsonReq({ message: "x" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits with skipped:true when GlitchTip is disabled", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { skipped: boolean } };
|
||||
expect(body.data.skipped).toBe(true);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when DSN is empty even if globally enabled", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { skipped: boolean } };
|
||||
expect(body.data.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 422 when JSON parse fails", async () => {
|
||||
const res = await POST(jsonReq("{not-json", "application/json"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails schema (missing message)", async () => {
|
||||
const res = await POST(jsonReq({ stack: "boom" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when message exceeds size cap (2000 chars)", async () => {
|
||||
// The Zod schema caps `message` at 2000 chars — bigger payloads must
|
||||
// be rejected before they ever leave the box.
|
||||
const huge = "x".repeat(2001);
|
||||
const res = await POST(jsonReq({ message: huge }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when stack exceeds 20 000 chars", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({ message: "boom", stack: "x".repeat(20001) }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("forwards a well-formed event with default level=error to the configured DSN", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
message: "Boom!",
|
||||
stack: "Error: Boom!\n at foo",
|
||||
type: "ReferenceError",
|
||||
url: "https://app.example.com/page",
|
||||
userAgent: "Mozilla/5.0",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { sent: boolean } };
|
||||
expect(body.data.sent).toBe(true);
|
||||
|
||||
expect(sendGlitchtipEvent).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0];
|
||||
expect(args?.dsn).toBe("https://pub@glitchtip.example.com/1");
|
||||
expect(args?.input).toMatchObject({
|
||||
environment: "production",
|
||||
message: "Boom!",
|
||||
level: "error",
|
||||
type: "ReferenceError",
|
||||
sourceTag: "healthlog-client",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the literal 'production' env when settings.glitchtipEnvironment is null", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://pub@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
await POST(jsonReq({ message: "boom" }));
|
||||
const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0];
|
||||
expect(args?.input.environment).toBe("production");
|
||||
});
|
||||
|
||||
it("returns 502 when the upstream delivery fails", async () => {
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValue({
|
||||
ok: false,
|
||||
method: "envelope",
|
||||
status: 500,
|
||||
details: "internal",
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("rate-limits per IP — uses x-forwarded-for as the bucket key", async () => {
|
||||
const r = new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "10.0.0.42",
|
||||
},
|
||||
body: JSON.stringify({ message: "boom" }),
|
||||
});
|
||||
await POST(r);
|
||||
const callArgs = vi.mocked(checkRateLimit).mock.calls[0];
|
||||
// First arg is the bucket key — must include the resolved client IP.
|
||||
expect(callArgs?.[0]).toContain("10.0.0.42");
|
||||
// 20 requests / 60 000 ms — guard the documented sliding window.
|
||||
expect(callArgs?.[1]).toBe(20);
|
||||
expect(callArgs?.[2]).toBe(60_000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user