commit 188c09c6edc28281713f5e890a107ca1d6168012
parent 319dfdb56db2e5aa8f3e51378ca565314336dde7
Author: Florian Dold <dold@taler.net>
Date: Fri, 24 Jul 2026 18:51:15 +0200
harness: reproduce a sanction investigation revoking completed KYC
Diffstat:
3 files changed, 347 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
@@ -1508,6 +1508,26 @@ export class ExchangeService implements ExchangeServiceInterface {
);
}
+ /**
+ * Screen accounts against the sanctions list once and exit ("-t" runs until
+ * idle). Requires an [exchange-sanctionscheck] section in the config.
+ */
+ async runSanctionsCheckOnce(opts: { reset?: boolean } = {}) {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-sanctionscheck-once`,
+ "taler-exchange-sanctionscheck",
+ [
+ ...this.timetravelArgArr,
+ "-c",
+ this.configFilename,
+ "-t",
+ "-LINFO",
+ ...(opts.reset ? ["-r"] : []),
+ ],
+ );
+ }
+
get currency() {
return this.exchangeConfig.currency;
}
diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-sanction-preserves-rules.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-sanction-preserves-rules.ts
@@ -0,0 +1,325 @@
+/*
+ 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/>
+ */
+
+/**
+ * Regression test: a sanction-list *investigation* must not undo completed KYC.
+ *
+ * taler-exchange-sanctionscheck.c:340 encodes "flag for review, but do not
+ * freeze" as
+ *
+ * new_rules = freeze ? freeze_rules : NULL;
+ *
+ * and exchange_do_insert_sanction_list_hit then deactivates *every* prior
+ * outcome for the account (insert_sanction_list_hit.sql:35 -- no expiry
+ * filter) before inserting that NULL. A NULL jnew_rules means "no custom
+ * rules", i.e. the account silently reverts to the exchange defaults.
+ *
+ * So an investigation flag wipes the rule set the customer just earned. With
+ * the TOPS rules that means p2p-domestic-identification-requirement
+ * (THRESHOLD = CHF:0) comes back and the SMS check they just passed is
+ * demanded again -- while nothing is frozen and no program ever fails, so
+ * neither the logs nor the account state record a problem.
+ *
+ * It also leaves the account in the state that breaks the *next* AML program:
+ * TALER_EXCHANGEDB_get_kyc_rules() returns SUCCESS_ONE_RESULT with a NULL
+ * json, and before the fix in exchangedb/account_history.c that reached the
+ * AML program as a missing "current_rules", killing every TOPS measure script
+ * with "Cannot iterate over null (null)".
+ *
+ * The rater here is taler-exchange-helper-sanctions-dummy, which reports
+ * rating 1.0 at confidence 0.0 for every account. That is enough to trigger
+ * an investigation because the threshold at
+ * taler-exchange-sanctionscheck.c:313 is
+ *
+ * rating > investigation_limit * confidence
+ *
+ * i.e. the limit is scaled *down* by the confidence, so a less certain match
+ * clears a lower bar and a zero-confidence match clears any bar at all. A
+ * real deployment reproduces this with ordinary noise: rating 0.375 at
+ * confidence 0.077 against a 0.9 limit gives an effective threshold of 0.069.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AccessToken,
+ AmountString,
+ Duration,
+ j2s,
+ Logger,
+ succeedOrThrow,
+ TalerExchangeHttpClient,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as fs from "node:fs";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { startFakeChallenger } from "../harness/fake-challenger.js";
+import {
+ GlobalTestState,
+ harnessHttpLib,
+ WalletClient,
+} from "../harness/harness.js";
+import {
+ createTopsEnvironment,
+ doFakeChallenger,
+ isFrozen,
+} from "../harness/tops.js";
+
+const logger = new Logger("test-tops-aml-sanction-preserves-rules.ts");
+
+/**
+ * Fake rater: emits whatever "<rating> <confidence> <name>" currently sits in
+ * ratingFile, once per input record. The exchange feeds one compact JSON
+ * object per line (kyclogic_sanctions.c:545) and parses the reply with
+ * sscanf("%lf %lf %1023s") (kyclogic_sanctions.c:231).
+ */
+function writeFakeRater(raterFile: string, ratingFile: string): void {
+ fs.writeFileSync(
+ raterFile,
+ ["#!/bin/bash", "while IFS= read -r _record; do", ` cat ${ratingFile}`, "done", ""].join("\n"),
+ );
+ fs.chmodSync(raterFile, 0o755);
+}
+
+export async function runTopsAmlSanctionPreservesRulesTest(t: GlobalTestState) {
+ const raterFile = `${t.testDir}/fake-sanction-rater.sh`;
+ const ratingFile = `${t.testDir}/fake-sanction-rating.txt`;
+ writeFakeRater(raterFile, ratingFile);
+
+ const setRating = (rating: string, confidence: string) =>
+ fs.writeFileSync(ratingFile, `${rating} ${confidence} fake-entry\n`);
+
+ const { walletClient, bankClient, exchange, officerAcc } =
+ await createTopsEnvironment(t, {
+ adjustExchangeConfig: (config) => {
+ config.setString(
+ "exchange-sanctionscheck",
+ "RATER_COMMAND",
+ raterFile,
+ );
+ config.setString(
+ "exchange-sanctionscheck",
+ "MIN_ROW_FILENAME",
+ `${t.testDir}/sanctions-min-row`,
+ );
+ // Same limits as the stock deployment.
+ config.setString(
+ "exchange-sanctionscheck",
+ "FREEZE_RATING_LIMIT",
+ "0.9",
+ );
+ config.setString(
+ "exchange-sanctionscheck",
+ "FREEZE_CONFIDENCE_LIMIT",
+ "0.9",
+ );
+ config.setString(
+ "exchange-sanctionscheck",
+ "INVESTIGATION_LIMIT",
+ "0.9",
+ );
+ config.setString(
+ "exchange-sanctionscheck",
+ "INVESTIGATION_CONFIDENCE_LIMIT",
+ "0.5",
+ );
+ },
+ });
+
+ const challengerSms = await startFakeChallenger({
+ port: 6002,
+ addressType: "phone",
+ });
+
+ const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl, {
+ httpClient: harnessHttpLib,
+ });
+
+ const smsRequirement = async (): Promise<string | undefined> => {
+ const resp = await exchangeClient.checkKycInfo(accessToken as AccessToken);
+ if (resp.case !== "ok") {
+ return undefined;
+ }
+ return resp.body.requirements.find((r) => r.form === "LINK")?.id;
+ };
+
+ // ------------------------------------------------------------------
+ // A fresh wallet receives a p2p payment, which trips
+ // p2p-domestic-identification-requirement (THRESHOLD = CHF:0).
+ // ------------------------------------------------------------------
+ const w0 = await createWalletDaemonWithClient(t, { name: "w0" });
+ const wres = await withdrawViaBankV3(t, {
+ bankClient,
+ amount: "CHF:20",
+ exchange,
+ walletClient: w0.walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const pushDebitRes = await doPeerPushDebit(t, {
+ walletClient: w0.walletClient,
+ amount: "CHF:10",
+ summary: "Test1",
+ });
+
+ const prepRes = await walletClient.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ { talerUri: pushDebitRes.talerUri },
+ );
+ await walletClient.call(WalletApiOperation.ConfirmPeerPushCredit, {
+ transactionId: prepRes.transactionId,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepRes.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ },
+ });
+
+ const txDet = await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId: prepRes.transactionId,
+ });
+ const accessToken = txDet.kycAccessToken;
+ t.assertTrue(!!accessToken);
+
+ const reqId = await smsRequirement();
+ t.assertTrue(reqId != null);
+
+ t.logStep("complete-sms-check");
+
+ await doFakeChallenger(t, {
+ exchangeClient,
+ requirementId: reqId,
+ challenger: challengerSms,
+ address: { CONTACT_PHONE: "+41791234567" },
+ });
+
+ // Control: tops-sms-check dropped the CHF:0 p2p rule, so the customer is
+ // done. This part passes today.
+ t.assertTrue((await smsRequirement()) == null);
+
+ t.logStep("screen-against-sanctions-list");
+
+ // The deployed daemon does this by itself: it listens for
+ // TALER_DBEVENT_EXCHANGE_NEW_KYC_ATTRIBUTES
+ // (taler-exchange-sanctionscheck.c:834), which do_insert_kyc_attributes
+ // raises for every completed check, and screens the account seconds later.
+ // Running it once here is the same code path, just synchronous.
+ t.logStep("screen-with-a-noise-match");
+
+ // The numbers a real deployment produced against a 0.9 limit: a 37.5% match
+ // at 7.7% confidence, i.e. noise. Under the old
+ // "rating > investigation_limit * confidence" the limit was scaled down by
+ // the confidence, giving an effective threshold of 0.069, and this
+ // investigated. It must not.
+ setRating("0.375000", "0.076923");
+ await exchange.runSanctionsCheckOnce({ reset: true });
+
+ t.assertTrue((await smsRequirement()) == null);
+
+ t.logStep("screen-with-a-real-match");
+
+ // Rating above the investigation limit and confidence above the
+ // investigation confidence limit, but confidence below the *freeze*
+ // confidence limit: a genuine "investigate, do not freeze" outcome.
+ setRating("0.950000", "0.700000");
+ await exchange.runSanctionsCheckOnce({ reset: true });
+
+ t.logStep("check-kyc-still-satisfied");
+
+ // Expected: an investigation flag records suspicion; it does not revoke the
+ // KYC the customer already passed.
+ //
+ // Actual: new_rules was NULL, every prior outcome was deactivated, the
+ // account is back on default rules, and the SMS check it just passed is
+ // demanded all over again.
+ t.assertTrue((await smsRequirement()) == null);
+
+ t.logStep("check-aml-decisions-listable");
+
+ // Second defect, found by this test: iterate_aml_decisions.c:101 binds
+ // jnew_rules with TALER_PQ_result_spec_json() and no
+ // GNUNET_PQ_result_spec_allow_null() wrapper -- unlike get_kyc_rules.c:101,
+ // which wraps the same column. Extracting a NULL therefore fails,
+ // GNUNET_break(0) fires, and GET /aml/$OFFICER_PUB/decisions answers 500.
+ // The query is not scoped to one account, so a single flagged account makes
+ // the officer's whole decision list unreadable -- precisely when there is
+ // something to review.
+ const decisionsResp = await exchangeClient.getAmlDecisions(officerAcc, {
+ active: true,
+ });
+ logger.info(`decisions after screening: ${j2s(decisionsResp)}`);
+ t.assertDeepEqual(decisionsResp.case, "ok");
+
+ const dec = succeedOrThrow(decisionsResp).records[0];
+ t.assertTrue(dec != null);
+ // The screening did fire -- this is not passing merely because nothing
+ // happened.
+ t.assertDeepEqual(dec.to_investigate, true);
+ // The match was far below the freeze limits, so this is "investigate only"
+ // and the customer should notice nothing at all.
+ t.assertTrue(!isFrozen(dec));
+}
+
+async function doPeerPushDebit(
+ t: GlobalTestState,
+ args: {
+ walletClient: WalletClient;
+ amount: AmountString;
+ summary?: string;
+ },
+): Promise<{ transactionId: string; talerUri: string }> {
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })),
+ );
+ const initRet = await args.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ amount: args.amount,
+ summary: args.summary ?? "Test P2P Payment",
+ purse_expiration,
+ },
+ },
+ );
+ await args.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: initRet.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ },
+ });
+ const txDet = await args.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ { transactionId: initRet.transactionId },
+ );
+ t.assertTrue(txDet.type === TransactionType.PeerPushDebit);
+ const talerUri = txDet.talerUri;
+ t.assertTrue(!!talerUri);
+ return { transactionId: initRet.transactionId, talerUri };
+}
+
+runTopsAmlSanctionPreservesRulesTest.suites = ["wallet"];
+runTopsAmlSanctionPreservesRulesTest.timeoutMs = 120000;
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -174,6 +174,7 @@ import { runTopsAmlLegiTest } from "./test-tops-aml-legi.js";
import { runTopsAmlExpiredSuccessorTest } from "./test-tops-aml-expired-successor.js";
import { runTopsAmlMeasuresTest } from "./test-tops-aml-measures.js";
import { runTopsAmlP2pFreshWalletTest } from "./test-tops-aml-p2p-fresh-wallet.js";
+import { runTopsAmlSanctionPreservesRulesTest } from "./test-tops-aml-sanction-preserves-rules.js";
import { runTopsAmlStaleValidationTest } from "./test-tops-aml-stale-validation.js";
import { runTopsAmlNullCurrentRulesTest } from "./test-tops-aml-null-current-rules.js";
import { runTopsAmlPdfTest } from "./test-tops-aml-pdf.js";
@@ -404,6 +405,7 @@ const allTests: TestMainFunction[] = [
runTopsAmlMeasuresTest,
runTopsAmlNullCurrentRulesTest,
runTopsAmlP2pFreshWalletTest,
+ runTopsAmlSanctionPreservesRulesTest,
runTopsAmlStaleValidationTest,
runTopsAmlLegiTest,
runTopsChallengerTwiceTest,