taler-typescript-core

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

commit bb2b54eacb9f63d7b5f8613070e2a29fb82dccf3
parent a733e533900718c6cc1a9860037ad2c99cc970e1
Author: Florian Dold <dold@taler.net>
Date:   Tue, 18 Aug 2026 23:19:10 +0200

taler-harness: test KYC status notifications

Issue: https://bugs.taler.net/n/11244

Diffstat:
Mpackages/taler-harness/src/harness/harness.ts | 9+++++++++
Apackages/taler-harness/src/integrationtests/test-merchant-kyc-notifications.ts | 359+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
3 files changed, 370 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -2504,6 +2504,15 @@ export class MerchantService implements MerchantServiceInterface { ); } + async runReportGeneratorOnce() { + await runCommand( + this.globalState, + `merchant-${this.name}-report-generator-once`, + "taler-merchant-report-generator", + [...this.timetravelArgArr, "-LINFO", "-c", this.configFilename, "-t"], + ); + } + async runExchangekeyupdateOnce() { await runCommand( this.globalState, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-kyc-notifications.ts b/packages/taler-harness/src/integrationtests/test-merchant-kyc-notifications.ts @@ -0,0 +1,359 @@ +/* + 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/> + */ + +import { + AccessToken, + Configuration, + Logger, + PaytoString, + TalerMerchantApi, + TalerMerchantInstanceHttpClient, + succeedOrThrow, +} from "@gnu-taler/taler-util"; +import * as fs from "node:fs"; +import { createKycTestkudosEnvironmentFull } from "../harness/environments.js"; +import { + delayMs, + getTestHarnessPaytoForLabel, + GlobalTestState, + harnessHttpLib, + MerchantService, + runCommand, +} from "../harness/harness.js"; + +const logger = new Logger("test-merchant-kyc-notifications.ts"); + +function sqlLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +async function psql( + t: GlobalTestState, + connStr: string, + label: string, + sql: string, +): Promise<string> { + return await runCommand(t, label, "psql", [ + connStr, + "-X", + "-A", + "-t", + "-v", + "ON_ERROR_STOP=1", + "-c", + sql, + ]); +} + +async function getInstanceSchema( + t: GlobalTestState, + connStr: string, +): Promise<string> { + const merchantSerial = ( + await psql( + t, + connStr, + "merchant-instance-serial", + "SELECT merchant_serial" + + " FROM merchant.merchant_instances" + + " WHERE merchant_id='admin'", + ) + ).trim(); + t.assertTrue( + /^[0-9]+$/.test(merchantSerial), + `invalid merchant serial returned by PostgreSQL: ${merchantSerial}`, + ); + return `merchant_instance_${merchantSerial}`; +} + +async function countKycReports( + t: GlobalTestState, + connStr: string, + instanceSchema: string, +): Promise<number> { + const result = await psql( + t, + connStr, + "count-kyc-notification-reports", + `SELECT COUNT(*) FROM ${instanceSchema}.merchant_reports` + + " WHERE one_shot_hidden" + + " AND report_description='automatically triggered KYC alert'", + ); + return Number.parseInt(result.trim(), 10); +} + +async function waitForKycRecord( + t: GlobalTestState, + connStr: string, + instanceSchema: string, + merchant: MerchantService, +): Promise<void> { + for (let i = 0; i < 20; i++) { + const result = await psql( + t, + connStr, + "count-merchant-kyc-records", + `SELECT COUNT(*) FROM ${instanceSchema}.merchant_kyc`, + ); + if (Number.parseInt(result.trim(), 10) > 0) { + return; + } + await merchant.runKyccheckOnce(); + await delayMs(250); + } + t.fail("merchant KYC checker did not create a KYC status record"); +} + +function compareKycEntries( + a: TalerMerchantApi.MerchantAccountKycRedirect, + b: TalerMerchantApi.MerchantAccountKycRedirect, +): number { + if (a.h_wire < b.h_wire) { + return -1; + } + if (a.h_wire > b.h_wire) { + return 1; + } + if (a.exchange_url < b.exchange_url) { + return -1; + } + if (a.exchange_url > b.exchange_url) { + return 1; + } + return 0; +} + +async function waitForStableKycResponse( + t: GlobalTestState, + merchantAccessToken: AccessToken, + merchantApi: TalerMerchantInstanceHttpClient, + expectedEntries: number, +) { + let previousFingerprint: string | undefined; + for (let i = 0; i < 30; i++) { + const response = succeedOrThrow( + await merchantApi.getCurrentInstanceKycStatus(merchantAccessToken), + ); + const fingerprint = JSON.stringify(response); + if ( + response.kyc_data.length === expectedEntries && + fingerprint === previousFingerprint + ) { + return response; + } + previousFingerprint = fingerprint; + await delayMs(250); + } + throw Error("KYC response did not become stable"); +} + +/** + * Test automatic KYC notifications and deterministic multi-account output. + * + * Regression test for https://bugs.gnunet.org/view.php?id=11244 and + * https://bugs.gnunet.org/view.php?id=11242. + */ +export async function runMerchantKycNotificationsTest(t: GlobalTestState) { + const notificationTarget = `${t.testDir}/kyc-notification`; + const notificationFile = `${notificationTarget}.txt`; + + const { + commonDb, + exchange, + merchant, + merchantApi, + merchantAdminAccessToken, + } = await createKycTestkudosEnvironmentFull(t, { + adjustMerchantConfig(config: Configuration) { + config.setString("merchant", "base_url", "http://localhost:8083/"); + config.setString( + "report-generator-email", + "binary", + "taler-merchant-report-generator-file", + ); + }, + }); + + const instanceSchema = await getInstanceSchema(t, commonDb.connStr); + await waitForKycRecord(t, commonDb.connStr, instanceSchema, merchant); + + await psql( + t, + commonDb.connStr, + "enable-kyc-notifications", + "UPDATE merchant.merchant_instances" + + ` SET email=${sqlLiteral(notificationTarget)}` + + ", notification_language='en'" + + " WHERE merchant_id='admin'", + ); + + t.assertDeepEqual( + await countKycReports(t, commonDb.connStr, instanceSchema), + 0, + ); + t.assertTrue(!fs.existsSync(notificationFile)); + + // last_rule_gen is part of the user-visible KYC status and is one of the + // fields guarded by merchant_kyc_update_trigger(). Moving it forward gives + // us a deterministic status transition without relying on an external KYC + // provider. Keep the row out of the background checker's work queue while + // the notification is inspected. + await psql( + t, + commonDb.connStr, + "trigger-kyc-notification", + `SET search_path TO ${instanceSchema}, merchant;` + + " UPDATE merchant_kyc" + + " SET last_rule_gen=last_rule_gen+1," + + " next_kyc_poll=9223372036854775807" + + " WHERE kyc_serial_id=(" + + " SELECT MIN(kyc_serial_id) FROM merchant_kyc)", + ); + t.assertDeepEqual( + await countKycReports(t, commonDb.connStr, instanceSchema), + 1, + ); + + await merchant.runReportGeneratorOnce(); + t.assertTrue(fs.existsSync(notificationFile)); + const notification = fs.readFileSync(notificationFile, "utf-8"); + t.assertTrue( + notification.includes("GNU Taler Merchant — Compliance Onboarding Status"), + ); + t.assertTrue(notification.includes(exchange.baseUrl)); + t.assertTrue(notification.includes("Status:")); + t.assertDeepEqual(notification.match(/Payment service:/g)?.length ?? 0, 1); + t.assertDeepEqual( + await countKycReports(t, commonDb.connStr, instanceSchema), + 0, + ); + + fs.unlinkSync(notificationFile); + + // An UPDATE that only touches a bookkeeping field must not be treated as a + // KYC status transition and must therefore not send the same notification + // again. Assigning the value to itself exercises the UPDATE trigger without + // changing when the background checker should next inspect the account. + await psql( + t, + commonDb.connStr, + "update-kyc-bookkeeping", + `SET search_path TO ${instanceSchema}, merchant;` + + " UPDATE merchant_kyc" + + " SET next_kyc_poll=next_kyc_poll" + + " WHERE kyc_serial_id=(" + + " SELECT MIN(kyc_serial_id) FROM merchant_kyc)", + ); + t.assertDeepEqual( + await countKycReports(t, commonDb.connStr, instanceSchema), + 0, + ); + await merchant.runReportGeneratorOnce(); + t.assertTrue(!fs.existsSync(notificationFile)); + + // Account changes also have a notification trigger. Disable notifications + // before expanding the account set so the ordering assertions below remain + // isolated from the one-shot KYC report tested above. + await psql( + t, + commonDb.connStr, + "disable-kyc-notifications", + "UPDATE merchant.merchant_instances" + + " SET notification_language=NULL" + + " WHERE merchant_id='admin'", + ); + + // Exercise the second ordering key from issue 11242 as well. The same test + // exchange is reachable through both loopback spellings, which gives one + // merchant account two exchange URLs without the cost of a second exchange. + const secondExchangeUrl = exchange.baseUrl.replace("localhost", "127.0.0.1"); + t.assertTrue(secondExchangeUrl !== exchange.baseUrl); + await merchant.stop(); + merchant.addExchange({ + baseUrl: secondExchangeUrl, + currency: exchange.currency, + masterPub: exchange.masterPub, + name: "testexchange-2", + port: exchange.port, + }); + await merchant.start({ skipDbinit: true }); + + const additionalAccounts = 4; + for (let i = 0; i < additionalAccounts; i++) { + const paytoUri = getTestHarnessPaytoForLabel(`merchant-notification-${i}`); + succeedOrThrow( + await merchantApi.addBankAccount(merchantAdminAccessToken, { + payto_uri: paytoUri as PaytoString, + }), + ); + } + + await merchant.runKyccheckOnce(); + const expectedEntries = (additionalAccounts + 1) * 2; + const baseline = await waitForStableKycResponse( + t, + merchantAdminAccessToken, + merchantApi, + expectedEntries, + ); + const expectedOrder = [...baseline.kyc_data].sort(compareKycEntries); + t.assertDeepEqual(baseline.kyc_data, expectedOrder); + t.assertDeepEqual( + [...new Set(baseline.kyc_data.map((entry) => entry.exchange_url))].sort(), + [exchange.baseUrl, secondExchangeUrl].sort(), + ); + t.assertTrue(baseline.etag !== undefined); + + for (let i = 0; i < 5; i++) { + const response = succeedOrThrow( + await merchantApi.getCurrentInstanceKycStatus(merchantAdminAccessToken), + ); + t.assertDeepEqual(response.etag, baseline.etag); + t.assertDeepEqual(response.kyc_data, baseline.kyc_data); + } + + let baselineText: string | undefined; + let baselineTextEtag: string | undefined; + for (let i = 0; i < 5; i++) { + const url = new URL("private/kyc", merchant.makeInstanceBaseUrl()); + const response = await harnessHttpLib.fetch(url.href, { + method: "GET", + headers: { + Authorization: `Bearer ${merchantAdminAccessToken}`, + Accept: "text/plain", + }, + }); + t.assertDeepEqual(response.status, 200); + const etag = response.headers.get("etag")?.replaceAll('"', ""); + t.assertTrue(etag !== undefined); + const body = await response.text(); + t.assertDeepEqual( + body.match(/Payment service:/g)?.length ?? 0, + expectedEntries, + ); + if (baselineText === undefined) { + baselineText = body; + baselineTextEtag = etag; + } else { + t.assertDeepEqual(body, baselineText); + t.assertDeepEqual(etag, baselineTextEtag); + } + } + + logger.info("KYC notification and ordering checks completed"); +} + +runMerchantKycNotificationsTest.suites = ["merchant", "kyc"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -123,6 +123,7 @@ import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete 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 { runMerchantKycNotificationsTest } from "./test-merchant-kyc-notifications.js"; import { runMerchantLongpollingTest } from "./test-merchant-longpolling.js"; import { runMerchantOrderListingTest } from "./test-merchant-order-listing.js"; import { runMerchantPaytoReuseTest } from "./test-merchant-payto-reuse.js"; @@ -474,6 +475,7 @@ const allTests: TestMainFunction[] = [ runMerchantReportsTest, runExchangeMerchantKycAuthTest, runMerchantKycAuthMultiTest, + runMerchantKycNotificationsTest, runTopsMerchantTosTest, runWalletWithdrawalRedenominateTest, runMerchantDepositLargeTest,