Files
HealthLog/src/app/api/integrations/moodlog/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

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