Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
|
|
import type { Request, Response, NextFunction } from "express";
|
|
|
|
type Row = {
|
|
method: string;
|
|
path: string;
|
|
key: string;
|
|
request_hash: string;
|
|
status_code: number;
|
|
response_body: unknown;
|
|
};
|
|
|
|
const store = new Map<string, Row>();
|
|
|
|
jest.mock("../../src/db/postgres", () => ({
|
|
query: async (sql: string, params: unknown[] = []) => {
|
|
if (sql.startsWith("SELECT request_hash")) {
|
|
const [method, path, key] = params as [string, string, string];
|
|
const row = store.get(`${method}|${path}|${key}`);
|
|
return row ? [row] : [];
|
|
}
|
|
if (sql.startsWith("INSERT INTO idempotency_keys")) {
|
|
const [method, path, key, request_hash, status_code, body] = params as [
|
|
string, string, string, string, number, string,
|
|
];
|
|
const k = `${method}|${path}|${key}`;
|
|
if (!store.has(k)) {
|
|
store.set(k, {
|
|
method,
|
|
path,
|
|
key,
|
|
request_hash,
|
|
status_code,
|
|
response_body: JSON.parse(body),
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
return [];
|
|
},
|
|
}));
|
|
|
|
import { idempotencyMiddleware, IDEMPOTENCY_HEADER } from "../../src/middleware/idempotency";
|
|
|
|
function makeReqRes(overrides: {
|
|
header?: string;
|
|
method?: string;
|
|
baseUrl?: string;
|
|
path?: string;
|
|
body?: unknown;
|
|
}) {
|
|
const req = {
|
|
method: overrides.method ?? "POST",
|
|
baseUrl: overrides.baseUrl ?? "",
|
|
path: overrides.path ?? "/api/plans",
|
|
body: overrides.body ?? { a: 1 },
|
|
header(name: string) {
|
|
return name.toLowerCase() === IDEMPOTENCY_HEADER ? overrides.header : undefined;
|
|
},
|
|
} as unknown as Request;
|
|
const captured: { status?: number; body?: unknown; headers: Record<string, string> } = {
|
|
headers: {},
|
|
};
|
|
const res: Partial<Response> = {
|
|
statusCode: 200,
|
|
status(code: number) {
|
|
this.statusCode = code;
|
|
captured.status = code;
|
|
return this as Response;
|
|
},
|
|
json(body: unknown) {
|
|
captured.body = body;
|
|
if (captured.status === undefined) captured.status = this.statusCode;
|
|
return this as Response;
|
|
},
|
|
setHeader(name: string, value: string | number | readonly string[]) {
|
|
captured.headers[name] = String(value);
|
|
return this as Response;
|
|
},
|
|
};
|
|
return { req, res: res as Response, captured };
|
|
}
|
|
|
|
describe("Idempotency middleware", () => {
|
|
beforeEach(() => {
|
|
store.clear();
|
|
});
|
|
|
|
it("skips when no Idempotency-Key header is set", async () => {
|
|
const { req, res } = makeReqRes({});
|
|
const next = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(req, res, next);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects malformed keys with 400", async () => {
|
|
const { req, res, captured } = makeReqRes({ header: "short" });
|
|
const next = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(req, res, next);
|
|
expect(next).not.toHaveBeenCalled();
|
|
expect(captured.status).toBe(400);
|
|
expect((captured.body as { error: string }).error).toBe("idempotency_key_invalid");
|
|
});
|
|
|
|
it("caches 2xx responses on first call and replays on second", async () => {
|
|
const key = "ABC12345_test-key";
|
|
|
|
const first = makeReqRes({ header: key });
|
|
const next1 = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(first.req, first.res, next1);
|
|
expect(next1).toHaveBeenCalledTimes(1);
|
|
// Simulate handler sending JSON response
|
|
first.res.status(201);
|
|
first.res.json({ plan_id: "p-1", created: true });
|
|
|
|
// Let the fire-and-forget INSERT microtask flush
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const second = makeReqRes({ header: key });
|
|
const next2 = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(second.req, second.res, next2);
|
|
expect(next2).not.toHaveBeenCalled();
|
|
expect(second.captured.status).toBe(201);
|
|
expect(second.captured.body).toEqual({ plan_id: "p-1", created: true });
|
|
expect(second.captured.headers["Idempotent-Replayed"]).toBe("true");
|
|
});
|
|
|
|
it("rejects reuse with a different body as 422", async () => {
|
|
const key = "ABC12345_test-key";
|
|
|
|
const first = makeReqRes({ header: key, body: { a: 1 } });
|
|
const next1 = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(first.req, first.res, next1);
|
|
first.res.status(200);
|
|
first.res.json({ ok: true });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const second = makeReqRes({ header: key, body: { a: 2 } });
|
|
const next2 = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(second.req, second.res, next2);
|
|
expect(next2).not.toHaveBeenCalled();
|
|
expect(second.captured.status).toBe(422);
|
|
expect((second.captured.body as { error: string }).error).toBe("idempotency_key_reused");
|
|
});
|
|
|
|
it("does NOT cache non-2xx responses (retryable)", async () => {
|
|
const key = "ABC12345_test-key";
|
|
|
|
const first = makeReqRes({ header: key });
|
|
await idempotencyMiddleware(first.req, first.res, jest.fn() as unknown as NextFunction);
|
|
first.res.status(500);
|
|
first.res.json({ error: "boom" });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
// Retry should go through (no replay)
|
|
const second = makeReqRes({ header: key });
|
|
const next2 = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(second.req, second.res, next2);
|
|
expect(next2).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("scopes by (method, path, key)", async () => {
|
|
const key = "ABC12345_test-key";
|
|
|
|
const createPlan = makeReqRes({ header: key, path: "/api/plans" });
|
|
await idempotencyMiddleware(createPlan.req, createPlan.res, jest.fn() as unknown as NextFunction);
|
|
createPlan.res.status(201);
|
|
createPlan.res.json({ plan_id: "p-1" });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
// Same key on a different path: should pass through, not replay
|
|
const execute = makeReqRes({ header: key, path: "/api/plans/p-1/execute" });
|
|
const nextExec = jest.fn() as unknown as NextFunction;
|
|
await idempotencyMiddleware(execute.req, execute.res, nextExec);
|
|
expect(nextExec).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|