Files
HealthLog/src/app/api/monitoring/umami/test/route.ts
T
Marc-André BombeckandMarc-André Bombeck 4a6be429ca feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami (#134)
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami

Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):

- /api/integrations/withings/test     — pings wbsapi.withings.net /measure
                                        with the decrypted user access token
- /api/integrations/moodlog/test      — HEAD against the user's decrypted
                                        moodLog URL (5 s timeout, isPublicUrl
                                        SSRF guard)
- /api/notifications/web-push/test    — sends "Push test" via web-push to
                                        every PushSubscription, returns only
                                        endpoint host (never full URL)
- /api/monitoring/glitchtip/test      — admin-only; sends a stub event via
                                        sendGlitchtipEvent() using the saved
                                        AppSettings.glitchtipDsn
- /api/monitoring/umami/test          — admin-only; HTTPS-only GET against
                                        AppSettings.umamiScriptUrl, checks
                                        body for "umami" marker

Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>

* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push

Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:

- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
  and Umami test routes were querying `id: "default"` and would silently
  return 422 in production. Tests now assert the where argument so the
  typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
  redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
  RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
  expired access token refreshes the same way the real sync does instead
  of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
  call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
  Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
  categorised separately from the HTTP status (e.g. `status: 401` →
  credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
  endpoint instead of a bare HEAD on the configured URL — a HEAD on the
  bare URL would return 200 from a generic landing page even when the
  API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
  registered subscription, so a user with three devices got three buzzes
  per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
  failure (errors land in the meta envelope, not in the visible
  response body) — the prior dead-code in the 401 sanitisation test is
  gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
  `credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
  upcoming UI can localise via `t("settings.testConnection.errors." + code)`
  instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
  before they reach Wide Events / Loki — push providers in particular
  embed the subscription endpoint (which is the routing secret) in
  410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
  cannot stream gigabytes through us; the marker check is tightened to
  tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
  page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
  can include the `errorCode` without breaking the existing
  `{ data: null, error: <string> }` envelope.

477 tests green.

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 13:40:22 +02:00

130 lines
3.8 KiB
TypeScript

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) => {
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);
}
});