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;