commit fab425b7a3e44fff1b7046e7440c1e4bd5d702fa
parent 8c286a684659fb714b16b3c7eaef282a9a7eab7e
Author: Florian Dold <dold@taler.net>
Date: Mon, 27 Jul 2026 19:24:32 +0200
harness: check the merchant's response to claiming a claimed order
Issue: https://bugs.taler.net/n/11660
Diffstat:
1 file changed, 105 insertions(+), 3 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-claim-loop.ts b/packages/taler-harness/src/integrationtests/test-claim-loop.ts
@@ -18,7 +18,11 @@
* Imports.
*/
import {
+ encodeCrock,
+ getRandomBytes,
+ HttpStatusCode,
succeedOrThrow,
+ TalerErrorCode,
TalerMerchantInstanceHttpClient,
TransactionMajorState,
TransactionMinorState,
@@ -32,10 +36,13 @@ import {
import { GlobalTestState } from "../harness/harness.js";
/**
- * Run test for the merchant's order lifecycle.
+ * Run test for the merchant's order lifecycle and for the responses of
+ * POST /orders/$ORDER_ID/claim.
*
- * FIXME: Is this test still necessary? We initially wrote if to confirm/document
- * assumptions about how the merchant should work.
+ * An order can only ever be claimed by one nonce. Everything the merchant
+ * answers afterwards follows from that: the nonce that holds the claim gets
+ * its contract back as often as it asks, every other nonce gets a conflict,
+ * and an order nobody created is not a conflict but a 404.
*/
export async function runClaimLoopTest(t: GlobalTestState) {
// Set up test environment
@@ -109,6 +116,101 @@ export async function runClaimLoopTest(t: GlobalTestState) {
);
t.assertTrue(orderStatusAfter.order_status === "claimed");
+ // A wallet that scans the pay URI of an order some other wallet has already
+ // claimed presents the very same claim token, but a nonce of its own. The
+ // claim token does not help it: the order belongs to whoever claimed first.
+ await t.runSpanAsync("claim by a second wallet conflicts", async () => {
+ const claimResp = await merchantClient.claimOrder({
+ orderId: orderResp.order_id,
+ body: {
+ nonce: encodeCrock(getRandomBytes(32)),
+ token: orderResp.token,
+ },
+ });
+ t.assertTrue(claimResp.type === "fail");
+ t.assertTrue(claimResp.case === HttpStatusCode.Conflict);
+ t.assertDeepEqual(
+ claimResp.detail?.code,
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED,
+ );
+
+ // The rejected claim leaves the order alone.
+ const orderStatus = succeedOrThrow(
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
+ );
+ t.assertTrue(orderStatus.order_status === "claimed");
+ });
+
+ // Claiming again with the nonce that holds the claim is not a conflict but
+ // a repetition: a wallet that lost the response to its first claim must be
+ // able to ask again instead of losing the order.
+ await t.runSpanAsync("re-claim with the same nonce", async () => {
+ const secondOrderResp = succeedOrThrow(
+ await merchantClient.createOrder(merchantAdminAccessToken, {
+ order: {
+ summary: "Buy me too!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ }),
+ );
+ const orderId = secondOrderResp.order_id;
+ const ownNonce = encodeCrock(getRandomBytes(32));
+
+ const firstClaim = succeedOrThrow(
+ await merchantClient.claimOrder({
+ orderId,
+ body: { nonce: ownNonce, token: secondOrderResp.token },
+ }),
+ );
+ t.assertDeepEqual(firstClaim.contract_terms.nonce, ownNonce);
+
+ // Somebody else tries in between and is turned away.
+ const conflictingClaim = await merchantClient.claimOrder({
+ orderId,
+ body: {
+ nonce: encodeCrock(getRandomBytes(32)),
+ token: secondOrderResp.token,
+ },
+ });
+ t.assertTrue(conflictingClaim.type === "fail");
+ t.assertTrue(conflictingClaim.case === HttpStatusCode.Conflict);
+ t.assertDeepEqual(
+ conflictingClaim.detail?.code,
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED,
+ );
+
+ // The failed claim did not take the order away from its owner, which
+ // still gets the identical contract back.
+ const repeatedClaim = succeedOrThrow(
+ await merchantClient.claimOrder({
+ orderId,
+ body: { nonce: ownNonce, token: secondOrderResp.token },
+ }),
+ );
+ t.assertDeepEqual(repeatedClaim.contract_terms, firstClaim.contract_terms);
+ });
+
+ // A conflict must mean "already claimed" and nothing else, so an order that
+ // was never created has to be distinguishable from one that is taken.
+ await t.runSpanAsync(
+ "claim of an unknown order is not a conflict",
+ async () => {
+ const claimResp = await merchantClient.claimOrder({
+ orderId: "does-not-exist",
+ body: { nonce: encodeCrock(getRandomBytes(32)) },
+ });
+ t.assertTrue(claimResp.type === "fail");
+ t.assertTrue(
+ claimResp.case ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND,
+ );
+ },
+ );
+
await t.shutdown();
}