diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index b0e6826..d1d3bbe 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -62,7 +62,18 @@ import { type IdParamContext } from "../utils/validation"; * Service for managing checkout sessions. */ export class CheckoutService { - private computeHash(data: unknown): string { + private computeHash( + operation: string, + data: unknown, + resourceId?: string + ): string { + // Scope the fingerprint to the operation and, for update/complete/cancel, + // the target checkout id — so a key cannot replay a different checkout or + // a different operation. Mirrors the Python sample's idempotency scoping. + const payload = + resourceId === undefined + ? { operation, data } + : { operation, resourceId, data }; const replacer = (_key: string, value: unknown) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value as Record) @@ -73,7 +84,7 @@ export class CheckoutService { }, {}) : value; return createHash("sha256") - .update(JSON.stringify(data, replacer)) + .update(JSON.stringify(payload, replacer)) .digest("hex"); } @@ -502,7 +513,7 @@ export class CheckoutService { let requestHash = ""; if (idempotencyKey) { - requestHash = this.computeHash(request); + requestHash = this.computeHash("create_checkout", request); const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { @@ -632,7 +643,7 @@ export class CheckoutService { let requestHash = ""; if (idempotencyKey) { - requestHash = this.computeHash(updateRequest); + requestHash = this.computeHash("update_checkout", updateRequest, id); const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { @@ -752,7 +763,7 @@ export class CheckoutService { // Idempotency check for payment data if (idempotencyKey) { - requestHash = this.computeHash(rawBody); + requestHash = this.computeHash("complete_checkout", rawBody, id); const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { @@ -1009,7 +1020,7 @@ export class CheckoutService { let requestHash = ""; if (idempotencyKey) { - requestHash = this.computeHash(rawBody); + requestHash = this.computeHash("cancel_checkout", rawBody, id); const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { diff --git a/rest/nodejs/test/idempotency.test.ts b/rest/nodejs/test/idempotency.test.ts index fc84c11..ebfbc1b 100644 --- a/rest/nodejs/test/idempotency.test.ts +++ b/rest/nodejs/test/idempotency.test.ts @@ -20,8 +20,11 @@ import { Hono } from "hono"; import { CheckoutService } from "../src/api/checkout"; import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; -import { ExtendedCheckoutCreateRequestSchema } from "../src/models"; -import { prettyValidation } from "../src/utils/validation"; +import { + ExtendedCheckoutCreateRequestSchema, + ExtendedCheckoutUpdateRequestSchema, +} from "../src/models"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; function buildApp() { const svc = new CheckoutService(); @@ -104,3 +107,90 @@ test("no idempotency key creates independent checkouts", async () => { const b = (await post(app, BODY).then((r) => r.json())) as { id: string }; assert.notEqual(a.id, b.id, "distinct requests must get distinct ids"); }); + +// A fuller app wiring create/update/cancel so idempotency scoping across +// operations and checkouts can be exercised end to end. +function buildFullApp() { + const svc = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + svc.createCheckout + ); + app.put( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", ExtendedCheckoutUpdateRequestSchema, prettyValidation), + svc.updateCheckout + ); + app.post( + "/checkout-sessions/:id/cancel", + zValidator("param", IdParamSchema, prettyValidation), + svc.cancelCheckout + ); + return app; +} + +const JSON_HEADERS = { "Content-Type": "application/json" }; + +async function newCheckout(app: ReturnType) { + const res = await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(BODY), + }); + assert.equal(res.status, 201); + return (await res.json()) as { id: string }; +} + +// An idempotency key must be scoped to the checkout it was first used on: +// reusing the same key against a different checkout is a conflict (409), not a +// silent replay of the first checkout's response. +test("an idempotency key is scoped to the checkout (cancel)", async () => { + const app = buildFullApp(); + const a = await newCheckout(app); + const b = await newCheckout(app); + const key = "shared-cancel-key"; + const first = await app.request(`/checkout-sessions/${a.id}/cancel`, { + method: "POST", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + }); + const second = await app.request(`/checkout-sessions/${b.id}/cancel`, { + method: "POST", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + }); + assert.equal(first.status, 200); + assert.equal( + second.status, + 409, + "reusing a key to cancel a different checkout must conflict, not replay" + ); +}); + +test("an idempotency key is scoped to the checkout (update)", async () => { + const app = buildFullApp(); + const a = await newCheckout(app); + const b = await newCheckout(app); + const key = "shared-update-key"; + const first = await app.request(`/checkout-sessions/${a.id}`, { + method: "PUT", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + body: JSON.stringify(BODY), + }); + const second = await app.request(`/checkout-sessions/${b.id}`, { + method: "PUT", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + body: JSON.stringify(BODY), + }); + assert.equal(first.status, 200); + assert.equal( + second.status, + 409, + "reusing a key to update a different checkout must conflict, not replay" + ); +});