From 9ef1dd6e02c7bcf6d47bedd3919d5bbc11f018c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 22:28:08 +0200 Subject: [PATCH] feat(api): legacy Withings webhook counter + migration URL in warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy ?secret=... route stays alive through v1.4.27 so existing Withings subscriptions keep delivering while users re-subscribe. The release-gate cannot tell when "legacy form usage has trended to zero" because the route only emits a fire-and-forget Wide Event warning and the warning text omits the destination URL anyone watching the access log would need. This pass adds: - An in-memory `withings.webhook.legacy_form_total` counter scoped to the process (the right granularity for "since cold-start" usage). - A `getLegacyFormTotal()` accessor + a test-only reset helper so the counter is observable without a backchannel. - A `counters.withingsWebhookLegacyFormTotal` field on the existing `/api/admin/status` payload — the natural surface for ops counters alongside the user / measurement / worker blocks. - The warning text now spells out `/api/withings/webhook/[token]` and the "re-subscribe with the path-segment token form" instruction. - Two new assertions on the webhook test (counter increments on the query path, stays at zero on the header path, warning contains the migration URL) and one new assertion on the admin-status test. --- .../api/admin/status/__tests__/route.test.ts | 17 +++++++++ src/app/api/admin/status/route.ts | 7 ++++ .../withings/webhook/__tests__/route.test.ts | 35 ++++++++++++++++-- src/app/api/withings/webhook/route.ts | 36 ++++++++++++++++++- 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index 9a5466505..459906c22 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,9 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); +// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form +// counter from the Withings webhook module. Mock it at module boundary +// so the test fixture stays deterministic. +vi.mock("@/app/api/withings/webhook/route", () => ({ + getLegacyFormTotal: vi.fn(() => 0), +})); + import { GET } from "../route"; import { prisma } from "@/lib/db"; import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { getLegacyFormTotal } from "@/app/api/withings/webhook/route"; const ADMIN_CTX = { session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -169,4 +177,13 @@ describe("GET /api/admin/status (integration health summary)", () => { const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0]; expect(tokenCallArgs).toEqual({ where: { revoked: false } }); }); + + it("surfaces the legacy Withings webhook counter on the counters block (Fix-K sec-M2)", async () => { + vi.mocked(getLegacyFormTotal).mockReturnValue(7); + const res = await GET(); + const body = (await res.json()) as { + data: { counters: { withingsWebhookLegacyFormTotal: number } }; + }; + expect(body.data.counters.withingsWebhookLegacyFormTotal).toBe(7); + }); }); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index 769005739..8bba954da 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { getWorkerStatus } from "@/lib/jobs/worker-status"; +import { getLegacyFormTotal } from "@/app/api/withings/webhook/route"; import { readFileSync } from "node:fs"; // Build timestamp (set at build time via next.config) @@ -89,5 +90,11 @@ export const GET = apiHandler(async () => { webPush: webPushConfigured ? { configured: true } : null, bugReport: bugReportConfigured ? { configured: true } : null, }, + // v1.4.25 W21 Fix-K — in-memory counters that the release-gate can + // read to confirm "legacy form usage trending toward zero" before + // the v1.4.27 cut of the legacy Withings webhook route. + counters: { + withingsWebhookLegacyFormTotal: getLegacyFormTotal(), + }, }); }); diff --git a/src/app/api/withings/webhook/__tests__/route.test.ts b/src/app/api/withings/webhook/__tests__/route.test.ts index a273789cb..abdbe298a 100644 --- a/src/app/api/withings/webhook/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/__tests__/route.test.ts @@ -45,7 +45,13 @@ vi.mock("@/lib/logging/context", () => ({ })), })); -import { POST, GET, HEAD } from "../route"; +import { + POST, + GET, + HEAD, + getLegacyFormTotal, + __resetLegacyFormTotalForTests, +} from "../route"; import { prisma } from "@/lib/db"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { syncUserActivity } from "@/lib/withings/sync-activity"; @@ -167,7 +173,8 @@ describe("POST /api/withings/webhook", () => { expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); }); - it("legacy ?secret=… query path still authorises and emits a Wide Event warning", async () => { + it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => { + __resetLegacyFormTotalForTests(); const addWarning = vi.fn(); vi.mocked(getEvent).mockReturnValue({ setAuth: vi.fn(), @@ -186,6 +193,30 @@ describe("POST /api/withings/webhook", () => { expect(addWarning).toHaveBeenCalledWith( expect.stringMatching(/legacy URL query/i), ); + // Fix-K sec-M2: warning text now spells out the migration URL so + // anyone watching the access log knows what to re-subscribe to. + expect(addWarning).toHaveBeenCalledWith( + expect.stringContaining("/api/withings/webhook/[token]"), + ); + // Fix-K sec-M2: in-memory counter increments on every legacy + // ?secret=… call so the release-gate can watch usage trend to zero. + expect(getLegacyFormTotal()).toBe(1); + }); + + it("counter stays at zero when the secret arrives via the header (Fix-K sec-M2)", async () => { + __resetLegacyFormTotalForTests(); + vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ + userId: "user-h", + withingsUserId: "wu-h", + } as never); + + await POST( + jsonRequest( + { userid: "wu-h" }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + expect(getLegacyFormTotal()).toBe(0); }); it("accepts form-encoded payloads (Withings legacy delivery format)", async () => { diff --git a/src/app/api/withings/webhook/route.ts b/src/app/api/withings/webhook/route.ts index 39f4f807c..786376b7e 100644 --- a/src/app/api/withings/webhook/route.ts +++ b/src/app/api/withings/webhook/route.ts @@ -29,7 +29,40 @@ import { * reconnect for the new `user.activity` OAuth scope shipped in W5d). * * Removal target: v1.4.27 (after all live subscriptions have rotated). + * + * v1.4.25 W21 Fix-K — added an in-memory `withings.webhook.legacy_form_total` + * counter the `/api/admin/status` endpoint surfaces so the release-gate + * can read "legacy form usage trending toward zero" before the v1.4.27 + * cut. The warning text now includes the re-subscription URL so anyone + * watching the access log knows what to migrate to. */ +const MIGRATION_URL = "/api/withings/webhook/[token]" as const; + +// In-memory counter — resets on every deploy / cold-start. That is the +// intended granularity: we want to know "how many legacy-form requests +// did this process serve since it came up", not a forever-running +// cumulative metric (which would belong in Prisma). +const counters = { + legacy_form_total: 0, +}; + +/** + * Read the legacy-form counter. Exported so `/api/admin/status` can + * surface it without a backchannel. + */ +export function getLegacyFormTotal(): number { + return counters.legacy_form_total; +} + +/** + * Reset the legacy-form counter. Test-only utility; production code + * does not call this. Documented so a future smoke test can wire up + * a deterministic "counter was 0 before the call" assertion. + */ +export function __resetLegacyFormTotalForTests(): void { + counters.legacy_form_total = 0; +} + function hasValidWebhookSecret( request: NextRequest, ): Promise<{ ok: boolean; via: "header" | "query" | "none" }> { @@ -56,8 +89,9 @@ async function checkAndWarn( return NextResponse.json({ status: "unauthorized" }, { status: 401 }); } if (auth.via === "query") { + counters.legacy_form_total += 1; getEvent()?.addWarning( - "withings webhook secret received via legacy URL query — migrate to /api/withings/webhook/[token]", + `withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`, ); } return null;