/** * TASK-0095 R1 — the idempotency middleware (the S12 gate's named unit test: * "the key hit/miss/expiry, the response storage, the GET exclusion, the * no-header no-op — the 6-case matrix"). * * A fake Redis (in-memory Map + a fake clock for the 24 h TTL) + a minimal * ExecutionContext/CallHandler (the interceptor never touches a real Nest * app — AGENTS rule 25, deterministic). */ import { describe, expect, it } from "vitest"; import { of, throwError } from "rxjs"; import { IdempotencyInterceptor, IDEM_TTL_SECONDS } from "./idempotency.js"; // --- fakes ------------------------------------------------------------------- /** A fake Redis with TTL semantics (the interceptor only uses GET + SET EX). */ class FakeRedis { private store = new Map(); private nowMs = 0; failLookups = false; failStores = false; getRedis() { return { get: async (k: string) => { if (this.failLookups) throw new Error("redis down (lookup)"); const e = this.store.get(k); if (!e) return null; if (e.expiresAt !== Infinity && this.nowMs > e.expiresAt) { this.store.delete(k); return null; // expired } return e.value; }, set: async (k: string, v: string, mode: string, ttl: number, nx?: string) => { if (this.failStores) throw new Error("redis down (store)"); if (mode !== "EX") throw new Error(`unexpected SET mode: ${mode}`); // SET … EX ttl NX — the single-flight lock (acquire only if absent). if (nx === "NX") { const existing = this.store.get(k); if (existing && (existing.expiresAt === Infinity || this.nowMs <= existing.expiresAt)) { return null; // lock held } } this.store.set(k, { value: v, expiresAt: this.nowMs + ttl * 1000 }); return "OK"; }, }; } advance(ms: number): void { this.nowMs += ms; } /** A stored record's decoded body (test seam). */ storedBody(k: string): string | null { const e = this.store.get(k); if (!e) return null; return (JSON.parse(e.value) as { body: string }).body; } storedTtlSeconds(k: string): number | null { const e = this.store.get(k); if (!e) return null; return Math.round((e.expiresAt - this.nowMs) / 1000); } } const DAY_MS = 24 * 60 * 60 * 1000; function makeCtx( over: { method?: string; path?: string; body?: unknown; auth?: { kind?: string; userId?: string; apiKeyId?: string; workspaceId?: string } | undefined; headers?: Record; statusCode?: number; } = {}, ) { const req = { method: over.method ?? "POST", path: over.path ?? "/workspaces/ws1/pages", body: over.body, auth: over.auth ?? { kind: "user", userId: "u1" }, headers: over.headers ?? {}, }; const headers: Record = {}; const res = { statusCode: over.statusCode ?? 200, setHeader: (k: string, v: string) => { headers[k] = v; }, }; const context: any = { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }), }; return { context, req, res, headers }; } /** A CallHandler: `next.handle()` returns an Observable (the Nest contract) * that emits `handler()` or errors with `error` on subscription. */ function callHandler(handler: () => unknown, error?: unknown): any { return { handle: () => (error ? throwError(() => error) : of(handler())), }; } function run(interceptor: IdempotencyInterceptor, ctx: any, handler: any): Promise { return new Promise((resolve, reject) => { interceptor.intercept(ctx, handler).subscribe({ next: resolve, error: reject }); }); } function makeInterceptor(redis: FakeRedis) { return new IdempotencyInterceptor({ getRedis: () => redis.getRedis() } as never); } const KEY = "key_0123456789abcdef0123456789abcdef"; // --- the 6-case matrix --------------------------------------------------------- describe("the idempotency middleware — 6-case matrix", () => { it("1. a SEEN key (HIT) → the stored body is replayed, the handler is NOT re-run", async () => { const redis = new FakeRedis(); // Seed a prior execution: the stored body under this user's key. await redis .getRedis() .set( `idem:u1:${KEY}`, JSON.stringify({ body: JSON.stringify({ id: "page_1", title: "Original" }) }), "EX", IDEM_TTL_SECONDS, ); const interceptor = makeInterceptor(redis); let handlerRan = false; const { context, headers } = makeCtx({ headers: { "idempotency-key": KEY } }); const out = await run( interceptor, context, callHandler(() => { handlerRan = true; return { id: "page_1", title: "SHOULD NOT HAPPEN" }; }), ); expect(out).toEqual({ id: "page_1", title: "Original" }); // the stored body expect(handlerRan).toBe(false); // the handler was skipped // TASK-0127: the replayed response is marked (docs/api/authentication.md §9). expect(headers["Idempotency-Replayed"]).toBe("true"); }); it("2. a FRESH key (MISS) → the handler runs, a 2xx body is stored", async () => { const redis = new FakeRedis(); const interceptor = makeInterceptor(redis); const { context } = makeCtx({ headers: { "idempotency-key": KEY } }); const out = await run( interceptor, context, callHandler(() => ({ id: "rec_1", title: "New" })), ); expect(out).toEqual({ id: "rec_1", title: "New" }); expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(JSON.stringify({ id: "rec_1", title: "New" })); expect(redis.storedTtlSeconds(`idem:u1:${KEY}`)).toBe(IDEM_TTL_SECONDS); }); it("3. expiry — a key past its 24 h TTL reads as MISS (Redis enforces the TTL)", async () => { const redis = new FakeRedis(); // Seed + let the TTL lapse. await redis .getRedis() .set( `idem:u1:${KEY}`, JSON.stringify({ body: JSON.stringify({ id: "page_1" }) }), "EX", IDEM_TTL_SECONDS, ); redis.advance(DAY_MS + 1); // 24 h + 1 ms → expired const interceptor = makeInterceptor(redis); let handlerRan = false; const { context } = makeCtx({ headers: { "idempotency-key": KEY } }); const out = await run( interceptor, context, callHandler(() => { handlerRan = true; return { id: "page_2" }; // the client re-executes after the TTL }), ); expect(handlerRan).toBe(true); // expired → miss → execute expect(out).toEqual({ id: "page_2" }); }); it("4. response storage — only a 2xx body is stored (a 4xx/5xx is rejected, not stored)", async () => { const redis = new FakeRedis(); const interceptor = makeInterceptor(redis); // A 409 result (the handler returned a conflict — NOT a throw): not stored. const { context: c409 } = makeCtx({ headers: { "idempotency-key": KEY }, statusCode: 409 }); await run( interceptor, c409, callHandler(() => ({ error: "conflict" })), ); expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null); // nothing stored // A thrown error (the normal 4xx/5xx path): tap is not called → not stored. const { context: cErr } = makeCtx({ headers: { "idempotency-key": KEY } }); await expect( run( interceptor, cErr, callHandler(() => undefined, new Error("boom")), ), ).rejects.toThrow("boom"); expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null); }); it("5. GET exclusion — a GET (even with a key) is a no-op (no Redis access, handler runs)", async () => { const redis = new FakeRedis(); const lookups = 0; const origGet = redis.getRedis().get; const interceptor = makeInterceptor(redis); // A GET with a header (defensive) → passes through untouched. const { context } = makeCtx({ method: "GET", headers: { "idempotency-key": KEY }, }); // Wrap to count (the interceptor should not touch Redis for a GET). const handler = callHandler(() => ({ data: "read" })); const out = await run(interceptor, context, handler); expect(out).toEqual({ data: "read" }); expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null); // nothing stored void lookups; void origGet; }); it("6. no-header — a request without Idempotency-Key passes through untouched", async () => { const redis = new FakeRedis(); let handlerRan = false; const interceptor = makeInterceptor(redis); const { context } = makeCtx({}); // no header const out = await run( interceptor, context, callHandler(() => { handlerRan = true; return { id: "rec_9" }; }), ); expect(handlerRan).toBe(true); // the middleware is a no-op expect(out).toEqual({ id: "rec_9" }); expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null); }); }); // --- auth + fail-open ---------------------------------------------------------- describe("idempotency — auth scope + fail-open", () => { it("an API-key caller (auth.kind !== user) is not honored (no keyspace write)", async () => { const redis = new FakeRedis(); const interceptor = makeInterceptor(redis); let handlerRan = false; const { context } = makeCtx({ auth: { kind: "api" }, headers: { "idempotency-key": KEY }, }); await run( interceptor, context, callHandler(() => { handlerRan = true; return { ok: true }; }), ); expect(handlerRan).toBe(true); expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null); // not user-scoped }); it("a key from ANOTHER user is not honored (the keyspace is user-scoped)", async () => { const redis = new FakeRedis(); // Seed u2's key (a different user's token — must not match u1's request). await redis .getRedis() .set( `idem:u2:${KEY}`, JSON.stringify({ body: JSON.stringify({ id: "u2_page" }) }), "EX", IDEM_TTL_SECONDS, ); const interceptor = makeInterceptor(redis); let handlerRan = false; const { context } = makeCtx({ auth: { kind: "user", userId: "u1" }, headers: { "idempotency-key": KEY }, }); const out = await run( interceptor, context, callHandler(() => { handlerRan = true; return { id: "u1_page" }; }), ); expect(handlerRan).toBe(true); // u1's own key is a miss → execute expect(out).toEqual({ id: "u1_page" }); }); it("a Redis LOOKUP error is fail-open (execute, don't throw)", async () => { const redis = new FakeRedis(); redis.failLookups = true; const interceptor = makeInterceptor(redis); let handlerRan = false; const { context } = makeCtx({ headers: { "idempotency-key": KEY } }); const out = await run( interceptor, context, callHandler(() => { handlerRan = true; return { id: "ok" }; }), ); expect(handlerRan).toBe(true); // degraded → execute expect(out).toEqual({ id: "ok" }); }); it("a Redis STORE error is fail-open (the mutation still succeeds, no throw)", async () => { const redis = new FakeRedis(); redis.failStores = true; const interceptor = makeInterceptor(redis); const { context } = makeCtx({ headers: { "idempotency-key": KEY } }); const out = await run( interceptor, context, callHandler(() => ({ id: "ok" })), ); expect(out).toEqual({ id: "ok" }); // the response is returned }); it("an EMPTY stored body (204 / nil) replays as undefined", async () => { const redis = new FakeRedis(); await redis .getRedis() .set(`idem:u1:${KEY}`, JSON.stringify({ body: "__IDEM_EMPTY__" }), "EX", IDEM_TTL_SECONDS); const interceptor = makeInterceptor(redis); let handlerRan = false; const { context } = makeCtx({ headers: { "idempotency-key": KEY } }); const out = await run( interceptor, context, callHandler(() => { handlerRan = true; return "SHOULD NOT HAPPEN"; }), ); expect(out).toBeUndefined(); // the empty body expect(handlerRan).toBe(false); }); });