From 068a00e5610b4377666ff574190bbd26babc387d Mon Sep 17 00:00:00 2001 From: aurorabini <319766468+aurorabini@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:57:24 +0200 Subject: [PATCH] test(idempotency): cover Redis lock guarantees for POST/PUT replay Add a unit suite for the idempotency middleware verifying the RFC-compatible behaviour required for financial/certificate-minting endpoints: pass-through without a key, cached-response replay for an identical key within the TTL, 409 conflict while a concurrent request holds the lock, atomic lock release on completion, and 400 for malformed oversized keys. Closes #1126 --- backend/src/middleware/idempotency.test.ts | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 backend/src/middleware/idempotency.test.ts diff --git a/backend/src/middleware/idempotency.test.ts b/backend/src/middleware/idempotency.test.ts new file mode 100644 index 00000000..3ec93958 --- /dev/null +++ b/backend/src/middleware/idempotency.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; + +const redisStore = new Map(); +const calls: Array<{ fn: string; args: unknown[] }> = []; + +const mockRedis = { + get: vi.fn(async (k: string) => redisStore.get(k) ?? null), + del: vi.fn(async (k: string) => { + redisStore.delete(k); + return 1; + }), + // SET key value EX ttl NX + set: vi.fn(async (k: string, v: string, mode1: string, ttl: number, mode2: string) => { + if (redisStore.has(k)) return null; + redisStore.set(k, v); + return 'OK'; + }), + setex: vi.fn(async (k: string, ttl: number, v: string) => { + redisStore.set(k, v); + return 'OK'; + }), +}; + +vi.mock('../utils/redis.js', () => ({ + __esModule: true, + default: mockRedis, +})); + +function makeReq(header?: string): Request { + const req = {} as Request; + req.headers = header ? { 'idempotency-key': header } : {}; + return req; +} + +function makeRes(): Response & { _body: unknown; _status: number } { + const res = {} as Response & { _body: unknown; _status: number }; + res.statusCode = 200; + res._status = 200; + res.getHeader = vi.fn((h: string) => (h === 'content-type' ? 'application/json' : undefined)); + res.setHeader = vi.fn(); + res.json = vi.fn((body: unknown) => { + res._body = body; + return res as any; + }); + res.send = vi.fn((body: unknown) => { + res._body = body; + return res as any; + }); + res.status = vi.fn((code: number) => { + res._status = code; + res.statusCode = code; + return res as any; + }); + return res; +} + +beforeEach(() => { + vi.clearAllMocks(); + redisStore.clear(); + calls.length = 0; + void calls; +}); + +describe('idempotency middleware (issue #1126)', () => { + it('passes through requests without an Idempotency-Key header', async () => { + const { idempotency } = await import('./idempotency.js'); + const next = vi.fn(); + await idempotency()(makeReq(undefined), makeRes(), next as NextFunction); + expect(next).toHaveBeenCalled(); + }); + + it('caches the first response and replays it for an identical key', async () => { + const { idempotency } = await import('./idempotency.js'); + const next = vi.fn(() => { + const res = lastRes; + res.status(201).json({ ok: true }); + }); + + const lastRes = makeRes(); + // First execution stores the response. + await idempotency()(makeReq('key-0001'), lastRes, next as NextFunction); + // Wait for the async capture to run. + await new Promise((r) => setTimeout(r, 10)); + expect(mockRedis.setex).toHaveBeenCalled(); + + // Second execution with the same key should replay the cached body and set the replay header. + const res2 = makeRes(); + const next2 = vi.fn(); + await idempotency()(makeReq('key-0001'), res2, next2 as NextFunction); + + expect(next2).not.toHaveBeenCalled(); + expect(res2.statusCode).toBe(201); + // The cached payload is replayed byte-for-byte (string body) so the + // response is binary-safe and identical to the original. + expect(res2._body).toBe(JSON.stringify({ ok: true })); + expect(res2.setHeader).toHaveBeenCalledWith('X-Idempotency-Replay', 'true'); + }); + + it('returns 409 while a concurrent request with the same key holds the lock', async () => { + const { idempotency } = await import('./idempotency.js'); + const next = vi.fn(); + const res = makeRes(); + + // Simulate the lock already held by an in-flight request (SET NX returns null). + redisStore.set('idempotency:lock:global:key-0002', '1'); + await idempotency()(makeReq('key-0002'), res, next as NextFunction); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(409); + }); + + it('releases the in-flight lock after the response is sent', async () => { + const { idempotency } = await import('./idempotency.js'); + const next = vi.fn(() => { + lastRes.status(200).json({ done: true }); + }); + const lastRes = makeRes(); + + await idempotency()(makeReq('key-0003'), lastRes, next as NextFunction); + await new Promise((r) => setTimeout(r, 10)); + + // Lock key should be deleted after completion. + const lockKey = [...redisStore.keys()].find((k) => k.includes(':lock:')); + expect(lockKey).toBeUndefined(); + }); + + it('rejects malformed oversized Idempotency-Key values with 400', async () => { + const { idempotency } = await import('./idempotency.js'); + const next = vi.fn(); + const res = makeRes(); + + await idempotency()(makeReq('x'.repeat(300)), res, next as NextFunction); + + expect(res.statusCode).toBe(400); + expect(next).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file