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:
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,