taler-typescript-core

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

commit 7f0956d858351eb04479b9b27e88ed902d07d159
parent 09d03b7efa13eaf1fb6e3cb30a73076f1a2774b4
Author: Florian Dold <florian@dold.me>
Date:   Tue,  6 May 2025 03:24:40 +0200

harness: extend challenger mock, also test challenger setup request

Diffstat:
Apackages/taler-harness/src/harness/fake-challenger.ts | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/harness/topsConfig.ts | 1+
Apackages/taler-harness/src/integrationtests/test-tops-aml-basic.ts | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-tops-aml-custom-addr-postal.ts | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/taler-harness/src/integrationtests/test-tops-aml.ts | 403-------------------------------------------------------------------------------
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 6++++--
6 files changed, 773 insertions(+), 405 deletions(-)

diff --git a/packages/taler-harness/src/harness/fake-challenger.ts b/packages/taler-harness/src/harness/fake-challenger.ts @@ -0,0 +1,222 @@ +/* + This file is part of GNU Taler + (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 + 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/> + */ + +import { AbsoluteTime, Duration, j2s, Logger } from "@gnu-taler/taler-util"; +import * as http from "node:http"; +import { inflateSync } from "node:zlib"; + +const logger = new Logger("fake-challenger.ts"); + +interface TestfakeChallengerService { + stop: () => void; + fakeVerification(nonce: string, attributes: Record<string, string>): void; + getSetupRequest(nonce: string): any; +} + +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)]; +} + +function readBodyStr(req: http.IncomingMessage): Promise<string> { + return new Promise((resolve, reject) => { + let reqBody = ""; + req.on("data", (x) => { + reqBody += x; + }); + + req.on("end", () => { + resolve(reqBody); + }); + }); +} + +function readBodyBytes(req: http.IncomingMessage): Promise<Uint8Array> { + return new Promise((resolve, reject) => { + let chunks: Buffer[] = []; + req.on("data", (x) => { + chunks.push(Buffer.from(x)); + }); + + req.on("end", () => { + resolve(new Uint8Array(Buffer.concat(chunks))); + }); + }); +} + +function respondJson( + resp: http.ServerResponse<http.IncomingMessage>, + status: number, + body: any, +): void { + resp.writeHead(status, { "Content-Type": "application/json" }); + resp.end(JSON.stringify(body)); +} + +/** + * Testfake for the kyc service that the exchange talks to. + */ +export async function startFakeChallenger(options: { + port: number; +}): Promise<TestfakeChallengerService> { + let nextNonceId = 1; + + const addressType = "postal-ch"; + + const infoForNonceId: Map< + number, + { + // Faked "validated" address + address?: any; + // Setup message + setup?: any; + } + > = new Map(); + + const server = http.createServer(async (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-${nonce}`); + redirUri.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, + }); + } else if (path === "/token") { + const reqBody = await readBodyStr(req); + logger.info("login request body:", reqBody); + const qs = new URLSearchParams(reqBody); + const code = qs.get("code"); + if (typeof code !== "string") { + throw Error("expected code"); + } + respondJson(res, 200, { + access_token: `tok-${code}`, + token_type: "Bearer", + expires_in: 60 * 60, + }); + } else if (path === "/info") { + const ath = req.headers.authorization; + logger.info(`authorization header: ${ath}`); + const tokPrefix = "Bearer tok-code-nonce-"; + if (!ath?.startsWith(tokPrefix)) { + throw Error("invalid token"); + } + const nonce = Number(ath.substring(tokPrefix.length)); + logger.info(`nonce for /info is ${nonce}`); + const myInfo = infoForNonceId.get(nonce); + if (!myInfo) { + respondJson(res, 400, { + error: "invalid_request", + }); + return; + } + respondJson(res, 200, { + id: nonce, + address: myInfo.address, + address_type: addressType, + expires: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 1 }), + ), + ), + }); + } else if (path.startsWith("/setup/")) { + let reqBody: string; + if (req.headers["content-encoding"] === "deflate") { + const bodyEnc = await readBodyBytes(req); + const td = new TextDecoder(); + reqBody = td.decode(inflateSync(bodyEnc)); + } else { + reqBody = await readBodyStr(req); + } + let bodyJson: any; + if (reqBody.length > 0) { + bodyJson = JSON.parse(reqBody); + } + logger.info(`setup content-encoding: ${req.headers["content-encoding"]}`); + logger.info(`setup request body: ${j2s(bodyJson)}`); + + const clientId = path.substring("/setup/".length); + logger.info(`client ID: ${clientId}`); + res.writeHead(200, { "Content-Type": "application/json" }); + let myNonce = nextNonceId++; + infoForNonceId.set(myNonce, { + setup: bodyJson, + }); + res.end( + JSON.stringify({ + nonce: `nonce-${myNonce}`, + }), + ); + } 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, address: Record<string, string>): void { + const prefix = "nonce-"; + if (!nonce.startsWith(prefix)) { + throw Error("invalid challenger nonce"); + } + const nonceId = Number(nonce.substring(prefix.length)); + const nonceInfo = infoForNonceId.get(nonceId); + if (!nonceInfo) { + throw Error("nonce does not exist yet"); + } + nonceInfo.address = address; + }, + getSetupRequest(nonce: string): any { + const prefix = "nonce-"; + if (!nonce.startsWith(prefix)) { + throw Error("invalid challenger nonce"); + } + const nonceId = Number(nonce.substring(prefix.length)); + return infoForNonceId.get(nonceId)?.setup; + }, + }; +} diff --git a/packages/taler-harness/src/harness/topsConfig.ts b/packages/taler-harness/src/harness/topsConfig.ts @@ -539,6 +539,7 @@ export async function createTopsEnvironment( extraProcEnv: { 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", }, }); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-basic.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-basic.ts @@ -0,0 +1,270 @@ +/* + This file is part of GNU Taler + (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 + 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 { + AccessToken, + decodeCrock, + encodeCrock, + hashNormalizedPaytoUri, + j2s, + KycStatusLongPollingReason, + Logger, + OfficerAccount, + OfficerId, + parsePaytoUriOrThrow, + succeedOrThrow, + TalerExchangeHttpClient, + TalerMerchantInstanceHttpClient, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { withdrawViaBankV3 } from "../harness/environments.js"; +import { startFakeChallenger } from "../harness/fake-challenger.js"; +import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; +import { createTopsEnvironment } from "../harness/topsConfig.js"; + +const logger = new Logger("test-tops-aml.ts"); + +export async function runTopsAmlBasicTest(t: GlobalTestState) { + // Set up test environment + + const { + walletClient, + bankClient, + exchange, + amlKeypair, + merchant, + exchangeBankAccount, + wireGatewayApi, + } = await createTopsEnvironment(t); + + const challenger = await startFakeChallenger({ + port: 6001, + }); + + // Withdrawal below threshold succeeds! + const wres = await withdrawViaBankV3(t, { + amount: "CHF:20", + bankClient, + exchange, + walletClient, + }); + + await wres.withdrawalFinishedCond; + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + // Do KYC auth transfer + { + const kycStatus = await merchantClient.getCurrentInstanceKycStatus( + undefined, + {}, + ); + + console.log(`kyc status: ${j2s(kycStatus)}`); + + t.assertDeepEqual(kycStatus.case, "ok"); + + t.assertTrue(kycStatus.body != null); + + t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-wire-required"); + + const depositPaytoUri = kycStatus.body.kyc_data[0].payto_uri; + t.assertTrue(kycStatus.body.kyc_data[0].payto_kycauths != null); + const authTxPayto = parsePaytoUriOrThrow( + kycStatus.body.kyc_data[0]?.payto_kycauths[0], + ); + const authTxMessage = authTxPayto?.params["message"]; + t.assertTrue(typeof authTxMessage === "string"); + t.assertTrue(authTxMessage.startsWith("KYC:")); + const accountPub = authTxMessage.substring(4); + logger.info(`merchant account pub: ${accountPub}`); + await wireGatewayApi.addKycAuth({ + auth: exchangeBankAccount.wireGatewayAuth, + body: { + amount: "CHF:0.1", + debit_account: depositPaytoUri, + account_pub: accountPub, + }, + }); + } + + let accessToken: AccessToken; + let merchantPaytoHash: string; + + // Wait for auth transfer to be registered by the exchange + { + const kycStatus = await merchantClient.getCurrentInstanceKycStatus( + undefined, + { + reason: KycStatusLongPollingReason.AUTH_TRANSFER, + timeout: 30000, + }, + ); + logger.info(`kyc status after transfer: ${j2s(kycStatus)}`); + t.assertDeepEqual(kycStatus.case, "ok"); + t.assertTrue(kycStatus.body != null); + 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, { + httpClient: harnessHttpLib, + }); + + // Accept ToS + { + const kycInfo = await exchangeClient.checkKycInfo( + accessToken, + undefined, + undefined, + ); + console.log(j2s(kycInfo)); + + t.assertDeepEqual(kycInfo.case, "ok"); + t.assertDeepEqual(kycInfo.body.requirements.length, 1); + t.assertDeepEqual(kycInfo.body.requirements[0].form, "accept-tos"); + const requirementId = kycInfo.body.requirements[0].id; + t.assertTrue(typeof requirementId === "string"); + + const uploadRes = await exchangeClient.uploadKycForm(requirementId, { + FORM_ID: "accept-tos", + FORM_VERSION: 1, + ACCEPTED_TERMS_OF_SERVICE: "v1", + }); + console.log("upload res", uploadRes); + t.assertDeepEqual(uploadRes.case, "ok"); + } + + { + const kycInfo = await exchangeClient.checkKycInfo( + accessToken, + undefined, + 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; + t.assertTrue(typeof nonce === "string"); + const proofRedirectUrl = respJson.redirect_url; + + challenger.fakeVerification(nonce, { + CONTACT_NAME: "Richard Stallman", + ADDRESS_LINES: "Bundesgasse 1\n1234 Bern", + }); + + console.log("nonce", nonce); + console.log("proof redirect URL", proofRedirectUrl); + + const proofResp = await harnessHttpLib.fetch(proofRedirectUrl, { + redirect: "manual", + }); + console.log("proof status:", proofResp.status); + t.assertDeepEqual(proofResp.status, 303); + + const setupReq = challenger.getSetupRequest(nonce); + console.log(`setup request: ${j2s(setupReq)}`); + } +} + +runTopsAmlBasicTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-custom-addr-postal.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-custom-addr-postal.ts @@ -0,0 +1,276 @@ +/* + This file is part of GNU Taler + (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 + 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 { + AccessToken, + decodeCrock, + encodeCrock, + hashNormalizedPaytoUri, + j2s, + KycStatusLongPollingReason, + Logger, + OfficerAccount, + OfficerId, + parsePaytoUriOrThrow, + succeedOrThrow, + TalerExchangeHttpClient, + TalerMerchantInstanceHttpClient, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { startFakeChallenger } from "../harness/fake-challenger.js"; +import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; +import { createTopsEnvironment } from "../harness/topsConfig.js"; + +const logger = new Logger("test-tops-aml.ts"); + +/** + * Test for the custom address validation measures. + */ +export async function runTopsAmlCustomAddrPostalTest(t: GlobalTestState) { + // Set up test environment + + const { + exchange, + amlKeypair, + merchant, + exchangeBankAccount, + wireGatewayApi, + } = await createTopsEnvironment(t); + + const challenger = await startFakeChallenger({ + port: 6001, + }); + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + // Do KYC auth transfer + { + const kycStatus = await merchantClient.getCurrentInstanceKycStatus( + undefined, + {}, + ); + + console.log(`kyc status: ${j2s(kycStatus)}`); + + t.assertDeepEqual(kycStatus.case, "ok"); + + t.assertTrue(kycStatus.body != null); + + t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-wire-required"); + + const depositPaytoUri = kycStatus.body.kyc_data[0].payto_uri; + t.assertTrue(kycStatus.body.kyc_data[0].payto_kycauths != null); + const authTxPayto = parsePaytoUriOrThrow( + kycStatus.body.kyc_data[0]?.payto_kycauths[0], + ); + const authTxMessage = authTxPayto?.params["message"]; + t.assertTrue(typeof authTxMessage === "string"); + t.assertTrue(authTxMessage.startsWith("KYC:")); + const accountPub = authTxMessage.substring(4); + logger.info(`merchant account pub: ${accountPub}`); + await wireGatewayApi.addKycAuth({ + auth: exchangeBankAccount.wireGatewayAuth, + body: { + amount: "CHF:0.1", + debit_account: depositPaytoUri, + account_pub: accountPub, + }, + }); + } + + let accessToken: AccessToken; + let merchantPaytoHash: string; + + // Wait for auth transfer to be registered by the exchange + { + const kycStatus = await merchantClient.getCurrentInstanceKycStatus( + undefined, + { + reason: KycStatusLongPollingReason.AUTH_TRANSFER, + timeout: 30000, + }, + ); + logger.info(`kyc status after transfer: ${j2s(kycStatus)}`); + t.assertDeepEqual(kycStatus.case, "ok"); + t.assertTrue(kycStatus.body != null); + 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, { + httpClient: harnessHttpLib, + }); + + // Accept ToS + { + const kycInfo = await exchangeClient.checkKycInfo( + accessToken, + undefined, + undefined, + ); + console.log(j2s(kycInfo)); + + t.assertDeepEqual(kycInfo.case, "ok"); + t.assertDeepEqual(kycInfo.body.requirements.length, 1); + t.assertDeepEqual(kycInfo.body.requirements[0].form, "accept-tos"); + const requirementId = kycInfo.body.requirements[0].id; + t.assertTrue(typeof requirementId === "string"); + + const uploadRes = await exchangeClient.uploadKycForm(requirementId, { + FORM_ID: "accept-tos", + FORM_VERSION: 1, + ACCEPTED_TERMS_OF_SERVICE: "v1", + }); + console.log("upload res", uploadRes); + t.assertDeepEqual(uploadRes.case, "ok"); + } + + { + const kycInfo = await exchangeClient.checkKycInfo( + accessToken, + undefined, + 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: "my-postal-registration", + new_rules: { + custom_measures: { + "my-postal-registration": { + prog_name: "challenger-postal-from-context", + context: { + FULL_NAME: "Richard Stallman", + ADDRESS_LINES: "Bundesgasse 1\n1234 Bern", + ADDRESS_COUNTRY: "CH", + }, + check_name: "SKIP", + }, + }, + expiration_time: TalerProtocolTimestamp.never(), + rules: rec.limits.rules, + }, + }), + ); + } + + { + const kycInfoResp = await exchangeClient.checkKycInfo( + accessToken, + undefined, + undefined, + ); + console.log( + `kyc info after my-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; + t.assertTrue(typeof nonce === "string"); + const proofRedirectUrl = respJson.redirect_url; + + challenger.fakeVerification(nonce, { + CONTACT_NAME: "Richard Stallman", + ADDRESS_LINES: "Bundesgasse 1\n1234 Bern", + }); + + console.log("nonce", nonce); + console.log("proof redirect URL", proofRedirectUrl); + + const proofResp = await harnessHttpLib.fetch(proofRedirectUrl, { + redirect: "manual", + }); + console.log("proof status:", proofResp.status); + t.assertDeepEqual(proofResp.status, 303); + + const setupReq = challenger.getSetupRequest(nonce); + console.log(`setup request: ${j2s(setupReq)}`); + + t.assertDeepEqual(setupReq.CONTACT_NAME, "Richard Stallman"); + t.assertDeepEqual(setupReq.read_only, true); + } +} + +runTopsAmlCustomAddrPostalTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml.ts b/packages/taler-harness/src/integrationtests/test-tops-aml.ts @@ -1,403 +0,0 @@ -/* - This file is part of GNU Taler - (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 - 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 { - 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 - - const { - walletClient, - bankClient, - exchange, - amlKeypair, - merchant, - exchangeBankAccount, - wireGatewayApi, - } = await createTopsEnvironment(t); - - const challenger = await startTestfakeChallenger({ - port: 6001, - }); - - // Withdrawal below threshold succeeds! - const wres = await withdrawViaBankV3(t, { - amount: "CHF:20", - bankClient, - exchange, - walletClient, - }); - - await wres.withdrawalFinishedCond; - - const merchantClient = new TalerMerchantInstanceHttpClient( - merchant.makeInstanceBaseUrl(), - ); - // Do KYC auth transfer - { - const kycStatus = await merchantClient.getCurrentInstanceKycStatus( - undefined, - {}, - ); - - console.log(`kyc status: ${j2s(kycStatus)}`); - - t.assertDeepEqual(kycStatus.case, "ok"); - - t.assertTrue(kycStatus.body != null); - - t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-wire-required"); - - const depositPaytoUri = kycStatus.body.kyc_data[0].payto_uri; - t.assertTrue(kycStatus.body.kyc_data[0].payto_kycauths != null); - const authTxPayto = parsePaytoUriOrThrow( - kycStatus.body.kyc_data[0]?.payto_kycauths[0], - ); - const authTxMessage = authTxPayto?.params["message"]; - t.assertTrue(typeof authTxMessage === "string"); - t.assertTrue(authTxMessage.startsWith("KYC:")); - const accountPub = authTxMessage.substring(4); - logger.info(`merchant account pub: ${accountPub}`); - await wireGatewayApi.addKycAuth({ - auth: exchangeBankAccount.wireGatewayAuth, - body: { - amount: "CHF:0.1", - debit_account: depositPaytoUri, - account_pub: accountPub, - }, - }); - } - - let accessToken: AccessToken; - let merchantPaytoHash: string; - - // Wait for auth transfer to be registered by the exchange - { - const kycStatus = await merchantClient.getCurrentInstanceKycStatus( - undefined, - { - reason: KycStatusLongPollingReason.AUTH_TRANSFER, - timeout: 30000, - }, - ); - logger.info(`kyc status after transfer: ${j2s(kycStatus)}`); - t.assertDeepEqual(kycStatus.case, "ok"); - t.assertTrue(kycStatus.body != null); - 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, { - httpClient: harnessHttpLib, - }); - - // Accept ToS - { - const kycInfo = await exchangeClient.checkKycInfo( - accessToken, - undefined, - undefined, - ); - console.log(j2s(kycInfo)); - - t.assertDeepEqual(kycInfo.case, "ok"); - t.assertDeepEqual(kycInfo.body.requirements.length, 1); - t.assertDeepEqual(kycInfo.body.requirements[0].form, "accept-tos"); - const requirementId = kycInfo.body.requirements[0].id; - t.assertTrue(typeof requirementId === "string"); - - const uploadRes = await exchangeClient.uploadKycForm(requirementId, { - FORM_ID: "accept-tos", - FORM_VERSION: 1, - ACCEPTED_TERMS_OF_SERVICE: "v1", - }); - console.log("upload res", uploadRes); - t.assertDeepEqual(uploadRes.case, "ok"); - } - - { - const kycInfo = await exchangeClient.checkKycInfo( - accessToken, - undefined, - 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)); - } -} - -runTopsAmlTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -119,7 +119,8 @@ import { runSimplePaymentTest } from "./test-simple-payment.js"; import { runStoredBackupsTest } from "./test-stored-backups.js"; import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh.js"; import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw.js"; -import { runTopsAmlTest } from "./test-tops-aml.js"; +import { runTopsAmlBasicTest } from "./test-tops-aml-basic.js"; +import { runTopsAmlCustomAddrPostalTest } from "./test-tops-aml-custom-addr-postal.js"; import { runTermOfServiceFormatTest } from "./test-tos-format.js"; import { runWalletBackupBasicTest } from "./test-wallet-backup-basic.js"; import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend.js"; @@ -319,7 +320,8 @@ const allTests: TestMainFunction[] = [ runAgeRestrictionsDepositTest, runKycDecisionEventsTest, runWalletDevexpFakeprotoverTest, - runTopsAmlTest, + runTopsAmlBasicTest, + runTopsAmlCustomAddrPostalTest, ]; export interface TestRunSpec {