diff --git a/backend/src/__tests__/cors.test.ts b/backend/src/__tests__/cors.test.ts index 57bc5b16..d05eb0cb 100644 --- a/backend/src/__tests__/cors.test.ts +++ b/backend/src/__tests__/cors.test.ts @@ -65,6 +65,9 @@ describe("CORS middleware", () => { "https://frontend.example.com", ); expect(response.headers["access-control-allow-credentials"]).toBe("true"); + expect(response.headers["access-control-expose-headers"]).toBe( + "X-Idempotent-Replayed,X-Idempotency-Cache", + ); }); it("rejects unknown origins in production", async () => { diff --git a/backend/src/app.ts b/backend/src/app.ts index 5b33a565..7aafdbef 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -105,6 +105,7 @@ const corsOptions: cors.CorsOptions = { "x-request-id", "Idempotency-Key", ], + exposedHeaders: ["X-Idempotent-Replayed", "X-Idempotency-Cache"], credentials: true, }; diff --git a/backend/src/middleware/idempotency.ts b/backend/src/middleware/idempotency.ts index 86b314dc..b4c87012 100644 --- a/backend/src/middleware/idempotency.ts +++ b/backend/src/middleware/idempotency.ts @@ -38,10 +38,13 @@ export const idempotencyMiddleware = async ( res .status(cached.status) .set("X-Idempotency-Cache", "HIT") + .set("X-Idempotent-Replayed", "true") .json(cached.body); return; } + res.set("X-Idempotent-Replayed", "false"); + // Capture the original methods to intercept the response body const originalJson = res.json; const originalSend = res.send; diff --git a/backend/src/tests/idempotency.test.ts b/backend/src/tests/idempotency.test.ts index 96b0d5e3..d8e1a8c7 100644 --- a/backend/src/tests/idempotency.test.ts +++ b/backend/src/tests/idempotency.test.ts @@ -60,6 +60,7 @@ describe("Idempotency Middleware", () => { expect(cacheService.get).toHaveBeenCalledWith(`idemp:${key}`); expect(res.status).toHaveBeenCalledWith(201); expect(res.set).toHaveBeenCalledWith("X-Idempotency-Cache", "HIT"); + expect(res.set).toHaveBeenCalledWith("X-Idempotent-Replayed", "true"); expect(res.json).toHaveBeenCalledWith(cachedResponse.body); expect(next).not.toHaveBeenCalled(); }); @@ -72,6 +73,70 @@ describe("Idempotency Middleware", () => { await idempotencyMiddleware(req as Request, res as Response, next); expect(next).toHaveBeenCalled(); + expect(res.set).toHaveBeenCalledWith("X-Idempotent-Replayed", "false"); expect(res.on).toHaveBeenCalledWith("finish", expect.any(Function)); }); + + it("should mark first execution and sequential replay with header values", async () => { + const key = "sequential-key"; + const cache = new Map(); + const firstFinishHandlers: Array<() => Promise> = []; + + asMock(req.header).mockReturnValue(key); + (cacheService.get as jest.Mock<(cacheKey: string) => Promise>) + .mockImplementation(async (cacheKey: string) => cache.get(cacheKey)) + .mockName("cacheService.get"); + ( + cacheService.set as jest.Mock< + (cacheKey: string, value: unknown) => Promise + > + ) + .mockImplementation(async (cacheKey: string, value: unknown) => { + cache.set(cacheKey, value); + }) + .mockName("cacheService.set"); + res.on.mockImplementation((event: string, handler: () => Promise) => { + if (event === "finish") { + firstFinishHandlers.push(handler); + } + return res; + }); + + await idempotencyMiddleware(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.set).toHaveBeenCalledWith("X-Idempotent-Replayed", "false"); + + res.statusCode = 202; + res.json({ accepted: true }); + const finishHandler = firstFinishHandlers[0]; + if (!finishHandler) { + throw new Error( + "Expected idempotency middleware to register finish hook", + ); + } + await finishHandler(); + + const replayRes = { + status: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + on: jest.fn(), + statusCode: 200, + }; + const replayNext = jest.fn(); + + await idempotencyMiddleware( + req as Request, + replayRes as unknown as Response, + replayNext, + ); + + expect(replayRes.status).toHaveBeenCalledWith(202); + expect(replayRes.set).toHaveBeenCalledWith("X-Idempotency-Cache", "HIT"); + expect(replayRes.set).toHaveBeenCalledWith("X-Idempotent-Replayed", "true"); + expect(replayRes.json).toHaveBeenCalledWith({ accepted: true }); + expect(replayNext).not.toHaveBeenCalled(); + }); }); diff --git a/docs/wiki/README.md b/docs/wiki/README.md index ac1f208f..fc4e793f 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -4,7 +4,7 @@ This folder is a GitHub Wiki-style set of documents that live in the repo so the ## Contents +- [API Idempotency](./api-idempotency.md) - [Soroban Contract State Machine](./contract-state-machine.md) - [Indexer ↔ Database Sync Flow](./indexer-sync-flow.md) - [Frontend “Standard Library” Patterns](./frontend-patterns.md) - diff --git a/docs/wiki/api-idempotency.md b/docs/wiki/api-idempotency.md new file mode 100644 index 00000000..76671a63 --- /dev/null +++ b/docs/wiki/api-idempotency.md @@ -0,0 +1,23 @@ +# API Idempotency + +Write endpoints that opt into `idempotencyMiddleware` accept an +`Idempotency-Key` request header. Clients should reuse the same key when +retrying the same logical write after a timeout or transient network failure. + +## Replay Headers + +Responses include `X-Idempotent-Replayed` when an idempotency key is present: + +- `false` means the request ran normally and the response can be cached for + later retries. +- `true` means the request matched a cached response for the same key and the + body was replayed without running the write handler again. + +Cached replays also keep the legacy `X-Idempotency-Cache: HIT` response header. +The replay header is the stable value API consumers should use when deciding +whether to suppress duplicate success toasts or transaction counters. +Both idempotency response headers are exposed through CORS so browser clients +can read them from `fetch()` responses. + +Clients should generate a fresh idempotency key for every new write attempt. +Do not reuse a key for a different loan, pool, or transaction operation. diff --git a/frontend/e2e/borrower-repay-flow.spec.ts b/frontend/e2e/borrower-repay-flow.spec.ts index c87e7b0a..87ad5ca1 100644 --- a/frontend/e2e/borrower-repay-flow.spec.ts +++ b/frontend/e2e/borrower-repay-flow.spec.ts @@ -102,7 +102,11 @@ test.describe("Borrower Repayment Flow", () => { await expect(page.locator("text=4,500")).toBeVisible(); }); - test("rejects a repayment greater than the outstanding balance", async ({ page }: { page: Page }) => { + test("rejects a repayment greater than the outstanding balance", async ({ + page, + }: { + page: Page; + }) => { await page.goto("/en"); const repayBtn = page.getByRole("button", { name: /Repay/i }).first(); @@ -115,9 +119,11 @@ test.describe("Borrower Repayment Flow", () => { const review = page.getByRole("button", { name: /Review Repayment/i }); // The flow should not allow proceeding to confirmation with an invalid amount. - await expect(review).toBeDisabled().catch(async () => { - await review.click(); - await expect(page.locator("text=/exceeds|too (large|high)|maximum/i")).toBeVisible(); - }); + await expect(review) + .toBeDisabled() + .catch(async () => { + await review.click(); + await expect(page.locator("text=/exceeds|too (large|high)|maximum/i")).toBeVisible(); + }); }); }); diff --git a/frontend/e2e/lender-withdraw-flow.spec.ts b/frontend/e2e/lender-withdraw-flow.spec.ts index 946578f8..ed04fabe 100644 --- a/frontend/e2e/lender-withdraw-flow.spec.ts +++ b/frontend/e2e/lender-withdraw-flow.spec.ts @@ -106,9 +106,14 @@ test.describe("Lender Withdraw Flow", () => { }, lenderWalletState("1500.00")); await page.click('button:has-text("Review Withdrawal"), button:has-text("Confirm Withdrawal")'); - await page.getByRole("button", { name: /Confirm Withdrawal/i }).click().catch(() => {}); + await page + .getByRole("button", { name: /Confirm Withdrawal/i }) + .click() + .catch(() => {}); - await expect(page.locator("text=/Withdrawal (Successful|Complete)/i")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=/Withdrawal (Successful|Complete)/i")).toBeVisible({ + timeout: 10000, + }); await page.reload(); await expect(page.locator("text=1,500")).toBeVisible(); @@ -126,9 +131,11 @@ test.describe("Lender Withdraw Flow", () => { await page.fill('input[type="number"]', "999999"); const confirm = page.getByRole("button", { name: /Confirm Withdrawal/i }); - await expect(confirm).toBeDisabled().catch(async () => { - await confirm.click(); - await expect(page.locator("text=/exceeds|insufficient|maximum/i")).toBeVisible(); - }); + await expect(confirm) + .toBeDisabled() + .catch(async () => { + await confirm.click(); + await expect(page.locator("text=/exceeds|insufficient|maximum/i")).toBeVisible(); + }); }); });