taler-typescript-core

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

commit 4bf4c4b1e262ae6cb30f77f7003a009d978d2db6
parent fdff5d56625e2aae7b7b7089e9234de0671d5a22
Author: Florian Dold <dold@taler.net>
Date:   Fri, 24 Jul 2026 17:42:35 +0200

harness: reproduce AML programs being run without current_rules

Diffstat:
Mpackages/taler-harness/src/harness/tops.ts | 15+++++++++++++++
Apackages/taler-harness/src/integrationtests/test-tops-aml-expired-successor.ts | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-tops-aml-null-current-rules.ts | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 4++++
4 files changed, 418 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/harness/tops.ts b/packages/taler-harness/src/harness/tops.ts @@ -582,6 +582,13 @@ export interface TopsTestEnv { export async function createTopsEnvironment( t: GlobalTestState, + opts: { + /** + * Applied after the TOPS rules/providers config has been loaded, so a test + * can tweak individual TOPS settings without forking the whole config. + */ + adjustExchangeConfig?: (config: Configuration) => void; + } = {}, ): Promise<TopsTestEnv> { const db = await setupDb(t); @@ -605,6 +612,11 @@ export async function createTopsEnvironment( 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", + // taler-exchange-helper-measure-tops-sms-check runs under "set -u" and + // dereferences this variable, so without it the SMS measure aborts with + // "unbound variable" (exit 1) before it ever looks at its input. + // Value matches the staging (rusty) deployment. + EXCHANGE_AML_PROGRAM_TOPS_SMS_CHECK_REGEX: "\\+41[0-9]+", }, }); @@ -654,6 +666,7 @@ export async function createTopsEnvironment( await exchange.modifyConfig(async (config) => { config.loadFromString(topsKycRulesConf); config.loadFromString(topsProvidersTestConf); + opts.adjustExchangeConfig?.(config); }); await exchange.start(); @@ -1014,6 +1027,7 @@ export interface MeasuresTestEnvironment { challengerPostal: TestfakeChallengerService; challengerSms: TestfakeChallengerService; accountPaytoHash: string; + accessToken: AccessToken; officerAcc: OfficerSession; exchangeClient: TalerExchangeHttpClient; } @@ -1184,6 +1198,7 @@ export async function setupMeasuresTestEnvironment( challengerPostal, challengerSms, accountPaytoHash: merchantPaytoHash, + accessToken, expectInfo, fakeChallenger, decideMeasure: myTriggerMeasure, diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-expired-successor.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-expired-successor.ts @@ -0,0 +1,216 @@ +/* + 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 rule set that expires into an *interactive* successor + * measure leaves the account with no usable "current_rules", which kills the + * next AML program. + * + * Unlike test-tops-aml-null-current-rules.ts, nothing here involves an AML + * officer. The whole chain is reached with the stock TOPS rules: + * + * 1. [kyc-rule-deposit-limit-zero] (DEPOSIT, THRESHOLD = CHF:0) puts every + * bank account on the "accept-tos" measure. + * 2. [kyc-measure-accept-tos] carries "successor_measure":"accept-tos" in its + * CONTEXT, and taler-exchange-helper-measure-validate-accepted-tos:128 + * copies that into the rules it emits. So after accepting the ToS the + * account's rule set names a successor measure -- automatically. + * 3. When that rule set expires, begin_rule_update.c:update_rules() resolves + * the successor. [kyc-check-form-accept-tos] is TYPE = FORM, i.e. + * interactive rather than SKIP, so run_measure() takes the branch at + * begin_rule_update.c:458 and calls exchange_do_insert_successor_measure(). + * That inserts a legitimization_outcomes row with jnew_rules = NULL, + * is_active defaulting to TRUE and expiration_time = "never". + * 4. The account is now on default rules, but TALER_EXCHANGEDB_get_kyc_rules() + * returns SUCCESS_ONE_RESULT with a NULL json and + * TALER_EXCHANGEDB_current_rule_builder() (exchangedb/account_history.c) + * passes that straight through, so the AML program is invoked with no + * "current_rules" key. validate-accepted-tos:136 then does + * + * jq '(.rules[] |= if (.measures[0]=="accept-tos") ...)' + * + * on null and dies: + * + * jq: error (at <stdin>:1): Cannot iterate over null (null) + * + * jq exits 5, and [aml-program-check-tos] FALLBACK = freeze-investigate + * freezes an account whose only offence was accepting the terms twice. + * + * On rusty the expiry in step 3 is 10 years for the ToS rules, or the ~365 day + * challenger validation lifetime once tops-sms-check/tops-postal-check have + * carried the successor forward into a shorter-lived rule set. A test cannot + * wait either out, so the only thing this test changes about the TOPS config is + * "validity_years", from 10 to 0, which makes validate-accepted-tos emit + * expiration_time = now. Everything else -- rules, measures, check types, + * programs, fallbacks -- is the stock config. + */ + +/** + * Imports. + */ +import { + AccessToken, + j2s, + Logger, + succeedOrThrow, + TalerExchangeHttpClient, + TalerMerchantInstanceHttpClient, +} from "@gnu-taler/taler-util"; +import { GlobalTestState, harnessHttpLib, waitMs } from "../harness/harness.js"; +import { createTopsEnvironment, doTopsKycAuth, isFrozen } from "../harness/tops.js"; + +const logger = new Logger("test-tops-aml-expired-successor.ts"); + +/** + * [kyc-measure-accept-tos] CONTEXT, with validity_years dropped from 10 to 0 + * so that the emitted rules are already expired. See the file comment. + */ +const ACCEPT_TOS_CONTEXT_ZERO_VALIDITY = JSON.stringify({ + tos_url: "https://exchange.taler-ops.ch/terms", + provider_name: "Taler Operations AG", + successor_measure: "accept-tos", + validity_years: 0, +}); + +const TOS_FORM = { + FORM_ID: "accept-tos", + FORM_VERSION: 1, + ACCEPTED_TERMS_OF_SERVICE: "v1", + DOWNLOADED_TERMS_OF_SERVICE: true, +}; + +export async function runTopsAmlExpiredSuccessorTest(t: GlobalTestState) { + const { + exchange, + merchant, + exchangeBankAccount, + wireGatewayApi, + merchantAdminAccessToken, + bank, + officerAcc, + } = await createTopsEnvironment(t, { + adjustExchangeConfig: (config) => { + config.setString( + "kyc-measure-accept-tos", + "context", + ACCEPT_TOS_CONTEXT_ZERO_VALIDITY, + ); + }, + }); + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl, { + httpClient: harnessHttpLib, + }); + + // Gets the account to "kyc-required". No AML officer involved: the pending + // requirement comes from [kyc-rule-deposit-limit-zero]. + const { accessToken } = await doTopsKycAuth(t, { + merchantClient, + merchantAdminAccessToken, + exchangeBankAccount, + wireGatewayApi, + bank, + }); + + const currentTosRequirement = async (): Promise<string | undefined> => { + const kycInfoResp = await exchangeClient.checkKycInfo( + accessToken as AccessToken, + ); + if (kycInfoResp.case !== "ok") { + return undefined; + } + const req = kycInfoResp.body.requirements[0]; + if (req == null || req.form !== "accept-tos") { + return undefined; + } + return req.id; + }; + + const isAccountFrozen = async (): Promise<boolean> => { + const decisionsResp = succeedOrThrow( + await exchangeClient.getAmlDecisions(officerAcc, { active: true }), + ); + logger.info(`active decision: ${j2s(decisionsResp)}`); + const dec = decisionsResp.records[0]; + t.assertTrue(dec != null); + return isFrozen(dec); + }; + + // ------------------------------------------------------------------ + // Step 1 (control): accept the ToS. current_rules is present, so + // validate-accepted-tos runs to completion. + // ------------------------------------------------------------------ + let firstRequirementId: string; + { + t.logStep("accept-tos-first-time"); + + const reqId = await currentTosRequirement(); + t.assertTrue(reqId != null); + firstRequirementId = reqId; + + succeedOrThrow(await exchangeClient.uploadKycForm(reqId, TOS_FORM)); + + t.assertTrue(!(await isAccountFrozen())); + } + + // ------------------------------------------------------------------ + // Step 2: the rules emitted above are already expired and name + // "accept-tos" as their successor. Any rule evaluation now runs + // insert_successor_measure() and writes the NULL jnew_rules row. The + // successor measure shows up as a *new* accept-tos requirement. + // ------------------------------------------------------------------ + { + t.logStep("wait-for-successor-measure"); + + // expiration_time has second granularity. + await waitMs(1500); + + let successorRequirementId: string | undefined; + for (let i = 0; i < 40; i++) { + const reqId = await currentTosRequirement(); + if (reqId != null && reqId !== firstRequirementId) { + successorRequirementId = reqId; + break; + } + await waitMs(500); + } + t.assertTrue(successorRequirementId != null); + + // ---------------------------------------------------------------- + // Step 3: satisfy the successor measure. Byte-for-byte the same form + // as step 1 -- only the account's rule row differs. + // ---------------------------------------------------------------- + t.logStep("accept-tos-on-null-rules"); + + succeedOrThrow( + await exchangeClient.uploadKycForm(successorRequirementId, TOS_FORM), + ); + + // Expected: validate-accepted-tos gets the default rule set and completes. + // + // Actual, before the fix in account_history.c: "current_rules" is missing, + // jq dies with "Cannot iterate over null (null)" / exit 5, and + // [aml-program-check-tos] FALLBACK = freeze-investigate freezes the + // account -- so this assertion fails. + t.assertTrue(!(await isAccountFrozen())); + } +} + +runTopsAmlExpiredSuccessorTest.suites = ["wallet"]; +runTopsAmlExpiredSuccessorTest.timeoutMs = 120000; diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-null-current-rules.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-null-current-rules.ts @@ -0,0 +1,183 @@ +/* + 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: an AML program must always receive "current_rules". + * + * An account whose active legitimization_outcomes row has jnew_rules = NULL is + * on the exchange's *default* rules. TALER_EXCHANGEDB_current_rule_builder() + * (exchangedb/account_history.c) does not treat that row as "default rules": + * TALER_EXCHANGEDB_get_kyc_rules() returns SUCCESS_ONE_RESULT with a NULL json, + * the ONE_RESULT branch passes it through unchanged, and because + * GNUNET_JSON_pack_allow_null() *omits* NULL fields the AML program is invoked + * with no "current_rules" key at all. + * + * Every TOPS measure program then dies, because they all do + * + * jq 'del(.rules[] | select (...))' + * + * on `.current_rules // null`: + * + * jq: error (at <stdin>:1): Cannot iterate over null (null) + * + * jq exits 5, the exchange logs + * + * Conversion helper exited with status 3 and code 5 after outputting 0 bytes + * AML program tops-sms-check returned non-zero status 3/5 + * + * and runs the FALLBACK measure, freezing an account that just *passed* its + * check. + * + * This test does the identical SMS verification twice. The only difference is + * the account's rule state beforehand, which is exactly why the failure looks + * intermittent in production: same operation, same phone number, different + * legitimization_outcomes row. + */ + +/** + * Imports. + */ +import { + j2s, + Logger, + succeedOrThrow, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { GlobalTestState, waitMs } from "../harness/harness.js"; +import { setupMeasuresTestEnvironment } from "../harness/tops.js"; + +const logger = new Logger("test-tops-aml-null-current-rules.ts"); + +/** + * Swiss mobile number, accepted by + * EXCHANGE_AML_PROGRAM_TOPS_SMS_CHECK_REGEX. It must be *valid*: only the + * valid-number branch of tops-sms-check reaches the `del(.rules[] | ...)` + * expression. The invalid branch uses `. + {"to_investigate": true}`, which + * tolerates a null input just fine. + */ +const VALID_PHONE = "+41791234567"; + +export async function runTopsAmlNullCurrentRulesTest(t: GlobalTestState) { + const { + decideMeasure, + decideReset, + fakeChallenger, + expectNotFrozen, + challengerSms, + exchangeClient, + officerAcc, + accountPaytoHash, + accessToken, + } = await setupMeasuresTestEnvironment(t); + + // ------------------------------------------------------------------ + // Part 1 (control): the account carries a normal, non-NULL rule set. + // ------------------------------------------------------------------ + { + t.logStep("sms-check-with-rules"); + + await decideReset(); + await decideMeasure("sms-registration"); + await fakeChallenger(challengerSms, { CONTACT_PHONE: VALID_PHONE }); + + // tops-sms-check got its current_rules, dropped withdraw-limit-low and + // p2p-domestic-identification-requirement, and returned cleanly. + await expectNotFrozen(); + } + + // ------------------------------------------------------------------ + // Part 2: put the account on default rules via a NULL jnew_rules row. + // + // A rule set that (a) has already expired and (b) names a successor + // measure whose check is interactive makes begin_rule_update.c:run_measure() + // call exchange_do_insert_successor_measure(), and that function inserts + // + // INSERT INTO legitimization_outcomes (..., jnew_rules) VALUES (..., NULL) + // + // with is_active defaulting to TRUE and expiration_time = "never". + // + // [kyc-check-sms-registration] is TYPE = LINK, i.e. interactive, so naming + // "sms-registration" as the successor measure is enough. (Had we named a + // SKIP-check measure instead, run_measure() would run its AML program in + // place and never write the NULL row -- no crash. That is the difference + // between a run that reproduces and one that does not.) + // ------------------------------------------------------------------ + { + t.logStep("install-null-rules-row"); + + await decideReset(); + + succeedOrThrow( + await exchangeClient.makeAmlDesicion(officerAcc, { + decision_time: TalerProtocolTimestamp.now(), + h_payto: accountPaytoHash, + justification: "expire into an interactive successor measure", + keep_investigating: false, + properties: {}, + new_rules: { + // Already expired => the next rule evaluation runs the successor. + expiration_time: TalerProtocolTimestamp.now(), + successor_measure: "sms-registration", + rules: [], + custom_measures: {}, + }, + }), + ); + + // expiration_time has second granularity; make sure "now" is really past. + await waitMs(1500); + + // Any kyc-info fetch drives begin_rule_update() -> update_rules() -> + // run_measure() -> insert_successor_measure(). Poll until the successor + // measure's LINK check shows up as the pending requirement. + let installed = false; + for (let i = 0; i < 30; i++) { + const kycInfoResp = await exchangeClient.checkKycInfo(accessToken); + logger.info(`kyc-info while waiting for successor: ${j2s(kycInfoResp)}`); + if ( + kycInfoResp.case === "ok" && + kycInfoResp.body.requirements.length > 0 && + kycInfoResp.body.requirements[0].form === "LINK" + ) { + installed = true; + break; + } + await waitMs(500); + } + t.assertTrue(installed); + } + + // ------------------------------------------------------------------ + // Part 3: byte-for-byte the same SMS verification as part 1. + // ------------------------------------------------------------------ + { + t.logStep("sms-check-on-default-rules"); + + await fakeChallenger(challengerSms, { CONTACT_PHONE: VALID_PHONE }); + + // Expected: the AML program receives the default rule set, relaxes it, and + // the customer walks away verified. + // + // Actual, before the fix in account_history.c: "current_rules" is missing + // from the AML program input, tops-sms-check dies inside jq with + // "Cannot iterate over null (null)" / exit 5, and the FALLBACK measure + // freezes the account -- so this assertion fails. + await expectNotFrozen(); + } +} + +runTopsAmlNullCurrentRulesTest.suites = ["wallet"]; +runTopsAmlNullCurrentRulesTest.timeoutMs = 120000; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -171,7 +171,9 @@ import { runTopsAmlCustomAddrPostalTest } from "./test-tops-aml-custom-addr-post import { runTopsAmlCustomAddrSmsTest } from "./test-tops-aml-custom-addr-sms.js"; import { runTopsAmlKyxNaturalTest } from "./test-tops-aml-kyx-natural.js"; 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 { runTopsAmlNullCurrentRulesTest } from "./test-tops-aml-null-current-rules.js"; import { runTopsAmlPdfTest } from "./test-tops-aml-pdf.js"; import { runTopsChallengerTwiceTest } from "./test-tops-challenger-twice.js"; import { runTopsMerchantSimpleKycauthTest } from "./test-tops-merchant-simple-kycauth.js"; @@ -396,7 +398,9 @@ const allTests: TestMainFunction[] = [ runTopsAmlCustomAddrPostalTest, runTopsAmlCustomAddrSmsTest, runTopsAmlKyxNaturalTest, + runTopsAmlExpiredSuccessorTest, runTopsAmlMeasuresTest, + runTopsAmlNullCurrentRulesTest, runTopsAmlLegiTest, runTopsChallengerTwiceTest, runKycFormBadMeasureTest,