mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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>
174 lines
5.0 KiB
TypeScript
174 lines
5.0 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { apiHandler, requireAuth } 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 { decrypt } from "@/lib/crypto";
|
|
import { isPublicUrl } from "@/lib/validations/notifications";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const TIMEOUT_MS = 5_000;
|
|
|
|
function categoriseHttpStatus(status: number): {
|
|
code: string;
|
|
message: string;
|
|
} {
|
|
if (status === 401 || status === 403) {
|
|
return {
|
|
code: "credentials_rejected",
|
|
message: "moodLog rejected the API key",
|
|
};
|
|
}
|
|
if (status === 404) {
|
|
return {
|
|
code: "endpoint_not_found",
|
|
message: "moodLog endpoint not found at the configured URL",
|
|
};
|
|
}
|
|
if (status === 429) {
|
|
return {
|
|
code: "rate_limited",
|
|
message: "moodLog rate-limited the request",
|
|
};
|
|
}
|
|
if (status >= 500) {
|
|
return {
|
|
code: "upstream_error",
|
|
message: "moodLog returned a server error",
|
|
};
|
|
}
|
|
return { code: "connection_failed", message: "moodLog connection failed" };
|
|
}
|
|
|
|
function redact(text: string): string {
|
|
return text
|
|
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
|
.replace(/https?:\/\/\S+/gi, "[url]");
|
|
}
|
|
|
|
export const POST = apiHandler(async (_request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "integrations.moodlog.test" } });
|
|
|
|
const rl = await checkRateLimit(`moodlog-test:${user.id}`, 5, 60_000);
|
|
if (!rl.allowed) {
|
|
return apiError("Too many test requests", 429, {
|
|
errorCode: "rate_limited_self",
|
|
});
|
|
}
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: {
|
|
moodLogUrlEncrypted: true,
|
|
moodLogApiKeyEncrypted: true,
|
|
moodLogLastSyncedAt: true,
|
|
},
|
|
});
|
|
|
|
if (!dbUser?.moodLogUrlEncrypted || !dbUser.moodLogApiKeyEncrypted) {
|
|
return apiError("moodLog not configured", 422, {
|
|
errorCode: "not_configured",
|
|
});
|
|
}
|
|
|
|
let baseUrl: string;
|
|
let apiKey: string;
|
|
try {
|
|
baseUrl = decrypt(dbUser.moodLogUrlEncrypted);
|
|
apiKey = decrypt(dbUser.moodLogApiKeyEncrypted);
|
|
} catch {
|
|
return apiError("moodLog credentials unreadable", 422, {
|
|
errorCode: "credentials_unreadable",
|
|
});
|
|
}
|
|
|
|
if (!isPublicUrl(baseUrl)) {
|
|
return apiError("moodLog URL must be a public HTTP(S) endpoint", 422, {
|
|
errorCode: "url_not_public",
|
|
});
|
|
}
|
|
|
|
// Probe the actual sync endpoint with the actual auth header — a HEAD on the
|
|
// bare URL would return 200 from the static landing page even if the API key
|
|
// is invalid. Use a 1-day window to keep the upstream payload small.
|
|
let probeUrl: URL;
|
|
try {
|
|
probeUrl = new URL("/api/integrations/health-log/mood", baseUrl);
|
|
} catch {
|
|
return apiError("moodLog URL is invalid", 422, {
|
|
errorCode: "url_invalid",
|
|
});
|
|
}
|
|
const yesterday = new Date(Date.now() - 24 * 3600 * 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
probeUrl.searchParams.set("from", yesterday);
|
|
probeUrl.searchParams.set("to", today);
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
const start = performance.now();
|
|
|
|
try {
|
|
const res = await fetch(probeUrl.toString(), {
|
|
method: "GET",
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
signal: controller.signal,
|
|
cache: "no-store",
|
|
// `manual` neutralises the redirect-follow SSRF where a public host
|
|
// serves a 302 to 169.254.169.254 (cloud metadata) or RFC1918 ranges.
|
|
redirect: "manual",
|
|
});
|
|
const latencyMs = Math.round(performance.now() - start);
|
|
|
|
// 3xx in `manual` redirect mode → we never followed; treat as success
|
|
// failure rather than chase the Location header.
|
|
if (res.status >= 300 && res.status < 400) {
|
|
annotate({ meta: { moodlog_test_status: res.status } });
|
|
return apiError("moodLog redirected — check the configured URL", 502, {
|
|
errorCode: "redirected",
|
|
});
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const cat = categoriseHttpStatus(res.status);
|
|
annotate({
|
|
meta: {
|
|
moodlog_test_status: res.status,
|
|
moodlog_test_code: cat.code,
|
|
moodlog_test_latency_ms: latencyMs,
|
|
},
|
|
});
|
|
return apiError(cat.message, 502, { errorCode: cat.code });
|
|
}
|
|
|
|
return apiSuccess({
|
|
ok: true,
|
|
statusCode: res.status,
|
|
latencyMs,
|
|
lastSyncedAt: dbUser.moodLogLastSyncedAt,
|
|
});
|
|
} catch (e) {
|
|
const err = e as Error;
|
|
const isAbort = err.name === "AbortError";
|
|
const code = isAbort ? "timeout" : "connection_failed";
|
|
annotate({
|
|
meta: {
|
|
moodlog_test_code: code,
|
|
moodlog_test_error: redact(err.message).slice(0, 300),
|
|
},
|
|
});
|
|
return apiError(
|
|
isAbort ? "moodLog request timed out" : "moodLog connection failed",
|
|
502,
|
|
{ errorCode: code },
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
});
|