taler-typescript-core

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

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:
Mpackages/taler-harness/src/harness/fake-challenger.ts | 10++++++++++
Apackages/taler-harness/src/integrationtests/test-tops-aml-challenger-failure.ts | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
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,