mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
* 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>
130 lines
3.8 KiB
TypeScript
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);
|
|
}
|
|
});
|