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