mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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>
This commit is contained in:
1 parent
b5bd730ec2
commit
4a6be429ca
12 files changed
+1501
-5
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
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<T> {
|
||||
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.user.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/moodlog/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ entries: [] }), { 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;
|
||||
lastSyncedAt: string;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
|
||||
// Probe must hit the actual sync endpoint with Bearer auth, not a bare HEAD
|
||||
// against the configured URL.
|
||||
const fetchCall = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
const init = fetchCall[1] as RequestInit;
|
||||
expect(init.method).toBe("GET");
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer ml-key-123");
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
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 no moodLog URL configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: null,
|
||||
moodLogApiKeyEncrypted: null,
|
||||
moodLogLastSyncedAt: 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("rejects SSRF (private IP) with 422", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "http://127.0.0.1/moodlog",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak upstream URL or Bearer token on upstream rejection", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "sk-secret",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Authorization: Bearer sk-secret echoed from https://example.com/api/integrations/health-log/mood",
|
||||
),
|
||||
);
|
||||
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(/example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: {
|
||||
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/withings/sync", () => ({
|
||||
getValidToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
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.withingsConnection.findUnique).mockReset();
|
||||
vi.mocked(getValidToken).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/withings/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/withings/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "plain-access",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ status: 0, body: {} }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
lastSyncedAt: string;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
});
|
||||
|
||||
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 no Withings connection exists", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not connected/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitises 401 upstream — does not leak Bearer token or upstream URL", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "sk-secret",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Authorization: Bearer sk-secret echoed",
|
||||
url: "https://wbsapi.withings.net/measure",
|
||||
}),
|
||||
{ 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(/wbsapi\.withings\.net/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
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 { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
type CategorisedError = { code: string; message: string };
|
||||
|
||||
function categoriseHttpStatus(status: number): CategorisedError {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "Withings connection failed" };
|
||||
}
|
||||
|
||||
// Withings returns HTTP 200 with `status` in the JSON body to signal API-level
|
||||
// errors. Reference: https://developer.withings.com/api-reference/#section/Response-status
|
||||
function categoriseApiStatus(apiStatus: number): CategorisedError {
|
||||
switch (apiStatus) {
|
||||
case 100:
|
||||
case 101:
|
||||
case 102:
|
||||
case 401:
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
case 601:
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
case 503:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings reported a temporary server error",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned an error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.withings.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`withings-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const tokenInfo = await getValidToken(user.id);
|
||||
if (!tokenInfo) {
|
||||
return apiError("Withings not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { lastSyncedAt: true },
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
action: "getmeas",
|
||||
meastypes: "1",
|
||||
lastupdate: Math.floor(Date.now() / 1000 - 60).toString(),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${tokenInfo.accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_status: res.status,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
let json: { status?: number } = {};
|
||||
try {
|
||||
json = (await res.json()) as { status?: number };
|
||||
} catch {
|
||||
annotate({
|
||||
meta: { withings_test_code: "upstream_invalid_json" },
|
||||
});
|
||||
return apiError("Withings returned an unparseable response", 502, {
|
||||
errorCode: "upstream_invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
if (json.status !== 0) {
|
||||
const apiStatus = json.status ?? -1;
|
||||
const cat = categoriseApiStatus(apiStatus);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_api_status: apiStatus,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
lastSyncedAt: connection?.lastSyncedAt ?? null,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_code: code,
|
||||
withings_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "Withings request timed out" : "Withings connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
// Strip any `Bearer <token>` substring before annotating; the catch path can
|
||||
// see DNS / TLS errors that include the request init via err.cause in some
|
||||
// runtimes, and we never want a token in Loki.
|
||||
function redact(text: string): string {
|
||||
return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => 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<T> {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
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) => {
|
||||
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<never>((_, 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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => 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<T> {
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
pushSubscription: {
|
||||
findFirst: 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/notifications/vapid-config", () => ({
|
||||
getVapidConfig: vi.fn(async () => ({
|
||||
subject: "mailto:test@example.com",
|
||||
publicKey:
|
||||
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
|
||||
privateKey: "private-key-stub",
|
||||
})),
|
||||
}));
|
||||
|
||||
const sendNotification = vi.fn();
|
||||
vi.mock("web-push", () => ({
|
||||
default: {
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotification(...args),
|
||||
},
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotification(...args),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
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.pushSubscription.findFirst).mockReset();
|
||||
sendNotification.mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/notifications/web-push/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/notifications/web-push/test", () => {
|
||||
it("happy path returns ok and sends to one subscription", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
|
||||
id: "p-1",
|
||||
userId: "u-1",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
} as never);
|
||||
sendNotification.mockResolvedValueOnce({ statusCode: 201 });
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
sent: number;
|
||||
latencyMs: number;
|
||||
perEndpoint: Array<{ host: string; status: number | null }>;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.sent).toBe(1);
|
||||
expect(body.data.perEndpoint).toHaveLength(1);
|
||||
expect(body.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
|
||||
// never the full endpoint URL (the routing token is the only thing
|
||||
// protecting that subscription from anyone who learns it).
|
||||
const text = JSON.stringify(body);
|
||||
expect(text).not.toMatch(/abc123token/);
|
||||
|
||||
// Spec: only the most-recent subscription gets the test push, not all.
|
||||
expect(prisma.pushSubscription.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "u-1" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
);
|
||||
expect(sendNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no subscriptions registered", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/no push subscriptions/i);
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak Bearer/sk- or full endpoint URL when upstream rejects", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
|
||||
id: "p-1",
|
||||
userId: "u-1",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
} as never);
|
||||
sendNotification.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error(
|
||||
"401 Unauthorized: Authorization: Bearer sk-secret from https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
),
|
||||
{ statusCode: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/abc123token/);
|
||||
const parsed = JSON.parse(text) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
sent: number;
|
||||
perEndpoint: Array<{ host: string; status: number | null }>;
|
||||
}>;
|
||||
expect(parsed.data.ok).toBe(false);
|
||||
expect(parsed.data.sent).toBe(0);
|
||||
expect(parsed.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
|
||||
expect(parsed.data.perEndpoint[0].status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { getVapidConfig } from "@/lib/notifications/vapid-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function hostFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
return new URL(endpoint).host;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Strip any URL from a stringified web-push library error. Push providers (FCM,
|
||||
// Mozilla autopush, etc.) embed the full subscription endpoint into 410/404
|
||||
// error messages — that endpoint token is the routing secret, so it must never
|
||||
// land in our Wide Events.
|
||||
function redactPushError(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "notifications.web-push.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`web-push-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
// Spec: only the most-recent subscription receives the test push. A user
|
||||
// with phone + tablet + desktop should not get three buzzes per click.
|
||||
const subscription = await prisma.pushSubscription.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return apiError("No push subscriptions registered", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getVapidConfig();
|
||||
if (!config) {
|
||||
return apiError("VAPID keys not configured", 422, {
|
||||
errorCode: "vapid_not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const webpush = await import("web-push");
|
||||
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
|
||||
|
||||
const pushPayload = JSON.stringify({
|
||||
title: "HealthLog",
|
||||
body: "This is a test notification from HealthLog",
|
||||
tag: "self-test",
|
||||
});
|
||||
|
||||
const host = hostFromEndpoint(subscription.endpoint);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const p256dh = decrypt(subscription.p256dh);
|
||||
const auth = decrypt(subscription.auth);
|
||||
|
||||
const result = await webpush.sendNotification(
|
||||
{ endpoint: subscription.endpoint, keys: { p256dh, auth } },
|
||||
pushPayload,
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
sent: 1,
|
||||
latencyMs,
|
||||
perEndpoint: [{ host, status: result.statusCode ?? 201 }],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number }).statusCode ?? null;
|
||||
const message = redactPushError((err as Error).message ?? "").slice(0, 200);
|
||||
annotate({
|
||||
meta: {
|
||||
web_push_test_status: status,
|
||||
web_push_test_host: host,
|
||||
web_push_test_error: message,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
ok: false,
|
||||
sent: 0,
|
||||
perEndpoint: [{ host, status }],
|
||||
});
|
||||
}
|
||||
});
|
||||
+16
-2
@@ -4,8 +4,22 @@ export function apiSuccess<T>(data: T, status = 200) {
|
||||
return NextResponse.json({ data, error: null }, { status });
|
||||
}
|
||||
|
||||
export function apiError(message: string, status = 400) {
|
||||
return NextResponse.json({ data: null, error: message }, { status });
|
||||
/**
|
||||
* `meta` is additive — clients that ignore it see the unchanged
|
||||
* `{ data: null, error: <string> }` envelope. New callers can pass
|
||||
* `{ errorCode: "credentials_rejected" }` so the UI translates the message
|
||||
* via `t("settings.testConnection.errors." + errorCode)` instead of
|
||||
* displaying the server's English fallback.
|
||||
*/
|
||||
export function apiError(
|
||||
message: string,
|
||||
status = 400,
|
||||
meta?: { errorCode?: string } & Record<string, unknown>,
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: message, ...(meta ? { meta } : {}) },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ export function getWithingsWebhookCallbackUrl(): string {
|
||||
/**
|
||||
* Get valid access token for a user, refreshing if expired.
|
||||
*/
|
||||
async function getValidToken(userId: string): Promise<{
|
||||
export async function getValidToken(userId: string): Promise<{
|
||||
accessToken: string;
|
||||
connection: { id: string; withingsUserId: string };
|
||||
} | null> {
|
||||
@@ -45,7 +45,9 @@ async function getValidToken(userId: string): Promise<{
|
||||
try {
|
||||
const creds = await getUserWithingsCredentials(userId);
|
||||
if (!creds) {
|
||||
getEvent()?.addWarning(`No credentials found for user ${userId} during token refresh`);
|
||||
getEvent()?.addWarning(
|
||||
`No credentials found for user ${userId} during token refresh`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -161,6 +163,8 @@ export async function setupWebhook(userId: string): Promise<void> {
|
||||
);
|
||||
getEvent()?.addMeta("webhook_subscribed", userId);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`Webhook subscribe failed for user ${userId}: ${err}`);
|
||||
getEvent()?.addWarning(
|
||||
`Webhook subscribe failed for user ${userId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user