mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Long-overdue prettier sweep; prettier had drifted vs. checked-in code across ~130 files (mostly long-argument line-break wrapping in TS and table-padding in markdown). Pure formatting — no semantic changes — and required to clear the v1.4.6 release gate's `pnpm format:check`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
idempotencyKey: {
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
delete: vi.fn(),
|
|
},
|
|
apiToken: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
annotate: vi.fn(),
|
|
}));
|
|
|
|
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,
|
|
} from "../idempotency";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
import { headers } from "next/headers";
|
|
|
|
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 }),
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} 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 → handler runs → response stored.
|
|
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);
|
|
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
|
|
|
// Capture the persisted body for replay.
|
|
const persistedBody = (
|
|
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
|
data: { responseBody: string; responseStatus: number };
|
|
}
|
|
).data;
|
|
|
|
// 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",
|
|
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("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" }));
|
|
expect(getSession).toHaveBeenCalledTimes(1);
|
|
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();
|
|
});
|
|
});
|
|
|
|
// 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({
|
|
userId: "u-bearer",
|
|
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);
|
|
});
|
|
|
|
it.each([
|
|
["hlk_ access token", '{"data":{"token":"hlk_abc123"},"error":null}'],
|
|
["hlr_ refresh token", '{"data":{"refresh":"hlr_xyz789"},"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 cache 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);
|
|
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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.create).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.create).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);
|
|
});
|
|
});
|