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