Skip to content
Open
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
3 changes: 3 additions & 0 deletions backend/src/__tests__/cors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const corsOptions: cors.CorsOptions = {
"x-request-id",
"Idempotency-Key",
],
exposedHeaders: ["X-Idempotent-Replayed", "X-Idempotency-Cache"],
credentials: true,
};

Expand Down
3 changes: 3 additions & 0 deletions backend/src/middleware/idempotency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions backend/src/tests/idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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<string, unknown>();
const firstFinishHandlers: Array<() => Promise<void>> = [];

asMock(req.header).mockReturnValue(key);
(cacheService.get as jest.Mock<(cacheKey: string) => Promise<unknown>>)
.mockImplementation(async (cacheKey: string) => cache.get(cacheKey))
.mockName("cacheService.get");
(
cacheService.set as jest.Mock<
(cacheKey: string, value: unknown) => Promise<void>
>
)
.mockImplementation(async (cacheKey: string, value: unknown) => {
cache.set(cacheKey, value);
})
.mockName("cacheService.set");
res.on.mockImplementation((event: string, handler: () => Promise<void>) => {
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();
});
});
2 changes: 1 addition & 1 deletion docs/wiki/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

23 changes: 23 additions & 0 deletions docs/wiki/api-idempotency.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 11 additions & 5 deletions frontend/e2e/borrower-repay-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
});
});
});
19 changes: 13 additions & 6 deletions frontend/e2e/lender-withdraw-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
});
});
});
Loading