commit 44988c85c2fe24478bdc5b48f13dba18137f4f47
parent ba837abbed323aac4e35930caae38f2af2840ea0
Author: Florian Dold <dold@taler.net>
Date: Wed, 23 Sep 2026 14:49:46 +0200
harness: test that a failed challenger validation fails the KYC process
Issue: https://bugs.taler.net/n/11740
Diffstat:
3 files changed, 199 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/harness/fake-challenger.ts b/packages/taler-harness/src/harness/fake-challenger.ts
@@ -95,10 +95,20 @@ export async function startFakeChallenger(
const redirUri = new URL(redirUriUnparsed);
redirUri.searchParams.set("code", `code-${nonce}`);
redirUri.searchParams.set("state", state);
+ // Where challenger sends the user once they used up every attempt to
+ // prove their address (RFC 6749 4.1.2.1 error response).
+ const errorRedirUri = new URL(redirUriUnparsed);
+ errorRedirUri.searchParams.set("error", "access_denied");
+ errorRedirUri.searchParams.set(
+ "error_description",
+ "address validation failed: all attempts exhausted",
+ );
+ errorRedirUri.searchParams.set("state", state);
respondJson(res, 200, {
// Return so that the nonce can be used to fake the address validation.
nonce,
redirect_url: redirUri.href,
+ error_redirect_url: errorRedirUri.href,
});
} else if (path === "/token") {
const reqBody = await readBodyStr(req);
diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-challenger-failure.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-challenger-failure.ts
@@ -0,0 +1,187 @@
+/*
+ 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ decodeCrock,
+ j2s,
+ OfficerId,
+ OfficerSession,
+ succeedOrThrow,
+ TalerErrorCode,
+ TalerExchangeHttpClient,
+ TalerMerchantInstanceHttpClient,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { startFakeChallenger } from "../harness/fake-challenger.js";
+import {
+ GlobalTestState,
+ harnessHttpLib,
+ runCommand,
+} from "../harness/harness.js";
+import {
+ createTopsEnvironment,
+ doTopsAcceptTos,
+ doTopsKycAuth,
+} from "../harness/tops.js";
+
+/**
+ * Test that the exchange records a challenger address validation that
+ * the user can no longer pass as a failed KYC process.
+ *
+ * Once the user used up every attempt to prove their address, challenger
+ * sends them back to the exchange with an OAuth2 "access_denied" error
+ * (bug 11740). The exchange must then finish the KYC process as failed
+ * instead of treating the answer as an unexpected provider reply and
+ * leaving the process pending.
+ */
+export async function runTopsAmlChallengerFailureTest(t: GlobalTestState) {
+ const {
+ exchange,
+ amlKeypair,
+ merchant,
+ bank,
+ exchangeBankAccount,
+ wireGatewayApi,
+ merchantAdminAccessToken,
+ commonDb,
+ } = await createTopsEnvironment(t);
+
+ await startFakeChallenger(t, {
+ port: 6002,
+ addressType: "phone",
+ });
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+ const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl, {
+ httpClient: harnessHttpLib,
+ });
+
+ const { accessToken, merchantPaytoHash } = await doTopsKycAuth(t, {
+ merchantClient,
+ merchantAdminAccessToken,
+ exchangeBankAccount,
+ wireGatewayApi,
+ bank,
+ });
+
+ await doTopsAcceptTos(t, {
+ exchangeClient,
+ accessToken,
+ merchantClient,
+ merchantAdminAccessToken,
+ merchant,
+ });
+
+ const officerAcc: OfficerSession = {
+ id: amlKeypair.pub as OfficerId,
+ __signingKey: decodeCrock(amlKeypair.priv),
+ };
+
+ // Require the phone number the exchange already knows to be confirmed,
+ // which is how the exchange uses challenger in practice: the address is
+ // fixed by the exchange and the user cannot change it.
+ {
+ const decisionsResp = succeedOrThrow(
+ await exchangeClient.getAmlDecisions(officerAcc, {
+ active: true,
+ }),
+ );
+ t.assertDeepEqual(decisionsResp.records.length, 1);
+ const rec = decisionsResp.records[0];
+ t.assertDeepEqual(merchantPaytoHash, rec.h_payto);
+
+ succeedOrThrow(
+ await exchangeClient.makeAmlDesicion(officerAcc, {
+ decision_time: TalerProtocolTimestamp.now(),
+ h_payto: rec.h_payto,
+ justification: "confirm phone",
+ properties: rec.properties ?? {},
+ keep_investigating: rec.to_investigate,
+ new_measures: "my-sms-registration",
+ new_rules: {
+ custom_measures: {
+ "my-sms-registration": {
+ prog_name: "challenger-sms-from-context",
+ context: {
+ CONTACT_PHONE: "+4123456789",
+ },
+ check_name: "SKIP",
+ },
+ },
+ expiration_time: TalerProtocolTimestamp.never(),
+ rules: rec.limits.rules,
+ },
+ }),
+ );
+ }
+
+ const kycInfoResp = await exchangeClient.checkKycInfo(accessToken);
+ t.assertDeepEqual(kycInfoResp.case, "ok");
+ const kycInfo = kycInfoResp.body;
+ t.assertDeepEqual(kycInfo.requirements[0].form, "LINK");
+ t.assertTrue(typeof kycInfo.requirements[0].id === "string");
+
+ const startResp = succeedOrThrow(
+ await exchangeClient.startExternalKycProcess(
+ kycInfo.requirements[0].id,
+ {},
+ ),
+ );
+ console.log(`start resp`, j2s(startResp));
+
+ const authorizeResp = await harnessHttpLib.fetch(startResp.redirect_url);
+ const authorizeJson = await authorizeResp.json();
+ console.log(`challenger resp: ${j2s(authorizeJson)}`);
+ const errorRedirectUrl = authorizeJson.error_redirect_url;
+ t.assertTrue(typeof errorRedirectUrl === "string");
+
+ // The user used up all their attempts; challenger sends them back.
+ const proofResp = await harnessHttpLib.fetch(errorRedirectUrl, {
+ redirect: "manual",
+ });
+ console.log("proof status:", proofResp.status);
+ // A failed process is reported to the user as such, not as the 502 of an
+ // unexpected provider reply.
+ t.assertDeepEqual(proofResp.status, 403);
+
+ const hPayto = `decode('${Buffer.from(decodeCrock(merchantPaytoHash)).toString("hex")}', 'hex')`;
+ const processState = await runCommand(t, "kyc-process-state", "psql", [
+ commonDb.connStr,
+ "-X",
+ "-qAt",
+ "-v",
+ "ON_ERROR_STOP=1",
+ "-c",
+ `SELECT finished, error_code
+ FROM exchange.legitimization_processes
+ WHERE h_payto=${hPayto}
+ AND provider_name='sms-challenger'
+ ORDER BY legitimization_process_serial_id DESC
+ LIMIT 1`,
+ ]);
+ console.log(`KYC process state: ${processState}`);
+ t.assertDeepEqual(
+ processState.trim(),
+ `t|${TalerErrorCode.EXCHANGE_GENERIC_KYC_FAILED}`,
+ );
+}
+
+runTopsAmlChallengerFailureTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -187,6 +187,7 @@ import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw.js";
import { runTopsTransactionRiskMonitoringTest } from "./test-tops-transaction-risk-monitoring.js";
import { runTopsAmlBasicTest } from "./test-tops-aml-basic.js";
import { runTopsAmlCustomAddrPostalTest } from "./test-tops-aml-custom-addr-postal.js";
+import { runTopsAmlChallengerFailureTest } from "./test-tops-aml-challenger-failure.js";
import { runTopsAmlCustomAddrSmsTest } from "./test-tops-aml-custom-addr-sms.js";
import { runTopsAmlKyxNaturalTest } from "./test-tops-aml-kyx-natural.js";
import { runTopsAmlExpiredSuccessorTest } from "./test-tops-aml-expired-successor.js";
@@ -509,6 +510,7 @@ const allTests: TestMainFunction[] = [
runTopsTransactionRiskMonitoringTest,
runTopsAmlCustomAddrPostalTest,
runTopsAmlCustomAddrSmsTest,
+ runTopsAmlChallengerFailureTest,
runTopsAmlKyxNaturalTest,
runTopsAmlExpiredSuccessorTest,
runTopsAmlMeasuresTest,