mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.
The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.
Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.
The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.
Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.
The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.
The contract follows the behaviour in the same commit.
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
/**
|
|
* The manual-sync trigger body, read once for every provider.
|
|
*
|
|
* Four sync routes each carried their own copy of this:
|
|
*
|
|
* let fullSync = false;
|
|
* try {
|
|
* const raw = await request.text();
|
|
* if (raw.length > 64 * 1024) return apiError(…, 413);
|
|
* const body = JSON.parse(raw);
|
|
* fullSync = body?.fullSync === true;
|
|
* } catch {
|
|
* // no body provided -> default incremental sync
|
|
* }
|
|
*
|
|
* The comment is only true for one of the three cases that reach the catch. An
|
|
* absent body means an incremental run, and that is the documented and used
|
|
* shape. But a body that is present and unparseable, and a body that is present
|
|
* and carries `"fullSync": "true"` as a string, both landed in the same place:
|
|
* `fullSync = false`, a 200, and a response that reads to the client as a
|
|
* successful incremental sync. A caller could ask for full history, get a typo
|
|
* wrong, and be told it worked.
|
|
*
|
|
* This separates the three. Absent still means incremental. Present and
|
|
* unparseable is a 400, present and failing the schema is a 422 with the
|
|
* multi-issue envelope the rest of the surface uses. No caller that sends
|
|
* nothing, `{}`, or a well-formed `{ "fullSync": <bool> }` sees any change; the
|
|
* only requests that now fail are the ones that were already being misread.
|
|
*/
|
|
import { apiError, returnAllZodIssues } from "@/lib/api-response";
|
|
import { syncTriggerSchema } from "@/lib/validations/sync-trigger";
|
|
|
|
/** Matches the per-route cap the four copies each applied by hand. */
|
|
export const SYNC_TRIGGER_MAX_BYTES = 64 * 1024;
|
|
|
|
export type SyncTriggerBody =
|
|
{ fullSync: boolean; error?: never } | { fullSync?: never; error: Response };
|
|
|
|
/**
|
|
* Resolve `fullSync` from the request body, refusing a body that is present
|
|
* and wrong instead of silently reading it as `false`.
|
|
*/
|
|
export async function readSyncTriggerBody(
|
|
request: Request,
|
|
): Promise<SyncTriggerBody> {
|
|
let raw: string;
|
|
try {
|
|
raw = await request.text();
|
|
} catch {
|
|
return { error: apiError("Invalid request body", 400) };
|
|
}
|
|
|
|
if (raw.length > SYNC_TRIGGER_MAX_BYTES) {
|
|
return {
|
|
error: apiError(
|
|
`Request body exceeds ${SYNC_TRIGGER_MAX_BYTES} bytes`,
|
|
413,
|
|
),
|
|
};
|
|
}
|
|
|
|
// An absent body is the documented way to ask for an incremental run, and
|
|
// stays one. Whitespace-only counts as absent: some HTTP clients send a
|
|
// newline for a bodyless POST, and refusing that would be a refusal of the
|
|
// shape this branch exists to keep working.
|
|
if (raw.trim() === "") return { fullSync: false };
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return { error: apiError("Invalid JSON body", 400) };
|
|
}
|
|
|
|
const result = syncTriggerSchema.safeParse(parsed);
|
|
if (!result.success) return { error: returnAllZodIssues(result.error, 422) };
|
|
|
|
return { fullSync: result.data.fullSync === true };
|
|
}
|