Skip to content

Commit e19fbc2

Browse files
Merge pull request #881 from arisu6804/feat/health-idempotency-665
Add Idempotency-Key support on /api/health mutations
2 parents 2884d4b + 642459f commit e19fbc2

2 files changed

Lines changed: 178 additions & 1 deletion

File tree

src/routes/health.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { db } from "../db/client";
55
import { auditLogs } from "../db/schema";
66
import { eq, desc } from "drizzle-orm";
77
import { requestTimeout, abortableRace } from "../middleware/timeout";
8+
import { idempotency } from "../middleware/idempotency";
89

910
export const healthRouter = Router();
1011

@@ -30,7 +31,11 @@ healthRouter.get("/", async (_req, res, next) => {
3031
}
3132
});
3233

33-
healthRouter.post("/mutations", async (req, res, next) => {
34+
// The global Idempotency-Key middleware (src/index.ts) is registered after
35+
// this router's /api/health mount, so it never runs for this route; applying
36+
// it here directly makes retried mutations (e.g. after a client-side timeout)
37+
// safe without double-writing audit log entries.
38+
healthRouter.post("/mutations", idempotency, async (req, res, next) => {
3439
try {
3540
const ip = req.ip || req.socket.remoteAddress || "unknown";
3641
const correlationId = getRequestId();

tests/healthIdempotency.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* tests/healthIdempotency.test.ts
3+
*
4+
* Verifies POST /api/health/mutations is protected by the Idempotency-Key
5+
* middleware (issue #665). The global idempotency middleware in src/index.ts
6+
* is registered *after* /api/health, so it never runs for this route;
7+
* src/routes/health.ts now applies it directly on the /mutations route.
8+
*
9+
* The db mock is a small stateful in-memory map (mirrors the approach in
10+
* tests/authIdempotency.test.ts) so persist-then-replay round trips can be
11+
* exercised without a real database.
12+
*/
13+
14+
import { auditLogs, idempotencyRecords } from "../src/db/schema";
15+
16+
const idempotencyStore = new Map<string, Record<string, unknown>>();
17+
18+
// src/middleware/timeout.ts's requestTimeout() aborts res.locals.abortSignal
19+
// on the request's "close" event, which — independent of this change — can
20+
// fire under supertest before the response is fully sent, racing
21+
// abortableRace() in the health route handler. That's a pre-existing,
22+
// unrelated issue (reproduces with requestTimeout + abortableRace alone, no
23+
// idempotency involved); stub both out here so this suite stays focused on
24+
// idempotency behavior specifically.
25+
jest.mock("../src/middleware/timeout", () => ({
26+
requestTimeout: () => (_req: unknown, _res: unknown, next: () => void) => next(),
27+
abortableRace: (promise: Promise<unknown>) => promise,
28+
}));
29+
30+
// src/middleware/idempotency.ts reads/writes via "../db" (src/db/index.ts) —
31+
// a separate module/instance from "../db/client", which the health route
32+
// itself uses for its own auditLogs query below.
33+
jest.mock("../src/db", () => ({
34+
db: {
35+
select: () => ({
36+
from: () => ({
37+
where: () => ({
38+
limit: async () => Array.from(idempotencyStore.values()),
39+
}),
40+
}),
41+
}),
42+
insert: () => ({
43+
values: async (record: Record<string, unknown>) => {
44+
idempotencyStore.set(record.key as string, record);
45+
},
46+
}),
47+
},
48+
}));
49+
50+
jest.mock("../src/db/client", () => ({
51+
db: {
52+
select: () => ({
53+
from: () => ({
54+
where: () => ({
55+
orderBy: () => ({
56+
limit: async () => [
57+
{ afterState: { mode: "active", maintenance: false } },
58+
],
59+
}),
60+
}),
61+
}),
62+
}),
63+
},
64+
pool: { query: jest.fn() },
65+
}));
66+
67+
jest.mock("../src/services/auditService", () => ({
68+
createAuditLog: jest.fn().mockResolvedValue("mock-correlation-id"),
69+
}));
70+
71+
import express from "express";
72+
import request from "supertest";
73+
import { healthRouter } from "../src/routes/health";
74+
import { errorHandler } from "../src/middleware/errorHandler";
75+
import { createAuditLog } from "../src/services/auditService";
76+
77+
const mockCreateAuditLog = createAuditLog as jest.MockedFunction<typeof createAuditLog>;
78+
79+
function makeApp(): express.Express {
80+
const app = express();
81+
app.use(express.json());
82+
app.use("/api/health", healthRouter);
83+
app.use(errorHandler);
84+
return app;
85+
}
86+
87+
let app: express.Express;
88+
89+
beforeEach(() => {
90+
jest.clearAllMocks();
91+
idempotencyStore.clear();
92+
app = makeApp();
93+
});
94+
95+
describe("Idempotency for POST /api/health/mutations", () => {
96+
it("replays the stored response for a repeated Idempotency-Key + body", async () => {
97+
const key = "health-mutation-key-1";
98+
const body = { mode: "maintenance", maintenance: true };
99+
100+
const first = await request(app)
101+
.post("/api/health/mutations")
102+
.set("Idempotency-Key", key)
103+
.send(body);
104+
105+
expect(first.status).toBe(200);
106+
expect(first.headers["idempotent-replayed"]).toBeUndefined();
107+
expect(first.body.status).toBe("updated");
108+
expect(mockCreateAuditLog).toHaveBeenCalledTimes(1);
109+
110+
const second = await request(app)
111+
.post("/api/health/mutations")
112+
.set("Idempotency-Key", key)
113+
.send(body);
114+
115+
expect(second.status).toBe(200);
116+
expect(second.headers["idempotent-replayed"]).toBe("true");
117+
expect(second.body).toEqual(first.body);
118+
// The route handler (and its audit log write) must not run a second time.
119+
expect(mockCreateAuditLog).toHaveBeenCalledTimes(1);
120+
});
121+
122+
it("returns 409 when the same Idempotency-Key is reused with a different body", async () => {
123+
const key = "health-mutation-key-2";
124+
125+
await request(app)
126+
.post("/api/health/mutations")
127+
.set("Idempotency-Key", key)
128+
.send({ mode: "maintenance", maintenance: true })
129+
.expect(200);
130+
131+
const conflict = await request(app)
132+
.post("/api/health/mutations")
133+
.set("Idempotency-Key", key)
134+
.send({ mode: "active", maintenance: false })
135+
.expect(409);
136+
137+
expect(conflict.body.error.code).toBe("conflict");
138+
expect(mockCreateAuditLog).toHaveBeenCalledTimes(1);
139+
});
140+
141+
it("returns 400 for a malformed Idempotency-Key", async () => {
142+
const res = await request(app)
143+
.post("/api/health/mutations")
144+
.set("Idempotency-Key", "a".repeat(256))
145+
.send({ mode: "maintenance", maintenance: true });
146+
147+
expect(res.status).toBe(400);
148+
expect(res.body.error.code).toBe("invalid_idempotency_key");
149+
expect(mockCreateAuditLog).not.toHaveBeenCalled();
150+
});
151+
152+
it("processes every request independently when no Idempotency-Key is sent", async () => {
153+
await request(app)
154+
.post("/api/health/mutations")
155+
.send({ mode: "maintenance", maintenance: true })
156+
.expect(200);
157+
158+
await request(app)
159+
.post("/api/health/mutations")
160+
.send({ mode: "maintenance", maintenance: true })
161+
.expect(200);
162+
163+
expect(mockCreateAuditLog).toHaveBeenCalledTimes(2);
164+
});
165+
});
166+
167+
// Sanity check that the schema imports used to build the mock actually exist.
168+
describe("schema sanity", () => {
169+
it("idempotencyRecords and auditLogs are distinct table objects", () => {
170+
expect(idempotencyRecords).not.toBe(auditLogs);
171+
});
172+
});

0 commit comments

Comments
 (0)