taler-typescript-core

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

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:
Mpackages/taler-harness/src/harness/harness.ts | 20++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-tops-aml-sanction-preserves-rules.ts | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
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,