Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions app/middleware/idempotency.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,24 @@
*/

const crypto = require('crypto');
const db = require('../config/db.config.js');
const log = require('../config/logger.js');

/**
* Late-bound + injectable DB accessor. Same pattern as
* `app/middleware/auth.js#getDb` — vitest's `vi.mock` does not
* reliably intercept this codebase's CJS `require()`, so HTTP-level
* tests that want to drive the replay-cache logic substitute a stub
* via `_setDbForTesting(stub)`. Production code MUST NOT call the
* setter. P5-M (idempotency follow-up).
*/
let _dbOverride = null;
function getDb() {
return _dbOverride || require('../config/db.config.js');
}
function _setDbForTesting(db) {
_dbOverride = db || null;
}

const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
// Keys are client-picked; reject anything that looks like garbage.
// Stripe accepts up to 255 chars; we mirror that and require
Expand Down Expand Up @@ -114,7 +129,7 @@ async function idempotency(req, res, next) {
});
}

if (!db.sequelize || typeof db.sequelize.query !== 'function') {
if (!getDb().sequelize || typeof getDb().sequelize.query !== 'function') {
// Test env or misconfiguration. Don't block writes.
return next();
}
Expand All @@ -124,11 +139,11 @@ async function idempotency(req, res, next) {

// Best-effort prune. Awaited so we don't pile up overlapping
// DELETEs under load; cheap because the index covers it.
pruneExpired(db.sequelize).catch(() => {});
pruneExpired(getDb().sequelize).catch(() => {});

let existing;
try {
const rows = await db.sequelize.query(
const rows = await getDb().sequelize.query(
`SELECT "ikRequestHash" AS "requestHash",
"ikResponseStatus" AS "status",
"ikResponseBody" AS "body"
Expand All @@ -137,7 +152,7 @@ async function idempotency(req, res, next) {
AND "ikExpiresAt" >= now()`,
{
replacements: { scope, key: rawKey },
type: db.Sequelize.QueryTypes.SELECT,
type: getDb().Sequelize.QueryTypes.SELECT,
},
);
existing = rows && rows[0];
Expand Down Expand Up @@ -176,7 +191,7 @@ async function idempotency(req, res, next) {
// Fire and forget — the response shouldn't block on the
// cache write. If the INSERT loses a race with a
// concurrent retry the UNIQUE constraint catches it.
db.sequelize.query(
getDb().sequelize.query(
`INSERT INTO "dbo"."IdempotencyKey"
("ikScope", "ikKey", "ikRequestHash",
"ikResponseStatus", "ikResponseBody", "ikExpiresAt")
Expand Down Expand Up @@ -211,4 +226,9 @@ module.exports = {
buildScope,
KEY_PATTERN,
TTL_MS,
// Test-only seam: pass a stub `{ sequelize: { query: ... }, Sequelize:
// { QueryTypes: { SELECT } } }` to drive the cache lookup + write
// paths from HTTP tests. Pass null (or no arg) to restore the
// production lookup. Production code MUST NOT call this.
_setDbForTesting,
};
67 changes: 67 additions & 0 deletions tests/api/idempotency.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,73 @@ describe('Idempotency middleware: mounted on POST routes', () => {
expect(res.status).not.toBe(409);
});

test('first write + replay round-trip works via the _setDbForTesting seam', async () => {
// Walk the full first-write → replay path that vi.mock alone
// can't reach. The stub plays both roles of the cache table:
// SELECT returns nothing on the first request (cache miss),
// INSERT writes a row, SELECT on the second request returns
// the stored row.
const idem = require('../../app/middleware/idempotency.js');
const storedRows = new Map();
const stub = {
sequelize: {
query: vi.fn(async (sql, opts) => {
if (/^SELECT/.test(sql)) {
const key = opts && opts.replacements && opts.replacements.key;
const scope = opts && opts.replacements && opts.replacements.scope;
const hit = storedRows.get(`${scope}::${key}`);
return hit ? [hit] : [];
}
if (/^INSERT/.test(sql)) {
const { scope, key, requestHash, status, body } = opts.replacements;
storedRows.set(`${scope}::${key}`, {
requestHash, status, body: JSON.parse(body),
});
return [[], 1];
}
return [];
}),
},
Sequelize: { QueryTypes: { SELECT: 'SELECT' } },
};
idem._setDbForTesting(stub);
try {
const key = '01HFREPLAY12345';
const body = { custCompanyName: 'Acme' };
// First request: cache miss → controller runs → response stored.
const first = await request(app)
.post('/v1/customer')
.set('authKey', 'any')
.set('Idempotency-Key', key)
.send(body);
// Whatever the controller decided (403/500 with the
// broken-DB env). We DON'T care about the underlying
// status, only that the response was cached.
expect(first.headers['idempotency-replay']).toBeUndefined();

// Second request: same key + same body. Should be a replay.
const second = await request(app)
.post('/v1/customer')
.set('authKey', 'any')
.set('Idempotency-Key', key)
.send(body);
expect(second.headers['idempotency-replay']).toBe('true');
expect(second.status).toBe(first.status);
expect(second.body).toEqual(first.body);

// Third request: same key, DIFFERENT body → 409 conflict.
const third = await request(app)
.post('/v1/customer')
.set('authKey', 'any')
.set('Idempotency-Key', key)
.send({ custCompanyName: 'Different' });
expect(third.status).toBe(409);
expect(third.body.code).toBe('idempotency_key_reused');
} finally {
idem._setDbForTesting(null);
}
});

test('GET requests are never gated by the middleware', async () => {
// Even with a malformed header, a GET should sail through —
// the middleware is wrapped in a method check that no-ops
Expand Down
Loading