feat(api): legacy Withings webhook counter + migration URL in warning

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:28:08 +02:00
1 parent 6f3b901c3e
commit 9ef1dd6e02
4 files changed
+92 -3

No files matched your search

@@ -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);
});
});
+7
View File
@@ -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(),
},
});
});
@@ -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 () => {
+35 -1
View File
@@ -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;