mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(api): keep logout resilient and drop the over-strict batch time bound
A transient database fault during logout must still clear the session cookie; record the delete failure on the wide event instead of throwing before the cookie is cleared, so a user can never stay authenticated after logging out. Remove the batch measurement future/past instant refine: cumulative HealthKit rows carry an end-of-day endDate that is legitimately hours ahead for the current day, and a whole-array parse would 422 the entire sync. Note the workout batch's deferred Withings path must write its source directly.
This commit is contained in:
1 parent
ba8118546d
commit
7ca2657454
4 files changed
+23
-26
No files matched your search
@@ -60,7 +60,6 @@ import {
|
||||
type MergeCandidate,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
@@ -116,23 +115,8 @@ const batchEntrySchema = z.object({
|
||||
hkIdentifier: z.string().min(1).max(120),
|
||||
value: z.number().finite(),
|
||||
unit: z.string().min(1).max(60),
|
||||
// Same plausibility bound the single-entry POST applies to `measuredAt`
|
||||
// via `validateEntryInstant` (shared `entry-instant` helper): no future
|
||||
// instant beyond a 5-min clock-skew tolerance, nothing before 1900. The
|
||||
// batch keeps the ISO string (`mapAppleHealthEntry` parses it), so the
|
||||
// bound rides on a `.refine` over the parsed instant rather than a
|
||||
// transform — the wire shape is unchanged and the `stats:` overwrite
|
||||
// semantics downstream are untouched.
|
||||
startDate: z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
|
||||
message: "startDate must not be in the future or predate 1900",
|
||||
}),
|
||||
endDate: z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
|
||||
message: "endDate must not be in the future or predate 1900",
|
||||
}),
|
||||
startDate: z.iso.datetime({ offset: true }),
|
||||
endDate: z.iso.datetime({ offset: true }),
|
||||
sleepStage: z.number().int().min(0).max(20).optional(),
|
||||
// v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue`
|
||||
// codepoint for an EVENT-class category sample (irregular-rhythm,
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
* shape locked by `createBatchWorkoutSchema` in
|
||||
* `src/lib/validations/workout.ts`.
|
||||
* 2. The Withings activity sync (W17b, deferred) — server-to-server
|
||||
* ingest with `source: "WITHINGS"` and `externalId` formatted as
|
||||
* ingest with `externalId` formatted as
|
||||
* `"${withings.id}:${withings.model_id ?? "0"}:${withings.startdate}"`.
|
||||
* Withings ships no route geometry, so the nested `route` is
|
||||
* always absent for WITHINGS-sourced entries.
|
||||
* always absent for WITHINGS-sourced entries. NOTE: the request
|
||||
* schema's `source` is narrowed to the client-writable set
|
||||
* (MANUAL / APPLE_HEALTH), so this server path must write its
|
||||
* `WITHINGS`-sourced rows directly via `prisma.workout.upsert`
|
||||
* (as the WHOOP / Fitbit syncs already do), not through this route.
|
||||
*
|
||||
* Cross-cutting concerns mirror `/api/measurements/batch` so the iOS
|
||||
* sync engine can re-use the same retry / cursor plumbing:
|
||||
|
||||
@@ -91,13 +91,17 @@ describe("destroySession", () => {
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
||||
});
|
||||
|
||||
it("propagates a real delete failure instead of reporting a false logout", async () => {
|
||||
it("clears the cookie even when the row delete fails on a transient fault", async () => {
|
||||
cookieState.sessionId = "sess-live";
|
||||
vi.mocked(prisma.session.delete).mockRejectedValue(
|
||||
Object.assign(new Error("connection reset"), { code: "P1001" }) as never,
|
||||
);
|
||||
|
||||
await expect(destroySession()).rejects.toThrow(/connection reset/);
|
||||
// A non-P2025 delete failure is recorded on the wide event, not thrown,
|
||||
// so logout never leaves the client authenticated with the cookie intact.
|
||||
await expect(destroySession()).resolves.toBeUndefined();
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -181,11 +181,16 @@ export async function destroySession(): Promise<void> {
|
||||
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (sessionId) {
|
||||
// Logout is idempotent: a session row that no longer exists (P2025) is a
|
||||
// no-op success — the cookie clear below still runs. Any other failure is
|
||||
// a real delete error that must NOT be masked as a successful logout, so
|
||||
// it propagates rather than being swallowed.
|
||||
// no-op. Any other failure is a real delete error — record it on the wide
|
||||
// event rather than swallowing it silently, but never block the cookie
|
||||
// clear below: a transient DB fault must not leave the client logged in
|
||||
// with the cookie intact.
|
||||
await prisma.session.delete({ where: { id: sessionId } }).catch((err) => {
|
||||
if (!isP2025(err)) throw err;
|
||||
if (!isP2025(err)) {
|
||||
getEvent()?.addWarning(
|
||||
`destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
|
||||
Reference in new issue
Block a user