mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
lib/sharing/grants.ts held both the grant transitions and the predicates every reader asks (state, level, scope, the live grant for a pair). The transitions reach into the managed-profile lifecycle and the OAuth connect target, and both of those read a live grant, so grants, lifecycle and connect-target formed an import cycle. The predicates, findActiveGrant and touchGrantUsage move verbatim to lib/sharing/grant-access.ts, which imports only the database client and the scope vocabulary. Importers and the mocks that replaced those functions point at the new module. Runtime import cycles fall from 7 to 6.
1171 lines
43 KiB
TypeScript
1171 lines
43 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// The replay cache encrypts the stored body (it echoes decrypted PHI on the
|
|
// create paths). Give the suite a real key so these cases exercise the
|
|
// encrypted path rather than the skip-caching fallback.
|
|
vi.stubEnv("ENCRYPTION_KEYS", "");
|
|
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
|
vi.stubEnv("ENCRYPTION_KEY", "0".repeat(64));
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
idempotencyKey: {
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
delete: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
},
|
|
apiToken: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
annotate: vi.fn(),
|
|
// v1.37.0 — the record-session fence stamps the response echo on the
|
|
// request-scoped wide event. There is no event outside a request scope and
|
|
// the fence handles that with an optional call, so `undefined` is the honest
|
|
// stub rather than a fake builder.
|
|
getEvent: vi.fn(() => undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/hmac", () => ({
|
|
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
|
}));
|
|
|
|
import {
|
|
withIdempotency,
|
|
defaultUserIdResolver,
|
|
isCachableStatus,
|
|
canonicalJson,
|
|
requestFingerprint,
|
|
} from "../idempotency";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
vi.mock("@/lib/sharing/grant-access", () => ({
|
|
findActiveGrant: vi.fn(),
|
|
}));
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { headers } from "next/headers";
|
|
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
|
|
|
/** A live WRITE grant, whole record, as `delegatedGrantFacet` reads it. */
|
|
const GRANT = { id: "grant-1", access: "WRITE", scopeJson: null };
|
|
function grantFacet(grant: { id: string; access: string; scopeJson: unknown }) {
|
|
const fp = createHash("sha256")
|
|
.update(JSON.stringify(grant.scopeJson ?? null))
|
|
.digest("hex")
|
|
.slice(0, 16);
|
|
return `g:${grant.id}:${grant.access}:${fp}`;
|
|
}
|
|
|
|
function makeRequest(
|
|
method: string,
|
|
headers: Record<string, string> = {},
|
|
): NextRequest {
|
|
return new NextRequest("http://localhost/api/example", {
|
|
method,
|
|
headers: { "content-type": "application/json", ...headers },
|
|
body: method === "GET" ? undefined : JSON.stringify({ ok: true }),
|
|
});
|
|
}
|
|
|
|
/** The fingerprint `makeRequest("POST", …)` produces. */
|
|
const OK_BODY_FINGERPRINT = requestFingerprint(
|
|
"POST",
|
|
"/api/example",
|
|
new TextEncoder().encode(JSON.stringify({ ok: true })),
|
|
);
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
|
vi.mocked(findActiveGrant).mockResolvedValue(GRANT as never);
|
|
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
|
|
vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
});
|
|
|
|
describe("withIdempotency", () => {
|
|
it("passes through when no Idempotency-Key header is present", async () => {
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
const res = await wrapped(makeRequest("POST"));
|
|
expect(res.status).toBe(201);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("caches the response and replays it on the second call", async () => {
|
|
let callCount = 0;
|
|
const handler = vi.fn(async () => {
|
|
callCount += 1;
|
|
return NextResponse.json(
|
|
{ data: { result: callCount }, error: null },
|
|
{ status: 201 },
|
|
);
|
|
});
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
|
|
// First call: nothing cached → key claimed (create) → handler runs →
|
|
// claim promoted to the completed response (updateMany).
|
|
const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
|
|
const res1 = await wrapped(req1);
|
|
const body1 = await res1.json();
|
|
expect(res1.status).toBe(201);
|
|
expect(body1).toEqual({ data: { result: 1 }, error: null });
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
// Claim inserted before the handler ran (pending sentinel).
|
|
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
|
const claim = (
|
|
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
|
data: { responseStatus: number; responseBody: string };
|
|
}
|
|
).data;
|
|
expect(claim.responseStatus).toBe(0);
|
|
expect(claim.responseBody).toBe("");
|
|
// Completed response promoted via updateMany.
|
|
expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1);
|
|
|
|
// Capture the persisted body for replay.
|
|
const persistedBody = (
|
|
vi.mocked(prisma.idempotencyKey.updateMany).mock.calls[0][0] as {
|
|
data: { responseBody: string; responseStatus: number };
|
|
}
|
|
).data;
|
|
const claimedFingerprint = (
|
|
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
|
data: { requestFingerprint: string };
|
|
}
|
|
).data.requestFingerprint;
|
|
|
|
// Second call: cache hit returns persisted envelope.
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
|
|
id: "idem-1",
|
|
userId: "u-1",
|
|
key: "abc-12345678",
|
|
method: "POST",
|
|
path: "/api/example",
|
|
requestFingerprint: claimedFingerprint,
|
|
responseStatus: persistedBody.responseStatus,
|
|
responseBody: persistedBody.responseBody,
|
|
expiresAt: new Date(Date.now() + 86_400_000),
|
|
createdAt: new Date(),
|
|
} as never);
|
|
|
|
const req2 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
|
|
const res2 = await wrapped(req2);
|
|
expect(res2.headers.get("X-Idempotent-Replay")).toBe("true");
|
|
expect(res2.status).toBe(201);
|
|
const body2 = await res2.json();
|
|
expect(body2).toEqual({ data: { result: 1 }, error: null });
|
|
expect(handler).toHaveBeenCalledTimes(1); // not called again
|
|
});
|
|
|
|
it("does not cache a successful response marked no-store", async () => {
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json(
|
|
{ data: { failed: 1 }, error: null },
|
|
{ headers: { "Cache-Control": "private, no-store" } },
|
|
),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
|
|
const response = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores expired cache rows and re-runs the handler", async () => {
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "fresh", error: null }, { status: 200 }),
|
|
);
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
|
|
id: "idem-old",
|
|
userId: "u-1",
|
|
key: "abc-12345678",
|
|
method: "POST",
|
|
path: "/api/example",
|
|
responseStatus: 200,
|
|
responseBody: '{"data":"stale","error":null}',
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
createdAt: new Date(),
|
|
} as never);
|
|
vi.mocked(prisma.idempotencyKey.delete).mockResolvedValue({} as never);
|
|
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
const body = await res.json();
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(body).toEqual({ data: "fresh", error: null });
|
|
expect(prisma.idempotencyKey.delete).toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores malformed Idempotency-Key headers", async () => {
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
await wrapped(makeRequest("POST", { "idempotency-key": "!!" }));
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing for GET requests", async () => {
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" }));
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("defaults to the cookie session when no resolver is given", async () => {
|
|
vi.mocked(getSession).mockResolvedValue({
|
|
session: { id: "s-1", expiresAt: new Date(Date.now() + 60_000) },
|
|
user: { id: "u-default" },
|
|
} as never);
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler);
|
|
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
|
// Twice: once to resolve who is calling, once to read which record they
|
|
// say they are acting on. The second read is what a delegated write costs
|
|
// here, and it is a single indexed lookup on a request that is about to
|
|
// write anyway.
|
|
expect(getSession).toHaveBeenCalledTimes(2);
|
|
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
|
const persisted = vi.mocked(prisma.idempotencyKey.create).mock
|
|
.calls[0][0] as { data: { userId: string } };
|
|
expect(persisted.data.userId).toBe("u-default");
|
|
});
|
|
|
|
it("skips caching when the default resolver finds no session", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
vi.mocked(headers).mockResolvedValue({
|
|
get: vi.fn().mockReturnValue(null),
|
|
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
|
|
? T
|
|
: never);
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler);
|
|
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// A3 — concurrent same-key requests must not both run the side-effect.
|
|
// The key is claimed (pending row) before the handler runs; a racing
|
|
// request either sees the pending row or loses the insert race, and
|
|
// must be refused with 409 instead of executing a second time.
|
|
describe("withIdempotency concurrency claim (A3)", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
|
|
vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
});
|
|
|
|
it("returns 409 without running the handler when a pending claim exists", async () => {
|
|
// A concurrent request already claimed the key — findUnique returns
|
|
// the pending sentinel row (responseStatus 0, not yet expired).
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
|
|
id: "idem-pending",
|
|
userId: "u-1",
|
|
key: "abc-12345678",
|
|
method: "POST",
|
|
path: "/api/example",
|
|
requestFingerprint: OK_BODY_FINGERPRINT,
|
|
responseStatus: 0,
|
|
responseBody: "",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
createdAt: new Date(),
|
|
} as never);
|
|
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 409 when a racing request wins the claim insert (P2002)", async () => {
|
|
// No row at lookup time, but the claim insert collides with a
|
|
// concurrent insert under the unique constraint.
|
|
vi.mocked(prisma.idempotencyKey.create).mockRejectedValueOnce(
|
|
Object.assign(new Error("unique"), { code: "P2002" }),
|
|
);
|
|
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("releases the claim and re-throws when the handler throws", async () => {
|
|
const boom = new Error("handler exploded");
|
|
const handler = vi.fn(async () => {
|
|
throw boom;
|
|
});
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
|
|
await expect(
|
|
wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })),
|
|
).rejects.toBe(boom);
|
|
|
|
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
|
// Claim released so a retry isn't locked out for the pending window.
|
|
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// Audit C-4 / phase P2: defaultUserIdResolver must support both cookie
|
|
// sessions AND Bearer tokens. Without the Bearer fallback, idempotency
|
|
// silently turned off for the iOS / external-ingest paths it was built for.
|
|
describe("defaultUserIdResolver (audit C-4)", () => {
|
|
function mockHeader(value: string | null) {
|
|
vi.mocked(headers).mockResolvedValue({
|
|
get: vi
|
|
.fn()
|
|
.mockImplementation((name: string) =>
|
|
name.toLowerCase() === "authorization" ? value : null,
|
|
),
|
|
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
|
|
? T
|
|
: never);
|
|
}
|
|
|
|
it("returns the session user id when a cookie session is present", async () => {
|
|
vi.mocked(getSession).mockResolvedValue({
|
|
session: { id: "s-1" },
|
|
user: { id: "u-cookie", role: "USER" },
|
|
} as Awaited<ReturnType<typeof getSession>>);
|
|
mockHeader(null);
|
|
expect(await defaultUserIdResolver()).toBe("u-cookie");
|
|
});
|
|
|
|
it("falls back to Bearer-token resolution when no cookie session", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
mockHeader("Bearer hlk_abcdef");
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "tok-device",
|
|
userId: "u-bearer",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
expect(await defaultUserIdResolver()).toBe("u-bearer");
|
|
// V3 audit: assert the where-clause used the hashed token, not the
|
|
// raw bearer. The hashToken mock returns "hashed:<raw>" — the lookup
|
|
// MUST be against that, otherwise we are storing recoverable secrets.
|
|
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { tokenHash: "hashed:hlk_abcdef" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects revoked Bearer tokens", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
mockHeader("Bearer hlk_abcdef");
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
userId: "u-bearer",
|
|
revoked: true,
|
|
expiresAt: null,
|
|
} as never);
|
|
expect(await defaultUserIdResolver()).toBeNull();
|
|
});
|
|
|
|
it("rejects expired Bearer tokens", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
mockHeader("Bearer hlk_abcdef");
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
userId: "u-bearer",
|
|
revoked: false,
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
} as never);
|
|
expect(await defaultUserIdResolver()).toBeNull();
|
|
});
|
|
|
|
it("returns null when no auth method is provided", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
mockHeader(null);
|
|
expect(await defaultUserIdResolver()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// P12: bodies that contain a freshly-issued bearer token, refresh token,
|
|
// or third-party AI provider key must NEVER be persisted to the
|
|
// idempotency cache. Even if a future caller forgets and wraps an
|
|
// auth/settings route in withIdempotency, the body-content guard refuses
|
|
// to write the secret into the DB.
|
|
describe("withIdempotency body-content exclusion (P12)", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
|
|
vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
|
|
count: 1,
|
|
} as never);
|
|
});
|
|
|
|
it.each([
|
|
["hlk_ access token", '{"data":{"token":"hlk_abc123"},"error":null}'],
|
|
["hlr_ refresh token", '{"data":{"refresh":"hlr_xyz789"},"error":null}'],
|
|
["hls_ share-link token", '{"data":{"link":"hls_def456"},"error":null}'],
|
|
[
|
|
"hlv_ registration invite token",
|
|
'{"data":{"token":"hlv_0a1b2c3d"},"error":null}',
|
|
],
|
|
["sk- OpenAI key", '{"data":{"echoed":"sk-1234567890"},"error":null}'],
|
|
[
|
|
"sk-ant- Anthropic key",
|
|
'{"data":{"echoed":"sk-ant-api03-xyz"},"error":null}',
|
|
],
|
|
])("does NOT persist responses containing %s", async (_label, body) => {
|
|
const handler = vi.fn(async () => new NextResponse(body, { status: 201 }));
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
// The claim row is still inserted, but the secret-shaped body must
|
|
// never be promoted into it — the claim is released instead.
|
|
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
["task-id substring", '{"error":"task-id must not contain spaces"}'],
|
|
["risk-management word", '{"data":{"note":"risk-management"}}'],
|
|
["disk-io metric", '{"data":{"metric":"disk-io"}}'],
|
|
])(
|
|
"still caches a benign payload whose text contains %s (no false positive)",
|
|
async (_label, body) => {
|
|
const handler = vi.fn(
|
|
async () => new NextResponse(body, { status: 422 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(
|
|
handler,
|
|
async () => "u-1",
|
|
);
|
|
await wrapped(makeRequest("POST", { "idempotency-key": "key-12345678" }));
|
|
expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it("still caches a normal payload that does not carry a secret", async () => {
|
|
const handler = vi.fn(
|
|
async () =>
|
|
new NextResponse('{"data":{"id":"m-1"},"error":null}', { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
|
expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// V3 audit STILL-V2-NEW: the cachable-status filter (do-not-cache for
|
|
// 401/403/408/429/5xx) had zero tests, so a regression that re-cached an
|
|
// expired bearer token's 401 would have been silent.
|
|
describe("isCachableStatus do-not-cache rules (V3 audit)", () => {
|
|
it("caches 2xx success responses", () => {
|
|
expect(isCachableStatus(200)).toBe(true);
|
|
expect(isCachableStatus(201)).toBe(true);
|
|
expect(isCachableStatus(204)).toBe(true);
|
|
});
|
|
|
|
it("caches 4xx validation responses (so retries don't re-execute side-effects)", () => {
|
|
expect(isCachableStatus(400)).toBe(true);
|
|
expect(isCachableStatus(404)).toBe(true);
|
|
expect(isCachableStatus(409)).toBe(true);
|
|
expect(isCachableStatus(422)).toBe(true);
|
|
});
|
|
|
|
it("does NOT cache 401 — the token may have been refreshed between attempts", () => {
|
|
expect(isCachableStatus(401)).toBe(false);
|
|
});
|
|
|
|
it("does NOT cache 403 — authorization can change between attempts", () => {
|
|
expect(isCachableStatus(403)).toBe(false);
|
|
});
|
|
|
|
it("does NOT cache 408 — caller-side timeout deserves a fresh attempt", () => {
|
|
expect(isCachableStatus(408)).toBe(false);
|
|
});
|
|
|
|
it("does NOT cache 429 — caller deserves a fresh window-check on retry", () => {
|
|
expect(isCachableStatus(429)).toBe(false);
|
|
});
|
|
|
|
it("does NOT cache any 5xx — server fault must not lock the user out", () => {
|
|
expect(isCachableStatus(500)).toBe(false);
|
|
expect(isCachableStatus(502)).toBe(false);
|
|
expect(isCachableStatus(503)).toBe(false);
|
|
expect(isCachableStatus(504)).toBe(false);
|
|
});
|
|
|
|
it("never stores the response body in cleartext", async () => {
|
|
// The create paths echo their own decrypted DTO, so the replay cache held
|
|
// cycle notes, mood text and allergy reactions in the clear for 24 hours -
|
|
// in a column that lands in every backup. The secret-shaped-body guard did
|
|
// not catch it because health data is not secret-SHAPED.
|
|
const PHI = "felt low on Tuesday, cramps returned";
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: { note: PHI }, error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
|
|
await wrapped(makeRequest("POST", { "idempotency-key": "phi-12345678" }));
|
|
|
|
const stored = (
|
|
vi.mocked(prisma.idempotencyKey.updateMany).mock.calls[0][0] as {
|
|
data: { responseBody: string };
|
|
}
|
|
).data.responseBody;
|
|
|
|
expect(stored).not.toContain(PHI);
|
|
expect(stored).not.toContain("cramps");
|
|
expect(stored.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The cell is scoped to the record the request claims, not only to the caller.
|
|
*
|
|
* `tests/integration/idempotency-record-scoped-cell.test.ts` proves the rows.
|
|
* These prove the key the wrapper composes, which is what decides whether two
|
|
* requests meet in one cell — including the two cases a row-level test cannot
|
|
* show, because both end in no row at all.
|
|
*/
|
|
describe("withIdempotency — record-scoped cells", () => {
|
|
const CLIENT_KEY = "abc-12345678";
|
|
|
|
/** Present these headers to the wrapper for the next call. */
|
|
function present(values: Record<string, string>): void {
|
|
vi.mocked(headers).mockResolvedValue({
|
|
get: (name: string) => values[name.toLowerCase()] ?? null,
|
|
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
|
|
? T
|
|
: never);
|
|
}
|
|
|
|
/** Sign the caller in over the cookie transport, optionally switched. */
|
|
function cookieSession(actingAsUserId: string | null): void {
|
|
vi.mocked(getSession).mockResolvedValue({
|
|
session: {
|
|
id: "s-1",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
actingAsUserId,
|
|
},
|
|
user: { id: "u-1", role: "USER" },
|
|
} as Awaited<ReturnType<typeof getSession>>);
|
|
}
|
|
|
|
async function run(key = CLIENT_KEY): Promise<void> {
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
await wrapped(makeRequest("POST", { "idempotency-key": key }));
|
|
}
|
|
|
|
/** The key the wrapper looked the cell up under. */
|
|
function lookedUpKey(): string {
|
|
const call = vi.mocked(prisma.idempotencyKey.findUnique).mock
|
|
.calls[0][0] as {
|
|
where: { userId_key_method_path: { userId: string; key: string } };
|
|
};
|
|
return call.where.userId_key_method_path.key;
|
|
}
|
|
|
|
/** The row the wrapper claimed. */
|
|
function claimed(): { userId: string; key: string } {
|
|
return (
|
|
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
|
data: { userId: string; key: string };
|
|
}
|
|
).data;
|
|
}
|
|
|
|
it("folds the account named by the Bearer selector into the key", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
present({ "x-healthlog-account": "owner-9" });
|
|
|
|
await run();
|
|
|
|
expect(lookedUpKey()).toBe(`owner-9|${grantFacet(GRANT)}|${CLIENT_KEY}`);
|
|
// The owner column stays the ACTOR. It is a foreign key to `users` with a
|
|
// cascade behind it, so it can only ever hold a real account id — which is
|
|
// why the record moved into the key and not into here.
|
|
expect(claimed()).toMatchObject({
|
|
userId: "u-1",
|
|
key: `owner-9|${grantFacet(GRANT)}|${CLIENT_KEY}`,
|
|
});
|
|
});
|
|
|
|
it("folds the account the cookie session is switched to", async () => {
|
|
cookieSession("owner-7");
|
|
present({});
|
|
|
|
await run();
|
|
|
|
expect(claimed().key).toBe(`owner-7|${grantFacet(GRANT)}|${CLIENT_KEY}`);
|
|
});
|
|
|
|
it("files a delegated request under the grant it acts through, so a replaced grant is a fresh cell", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
present({ "x-healthlog-account": "owner-9" });
|
|
await run();
|
|
const underFirstGrant = claimed().key;
|
|
|
|
vi.clearAllMocks();
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
|
|
vi.mocked(findActiveGrant).mockResolvedValue({
|
|
...GRANT,
|
|
id: "grant-2",
|
|
access: "READ",
|
|
} as never);
|
|
present({ "x-healthlog-account": "owner-9" });
|
|
await run();
|
|
|
|
expect(claimed().key).not.toBe(underFirstGrant);
|
|
expect(claimed().key).toBe(
|
|
`owner-9|${grantFacet({ ...GRANT, id: "grant-2", access: "READ" })}|${CLIENT_KEY}`,
|
|
);
|
|
});
|
|
|
|
it("files a scope edited in place under a fresh cell too", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
vi.mocked(findActiveGrant).mockResolvedValue({
|
|
...GRANT,
|
|
scopeJson: { areas: ["vitals"] },
|
|
} as never);
|
|
present({ "x-healthlog-account": "owner-9" });
|
|
await run();
|
|
expect(claimed().key).not.toBe(
|
|
`owner-9|${grantFacet(GRANT)}|${CLIENT_KEY}`,
|
|
);
|
|
});
|
|
|
|
it("neither reads nor claims a cell when no live grant exists", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
vi.mocked(findActiveGrant).mockResolvedValue(null);
|
|
present({ "x-healthlog-account": "owner-9" });
|
|
await run();
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keys a narrow-scoped token's own-record cell by the token, and a wildcard token's byte-for-byte", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "tok-narrow",
|
|
userId: "u-1",
|
|
permissions: ["measurements:write"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
present({ authorization: `Bearer hlk_${"b".repeat(64)}` });
|
|
await run();
|
|
expect(claimed().key).toBe(`t:tok-narrow|${CLIENT_KEY}`);
|
|
|
|
vi.clearAllMocks();
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "tok-wild",
|
|
userId: "u-1",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
present({ authorization: `Bearer hlk_${"c".repeat(64)}` });
|
|
await run();
|
|
expect(claimed().key).toBe(CLIENT_KEY);
|
|
});
|
|
|
|
it("keys a request with no acting account byte-for-byte as the client sent it", async () => {
|
|
cookieSession(null);
|
|
present({});
|
|
|
|
await run();
|
|
|
|
expect(lookedUpKey()).toBe(CLIENT_KEY);
|
|
expect(claimed()).toMatchObject({ userId: "u-1", key: CLIENT_KEY });
|
|
});
|
|
|
|
it("ignores a selector sent over the cookie transport", async () => {
|
|
// The resolver refuses that request outright (`misplaced_selector`). The
|
|
// cell must not pretend it named a record either — a claim the request was
|
|
// never going to be served must not move where its answer is filed.
|
|
cookieSession(null);
|
|
present({ "x-healthlog-account": "owner-9" });
|
|
|
|
await run();
|
|
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a client key that carries the separator", async () => {
|
|
// The one way a caller could aim at somebody else's cell: send
|
|
// `owner-9|abc-12345678` as the key from their own un-switched session. The
|
|
// key validator does not admit the byte, so the header is malformed and the
|
|
// wrapper does not cache at all.
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
present({});
|
|
|
|
await run(`owner-9|${CLIENT_KEY}`);
|
|
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips the cache when the claimed account names nothing that could exist", async () => {
|
|
// Longer than any account id. The request is refused downstream; caching it
|
|
// would let a caller write an arbitrarily long key into the table on the
|
|
// way to a 403.
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
present({ "x-healthlog-account": "x".repeat(65) });
|
|
|
|
await run();
|
|
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.37.0 — the record-session fence, above the replay cache.
|
|
*
|
|
* The property is a PLACEMENT, and a status-code assertion cannot see it: a
|
|
* wrapper that read the cell, replayed nothing, released its claim and then
|
|
* returned 409 would look identical from outside. `findCached` and `claimKey`
|
|
* are module-private, so the instrument is one layer lower and falsifiable: the
|
|
* two Prisma calls they make must receive zero calls for a refused request and
|
|
* non-zero for a passing one.
|
|
*
|
|
* The first draft of this plan proposed asserting "no row exists for the target
|
|
* cell" instead. That assertion cannot fail: `releaseClaim` deletes the pending
|
|
* row whenever the handler throws, so it would pass with the fence placed after
|
|
* `claimKey` too.
|
|
*
|
|
* Break it by moving the fence call below `findCached` in `withIdempotency`:
|
|
* the `findUnique` zero-call assertion fails.
|
|
*/
|
|
describe("the record-session fence sits above the replay cache", () => {
|
|
function fencedSession(recordEpoch: number, actingAsUserId: string | null) {
|
|
vi.mocked(getSession).mockResolvedValue({
|
|
session: {
|
|
id: "s-1",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
actingAsUserId,
|
|
recordEpoch,
|
|
},
|
|
user: { id: "u-1" },
|
|
} as never);
|
|
}
|
|
|
|
function asserts(epoch: string | null, scope: string | null) {
|
|
vi.mocked(headers).mockResolvedValue({
|
|
get: vi.fn((name: string) => {
|
|
if (name.toLowerCase() === "x-healthlog-record-epoch") return epoch;
|
|
if (name.toLowerCase() === "x-healthlog-record-scope") return scope;
|
|
return null;
|
|
}),
|
|
} as never);
|
|
}
|
|
|
|
it("refuses a stale claim without reading or claiming a cell", async () => {
|
|
fencedSession(4, "owner-1");
|
|
asserts("3", "owner-1");
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler);
|
|
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(res.status).toBe(409);
|
|
expect((await res.json()).meta.errorCode).toBe("sharing.session.changed");
|
|
// The placement, stated as the only thing that can prove it.
|
|
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
// And the side effect the whole wrapper exists to run once never ran.
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("POSITIVE CONTROL: a matching claim reads the cell and claims it", async () => {
|
|
// Without this the assertion above would pass for a wrapper that never
|
|
// touched the cache at all.
|
|
fencedSession(4, "owner-1");
|
|
asserts("4", "owner-1");
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler);
|
|
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("lets an unfenced client through to the route's own refusal", async () => {
|
|
// `unfenced-client` is deliberately NOT refused here: the route owns the
|
|
// 403 a pre-fence bundle recovers from, together with its annotation and
|
|
// audit posture. The wrapper still files the cell, because the handler is
|
|
// about to answer.
|
|
fencedSession(4, "owner-1");
|
|
asserts(null, null);
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json(
|
|
{ data: null, error: "Account access denied" },
|
|
{ status: 403 },
|
|
),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler);
|
|
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves a never-switched session byte-identical", async () => {
|
|
fencedSession(0, null);
|
|
asserts(null, null);
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler);
|
|
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves the Bearer transport byte-identical", async () => {
|
|
// No session row at all, so no context to be stale about — and a fence
|
|
// header on this transport is neither read nor honoured.
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
|
id: "tok-device",
|
|
userId: "u-bearer",
|
|
permissions: ["*"],
|
|
revoked: false,
|
|
expiresAt: null,
|
|
} as never);
|
|
vi.mocked(headers).mockResolvedValue({
|
|
get: vi.fn((name: string) => {
|
|
if (name.toLowerCase() === "authorization")
|
|
return `Bearer hlk_${"a".repeat(64)}`;
|
|
if (name.toLowerCase() === "x-healthlog-record-epoch") return "1";
|
|
if (name.toLowerCase() === "x-healthlog-record-scope") return "owner-1";
|
|
return null;
|
|
}),
|
|
} as never);
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler);
|
|
|
|
const res = await wrapped(
|
|
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("request fingerprint", () => {
|
|
const enc = (text: string) => new TextEncoder().encode(text);
|
|
|
|
it("sorts object keys recursively and keeps array order", () => {
|
|
expect(canonicalJson({ b: 1, a: { d: [3, 1], c: null } })).toBe(
|
|
'{"a":{"c":null,"d":[3,1]},"b":1}',
|
|
);
|
|
expect(canonicalJson([{ y: 1, x: 2 }, "s"])).toBe('[{"x":2,"y":1},"s"]');
|
|
});
|
|
|
|
it("treats the same JSON object in a different key order as one request", () => {
|
|
const a = requestFingerprint("POST", "/p", enc('{"type":"X","value":1}'));
|
|
const b = requestFingerprint(
|
|
"POST",
|
|
"/p",
|
|
enc('{ "value": 1, "type": "X" }'),
|
|
);
|
|
expect(a).toBe(b);
|
|
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it("tells nested values and array order apart", () => {
|
|
const base = requestFingerprint("POST", "/p", enc('{"a":{"b":[1,2]}}'));
|
|
expect(requestFingerprint("POST", "/p", enc('{"a":{"b":[2,1]}}'))).not.toBe(
|
|
base,
|
|
);
|
|
expect(requestFingerprint("POST", "/p", enc('{"a":{"b":[1,3]}}'))).not.toBe(
|
|
base,
|
|
);
|
|
});
|
|
|
|
it("tells the two halves of a blood pressure apart", () => {
|
|
const sys = requestFingerprint(
|
|
"POST",
|
|
"/api/measurements",
|
|
enc('{"type":"BLOOD_PRESSURE_SYS","value":120}'),
|
|
);
|
|
const dia = requestFingerprint(
|
|
"POST",
|
|
"/api/measurements",
|
|
enc('{"type":"BLOOD_PRESSURE_DIA","value":80}'),
|
|
);
|
|
expect(sys).not.toBe(dia);
|
|
});
|
|
|
|
it("reads a rebuilt multipart form with a fresh boundary as the same upload", () => {
|
|
const form = (boundary: string, name: string) =>
|
|
enc(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4 body\r\n--${boundary}--\r\n`,
|
|
);
|
|
const type = (boundary: string) =>
|
|
`multipart/form-data; boundary=${boundary}`;
|
|
const first = requestFingerprint(
|
|
"POST",
|
|
"/api/documents",
|
|
form("----formdata-a1", "brief.pdf"),
|
|
null,
|
|
type("----formdata-a1"),
|
|
);
|
|
const retry = requestFingerprint(
|
|
"POST",
|
|
"/api/documents",
|
|
form("----formdata-b2", "brief.pdf"),
|
|
null,
|
|
type("----formdata-b2"),
|
|
);
|
|
expect(retry).toBe(first);
|
|
const other = requestFingerprint(
|
|
"POST",
|
|
"/api/documents",
|
|
form("----formdata-b2", "other.pdf"),
|
|
null,
|
|
type("----formdata-b2"),
|
|
);
|
|
expect(other).not.toBe(first);
|
|
});
|
|
|
|
it("hashes a non-JSON or empty body as its raw bytes", () => {
|
|
const text = requestFingerprint("POST", "/p", enc("not json"));
|
|
expect(text).toBe(requestFingerprint("POST", "/p", enc("not json")));
|
|
expect(text).not.toBe(requestFingerprint("POST", "/p", enc("not json!")));
|
|
const empty = requestFingerprint("DELETE", "/p", new Uint8Array());
|
|
expect(empty).toBe(requestFingerprint("DELETE", "/p", new Uint8Array()));
|
|
expect(empty).not.toBe(text);
|
|
// Invalid UTF-8 is raw bytes, not an error.
|
|
expect(() =>
|
|
requestFingerprint("POST", "/p", new Uint8Array([0xff, 0xfe])),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("is scoped by method and path", () => {
|
|
const body = enc('{"a":1}');
|
|
const base = requestFingerprint("POST", "/p", body);
|
|
expect(requestFingerprint("PUT", "/p", body)).not.toBe(base);
|
|
expect(requestFingerprint("POST", "/q", body)).not.toBe(base);
|
|
});
|
|
});
|
|
|
|
describe("withIdempotency with a different body under one key", () => {
|
|
function postWith(body: unknown): NextRequest {
|
|
return new NextRequest("http://localhost/api/example", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"idempotency-key": "abc-12345678",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function liveRow(fingerprint: string | null, responseStatus: number) {
|
|
return {
|
|
id: "idem-1",
|
|
userId: "u-1",
|
|
key: "abc-12345678",
|
|
method: "POST",
|
|
path: "/api/example",
|
|
requestFingerprint: fingerprint,
|
|
responseStatus,
|
|
responseBody: "",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
createdAt: new Date(),
|
|
} as never;
|
|
}
|
|
|
|
it.each([
|
|
["a completed row for another body", "f".repeat(64), 201],
|
|
["an in-flight claim for another body", "f".repeat(64), 0],
|
|
["a legacy row without a fingerprint", null, 201],
|
|
])(
|
|
"runs the handler without claiming or caching over %s",
|
|
async (_label, fingerprint, status) => {
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce(
|
|
liveRow(fingerprint, status),
|
|
);
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "dia", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(
|
|
handler,
|
|
async () => "u-1",
|
|
);
|
|
const res = await wrapped(postWith({ type: "BLOOD_PRESSURE_DIA" }));
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(res.headers.get("X-Idempotent-Replay")).toBeNull();
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.deleteMany).not.toHaveBeenCalled();
|
|
expect(prisma.idempotencyKey.delete).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("runs the handler when a racing claim for another body wins the insert", async () => {
|
|
vi.mocked(prisma.idempotencyKey.create).mockRejectedValueOnce(
|
|
Object.assign(new Error("unique"), { code: "P2002" }),
|
|
);
|
|
// First lookup: nothing yet. Second, after the collision: the other body.
|
|
vi.mocked(prisma.idempotencyKey.findUnique)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(liveRow("f".repeat(64), 0));
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "dia", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
const res = await wrapped(postWith({ type: "BLOOD_PRESSURE_DIA" }));
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stores the fingerprint, never the body, on the claim", async () => {
|
|
const handler = vi.fn(async () =>
|
|
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
await wrapped(postWith({ type: "BLOOD_PRESSURE_SYS", value: 123 }));
|
|
|
|
const claim = (
|
|
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
|
data: Record<string, unknown>;
|
|
}
|
|
).data;
|
|
expect(claim.requestFingerprint).toBe(
|
|
requestFingerprint(
|
|
"POST",
|
|
"/api/example",
|
|
new TextEncoder().encode(
|
|
JSON.stringify({ type: "BLOOD_PRESSURE_SYS", value: 123 }),
|
|
),
|
|
),
|
|
);
|
|
expect(JSON.stringify(claim)).not.toContain("BLOOD_PRESSURE_SYS");
|
|
});
|
|
|
|
it("leaves the body readable for the handler", async () => {
|
|
const handler = vi.fn(async (req: NextRequest) =>
|
|
NextResponse.json({ data: await req.json(), error: null }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
const res = await wrapped(postWith({ type: "WEIGHT", value: 70 }));
|
|
expect((await res.json()).data).toEqual({ type: "WEIGHT", value: 70 });
|
|
});
|
|
|
|
it("reads at most a bounded prefix of a large body and still hands the whole body on", async () => {
|
|
const big = "x".repeat(3 * 1024 * 1024);
|
|
const handler = vi.fn(async (req: NextRequest) =>
|
|
NextResponse.json({ data: (await req.text()).length, error: null }),
|
|
);
|
|
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
|
const res = await wrapped(
|
|
new NextRequest("http://localhost/api/example", {
|
|
method: "POST",
|
|
headers: { "idempotency-key": "abc-12345678" },
|
|
body: big,
|
|
}),
|
|
);
|
|
expect((await res.json()).data).toBe(big.length);
|
|
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|