taler-typescript-core

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

commit 09d03b7efa13eaf1fb6e3cb30a73076f1a2774b4
parent 1f13e183531c7f785e31bd602a498053dcee0c38
Author: Florian Dold <florian@dold.me>
Date:   Tue,  6 May 2025 00:34:10 +0200

harness: extend tops-aml test

Diffstat:
Mpackages/taler-harness/src/harness/topsConfig.ts | 6+++---
Mpackages/taler-harness/src/integrationtests/test-tops-aml.ts | 253++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/bank-api-client.ts | 3+--
3 files changed, 256 insertions(+), 6 deletions(-)

diff --git a/packages/taler-harness/src/harness/topsConfig.ts b/packages/taler-harness/src/harness/topsConfig.ts @@ -469,7 +469,7 @@ KYC_OAUTH2_INFO_URL = http://localhost:6001/info KYC_OAUTH2_CLIENT_ID = test-postal-id KYC_OAUTH2_CLIENT_SECRET = test-postal-secret KYC_OAUTH2_POST_URL = http://localhost:6001/done -KYC_OAUTH2_CONVERTER_HELPER = /usr/local/bin/jq-postal-converter +KYC_OAUTH2_CONVERTER_HELPER = taler-exchange-kyc-challenger-postal-converter KYC_OAUTH2_DEBUG_MODE = YES [kyc-provider-sms-challenger] @@ -481,7 +481,7 @@ KYC_OAUTH2_INFO_URL = http://localhost:6002/info KYC_OAUTH2_CLIENT_ID = test-postal-id KYC_OAUTH2_CLIENT_SECRET = test-postal-secret KYC_OAUTH2_POST_URL = http://localhost:6002/done -KYC_OAUTH2_CONVERTER_HELPER = /usr/local/bin/jq-sms-converter +KYC_OAUTH2_CONVERTER_HELPER = taler-exchange-kyc-challenger-sms-converter KYC_OAUTH2_DEBUG_MODE = YES [kyc-provider-email-challenger] @@ -493,7 +493,7 @@ KYC_OAUTH2_INFO_URL = http://localhost:6003/info KYC_OAUTH2_CLIENT_ID = test-postal-id KYC_OAUTH2_CLIENT_SECRET = test-postal-secret KYC_OAUTH2_POST_URL = http://localhost:6003/done -KYC_OAUTH2_CONVERTER_HELPER = /usr/local/bin/jq-email-converter +KYC_OAUTH2_CONVERTER_HELPER = taler-exchange-kyc-challenger-email-converter KYC_OAUTH2_DEBUG_MODE = YES [kyc-provider-kycaid-business] diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml.ts b/packages/taler-harness/src/integrationtests/test-tops-aml.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2020 Taler Systems S.A. + (C) 2025 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 @@ -18,19 +18,147 @@ * Imports. */ import { + AbsoluteTime, AccessToken, + decodeCrock, + Duration, + encodeCrock, + hashNormalizedPaytoUri, j2s, KycStatusLongPollingReason, Logger, + OfficerAccount, + OfficerId, parsePaytoUriOrThrow, + succeedOrThrow, TalerExchangeHttpClient, TalerMerchantInstanceHttpClient, + TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; +import * as http from "node:http"; import { withdrawViaBankV3 } from "../harness/environments.js"; import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; import { createTopsEnvironment } from "../harness/topsConfig.js"; + const logger = new Logger("test-tops-accept-tos.ts"); +interface TestfakeChallengerService { + stop: () => void; + fakeVerification(nonce: string, attributes: Record<string, string>): void; +} + +function splitInTwoAt(s: string, separator: string): [string, string] { + const idx = s.indexOf(separator); + if (idx === -1) { + return [s, ""]; + } + return [s.slice(0, idx), s.slice(idx + 1)]; +} + +/** + * Testfake for the kyc service that the exchange talks to. + */ +async function startTestfakeChallenger(options: { + port: number; +}): Promise<TestfakeChallengerService> { + const server = http.createServer((req, res) => { + const requestUrl = req.url!; + logger.info(`fake-challenger: got ${req.method} request, ${requestUrl}`); + + const [path, query] = splitInTwoAt(requestUrl, "?"); + + const qp = new URLSearchParams(query); + + if (path.startsWith("/authorize/")) { + const nonce = path.substring("/authorize/".length); + logger.info(`got authorize request with noce ${nonce}`); + // Usually this would render some HTML page for the user to log in, + // but we return JSON here. + const redirUriUnparsed = qp.get("redirect_uri"); + if (!redirUriUnparsed) { + throw Error("missing redirect_url"); + } + const state = qp.get("state"); + if (!state) { + throw Error("missing state"); + } + const redirUri = new URL(redirUriUnparsed); + redirUri.searchParams.set("code", "code_is_ok"); + redirUri.searchParams.set("state", state); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + nonce, + redirect_url: redirUri.href, + }), + ); + } else if (path === "/token") { + let reqBody = ""; + req.on("data", (x) => { + reqBody += x; + }); + + req.on("end", () => { + logger.info("login request body:", reqBody); + + res.writeHead(200, { "Content-Type": "application/json" }); + // Normally, the access_token would also include which user we're trying + // to get info about, but we (for now) skip it in this test. + res.end( + JSON.stringify({ + access_token: "exchange_access_token", + token_type: "Bearer", + expires_in: 60 * 60, + }), + ); + }); + } else if (path === "/info") { + logger.info(`authorization header: ${req.headers.authorization}`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: 1, + address: { + CONTACT_NAME: "Richard Stallman", + ADDRESS_LINES: "Bundesgasse 1\n1234 Bern", + }, + address_type: "postal-ch", + expires: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 1 }), + ), + ), + }), + ); + } else if (path.startsWith("/setup/")) { + const clientId = path.substring("/setup/".length); + logger.info(`client ID: ${clientId}`); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + nonce: "42", + }), + ); + } else { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code: 1, message: "bad request" })); + } + }); + await new Promise<void>((resolve, reject) => { + server.listen(options.port, () => resolve()); + }); + return { + stop() { + server.close(); + }, + fakeVerification( + nonce: string, + attributes: Record<string, string>, + ): void {}, + }; +} + export async function runTopsAmlTest(t: GlobalTestState) { // Set up test environment @@ -44,6 +172,10 @@ export async function runTopsAmlTest(t: GlobalTestState) { wireGatewayApi, } = await createTopsEnvironment(t); + const challenger = await startTestfakeChallenger({ + port: 6001, + }); + // Withdrawal below threshold succeeds! const wres = await withdrawViaBankV3(t, { amount: "CHF:20", @@ -93,6 +225,7 @@ export async function runTopsAmlTest(t: GlobalTestState) { } let accessToken: AccessToken; + let merchantPaytoHash: string; // Wait for auth transfer to be registered by the exchange { @@ -109,6 +242,9 @@ export async function runTopsAmlTest(t: GlobalTestState) { t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-required"); t.assertTrue(typeof kycStatus.body.kyc_data[0].access_token === "string"); accessToken = kycStatus.body.kyc_data[0].access_token as AccessToken; + merchantPaytoHash = encodeCrock( + hashNormalizedPaytoUri(kycStatus.body.kyc_data[0].payto_uri), + ); } const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl, { @@ -146,6 +282,121 @@ export async function runTopsAmlTest(t: GlobalTestState) { undefined, ); console.log(j2s(kycInfo)); + + // FIXME: Do we expect volunary measures here? + // => not yet, see https://bugs.gnunet.org/view.php?id=9879 + } + + await merchant.runKyccheckOnce(); + + { + const kycStatus = await merchantClient.getCurrentInstanceKycStatus( + undefined, + { + reason: KycStatusLongPollingReason.AUTH_TRANSFER, + timeout: 30000, + }, + ); + logger.info(`kyc status after accept-tos: ${j2s(kycStatus)}`); + } + + const officerAcc: OfficerAccount = { + id: amlKeypair.pub as OfficerId, + signingKey: decodeCrock(amlKeypair.priv), + }; + + // Trigger postal registration check + // via AML officer. + { + const decisionsResp = succeedOrThrow( + await exchangeClient.getAmlDecisions(officerAcc, { + active: true, + }), + ); + console.log(j2s(decisionsResp)); + + 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: "bla", + properties: rec.properties ?? {}, + keep_investigating: rec.to_investigate, + new_measures: "postal-registration", + new_rules: { + custom_measures: {}, + expiration_time: TalerProtocolTimestamp.never(), + rules: rec.limits.rules, + }, + }), + ); + } + + { + const kycInfoResp = await exchangeClient.checkKycInfo( + accessToken, + undefined, + undefined, + ); + console.log(`kyc info after postal-registration measure`, j2s(kycInfoResp)); + 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)); + + let challengerRedirectUrl = startResp.redirect_url; + + const resp = await harnessHttpLib.fetch(challengerRedirectUrl); + const respJson = await resp.json(); + console.log(`challenger resp: ${j2s(respJson)}`); + + const nonce = respJson.nonce; + const proofRedirectUrl = respJson.redirect_url; + + // FIXME: This doesn't do anything, the fake challenger hard-codes the address. + challenger.fakeVerification(nonce, { + FOO: "BAR", + }); + + console.log("nonce", nonce); + console.log("proof redirect URL", proofRedirectUrl); + + const proofResp = await harnessHttpLib.fetch(proofRedirectUrl); + console.log("proof status:", proofResp.status); + } + + // Check that no requirements are left. + { + const kycInfo = await exchangeClient.checkKycInfo( + accessToken, + undefined, + undefined, + ); + console.log(j2s(kycInfo)); + t.assertDeepEqual(kycInfo.case, "ok"); + t.assertDeepEqual(kycInfo.body.requirements.length, 0); + } + + { + const decisionsResp = succeedOrThrow( + await exchangeClient.getAmlDecisions(officerAcc, { + active: true, + }), + ); + console.log(j2s(decisionsResp)); } } diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts @@ -35,7 +35,6 @@ import { Logger, opEmptySuccess, opKnownHttpFailure, - opUnknownFailure, opUnknownHttpFailure, PaytoString, stringToBytes, @@ -48,7 +47,6 @@ import { expectSuccessResponseOrThrow, HttpRequestLibrary, readSuccessResponseJsonOrThrow, - readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; const logger = new Logger("bank-api-client.ts"); @@ -298,6 +296,7 @@ export class TalerCorebankApiClient { body: {}, headers: this.makeAuthHeader(), }); + logger.info(`confirm response status ${resp.status}`); switch (resp.status) { case HttpStatusCode.Ok: