diff --git a/src/app/api/integrations/moodlog/test/__tests__/route.test.ts b/src/app/api/integrations/moodlog/test/__tests__/route.test.ts new file mode 100644 index 000000000..b2a77a689 --- /dev/null +++ b/src/app/api/integrations/moodlog/test/__tests__/route.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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 { + 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).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).mock + .calls[0]; + const init = fetchCall[1] as RequestInit; + expect(init.method).toBe("GET"); + const headers = init.headers as Record; + 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).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/); + }); +}); diff --git a/src/app/api/integrations/moodlog/test/route.ts b/src/app/api/integrations/moodlog/test/route.ts new file mode 100644 index 000000000..076cf956a --- /dev/null +++ b/src/app/api/integrations/moodlog/test/route.ts @@ -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); + } +}); diff --git a/src/app/api/integrations/withings/test/__tests__/route.test.ts b/src/app/api/integrations/withings/test/__tests__/route.test.ts new file mode 100644 index 000000000..bfacf8dec --- /dev/null +++ b/src/app/api/integrations/withings/test/__tests__/route.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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 { + 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).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).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/); + }); +}); diff --git a/src/app/api/integrations/withings/test/route.ts b/src/app/api/integrations/withings/test/route.ts new file mode 100644 index 000000000..355f2af94 --- /dev/null +++ b/src/app/api/integrations/withings/test/route.ts @@ -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 ` 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]"); +} diff --git a/src/app/api/monitoring/glitchtip/test/__tests__/route.test.ts b/src/app/api/monitoring/glitchtip/test/__tests__/route.test.ts new file mode 100644 index 000000000..3c4180b46 --- /dev/null +++ b/src/app/api/monitoring/glitchtip/test/__tests__/route.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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 { + 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/); + }); +}); diff --git a/src/app/api/monitoring/glitchtip/test/route.ts b/src/app/api/monitoring/glitchtip/test/route.ts new file mode 100644 index 000000000..93c103afb --- /dev/null +++ b/src/app/api/monitoring/glitchtip/test/route.ts @@ -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((_, 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 }, + ); + } +}); diff --git a/src/app/api/monitoring/umami/test/__tests__/route.test.ts b/src/app/api/monitoring/umami/test/__tests__/route.test.ts new file mode 100644 index 000000000..689992b73 --- /dev/null +++ b/src/app/api/monitoring/umami/test/__tests__/route.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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 { + 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).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).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/); + }); +}); diff --git a/src/app/api/monitoring/umami/test/route.ts b/src/app/api/monitoring/umami/test/route.ts new file mode 100644 index 000000000..6f1767648 --- /dev/null +++ b/src/app/api/monitoring/umami/test/route.ts @@ -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); + } +}); diff --git a/src/app/api/notifications/web-push/test/__tests__/route.test.ts b/src/app/api/notifications/web-push/test/__tests__/route.test.ts new file mode 100644 index 000000000..ba8532fe8 --- /dev/null +++ b/src/app/api/notifications/web-push/test/__tests__/route.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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 { + 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); + }); +}); diff --git a/src/app/api/notifications/web-push/test/route.ts b/src/app/api/notifications/web-push/test/route.ts new file mode 100644 index 000000000..4ec620890 --- /dev/null +++ b/src/app/api/notifications/web-push/test/route.ts @@ -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 }], + }); + } +}); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 4a27a5e74..68590c4c2 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -4,8 +4,22 @@ export function apiSuccess(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: }` 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, +) { + return NextResponse.json( + { data: null, error: message, ...(meta ? { meta } : {}) }, + { status }, + ); } /** diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 1a6fa2e65..0afb7c6a3 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -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 { ); 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}`, + ); } }