commit 00522e511628cab0c8f07100f2da34136c89c206
parent da08ac723b8c079fe3058704adc20e916ddbf112
Author: Florian Dold <florian@dold.me>
Date: Wed, 1 Apr 2026 16:45:39 +0200
harness: extended test for payto URI reuse across merchant instances
Diffstat:
4 files changed, 404 insertions(+), 13 deletions(-)
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
@@ -749,6 +749,11 @@ export class FakebankService
return new FakebankService(gc, bc, cfgFilename);
}
+ async getAdminTok(): Promise<AccessToken> {
+ // fakebank does not support token auth.
+ return "secret-token:admin" as AccessToken;
+ }
+
static fromExistingConfig(
gc: GlobalTestState,
opts: { overridePath?: string },
@@ -988,6 +993,16 @@ export class LibeufinBankService
return new LibeufinBankService(gc, bc, cfgFilename);
}
+ async getAdminTok(): Promise<AccessToken> {
+ const bankClient = new TalerCoreBankHttpClient(this.corebankApiBaseUrl);
+ const bankAdminTok = succeedOrThrow(
+ await bankClient.createAccessToken("admin", harnessBankAdminCreds, {
+ scope: "readwrite",
+ }),
+ ).access_token;
+ return bankAdminTok;
+ }
+
static fromExistingConfig(
gc: GlobalTestState,
opts: { overridePath?: string },
@@ -1142,6 +1157,8 @@ export interface BankServiceHandle {
stop(): Promise<void>;
getAdminAuth(): { username: string; password: string };
+ getAdminTok(): Promise<AccessToken>;
+
changeConfig(f: (config: Configuration) => void): void;
}
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-payto-reuse.ts b/packages/taler-harness/src/integrationtests/test-merchant-payto-reuse.ts
@@ -0,0 +1,380 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 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 {
+ AccessToken,
+ Configuration,
+ ConfirmPayResultType,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ KycStatusLongPollingReason,
+ Logger,
+ parsePaytoUriOrThrow,
+ PreparePayResultType,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TalerProtocolDuration,
+ TalerWireGatewayHttpClient,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ configureCommonKyc,
+ createKycTestkudosEnvironmentFull,
+ withdrawViaBankV4,
+} from "../harness/environments.js";
+import {
+ BankService,
+ ExchangeService,
+ getTestHarnessPaytoForLabel,
+ GlobalTestState,
+ MerchantService,
+} from "../harness/harness.js";
+
+const myAmlConfig = `
+# Fallback measure on errors.
+[kyc-measure-freeze-investigate]
+CHECK_NAME = skip
+PROGRAM = freeze-investigate
+VOLUNTARY = NO
+CONTEXT = {}
+
+[aml-program-freeze-investigate]
+DESCRIPTION = "Fallback measure on errors that freezes the account and asks AML staff to investigate the system failure."
+COMMAND = taler-exchange-helper-measure-freeze
+ENABLED = YES
+FALLBACK = freeze-investigate
+
+[aml-program-inform-investigate]
+DESCRIPTION = "Measure that asks AML staff to investigate an account and informs the account owner about it."
+COMMAND = taler-exchange-helper-measure-inform-investigate
+ENABLED = YES
+FALLBACK = freeze-investigate
+
+[kyc-check-form-gls-merchant-onboarding]
+TYPE = FORM
+FORM_NAME = gls-merchant-onboarding
+DESCRIPTION = "GLS Merchant Onboarding"
+DESCRIPTION_I18N = {}
+OUTPUTS =
+FALLBACK = freeze-investigate
+
+[kyc-measure-merchant-onboarding]
+CHECK_NAME = form-gls-merchant-onboarding
+PROGRAM = inform-investigate
+CONTEXT = {}
+VOLUNTARY = NO
+
+[kyc-rule-deposit-limit-zero]
+OPERATION_TYPE = DEPOSIT
+NEXT_MEASURES = merchant-onboarding
+EXPOSED = YES
+ENABLED = YES
+THRESHOLD = TESTKUDOS:1
+TIMEFRAME = "1 days"
+`;
+
+function adjustExchangeConfig(config: Configuration) {
+ configureCommonKyc(config);
+ config.loadFromString(myAmlConfig);
+}
+
+const logger = new Logger("test-merchant-kyc-auth-multi.ts");
+
+async function doAccountKycAuth(
+ t: GlobalTestState,
+ args: {
+ exchange: ExchangeService;
+ merchant: MerchantService;
+ bank: BankService;
+ merchantInstId: string;
+ merchantInstPaytoUri: string;
+ merchantAccessToken: AccessToken;
+ wireGatewayApi: TalerWireGatewayHttpClient;
+ },
+): Promise<void> {
+ const {
+ merchant,
+ exchange,
+ merchantInstId,
+ merchantAccessToken,
+ merchantInstPaytoUri,
+ bank,
+ wireGatewayApi,
+ } = args;
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(merchantInstId),
+ );
+ {
+ const kycRes1 = succeedOrThrow(
+ await merchantClient.getCurrentInstanceKycStatus(merchantAccessToken, {}),
+ );
+ console.log(`kyc res: ${j2s(kycRes1)}`);
+ const myRow = kycRes1.kyc_data.find(
+ (x) => x.exchange_url === exchange.baseUrl,
+ );
+ t.assertTrue(
+ myRow?.payto_kycauths != null && myRow.payto_kycauths.length == 1,
+ );
+ const authTxPayto = parsePaytoUriOrThrow(myRow.payto_kycauths[0]);
+ const authTxMessage = authTxPayto?.params["message"];
+ t.assertTrue(typeof authTxMessage === "string");
+ t.assertTrue(authTxMessage.startsWith("KYC:"));
+ const accountPub = authTxMessage.substring(4);
+ logger.info(`merchant account pub: ${accountPub}`);
+ await wireGatewayApi.addKycAuth({
+ auth: bank.getAdminAuth(),
+ body: {
+ amount: "TESTKUDOS:0.1",
+ debit_account: merchantInstPaytoUri,
+ account_pub: accountPub,
+ },
+ });
+ }
+
+ // Wait for auth transfer to be registered by the exchange
+ {
+ const kycStatus = await merchantClient.getCurrentInstanceKycStatus(
+ merchantAccessToken,
+ {
+ reason: KycStatusLongPollingReason.AUTH_TRANSFER,
+ timeout: 30000,
+ },
+ );
+ logger.info(`kyc status after transfer: ${j2s(kycStatus)}`);
+ t.assertDeepEqual(kycStatus.case, "ok");
+ const myRow = kycStatus.body.kyc_data.find(
+ (x) =>
+ x.exchange_url === exchange.baseUrl &&
+ x.payto_uri === merchantInstPaytoUri,
+ );
+ t.assertTrue(myRow != null);
+ t.assertDeepEqual(myRow.status, "ready");
+ t.assertTrue(typeof myRow.access_token === "string");
+ }
+}
+
+/**
+ * Test for multiple merchant instances using the same
+ * bank account (with multiple public keys and thus KYC auth transfers).
+ */
+export async function runMerchantPaytoReuseTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ bankClient,
+ merchant,
+ bank,
+ exchange,
+ merchantAdminAccessToken,
+ wireGatewayApi,
+ walletClient,
+ } = await createKycTestkudosEnvironmentFull(t, { adjustExchangeConfig });
+
+ const wres = await withdrawViaBankV4(t, {
+ walletClient,
+ bank,
+ amount: "TESTKUDOS:30",
+ exchange,
+ bankAdminTok: await bank.getAdminTok(),
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const merchantInstPaytoUri = getTestHarnessPaytoForLabel("mymerchant");
+
+ await bankClient.registerAccountExtended({
+ name: "mymerchant",
+ password: encodeCrock(getRandomBytes(32)),
+ username: "mymerchant",
+ payto_uri: merchantInstPaytoUri,
+ });
+
+ const merchantInstId2 = "minst2";
+ const merchantInstId1 = "minst1";
+
+ let minst1AccessToken: AccessToken;
+
+ {
+ const m1Res = await merchant.addInstanceWithWireAccount(
+ {
+ id: merchantInstId1,
+ name: merchantInstId1,
+ paytoUris: [merchantInstPaytoUri],
+ defaultWireTransferDelay: TalerProtocolDuration.fromSpec({
+ minutes: 1,
+ }),
+ },
+ { adminAccessToken: merchantAdminAccessToken },
+ );
+
+ minst1AccessToken = m1Res.accessToken;
+
+ await merchant.runExchangekeyupdateOnce();
+
+ t.logStep("start first-account-kyc-auth");
+
+ await doAccountKycAuth(t, {
+ exchange,
+ merchant,
+ bank,
+ merchantInstId: merchantInstId1,
+ merchantAccessToken: minst1AccessToken,
+ merchantInstPaytoUri,
+ wireGatewayApi,
+ });
+ }
+
+ t.logStep("first-account-ready");
+
+ let minst2AccessToken: AccessToken;
+
+ {
+ const m2Res = await merchant.addInstanceWithWireAccount(
+ {
+ id: merchantInstId2,
+ name: merchantInstId2,
+ paytoUris: [merchantInstPaytoUri],
+ defaultWireTransferDelay: TalerProtocolDuration.fromSpec({
+ minutes: 1,
+ }),
+ },
+ { adminAccessToken: merchantAdminAccessToken },
+ );
+
+ await merchant.runKyccheckOnce();
+
+ minst2AccessToken = m2Res.accessToken;
+
+ t.logStep("start second-account-kyc-auth");
+
+ await doAccountKycAuth(t, {
+ exchange,
+ merchant,
+ bank,
+ merchantInstId: merchantInstId2,
+ merchantAccessToken: m2Res.accessToken,
+ merchantInstPaytoUri,
+ wireGatewayApi,
+ });
+ }
+
+ t.logStep("second-account-ready");
+
+ {
+ const m1Client = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(merchantInstId1),
+ );
+
+ const orderResp = succeedOrThrow(
+ await m1Client.createOrder(minst1AccessToken, {
+ order: {
+ amount: "TESTKUDOS:1",
+ summary: "test1",
+ },
+ }),
+ );
+
+ let orderStatus = succeedOrThrow(
+ await m1Client.getOrderDetails(minst1AccessToken, orderResp.order_id),
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertDeepEqual(r2.type, ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ orderStatus = succeedOrThrow(
+ await m1Client.getOrderDetails(minst1AccessToken, orderResp.order_id),
+ );
+
+ t.assertDeepEqual(orderStatus.order_status, "paid");
+ }
+
+ {
+ const m2Client = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(merchantInstId2),
+ );
+
+ const orderResp = succeedOrThrow(
+ await m2Client.createOrder(minst2AccessToken, {
+ order: {
+ amount: "TESTKUDOS:1",
+ summary: "test2",
+ },
+ }),
+ );
+
+ let orderStatus = succeedOrThrow(
+ await m2Client.getOrderDetails(minst2AccessToken, orderResp.order_id),
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertDeepEqual(r2.type, ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ orderStatus = succeedOrThrow(
+ await m2Client.getOrderDetails(minst2AccessToken, orderResp.order_id),
+ );
+
+ t.assertDeepEqual(orderStatus.order_status, "paid");
+ }
+}
+
+runMerchantPaytoReuseTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wire-metadata.ts b/packages/taler-harness/src/integrationtests/test-wire-metadata.ts
@@ -43,7 +43,6 @@ import {
ExchangeService,
getTestHarnessPaytoForLabel,
GlobalTestState,
- harnessBankAdminCreds,
HarnessExchangeBankAccount,
LibeufinBankService,
MerchantService,
@@ -99,11 +98,7 @@ export async function runWireMetadataTest(t: GlobalTestState) {
await bank.start();
const bankClient = new TalerCoreBankHttpClient(bank.corebankApiBaseUrl);
- const bankAdminTok = succeedOrThrow(
- await bankClient.createAccessToken("admin", harnessBankAdminCreds, {
- scope: "readwrite",
- }),
- ).access_token;
+ const bankAdminTok = await bank.getAdminTok();
await bankClient.createAccount(bankAdminTok, {
name: receiverName,
@@ -194,13 +189,10 @@ export async function runWireMetadataTest(t: GlobalTestState) {
}),
);
- const { walletClient, walletService } = await createWalletDaemonWithClient(
- t,
- {
- name: "wallet",
- persistent: true,
- },
- );
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ persistent: true,
+ });
const wres = await withdrawViaBankV4(t, {
walletClient,
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -114,6 +114,7 @@ import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js"
import { runMerchantInstancesTest } from "./test-merchant-instances.js";
import { runMerchantKycAuthMultiTest } from "./test-merchant-kyc-auth-multi.js";
import { runMerchantLongpollingTest } from "./test-merchant-longpolling.js";
+import { runMerchantPaytoReuseTest } from "./test-merchant-payto-reuse.js";
import { runMerchantRefundApiTest } from "./test-merchant-refund-api.js";
import { runMerchantReportsTest } from "./test-merchant-reports.js";
import { runMerchantSelfProvisionActivationAndLoginTest } from "./test-merchant-self-provision-activation-and-login.js";
@@ -430,6 +431,7 @@ const allTests: TestMainFunction[] = [
runWalletWithdrawalRedenominateTest,
runMerchantDepositLargeTest,
runWireMetadataTest,
+ runMerchantPaytoReuseTest,
];
export interface TestRunSpec {