diff --git a/src/app/api/monitoring/glitchtip/test/__tests__/route.test.ts b/src/app/api/monitoring/glitchtip/test/__tests__/route.test.ts deleted file mode 100644 index 3c4180b46..000000000 --- a/src/app/api/monitoring/glitchtip/test/__tests__/route.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAdmin: vi.fn(async () => ({ - user: { id: "u-admin" }, - session: { id: "s-1" }, - })), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { - findUnique: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(async () => ({ allowed: true })), -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn((x: string) => x), -})); - -vi.mock("@/lib/monitoring/glitchtip", () => ({ - sendGlitchtipEvent: vi.fn(), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip"; - -interface ApiErrorEnvelope { - data: null; - error: string; -} -interface ApiSuccessEnvelope { - data: T; - error: null; -} - -const originalFetch = global.fetch; - -beforeEach(() => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 5, - resetAt: Date.now() + 60_000, - } as never); - vi.mocked(prisma.appSettings.findUnique).mockReset(); - vi.mocked(sendGlitchtipEvent).mockReset(); - global.fetch = vi.fn() as never; -}); - -afterEach(() => { - global.fetch = originalFetch; -}); - -function emptyRequest(): Request { - return new Request("http://localhost/api/monitoring/glitchtip/test", { - method: "POST", - headers: { "content-length": "0" }, - }); -} - -describe("POST /api/monitoring/glitchtip/test", () => { - it("happy path returns ok with statusCode", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - glitchtipDsn: "https://abc@glitch.example.com/1", - } as never); - vi.mocked(sendGlitchtipEvent).mockResolvedValueOnce({ - ok: true, - method: "envelope", - status: 200, - }); - - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(200); - const body = (await response.json()) as ApiSuccessEnvelope<{ - ok: boolean; - statusCode: number; - latencyMs: number; - }>; - expect(body.data.ok).toBe(true); - expect(body.data.statusCode).toBe(200); - expect(typeof body.data.latencyMs).toBe("number"); - - // CRITICAL guard against the `id: "default"` typo regression. - expect(prisma.appSettings.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: "singleton" } }), - ); - }); - - it("rejects HTTP DSN with 422", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - glitchtipDsn: "http://abc@glitch.example.com/1", - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(422); - const body = (await response.json()) as ApiErrorEnvelope & { - meta: { errorCode: string }; - }; - expect(body.meta.errorCode).toBe("dsn_not_https"); - expect(sendGlitchtipEvent).not.toHaveBeenCalled(); - }); - - it("rejects DSN pointing at a private host (SSRF guard)", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - glitchtipDsn: "https://abc@127.0.0.1/1", - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(422); - const body = (await response.json()) as ApiErrorEnvelope & { - meta: { errorCode: string }; - }; - expect(body.meta.errorCode).toBe("dsn_host_not_public"); - expect(sendGlitchtipEvent).not.toHaveBeenCalled(); - }); - - it("rate-limit denial returns 429 with no upstream call", async () => { - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(429); - expect(sendGlitchtipEvent).not.toHaveBeenCalled(); - }); - - it("returns 422 when DSN is not configured", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - glitchtipDsn: null, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(422); - const body = (await response.json()) as ApiErrorEnvelope; - expect(body.error).toMatch(/not configured/i); - expect(sendGlitchtipEvent).not.toHaveBeenCalled(); - }); - - it("does not leak DSN/Bearer/sk- on upstream 401", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - glitchtipDsn: "https://abc@glitch.example.com/1", - } as never); - vi.mocked(sendGlitchtipEvent).mockRejectedValueOnce( - new Error( - "401 Unauthorized: Authorization: Bearer sk-secret from https://glitch.example.com/api/1/envelope/", - ), - ); - const response = await POST(emptyRequest() as never); - const text = await response.text(); - expect(response.status).toBe(502); - expect(text).not.toMatch(/sk-/); - expect(text).not.toMatch(/glitch\.example\.com/); - }); -}); diff --git a/src/app/api/monitoring/glitchtip/test/route.ts b/src/app/api/monitoring/glitchtip/test/route.ts deleted file mode 100644 index 6b94815d6..000000000 --- a/src/app/api/monitoring/glitchtip/test/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { NextRequest } from "next/server"; -import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip"; -import { isPublicUrl } from "@/lib/validations/notifications"; - -export const dynamic = "force-dynamic"; - -const TIMEOUT_MS = 5_000; - -export const POST = apiHandler(async (request: NextRequest) => { - void request; - const { user } = await requireAdmin(); - annotate({ action: { name: "monitoring.glitchtip.test" } }); - - const rl = await checkRateLimit(`glitchtip-test:${user.id}`, 5, 60_000); - if (!rl.allowed) { - return apiError("Too many test requests", 429, { - errorCode: "rate_limited_self", - }); - } - - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { glitchtipDsn: true }, - }); - - if (!settings?.glitchtipDsn) { - return apiError("Glitchtip DSN not configured", 422, { - errorCode: "not_configured", - }); - } - - // DSN is admin-set, but a compromised/social-engineered admin (or a stored - // settings record from a hostile import) could aim Glitchtip at internal - // hosts. Validate the host is publicly reachable and uses HTTPS before we - // hand the URL to the helper. - let dsnHost: string; - try { - const parsed = new URL(settings.glitchtipDsn); - if (parsed.protocol !== "https:") { - return apiError("Glitchtip DSN must use HTTPS", 422, { - errorCode: "dsn_not_https", - }); - } - if (!isPublicUrl(`${parsed.protocol}//${parsed.host}`)) { - return apiError("Glitchtip DSN host is not publicly reachable", 422, { - errorCode: "dsn_host_not_public", - }); - } - dsnHost = parsed.host; - } catch { - return apiError("Glitchtip DSN is malformed", 422, { - errorCode: "dsn_invalid", - }); - } - - const start = performance.now(); - const send = sendGlitchtipEvent({ - dsn: settings.glitchtipDsn, - input: { - environment: process.env.NODE_ENV ?? "production", - message: "HealthLog Glitchtip self-test", - level: "info", - // sourceTag rides through into the event tags. Adding "noise: true" lets - // operators silence these in their inbound Glitchtip rules so the test - // button doesn't pollute the issue inbox. - sourceTag: "self-test:noise", - }, - }); - - const timeoutErr = new Error("timeout"); - timeoutErr.name = "AbortError"; - const timeout = new Promise((_, reject) => - setTimeout(() => reject(timeoutErr), TIMEOUT_MS), - ); - - try { - const result = await Promise.race([send, timeout]); - const latencyMs = Math.round(performance.now() - start); - - if (!result.ok) { - annotate({ - meta: { - glitchtip_test_status: result.status ?? null, - glitchtip_test_method: result.method ?? null, - glitchtip_test_latency_ms: latencyMs, - glitchtip_test_host: dsnHost, - }, - }); - return apiError("Glitchtip rejected the event", 502, { - errorCode: "upstream_error", - }); - } - - return apiSuccess({ - ok: true, - statusCode: result.status ?? 200, - latencyMs, - }); - } catch (e) { - const err = e as Error; - const isTimeout = err.name === "AbortError"; - const code = isTimeout ? "timeout" : "connection_failed"; - annotate({ - meta: { - glitchtip_test_code: code, - glitchtip_test_host: dsnHost, - glitchtip_test_error: err.message.slice(0, 200), - }, - }); - return apiError( - isTimeout ? "Glitchtip request timed out" : "Glitchtip connection failed", - 502, - { errorCode: code }, - ); - } -}); diff --git a/src/app/api/monitoring/umami/test/__tests__/route.test.ts b/src/app/api/monitoring/umami/test/__tests__/route.test.ts deleted file mode 100644 index 689992b73..000000000 --- a/src/app/api/monitoring/umami/test/__tests__/route.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAdmin: vi.fn(async () => ({ - user: { id: "u-admin" }, - session: { id: "s-1" }, - })), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { - findUnique: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(async () => ({ allowed: true })), -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn((x: string) => x), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { checkRateLimit } from "@/lib/rate-limit"; - -interface ApiErrorEnvelope { - data: null; - error: string; -} -interface ApiSuccessEnvelope { - data: T; - error: null; -} - -const originalFetch = global.fetch; - -beforeEach(() => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 5, - resetAt: Date.now() + 60_000, - } as never); - vi.mocked(prisma.appSettings.findUnique).mockReset(); - global.fetch = vi.fn() as never; -}); - -afterEach(() => { - global.fetch = originalFetch; -}); - -function emptyRequest(): Request { - return new Request("http://localhost/api/monitoring/umami/test", { - method: "POST", - headers: { "content-length": "0" }, - }); -} - -describe("POST /api/monitoring/umami/test", () => { - it("happy path returns ok with hasMarker=true", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - umamiScriptUrl: "https://analytics.example.com/script.js", - } as never); - (global.fetch as unknown as ReturnType).mockResolvedValueOnce( - new Response("(function(){window.umami=function(){};})();", { - status: 200, - }), - ); - - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(200); - const body = (await response.json()) as ApiSuccessEnvelope<{ - ok: boolean; - statusCode: number; - hasMarker: boolean; - }>; - expect(body.data.ok).toBe(true); - expect(body.data.hasMarker).toBe(true); - expect(body.data.statusCode).toBe(200); - - // CRITICAL guard: AppSettings is keyed on `id: "singleton"` everywhere - // else in the codebase. A typo here ("default") would silently make the - // route return 422 in prod even with valid settings. - expect(prisma.appSettings.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: "singleton" } }), - ); - }); - - it("rate-limit denial returns 429 with no upstream call", async () => { - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(429); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it("returns 422 when URL not configured", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - umamiScriptUrl: null, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(422); - const body = (await response.json()) as ApiErrorEnvelope; - expect(body.error).toMatch(/not configured/i); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it("does not leak Bearer/sk- or upstream URL on upstream 401", async () => { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - umamiScriptUrl: "https://analytics.example.com/script.js", - } as never); - (global.fetch as unknown as ReturnType).mockResolvedValueOnce( - new Response( - "Authorization: Bearer sk-secret echoed back from https://analytics.example.com/script.js", - { status: 401 }, - ), - ); - const response = await POST(emptyRequest() as never); - const text = await response.text(); - expect(response.status).toBe(502); - expect(text).not.toMatch(/sk-/); - expect(text).not.toMatch(/analytics\.example\.com/); - }); -}); diff --git a/src/app/api/monitoring/umami/test/route.ts b/src/app/api/monitoring/umami/test/route.ts deleted file mode 100644 index 02842768c..000000000 --- a/src/app/api/monitoring/umami/test/route.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { NextRequest } from "next/server"; -import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import { isPublicUrl } from "@/lib/validations/notifications"; - -export const dynamic = "force-dynamic"; - -const TIMEOUT_MS = 5_000; -const MAX_BODY_BYTES = 256 * 1024; - -function redact(text: string): string { - return text.replace(/https?:\/\/\S+/gi, "[url]"); -} - -export const POST = apiHandler(async (request: NextRequest) => { - void request; - const { user } = await requireAdmin(); - annotate({ action: { name: "monitoring.umami.test" } }); - - const rl = await checkRateLimit(`umami-test:${user.id}`, 5, 60_000); - if (!rl.allowed) { - return apiError("Too many test requests", 429, { - errorCode: "rate_limited_self", - }); - } - - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { umamiScriptUrl: true }, - }); - - if (!settings?.umamiScriptUrl) { - return apiError("Umami script URL not configured", 422, { - errorCode: "not_configured", - }); - } - - const url = settings.umamiScriptUrl; - if (!isPublicUrl(url) || !url.startsWith("https://")) { - return apiError("Umami URL must be a public HTTPS endpoint", 422, { - errorCode: "url_not_public", - }); - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); - const start = performance.now(); - - try { - const res = await fetch(url, { - method: "GET", - signal: controller.signal, - cache: "no-store", - // `manual` neutralises a redirect-follow SSRF — even though the URL was - // checked with isPublicUrl(), a public host can 302 to a private IP. - redirect: "manual", - }); - const latencyMs = Math.round(performance.now() - start); - - if (res.status >= 300 && res.status < 400) { - annotate({ meta: { umami_test_status: res.status } }); - return apiError("Umami URL redirects — check configuration", 502, { - errorCode: "redirected", - }); - } - - // Cap response read to 256 KB so a hostile public server can't stream - // gigabytes through us; combined with the 5 s abort, RAM use stays bounded. - let body = ""; - if (res.body) { - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let total = 0; - try { - while (total < MAX_BODY_BYTES) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - body += decoder.decode(value, { stream: true }); - } - body += decoder.decode(); - } finally { - reader.releaseLock(); - } - } - - if (!res.ok) { - annotate({ - meta: { - umami_test_status: res.status, - umami_test_latency_ms: latencyMs, - }, - }); - return apiError("Umami connection failed", 502, { - errorCode: "upstream_error", - }); - } - - // Look for tracker-shape signal rather than a literal "umami" substring — - // a 404 page that mentions "Powered by Umami" would otherwise slip through. - const hasMarker = /umami\.track|window\.umami|"umami"/i.test(body); - - return apiSuccess({ - ok: hasMarker, - statusCode: res.status, - latencyMs, - hasMarker, - }); - } catch (e) { - const err = e as Error; - const isAbort = err.name === "AbortError"; - const code = isAbort ? "timeout" : "connection_failed"; - annotate({ - meta: { - umami_test_code: code, - umami_test_error: redact(err.message).slice(0, 300), - }, - }); - return apiError( - isAbort ? "Umami request timed out" : "Umami connection failed", - 502, - { errorCode: code }, - ); - } finally { - clearTimeout(timer); - } -});