mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Mint a default Idempotency-Key on every client POST and PATCH
Every apiHandler route accepts the header and the wrapped write routes dedupe on it, yet almost no client call site sent one — a replayed create could land a duplicate dose or vaccination and shift the series index and booster-due date behind it. apiPost and apiPatch now mint a per-call crypto.randomUUID() key unless the caller set its own; reads stay bare.
This commit is contained in:
1 parent
3973af69d4
commit
d75034a9f4
2 files changed
+72
-2
No files matched your search
@@ -312,6 +312,51 @@ describe("verb helpers", () => {
|
||||
expect(new Headers(bodylessInit.headers).get("Content-Type")).toBeNull();
|
||||
});
|
||||
|
||||
// Watched red: with the `withDefaultIdempotencyKey` wrap removed from
|
||||
// apiPost/apiPatch the first two tests fail with a null header — the
|
||||
// pre-fix client sent no key, so a transport-level replay of a create
|
||||
// could land a duplicate row on every unwrapped write surface.
|
||||
describe("default Idempotency-Key", () => {
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
it.each([
|
||||
["apiPost", apiPost],
|
||||
["apiPatch", apiPatch],
|
||||
] as const)("%s mints a per-call UUID key", async (_name, helper) => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ data: null, error: null }),
|
||||
);
|
||||
await helper("/api/foo", { a: 1 });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(new Headers(init.headers).get("Idempotency-Key")).toMatch(UUID_RE);
|
||||
});
|
||||
|
||||
it("a caller-supplied key wins", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ data: null, error: null }),
|
||||
);
|
||||
await apiPost(
|
||||
"/api/foo",
|
||||
{ a: 1 },
|
||||
{ headers: { "Idempotency-Key": "natural-key-1" } },
|
||||
);
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(new Headers(init.headers).get("Idempotency-Key")).toBe(
|
||||
"natural-key-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads mint no key", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ data: null, error: null }),
|
||||
);
|
||||
await apiGet("/api/foo");
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(new Headers(init.headers).get("Idempotency-Key")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("merges caller headers with the JSON Content-Type", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
|
||||
|
||||
|
||||
@@ -195,13 +195,35 @@ export function apiGet<T = unknown>(
|
||||
return apiFetch<T>(path, { ...init, method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a per-call `Idempotency-Key` unless the caller already set one.
|
||||
*
|
||||
* Every `apiHandler` route accepts the header, and the write routes
|
||||
* wrapped in `withIdempotency` dedupe on it — but almost no client call
|
||||
* site sent one, so a transport-level replay of a create (a retried
|
||||
* request, a re-sent form submission) could land a duplicate dose or
|
||||
* vaccination and shift every derived series. Minting the key here
|
||||
* protects every `apiPost` / `apiPatch` surface at once; a caller with a
|
||||
* real natural key still wins by setting the header itself.
|
||||
*/
|
||||
function withDefaultIdempotencyKey(init: RequestInit): RequestInit {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Idempotency-Key")) {
|
||||
headers.set("Idempotency-Key", crypto.randomUUID());
|
||||
}
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
/** POST with optional JSON body — resolves with the unwrapped `data`. */
|
||||
export function apiPost<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
return apiFetch<T>(path, withJsonBody("POST", body, init));
|
||||
return apiFetch<T>(
|
||||
path,
|
||||
withDefaultIdempotencyKey(withJsonBody("POST", body, init)),
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT with optional JSON body — resolves with the unwrapped `data`. */
|
||||
@@ -219,7 +241,10 @@ export function apiPatch<T = unknown>(
|
||||
body?: unknown,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
return apiFetch<T>(path, withJsonBody("PATCH", body, init));
|
||||
return apiFetch<T>(
|
||||
path,
|
||||
withDefaultIdempotencyKey(withJsonBody("PATCH", body, init)),
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE with optional JSON body — resolves with the unwrapped `data`. */
|
||||
|
||||
Reference in new issue
Block a user