commit 119c423765650e10263ce8462e9fbbffd6506eb1
parent 4307c0a849e4b7f532161a5f8b7f544704ab2ff0
Author: Florian Dold <florian@dold.me>
Date: Mon, 23 Feb 2026 19:55:09 +0100
harness: add for multiple instances with same bank account in merchant
Diffstat:
4 files changed, 310 insertions(+), 4 deletions(-)
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
@@ -2238,6 +2238,15 @@ export class MerchantService implements MerchantServiceInterface {
);
}
+ async runExchangekeyupdateOnce() {
+ await runCommand(
+ this.globalState,
+ `merchant-${this.name}-exchangekeyupdate-once`,
+ "taler-merchant-exchangekeyupdate",
+ [...this.timetravelArgArr, "-LINFO", "-c", this.configFilename, "-t"],
+ );
+ }
+
async runDonaukeyupdateOnce() {
await runCommand(
this.globalState,
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-kyc-auth-multi.ts b/packages/taler-harness/src/integrationtests/test-merchant-kyc-auth-multi.ts
@@ -0,0 +1,279 @@
+/*
+ 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 {
+ Configuration,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ KycStatusLongPollingReason,
+ Logger,
+ parsePaytoUriOrThrow,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TalerProtocolDuration,
+} from "@gnu-taler/taler-util";
+import {
+ configureCommonKyc,
+ createKycTestkudosEnvironment,
+} from "../harness/environments.js";
+import {
+ getTestHarnessPaytoForLabel,
+ GlobalTestState,
+} 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");
+
+/**
+ * Test for multiple merchant instances using the same
+ * bank account (with multiple public keys and thus KYC auth transfers).
+ */
+export async function runMerchantKycAuthMultiTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ bankClient,
+ exchangeBankAccount,
+ exchangeApi,
+ merchant,
+ bank,
+ exchange,
+ merchantAdminAccessToken,
+ wireGatewayApi,
+ } = await createKycTestkudosEnvironment(t, { adjustExchangeConfig });
+
+ const merchantPayto = getTestHarnessPaytoForLabel("merchant-default");
+
+ await bankClient.registerAccountExtended({
+ name: "merchant-default",
+ password: encodeCrock(getRandomBytes(32)),
+ username: "merchant-default",
+ payto_uri: merchantPayto,
+ });
+
+ {
+ const merchantInstId = "minst1";
+ const merchantInstPaytoUri = getTestHarnessPaytoForLabel(merchantInstId);
+
+ const m1Res = await merchant.addInstanceWithWireAccount(
+ {
+ id: merchantInstId,
+ name: merchantInstId,
+ paytoUris: [merchantInstPaytoUri],
+ defaultWireTransferDelay: TalerProtocolDuration.fromSpec({
+ minutes: 1,
+ }),
+ },
+ { adminAccessToken: merchantAdminAccessToken },
+ );
+
+ await bankClient.registerAccountExtended({
+ name: merchantInstId,
+ password: encodeCrock(getRandomBytes(32)),
+ username: merchantInstId,
+ payto_uri: merchantInstPaytoUri,
+ });
+
+ await merchant.runExchangekeyupdateOnce();
+ await merchant.runKyccheckOnce();
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl("minst1"),
+ );
+ {
+ const kycRes1 = succeedOrThrow(
+ await merchantClient.getCurrentInstanceKycStatus(m1Res.accessToken, {}),
+ );
+ console.log(`kyc res: ${j2s(kycRes1)}`);
+ t.assertDeepEqual(kycRes1.kycRequired, true);
+ 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(
+ m1Res.accessToken,
+ {
+ reason: KycStatusLongPollingReason.AUTH_TRANSFER,
+ timeout: 30000,
+ },
+ );
+ logger.info(`kyc status after transfer: ${j2s(kycStatus)}`);
+ t.assertDeepEqual(kycStatus.case, "ok");
+ t.assertTrue(kycStatus.body.kycRequired === true);
+ const myRow = kycStatus.body.kyc_data.find(
+ (x) => x.exchange_url === exchange.baseUrl,
+ );
+ t.assertTrue(myRow != null);
+ t.assertDeepEqual(myRow.status, "ready");
+ t.assertTrue(typeof myRow.access_token === "string");
+ }
+ }
+
+ t.logStep("fist-account-ready");
+
+ {
+ const merchantInstId = "minst2";
+ const merchantInstPaytoUri = getTestHarnessPaytoForLabel(merchantInstId);
+
+ const m1Res = await merchant.addInstanceWithWireAccount(
+ {
+ id: merchantInstId,
+ name: merchantInstId,
+ paytoUris: [merchantInstPaytoUri],
+ defaultWireTransferDelay: TalerProtocolDuration.fromSpec({
+ minutes: 1,
+ }),
+ },
+ { adminAccessToken: merchantAdminAccessToken },
+ );
+
+ await bankClient.registerAccountExtended({
+ name: merchantInstId,
+ password: encodeCrock(getRandomBytes(32)),
+ username: merchantInstId,
+ payto_uri: merchantInstPaytoUri,
+ });
+
+ await merchant.runExchangekeyupdateOnce();
+ await merchant.runKyccheckOnce();
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl("minst1"),
+ );
+ {
+ const kycRes1 = succeedOrThrow(
+ await merchantClient.getCurrentInstanceKycStatus(m1Res.accessToken, {}),
+ );
+ console.log(`kyc res: ${j2s(kycRes1)}`);
+ t.assertDeepEqual(kycRes1.kycRequired, true);
+ 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(
+ m1Res.accessToken,
+ {
+ reason: KycStatusLongPollingReason.AUTH_TRANSFER,
+ timeout: 30000,
+ },
+ );
+ logger.info(`kyc status after transfer: ${j2s(kycStatus)}`);
+ t.assertDeepEqual(kycStatus.case, "ok");
+ t.assertTrue(kycStatus.body.kycRequired === true);
+ const myRow = kycStatus.body.kyc_data.find(
+ (x) => x.exchange_url === exchange.baseUrl,
+ );
+ t.assertTrue(myRow != null);
+ t.assertDeepEqual(myRow.status, "ready");
+ t.assertTrue(typeof myRow.access_token === "string");
+ }
+ }
+
+ t.logStep("second-account-ready");
+}
+
+runMerchantKycAuthMultiTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -112,6 +112,7 @@ import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confu
import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete.js";
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 { runMerchantRefundApiTest } from "./test-merchant-refund-api.js";
import { runMerchantReportsTest } from "./test-merchant-reports.js";
@@ -423,6 +424,7 @@ const allTests: TestMainFunction[] = [
runWalletRefreshRedenominateTest,
runMerchantReportsTest,
runExchangeMerchantKycAuthTest,
+ runMerchantKycAuthMultiTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -171,6 +171,16 @@ export enum TalerMerchantManagementCacheEviction {
DELETE_INSTANCE,
}
+export type MerchantKycStatusResult =
+ | {
+ kycRequired: false | undefined;
+ }
+ | {
+ kycRequired: true;
+ kyc_data: TalerMerchantApi.MerchantAccountKycRedirect[];
+ etag?: string;
+ };
+
/**
* Protocol version spoken with the core bank.
*
@@ -248,9 +258,9 @@ export class TalerMerchantInstanceHttpClient {
| OperationFail<HttpStatusCode.NotFound>
| OperationOk<TalerMerchantApi.LoginTokenSuccessResponse>
| OperationAlternative<
- HttpStatusCode.Accepted,
- TalerMerchantApi.ChallengeResponse
- >
+ HttpStatusCode.Accepted,
+ TalerMerchantApi.ChallengeResponse
+ >
| OperationFail<HttpStatusCode.Unauthorized>
> {
const url = new URL(`private/token`, this.baseUrl);
@@ -831,7 +841,13 @@ export class TalerMerchantInstanceHttpClient {
async getCurrentInstanceKycStatus(
token: AccessToken,
params: TalerMerchantApi.GetKycStatusRequestParams = {},
- ) {
+ ): Promise<
+ | OperationOk<MerchantKycStatusResult>
+ | OperationFail<HttpStatusCode.Unauthorized>
+ | OperationFail<HttpStatusCode.NotFound>
+ | OperationFail<HttpStatusCode.ServiceUnavailable>
+ | OperationFail<HttpStatusCode.GatewayTimeout>
+ > {
const url = new URL(`private/kyc`, this.baseUrl);
if (params.wireHash) {