taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Apackages/taler-harness/src/integrationtests/test-merchant-tokenfamily-keys.ts | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
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,