commit 407e080c9c7de7008ec96c0970be378fff5bf86b
parent ac60a4467b5eaa8f8c431ee9c32b29a4bb290db2
Author: Florian Dold <florian@dold.me>
Date: Fri, 26 Jun 2026 02:12:48 +0200
harness: test for merchant /kycauth endpoint in TOPS-style environment
Diffstat:
5 files changed, 277 insertions(+), 167 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-tops-merchant-kycauths.ts b/packages/taler-harness/src/integrationtests/test-tops-merchant-kycauths.ts
@@ -1,165 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2025 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 {
- IbanString,
- j2s,
- Logger,
- MerchantAccountKycStatus,
- Paytos,
- TalerMerchantInstanceHttpClient,
- TalerProtocolDuration,
-} from "@gnu-taler/taler-util";
-import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
-import { startFakeChallenger } from "../harness/fake-challenger.js";
-import {
- ExchangeService,
- GlobalTestState,
- LibeufinNexusService,
- MerchantService,
- NexusBankAccountInfo,
- setupDb,
-} from "../harness/harness.js";
-import { topsKycRulesConf, topsProvidersTestConf } from "../harness/tops.js";
-
-const logger = new Logger("test-tops-merchant-kycauths.ts");
-
-/**
- * Test short wire transfers in an exchange setup that simulates
- * the Taler Operations CH exchange, using libeufin-nexus
- * as the taler wire gateway API.
- */
-export async function runTopsMerchantKycauthsTest(t: GlobalTestState) {
- const db = await setupDb(t);
-
- const challenger = await startFakeChallenger({
- port: 6001,
- addressType: "postal-ch",
- });
-
- let coinConfig: CoinConfig[];
- coinConfig = defaultCoinConfig.map((x) => x("CHF"));
-
- const bankAccountInfo: NexusBankAccountInfo = {
- // Random IBAN generated via "libeufin-nexus testing iban gen --country CH"
- iban: "CH7347363QVFHHFR8BWWB",
- // PostFinance BIC
- bic: "POFICHBEXXX",
- name: "Harness Test Exchange",
- qrIban: "CH1130000001166556117",
- };
-
- const nexus = await LibeufinNexusService.create(t, {
- currency: "CHF",
- database: db.connStr,
- httpPort: 8085,
- bankAccountInfo,
- });
-
- const exchangePayto = Paytos.toFullString(
- Paytos.createIban(bankAccountInfo.qrIban as IbanString, undefined, {
- "receiver-name": bankAccountInfo.name,
- }),
- );
-
- await nexus.dbinit();
-
- await nexus.start();
-
- const exchange = ExchangeService.create(t, {
- name: "testexchange-1",
- currency: "CHF",
- httpPort: 8081,
- database: db.connStr,
- // FIXME: This is a terrible way to configure the exchange, should be moved into config.
- extraProcEnv: {
- EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "v1",
- EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_THRESHOLD: "CHF:0",
- EXCHANGE_AML_PROGRAM_TOPS_POSTAL_CHECK_COUNTRY_REGEX: "ch|CH|Ch",
- },
- });
-
- exchange.addBankAccount("nexusacct", {
- accountPaytoUri: exchangePayto,
- wireGatewayApiBaseUrl: nexus.wireGatewayApiBaseUrl,
- wireGatewayAuth: {
- type: "basic",
- username: "exchange-test",
- password: "exchange-test",
- },
- preparedTransferUrl: nexus.preparedTransferApiBaseUrl,
- });
-
- exchange.addCoinConfigList(coinConfig);
-
- await exchange.modifyConfig(async (config) => {
- config.loadFromString(topsKycRulesConf);
- config.loadFromString(topsProvidersTestConf);
- });
-
- await exchange.start();
-
- const merchantIban = "CH4810303KL83V1QZ05T7";
-
- const merchant = await MerchantService.create(t, {
- name: "testmerchant-1",
- httpPort: 8083,
- database: db.connStr,
- });
-
- merchant.addExchange(exchange);
-
- await merchant.start();
-
- const merchantPayto = Paytos.toFullString(
- Paytos.createIban(merchantIban as IbanString, undefined, {
- "receiver-name": "Merchant Test",
- }),
- );
-
- const { accessToken: merchantAdminAccessToken } =
- await merchant.addInstanceWithWireAccount({
- id: "admin",
- name: "Default Instance",
- paytoUris: [merchantPayto],
- defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }),
- });
-
- const merchantClient = new TalerMerchantInstanceHttpClient(
- merchant.makeInstanceBaseUrl(),
- );
- // Do KYC auth transfer
-
- const kycStatus = await merchantClient.getCurrentInstanceKycStatus(
- merchantAdminAccessToken,
- {
- longpoll: {
- type: "state-enter",
- status: MerchantAccountKycStatus.KYC_WIRE_REQUIRED,
- timeout: 10000,
- },
- },
- );
- t.assertTrue(kycStatus.type === "ok");
- console.log(`kyc status: ${j2s(kycStatus)}`);
-
- t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-wire-required");
-}
-
-runTopsMerchantKycauthsTest.suites = ["tops", "libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts b/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts
@@ -0,0 +1,187 @@
+/*
+ This file is part of GNU Taler
+ (C) 2025 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 {
+ IbanString,
+ j2s,
+ Logger,
+ MerchantAccountKycStatus,
+ Paytos,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TalerProtocolDuration,
+} from "@gnu-taler/taler-util";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import { startFakeChallenger } from "../harness/fake-challenger.js";
+import {
+ ExchangeService,
+ GlobalTestState,
+ LibeufinNexusService,
+ MerchantService,
+ NexusBankAccountInfo,
+ setupDb,
+} from "../harness/harness.js";
+import { topsKycRulesConf, topsProvidersTestConf } from "../harness/tops.js";
+
+const logger = new Logger("test-tops-merchant-kycauths.ts");
+
+/**
+ * Test short wire transfers in an exchange setup that simulates
+ * the Taler Operations CH exchange, using libeufin-nexus
+ * as the taler wire gateway API.
+ *
+ * This test exercises the .../kycauths endpoint of the merchant backend.
+ */
+export async function runTopsMerchantSwtKycauthTest(t: GlobalTestState) {
+ const db = await setupDb(t);
+
+ const challenger = await startFakeChallenger({
+ port: 6001,
+ addressType: "postal-ch",
+ });
+
+ let coinConfig: CoinConfig[];
+ coinConfig = defaultCoinConfig.map((x) => x("CHF"));
+
+ const bankAccountInfo: NexusBankAccountInfo = {
+ // Random IBAN generated via "libeufin-nexus testing iban gen --country CH"
+ iban: "CH7347363QVFHHFR8BWWB",
+ // PostFinance BIC
+ bic: "POFICHBEXXX",
+ name: "Harness Test Exchange",
+ qrIban: "CH1130000001166556117",
+ };
+
+ const nexus = await LibeufinNexusService.create(t, {
+ currency: "CHF",
+ database: db.connStr,
+ httpPort: 8085,
+ bankAccountInfo,
+ });
+
+ const exchangePayto = Paytos.toFullString(
+ Paytos.createIban(bankAccountInfo.qrIban as IbanString, undefined, {
+ "receiver-name": bankAccountInfo.name,
+ }),
+ );
+
+ await nexus.dbinit();
+
+ await nexus.start();
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "CHF",
+ httpPort: 8081,
+ database: db.connStr,
+ // FIXME: This is a terrible way to configure the exchange, should be moved into config.
+ extraProcEnv: {
+ EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "v1",
+ EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_THRESHOLD: "CHF:0",
+ EXCHANGE_AML_PROGRAM_TOPS_POSTAL_CHECK_COUNTRY_REGEX: "ch|CH|Ch",
+ },
+ });
+
+ exchange.addBankAccount("nexusacct", {
+ accountPaytoUri: exchangePayto,
+ wireGatewayApiBaseUrl: nexus.wireGatewayApiBaseUrl,
+ wireGatewayAuth: {
+ type: "basic",
+ username: "exchange-test",
+ password: "exchange-test",
+ },
+ preparedTransferUrl: nexus.preparedTransferApiBaseUrl,
+ });
+
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.modifyConfig(async (config) => {
+ config.loadFromString(topsKycRulesConf);
+ config.loadFromString(topsProvidersTestConf);
+ });
+
+ await exchange.start();
+
+ const merchantIban = "CH4810303KL83V1QZ05T7";
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+
+ const merchantPayto = Paytos.toFullString(
+ Paytos.createIban(merchantIban as IbanString, undefined, {
+ "receiver-name": "Merchant Test",
+ }),
+ );
+
+ const { accessToken: merchantAdminAccessToken } =
+ await merchant.addInstanceWithWireAccount({
+ id: "admin",
+ name: "Default Instance",
+ paytoUris: [merchantPayto],
+ defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }),
+ });
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+ // Do KYC auth transfer
+
+ const kycStatus = await merchantClient.getCurrentInstanceKycStatus(
+ merchantAdminAccessToken,
+ {
+ longpoll: {
+ type: "state-enter",
+ status: MerchantAccountKycStatus.KYC_WIRE_REQUIRED,
+ timeout: 10000,
+ },
+ },
+ );
+ t.assertTrue(kycStatus.type === "ok");
+ console.log(`kyc status: ${j2s(kycStatus)}`);
+
+ t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-wire-required");
+
+ const kycData = kycStatus.body.kyc_data[0];
+
+ const kycResp = succeedOrThrow(
+ await merchantClient.getInstancePrivateAccountKycauth(
+ merchantAdminAccessToken,
+ {
+ exchangeBaseUrl: kycData.exchange_url,
+ wireAccountHash: kycData.h_wire,
+ },
+ ),
+ );
+
+ console.log(j2s(kycResp));
+
+ t.assertTrue(
+ kycResp.wire_instructions.length > 0,
+ "expected at least one wire instruction",
+ );
+}
+
+runTopsMerchantSwtKycauthTest.suites = ["tops", "libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -173,7 +173,7 @@ import { runTopsAmlLegiTest } from "./test-tops-aml-legi.js";
import { runTopsAmlMeasuresTest } from "./test-tops-aml-measures.js";
import { runTopsAmlPdfTest } from "./test-tops-aml-pdf.js";
import { runTopsChallengerTwiceTest } from "./test-tops-challenger-twice.js";
-import { runTopsMerchantKycauthsTest } from "./test-tops-merchant-kycauths.js";
+import { runTopsMerchantSwtKycauthTest } from "./test-tops-merchant-swt-kycauth.js";
import { runTopsMerchantTosTest } from "./test-tops-merchant-tos.js";
import { runTopsNexusBasicTest } from "./test-tops-nexus-basic.js";
import { runTopsNexusSwtTest } from "./test-tops-nexus-swt.js";
@@ -443,7 +443,7 @@ const allTests: TestMainFunction[] = [
runMerchantTemplatesTest,
runTopsNexusBasicTest,
runTopsNexusSwtTest,
- runTopsMerchantKycauthsTest,
+ runTopsMerchantSwtKycauthTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -55,6 +55,7 @@ import {
codecForGroupsSummaryResponse,
codecForInstancesResponse,
codecForInventorySummaryResponse,
+ codecForKycAuthWireTransferInstructionResponse,
codecForLoginTokenSuccessResponse,
codecForMerchantOrderPrivateStatusResponse,
codecForMerchantRefundResponse,
@@ -872,6 +873,48 @@ export class TalerMerchantInstanceHttpClient {
}
/**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-accounts-$H_WIRE-kycauth
+ */
+ async getInstancePrivateAccountKycauth(
+ token: AccessToken,
+ params: {
+ wireAccountHash: string;
+ exchangeBaseUrl: string;
+ },
+ ) {
+ const url = new URL(
+ `private/accounts/${params.wireAccountHash}/kycauth`,
+ this.baseUrl,
+ );
+
+ const headers: Record<string, string> = {};
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+
+ const cancellationToken = this.cancellationToken;
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers,
+ cancellationToken,
+ body: {
+ exchange_url: params.exchangeBaseUrl,
+ },
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ return await opSuccessFromHttp(
+ resp,
+ codecForKycAuthWireTransferInstructionResponse(),
+ );
+ }
+ case HttpStatusCode.NotFound: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ /**
* https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-kyc
*/
async getCurrentInstanceKycStatus(
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -87,6 +87,10 @@ import {
codecForInternationalizedString,
codecForURLString,
} from "./types-taler-common.js";
+import {
+ TransferSubject,
+ codecForTransferSubject,
+} from "./types-taler-prepared-transfer.js";
import { PayWalletData, codecForCanonBaseUrl } from "./types-taler-wallet.js";
/**
@@ -6004,3 +6008,44 @@ export const codecForPotDetailResponse = (): Codec<PotDetailResponse> =>
.property("pot_name", codecForString())
.property("pot_totals", codecForList(codecForAmountString()))
.build("TalerMerchantApi.PotDetailResponse");
+
+export interface KycAuthWireTransferInstructionResponse {
+ // Array of possible wire transfers to do. There might
+ // be more than one possibility, for example if the
+ // exchange supports multiple bank accounts.
+ wire_instructions: WireTransferInstructionDetail[];
+}
+
+export interface WireTransferInstructionDetail {
+ // Amount to wire (minimum amount, as per tiny_amount
+ // of the /keys of the exchange).
+ amount: AmountString;
+
+ // Full payto:// URL of the (exchange) bank account to which the
+ // money should be sent, excludes the wire subject and the amount.
+ target_payto: string;
+
+ // Subject to use. See also DD80!
+ subject: TransferSubject;
+
+ // Expiration date after which this subject might be reused
+ expiration: Timestamp;
+}
+
+export const codecForKycAuthWireTransferInstructionResponse =
+ (): Codec<KycAuthWireTransferInstructionResponse> =>
+ buildCodecForObject<KycAuthWireTransferInstructionResponse>()
+ .property(
+ "wire_instructions",
+ codecForList(codecForWireTransferInstructionDetail()),
+ )
+ .build("WireTransferInstructionDetail");
+
+export const codecForWireTransferInstructionDetail =
+ (): Codec<WireTransferInstructionDetail> =>
+ buildCodecForObject<WireTransferInstructionDetail>()
+ .property("amount", codecForAmountString())
+ .property("target_payto", codecForString())
+ .property("subject", codecForTransferSubject())
+ .property("expiration", codecForTimestamp)
+ .build("WireTransferInstructionDetail");