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:
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 {