Spaces:
Sleeping
Sleeping
File size: 7,094 Bytes
1f5ea39 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | import { describe, it, expect, vi, beforeEach } from 'vitest';
// ββ In-memory store + DB mock using vi.hoisted ββββββββββββββββββββββββββββββββ
const { rows, dbMock } = vi.hoisted(() => {
const rows: Record<string, { status_code: number; response_body: string }> = {};
const dbMock = {
db: {
prepare: vi.fn((sql: string) => ({
get: vi.fn((...args: unknown[]) => {
const [key, userId, method, path] = args;
return rows[`${key}:${userId}:${method}:${path}`] ?? undefined;
}),
run: vi.fn((...args: unknown[]) => {
const [key, userId, method, path, status_code, response_body] = args as [string, number, string, string, number, string];
const k = `${key}:${userId}:${method}:${path}`;
if (!rows[k]) rows[k] = { status_code, response_body };
}),
})),
},
};
return { rows, dbMock };
});
vi.mock('../../../src/db/database', () => dbMock);
import { applyIdempotency } from '../../../src/middleware/idempotency';
import type { Request, Response, NextFunction } from 'express';
function makeReq(method = 'POST', headers: Record<string, string> = {}, path = '/api/test'): Request {
return { method, path, headers } as unknown as Request;
}
function makeRes(statusCode = 200): Response {
const ctx = { status: statusCode };
const res = {
get statusCode() { return ctx.status; },
status(code: number) { ctx.status = code; return res; },
json: vi.fn((_body: unknown) => res),
} as unknown as Response;
return res;
}
beforeEach(() => {
Object.keys(rows).forEach(k => delete rows[k]);
vi.clearAllMocks();
});
describe('applyIdempotency', () => {
it('calls next() for GET requests', () => {
const req = makeReq('GET', { 'x-idempotency-key': 'key1' });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).toHaveBeenCalledOnce();
});
it('calls next() when header is absent for POST', () => {
const req = makeReq('POST', {});
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).toHaveBeenCalledOnce();
});
it('replays cached response when key+user+method+path already stored', () => {
rows['cached-key:42:POST:/api/test'] = { status_code: 201, response_body: JSON.stringify({ id: 99 }) };
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 42);
expect(next).not.toHaveBeenCalled();
expect(res.json as ReturnType<typeof vi.fn>).toHaveBeenCalledWith({ id: 99 });
});
it('different user same key does NOT replay', () => {
rows['cached-key:1:POST:/api/test'] = { status_code: 200, response_body: JSON.stringify({ ok: true }) };
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 99); // different user
expect(next).toHaveBeenCalledOnce();
});
it('same key+user on different path does NOT replay (scoped cache)', () => {
// Key 'dual-key' is cached under /api/a but reused against /api/b.
// Without the (key, user_id, method, path) scoping, /api/b would
// have replayed /api/a's body β a silent cross-endpoint leak.
rows['dual-key:7:POST:/api/a'] = { status_code: 200, response_body: JSON.stringify({ from: 'a' }) };
const req = makeReq('POST', { 'x-idempotency-key': 'dual-key' }, '/api/b');
const res = makeRes();
const next = vi.fn(() => {
(res.json as ReturnType<typeof vi.fn>)({ from: 'b' });
});
applyIdempotency(req, res, next, 7);
expect(next).toHaveBeenCalledOnce();
expect(rows['dual-key:7:POST:/api/b']).toBeDefined();
expect(JSON.parse(rows['dual-key:7:POST:/api/b'].response_body)).toEqual({ from: 'b' });
// /api/a's row is untouched.
expect(JSON.parse(rows['dual-key:7:POST:/api/a'].response_body)).toEqual({ from: 'a' });
});
it('same key+user+path but different method does NOT replay', () => {
rows['m-key:3:POST:/api/x'] = { status_code: 201, response_body: JSON.stringify({ m: 'post' }) };
const req = makeReq('PATCH', { 'x-idempotency-key': 'm-key' }, '/api/x');
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 3);
expect(next).toHaveBeenCalledOnce();
});
it('stores 2xx response on first execution via wrapped res.json', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'new-key' });
const res = makeRes(201);
const next = vi.fn(() => {
// Simulate handler calling res.json
(res.json as ReturnType<typeof vi.fn>)({ id: 5 });
});
applyIdempotency(req, res, next, 7);
expect(next).toHaveBeenCalledOnce();
expect(rows['new-key:7:POST:/api/test']).toBeDefined();
expect(rows['new-key:7:POST:/api/test'].status_code).toBe(201);
expect(JSON.parse(rows['new-key:7:POST:/api/test'].response_body)).toEqual({ id: 5 });
});
it('does NOT store 4xx responses', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'fail-key' });
const res = makeRes(422);
const next = vi.fn(() => {
(res.json as ReturnType<typeof vi.fn>)({ error: 'Invalid' });
});
applyIdempotency(req, res, next, 3);
expect(rows['fail-key:3:POST:/api/test']).toBeUndefined();
});
it('returns 400 when X-Idempotency-Key exceeds 128 characters', () => {
const longKey = 'a'.repeat(129);
const req = makeReq('POST', { 'x-idempotency-key': longKey });
const res = makeRes();
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).not.toHaveBeenCalled();
expect(res.json as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('128') }),
);
});
it('does NOT cache response body exceeding 256 KiB', () => {
const req = makeReq('POST', { 'x-idempotency-key': 'big-key' });
const res = makeRes(200);
const originalJsonSpy = res.json as ReturnType<typeof vi.fn>;
const largePayload = { data: 'x'.repeat(256 * 1024 + 1) };
const next = vi.fn(() => {
// res.json is now the wrapper; calling it exercises the size-cap branch
res.json(largePayload);
});
applyIdempotency(req, res, next, 5);
expect(next).toHaveBeenCalledOnce();
// Underlying spy was called (response reached the client)
expect(originalJsonSpy).toHaveBeenCalledWith(largePayload);
// But NOT stored in the idempotency store
expect(rows['big-key:5:POST:/api/test']).toBeUndefined();
});
it('handles PUT, PATCH, and DELETE the same as POST', () => {
for (const method of ['PUT', 'PATCH', 'DELETE'] as const) {
const req = makeReq(method, { 'x-idempotency-key': `key-${method}` });
const res = makeRes(200);
const next = vi.fn();
applyIdempotency(req, res, next, 1);
expect(next).toHaveBeenCalled();
vi.clearAllMocks();
}
});
});
|