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();
    }
  });
});