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:
Marc-André BombeckandClaude Opus 4.7 authored and GitHub committed 2026-05-08 14:29:48 +02:00
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);
});
});