mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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,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