commit 148533ba6b91fe8bad5b6940b9c60d9c067d6291
parent 5d35ea590cc66aeba0a6beb69bd94c3b8c6627a5
Author: Florian Dold <dold@taler.net>
Date: Thu, 27 Aug 2026 01:00:04 +0200
AML web UI: run decision regression tests
Diffstat:
3 files changed, 299 insertions(+), 155 deletions(-)
diff --git a/packages/taler-exchange-aml-webui/package.json b/packages/taler-exchange-aml-webui/package.json
@@ -12,7 +12,7 @@
"clean": "rm -rf dist lib",
"build": "tsc && ./build.mjs",
"build:with-deps": "pnpm --filter \"{.}...\" run build",
- "test": "echo No tests for aml-backoffice-ui",
+ "test": "./test.mjs && node --test 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
"lint": "../qa-tooling/bin/eslint.mjs .",
"i18n:source2po": "pogen extract && pogen merge",
"i18n:po2strings": "pogen emit",
diff --git a/packages/taler-exchange-aml-webui/src/utils/decision-validation.test.ts b/packages/taler-exchange-aml-webui/src/utils/decision-validation.test.ts
@@ -0,0 +1,136 @@
+/*
+ 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.
+*/
+
+import assert from "node:assert";
+import { test } from "node:test";
+import {
+ AbsoluteTime,
+ AvailableMeasureSummary,
+ TalerFormAttributes,
+} from "@gnu-taler/taler-util";
+import { DecisionRequest } from "../hooks/decision-request.js";
+import { decisionTargetsMatch } from "../hooks/decision-request.js";
+import {
+ attributesAreComplete,
+ buildAmlDecisionRequest,
+} from "./decision-validation.js";
+
+const measureSummary: AvailableMeasureSummary = {
+ roots: {
+ review: { check_name: "review" },
+ skipOne: { check_name: "SKIP" },
+ skipTwo: { check_name: "SKIP" },
+ },
+ programs: {},
+ checks: {},
+ default_rules: [],
+};
+
+function completeDecision(): DecisionRequest {
+ return {
+ rules: [],
+ deadline: AbsoluteTime.never(),
+ properties: { builtin: true },
+ custom_properties: { custom: "value" },
+ triggering_events: ["KNOWN"],
+ custom_events: ["CUSTOM", "KNOWN"],
+ new_measures: ["review"],
+ measures_and: true,
+ justification: "Reviewed account",
+ keep_investigating: false,
+ };
+}
+
+test("decision builder preserves custom state and AND measures", () => {
+ const result = buildAmlDecisionRequest({
+ account: "ACCOUNT",
+ fullPayto: undefined,
+ decision: completeDecision(),
+ measures: measureSummary,
+ });
+ assert.deepStrictEqual(result.errors, []);
+ assert.deepStrictEqual(result.request?.properties, {
+ builtin: true,
+ custom: "value",
+ });
+ assert.deepStrictEqual(result.request?.events, ["KNOWN", "CUSTOM"]);
+ assert.strictEqual(result.request?.new_measures, "+review");
+});
+
+test("attribute uploads require salt and a future expiration", () => {
+ const decision = completeDecision();
+ decision.attributes = {
+ data: { answer: 42 },
+ formId: "example",
+ formVersion: 3,
+ formSalt: "HIGH_ENTROPY_SALT",
+ expiration: AbsoluteTime.never(),
+ };
+ assert.strictEqual(attributesAreComplete(decision), true);
+ const result = buildAmlDecisionRequest({
+ account: "ACCOUNT",
+ fullPayto: undefined,
+ decision,
+ measures: measureSummary,
+ });
+ assert.strictEqual(
+ (result.request?.attributes as Record<string, unknown> | undefined)?.[
+ TalerFormAttributes.FORM_SALT
+ ],
+ "HIGH_ENTROPY_SALT",
+ );
+ delete decision.attributes.formSalt;
+ assert.strictEqual(attributesAreComplete(decision), false);
+});
+
+test("decision builder rejects stale and incompatible measure references", () => {
+ const decision = completeDecision();
+ decision.new_measures = ["skipOne", "skipTwo", "missing"];
+ const result = buildAmlDecisionRequest({
+ account: "ACCOUNT",
+ fullPayto: undefined,
+ decision,
+ measures: measureSummary,
+ });
+ assert.ok(result.errors.includes("unknown-measure"));
+ assert.ok(result.errors.includes("multiple-skip-measures"));
+ assert.strictEqual(result.request, undefined);
+});
+
+test("custom properties cannot replace built-in properties", () => {
+ const decision = completeDecision();
+ decision.custom_properties = { builtin: "replacement" };
+ const result = buildAmlDecisionRequest({
+ account: "ACCOUNT",
+ fullPayto: undefined,
+ decision,
+ measures: measureSummary,
+ });
+ assert.ok(result.errors.includes("invalid-properties"));
+});
+
+test("decision draft targets cannot cross officers or accounts", () => {
+ const target = { officerId: "OFFICER", account: "ACCOUNT" };
+ assert.strictEqual(decisionTargetsMatch(target, target), true);
+ assert.strictEqual(
+ decisionTargetsMatch(target, { ...target, account: "OTHER" }),
+ false,
+ );
+ assert.strictEqual(
+ decisionTargetsMatch(target, { ...target, officerId: "OTHER" }),
+ false,
+ );
+ assert.strictEqual(
+ decisionTargetsMatch(
+ { ...target, fullPayto: "payto://iban/one" },
+ { ...target, fullPayto: "payto://iban/two" },
+ ),
+ false,
+ );
+});
diff --git a/packages/taler-util/src/types-taler-exchange.test.ts b/packages/taler-util/src/types-taler-exchange.test.ts
@@ -18,6 +18,7 @@ import { test } from "node:test";
import assert from "node:assert";
import {
codecForAuditor,
+ codecForAmlSpaDialect,
codecForEventCounter,
codecForExchangeKeysResponse,
codecForPurseCreateSuccessResponse,
@@ -25,6 +26,12 @@ import {
} from "./types-taler-exchange.js";
import { encodeCrock } from "./taler-crypto.js";
+test("AML SPA dialect rejects removed GLS configuration", () => {
+ assert.strictEqual(codecForAmlSpaDialect.decode("tops"), "tops");
+ assert.strictEqual(codecForAmlSpaDialect.decode("testing"), "testing");
+ assert.throws(() => codecForAmlSpaDialect.decode("gls"));
+});
+
/**
* A /keys response captured from a live exchange (protocol 37:0:3), reduced
* to one denomination group per cipher and one denomination each, with the
@@ -34,178 +41,179 @@ import { encodeCrock } from "./taler-crypto.js";
* carries no age_mask although the interface declares one.
*/
const REAL_KEYS: any = {
- "version": "37:0:3",
- "base_url": "http://localhost:8081/",
- "currency": "TESTKUDOS",
- "currency_specification": {
- "name": "Test-kudos (Taler Demonstrator)",
- "currency": "TESTKUDOS",
- "common_amounts": [
- "TESTKUDOS:5",
- "TESTKUDOS:10",
- "TESTKUDOS:25",
- "TESTKUDOS:50"
- ],
- "num_fractional_input_digits": 2,
- "num_fractional_normal_digits": 2,
- "num_fractional_trailing_zero_digits": 2,
- "alt_unit_names": {
- "0": "\u30c6",
- "3": "k\u30c6",
- "-3": "m\u30c6"
- }
+ version: "37:0:3",
+ base_url: "http://localhost:8081/",
+ currency: "TESTKUDOS",
+ currency_specification: {
+ name: "Test-kudos (Taler Demonstrator)",
+ currency: "TESTKUDOS",
+ common_amounts: [
+ "TESTKUDOS:5",
+ "TESTKUDOS:10",
+ "TESTKUDOS:25",
+ "TESTKUDOS:50",
+ ],
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": "\u30c6",
+ "3": "k\u30c6",
+ "-3": "m\u30c6",
},
- "hard_limits": [],
- "zero_limits": [],
- "stefan_abs": "TESTKUDOS:1",
- "stefan_log": "TESTKUDOS:1",
- "stefan_lin": 0,
- "asset_type": "fiat",
- "kyc_enabled": false,
- "kyc_swap_tos_acceptance": false,
- "disable_direct_deposit": false,
- "rewards_allowed": false,
- "master_public_key": "DK9ESDMR9VGK66PJ",
- "reserve_closing_delay": {
- "d_us": 2419200000000
+ },
+ hard_limits: [],
+ zero_limits: [],
+ stefan_abs: "TESTKUDOS:1",
+ stefan_log: "TESTKUDOS:1",
+ stefan_lin: 0,
+ asset_type: "fiat",
+ kyc_enabled: false,
+ kyc_swap_tos_acceptance: false,
+ disable_direct_deposit: false,
+ rewards_allowed: false,
+ master_public_key: "DK9ESDMR9VGK66PJ",
+ reserve_closing_delay: {
+ d_us: 2419200000000,
+ },
+ default_p2p_push_expiration: {
+ d_us: 2419200000000,
+ },
+ signkeys: [
+ {
+ stamp_start: {
+ t_s: 1784568055,
+ },
+ stamp_expire: {
+ t_s: 1791825655,
+ },
+ stamp_end: {
+ t_s: 1854897655,
+ },
+ master_sig: "5RBMXK3WQK2BX6CH",
+ key: "GV59TV4135ENJAES",
},
- "default_p2p_push_expiration": {
- "d_us": 2419200000000
+ ],
+ recoup: [],
+ wads: [],
+ accounts: [
+ {
+ payto_uri:
+ "payto://x-taler-bank/localhost/exchange?receiver-name=exchange",
+ priority: 0,
+ debit_restrictions: [],
+ credit_restrictions: [],
+ master_sig: "MGFAA1ZVYHPNK54G",
},
- "signkeys": [
+ ],
+ wire_fees: {
+ "x-taler-bank": [
{
- "stamp_start": {
- "t_s": 1784568055
- },
- "stamp_expire": {
- "t_s": 1791825655
+ wire_fee: "TESTKUDOS:0.01",
+ closing_fee: "TESTKUDOS:0.01",
+ start_date: {
+ t_s: 1767225600,
},
- "stamp_end": {
- "t_s": 1854897655
+ end_date: {
+ t_s: 1798761600,
},
- "master_sig": "5RBMXK3WQK2BX6CH",
- "key": "GV59TV4135ENJAES"
- }
- ],
- "recoup": [],
- "wads": [],
- "accounts": [
- {
- "payto_uri": "payto://x-taler-bank/localhost/exchange?receiver-name=exchange",
- "priority": 0,
- "debit_restrictions": [],
- "credit_restrictions": [],
- "master_sig": "MGFAA1ZVYHPNK54G"
- }
+ sig: "F4K7E168YX587XWQ",
+ },
],
- "wire_fees": {
- "x-taler-bank": [
+ },
+ denominations: [
+ {
+ cipher: "RSA",
+ denoms: [
{
- "wire_fee": "TESTKUDOS:0.01",
- "closing_fee": "TESTKUDOS:0.01",
- "start_date": {
- "t_s": 1767225600
+ master_sig: "EBDT9N9RWR4X0SF1",
+ stamp_start: {
+ t_s: 1784568055,
},
- "end_date": {
- "t_s": 1798761600
+ stamp_expire_withdraw: {
+ t_s: 1784764800,
},
- "sig": "F4K7E168YX587XWQ"
- }
- ]
- },
- "denominations": [
- {
- "cipher": "RSA",
- "denoms": [
- {
- "master_sig": "EBDT9N9RWR4X0SF1",
- "stamp_start": {
- "t_s": 1784568055
- },
- "stamp_expire_withdraw": {
- "t_s": 1784764800
- },
- "stamp_expire_deposit": {
- "t_s": 1847836800
- },
- "stamp_expire_legal": {
- "t_s": 1942444800
- },
- "rsa_pub": "020000XDV5WEHG8Q"
- }
- ],
- "fee_withdraw": "TESTKUDOS:0.01",
- "fee_deposit": "TESTKUDOS:0.01",
- "fee_refresh": "TESTKUDOS:0.01",
- "fee_refund": "TESTKUDOS:0.01",
- "value": "TESTKUDOS:2"
- },
- {
- "cipher": "CS",
- "denoms": [
- {
- "master_sig": "WGMHHKDJ4WRAQM8Q",
- "stamp_start": {
- "t_s": 1815412833
- },
- "stamp_expire_withdraw": {
- "t_s": 1816214400
- },
- "stamp_expire_deposit": {
- "t_s": 1879286400
- },
- "stamp_expire_legal": {
- "t_s": 1973894400
- },
- "cs_pub": "97ZMTV6M1SC4N00W"
- }
- ],
- "fee_withdraw": "TESTKUDOS:0.01",
- "fee_deposit": "TESTKUDOS:0.01",
- "fee_refresh": "TESTKUDOS:0.01",
- "fee_refund": "TESTKUDOS:0.01",
- "value": "TESTKUDOS:0.1"
- }
- ],
- "auditors": [],
- "global_fees": [
- {
- "start_date": {
- "t_s": 1767225600
- },
- "end_date": {
- "t_s": 1798761600
- },
- "history_fee": "TESTKUDOS:0.01",
- "account_fee": "TESTKUDOS:0.01",
- "purse_fee": "TESTKUDOS:0",
- "history_expiration": {
- "d_us": 31536000000000
+ stamp_expire_deposit: {
+ t_s: 1847836800,
+ },
+ stamp_expire_legal: {
+ t_s: 1942444800,
+ },
+ rsa_pub: "020000XDV5WEHG8Q",
},
- "purse_timeout": {
- "d_us": 3600000000
+ ],
+ fee_withdraw: "TESTKUDOS:0.01",
+ fee_deposit: "TESTKUDOS:0.01",
+ fee_refresh: "TESTKUDOS:0.01",
+ fee_refund: "TESTKUDOS:0.01",
+ value: "TESTKUDOS:2",
+ },
+ {
+ cipher: "CS",
+ denoms: [
+ {
+ master_sig: "WGMHHKDJ4WRAQM8Q",
+ stamp_start: {
+ t_s: 1815412833,
+ },
+ stamp_expire_withdraw: {
+ t_s: 1816214400,
+ },
+ stamp_expire_deposit: {
+ t_s: 1879286400,
+ },
+ stamp_expire_legal: {
+ t_s: 1973894400,
+ },
+ cs_pub: "97ZMTV6M1SC4N00W",
},
- "purse_account_limit": 5,
- "master_sig": "V9X36VW7GQD6DF5C"
- }
- ],
- "list_issue_date": {
- "t_s": 1784568055
+ ],
+ fee_withdraw: "TESTKUDOS:0.01",
+ fee_deposit: "TESTKUDOS:0.01",
+ fee_refresh: "TESTKUDOS:0.01",
+ fee_refund: "TESTKUDOS:0.01",
+ value: "TESTKUDOS:0.1",
},
- "wallet_balance_limit_without_kyc": [],
- "tiny_amount": "TESTKUDOS:0.01",
- "exchange_pub": "GV59TV4135ENJAES",
- "exchange_sig": "QXYANBP82S1HGKR3"
- };
+ ],
+ auditors: [],
+ global_fees: [
+ {
+ start_date: {
+ t_s: 1767225600,
+ },
+ end_date: {
+ t_s: 1798761600,
+ },
+ history_fee: "TESTKUDOS:0.01",
+ account_fee: "TESTKUDOS:0.01",
+ purse_fee: "TESTKUDOS:0",
+ history_expiration: {
+ d_us: 31536000000000,
+ },
+ purse_timeout: {
+ d_us: 3600000000,
+ },
+ purse_account_limit: 5,
+ master_sig: "V9X36VW7GQD6DF5C",
+ },
+ ],
+ list_issue_date: {
+ t_s: 1784568055,
+ },
+ wallet_balance_limit_without_kyc: [],
+ tiny_amount: "TESTKUDOS:0.01",
+ exchange_pub: "GV59TV4135ENJAES",
+ exchange_sig: "QXYANBP82S1HGKR3",
+};
test("codecForExchangeKeysResponse accepts a real /keys document", (t) => {
const keys = codecForExchangeKeysResponse().decode(REAL_KEYS);
assert.strictEqual(keys.currency, "TESTKUDOS");
assert.strictEqual(keys.denominations.length, 2);
- assert.deepStrictEqual(
- keys.denominations.map((g) => g.cipher).sort(),
- ["CS", "RSA"],
- );
+ assert.deepStrictEqual(keys.denominations.map((g) => g.cipher).sort(), [
+ "CS",
+ "RSA",
+ ]);
assert.ok(keys.master_public_key.length > 0);
assert.ok(keys.signkeys.length > 0);
assert.ok(keys.accounts.length > 0);