commit ba48228b44af062eeca3c51c7e09810f32fabea7
parent b64e2aab5222dd5f4a92a259773b9bc8d73d2a97
Author: Florian Dold <dold@taler.net>
Date: Mon, 27 Jul 2026 21:58:23 +0200
harness: reproduce duplicate token family keys in contract terms
Diffstat:
2 files changed, 254 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-tokenfamily-keys.ts b/packages/taler-harness/src/integrationtests/test-merchant-tokenfamily-keys.ts
@@ -0,0 +1,252 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ Duration,
+ j2s,
+ OrderInputType,
+ OrderOutputType,
+ OrderVersion,
+ PostOrderRequest,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TokenFamilyKind,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js";
+import { GlobalTestState } from "../harness/harness.js";
+
+/**
+ * Test that a token family gets exactly one signing key per validity
+ * period in the contract terms.
+ *
+ * The merchant looks up an existing key for an *output* token with a query
+ * that requires the private key to still exist at the order's pay deadline,
+ * but lists the keys for an *input* token with a query that has no such
+ * condition. So when an order's pay deadline is later than the point at
+ * which the already-issued key's private key may be deleted, the backend
+ * considers that key missing, mints a second key for the very same validity
+ * period, and then reports both of them in the contract terms.
+ *
+ * See https://bugs.gnunet.org/view.php?id=11563
+ */
+export async function runMerchantTokenfamilyKeysTest(t: GlobalTestState) {
+ const { merchant, walletClient, merchantAdminAccessToken } =
+ await createSimpleTestkudosEnvironmentV3(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ );
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ const slugDiscount1 = "slugdiscount1";
+ const slugSubscription1 = "slugsubscription1";
+
+ // Short token duration relative to the pay deadline of the second order.
+ // (The backend requires duration >= validity_granularity + start_offset.)
+ const duration = Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 2 }),
+ );
+ const validBefore = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ years: 1 }),
+ ),
+ );
+ const granularity = Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ );
+
+ succeedOrThrow(
+ await merchantClient.createTokenFamily(merchantAdminAccessToken, {
+ name: "discount1",
+ slug: slugDiscount1,
+ description: "My Discount 1",
+ duration,
+ valid_before: validBefore,
+ kind: TokenFamilyKind.Discount,
+ validity_granularity: granularity,
+ }),
+ );
+
+ succeedOrThrow(
+ await merchantClient.createTokenFamily(merchantAdminAccessToken, {
+ name: "subscription1",
+ slug: slugSubscription1,
+ description: "My Subscription 1",
+ duration,
+ valid_before: validBefore,
+ kind: TokenFamilyKind.Subscription,
+ validity_granularity: granularity,
+ }),
+ );
+
+ // Mirrors "taler-harness playground advanced-tokens-1": each family is
+ // used as an output in one choice and as an input in another choice.
+ const makeRequest = (payDeadline: Duration): PostOrderRequest => ({
+ order: {
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), payDeadline),
+ ),
+ version: OrderVersion.V1,
+ summary: "Test Payment",
+ choices: [
+ {
+ amount: "TESTKUDOS:1",
+ description: "Buy an individual article",
+ },
+ {
+ amount: "TESTKUDOS:3",
+ description: "Article and 3 discounts",
+ outputs: [
+ {
+ type: OrderOutputType.Token,
+ token_family_slug: slugDiscount1,
+ count: 3,
+ },
+ ],
+ },
+ {
+ amount: "TESTKUDOS:5",
+ description: "Article and subscription",
+ outputs: [
+ {
+ type: OrderOutputType.Token,
+ token_family_slug: slugSubscription1,
+ },
+ ],
+ },
+ {
+ amount: "TESTKUDOS:0.5",
+ description: "Pay half with a discount token",
+ inputs: [
+ {
+ type: OrderInputType.Token,
+ token_family_slug: slugDiscount1,
+ },
+ ],
+ outputs: [],
+ },
+ {
+ amount: "TESTKUDOS:0",
+ description: "Article via subscription",
+ inputs: [
+ {
+ type: OrderInputType.Token,
+ token_family_slug: slugSubscription1,
+ },
+ ],
+ outputs: [
+ {
+ type: OrderOutputType.Token,
+ token_family_slug: slugSubscription1,
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ // Create the order, claim it, and return its contract terms.
+ const createAndClaim = async (
+ label: string,
+ payDeadline: Duration,
+ ): Promise<any> => {
+ const createResp = succeedOrThrow(
+ await merchantClient.createOrder(
+ merchantAdminAccessToken,
+ makeRequest(payDeadline),
+ ),
+ );
+
+ const ordDet1 = succeedOrThrow(
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ createResp.order_id,
+ ),
+ );
+
+ t.assertDeepEqual(ordDet1.order_status, "unpaid");
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUriV2,
+ {
+ talerPayUri: ordDet1.taler_pay_uri,
+ },
+ );
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: preparePayResult.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+
+ const ordDet2 = succeedOrThrow(
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ createResp.order_id,
+ ),
+ );
+
+ t.assertDeepEqual(ordDet2.order_status, "claimed");
+
+ const ct: any = ordDet2.contract_terms;
+
+ for (const slug of [slugDiscount1, slugSubscription1]) {
+ const fam = ct.token_families[slug];
+ t.assertTrue(fam != null);
+ console.log(
+ `[${label}] family ${slug} has ${fam.keys.length} key(s): ${j2s(fam.keys)}`,
+ );
+ }
+
+ return ct;
+ };
+
+ // First order issues the keys, with a pay deadline that is earlier than
+ // the second order's.
+ const ct1 = await createAndClaim(
+ "order-1",
+ Duration.fromSpec({ minutes: 10 }),
+ );
+ // Second order's pay deadline is beyond the point at which the first
+ // order's key may have its private key deleted.
+ const ct2 = await createAndClaim("order-2", Duration.fromSpec({ days: 365 }));
+
+ for (const slug of [slugDiscount1, slugSubscription1]) {
+ t.assertDeepEqual(ct1.token_families[slug].keys.length, 1);
+ // Exactly one key must be issued for the single validity period
+ // covered by this order.
+ t.assertDeepEqual(ct2.token_families[slug].keys.length, 1);
+ }
+}
+
+runMerchantTokenfamilyKeysTest.suites = ["merchant"];
+// Known to fail: the backend mints a second key for the same validity
+// period once an order's pay deadline is later than the already-issued
+// key's private_key_deleted_at. See bug #11563.
+runMerchantTokenfamilyKeysTest.todo = true;
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -114,6 +114,7 @@ import { runMerchantCategoriesTest } from "./test-merchant-categories.js";
import { runMerchantDepositLargeTest } from "./test-merchant-deposit-large.js";
import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js";
import { runMerchantExchangeDuplicateTest } from "./test-merchant-exchange-duplicate.js";
+import { runMerchantTokenfamilyKeysTest } from "./test-merchant-tokenfamily-keys.js";
import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete.js";
import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js";
import { runMerchantInstancesTest } from "./test-merchant-instances.js";
@@ -285,6 +286,7 @@ const allTests: TestMainFunction[] = [
runExchangeEphemeralTest,
runMerchantExchangeConfusionTest,
runMerchantExchangeDuplicateTest,
+ runMerchantTokenfamilyKeysTest,
runMerchantInstancesDeleteTest,
runMerchantInstancesTest,
runMerchantInstancesUrlsTest,