taler-typescript-core

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

commit 8168758a63c83f01242876fe3ef950f861652e0a
parent 2d1d55b4fab14a3f4590794eb04bfedc1b3ac32a
Author: Florian Dold <florian@dold.me>
Date:   Mon,  5 May 2025 17:17:07 +0200

harness: towards TOPS integration test

Diffstat:
Mpackages/taler-harness/src/harness/harness.ts | 5+++++
Apackages/taler-harness/src/harness/topsConfig.ts | 691+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-kyc-challenger.ts | 375+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/taler-harness/src/integrationtests/test-kyc.ts | 375-------------------------------------------------------------------------------
Apackages/taler-harness/src/integrationtests/test-tops-aml.ts | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 6++++--
Mpackages/taler-util/src/http-client/exchange.ts | 4++--
Mpackages/taler-util/src/payto.ts | 8++++++++
Mpackages/taler-util/src/talerconfig.ts | 15++++++++++++++-
Mpackages/taler-util/src/types-taler-exchange.ts | 9++++++---
10 files changed, 1257 insertions(+), 383 deletions(-)

diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -1057,6 +1057,10 @@ export interface ExchangeConfig { database: string; overrideTestDir?: string; overrideWireFee?: string; + /** + * Extra environment variables to pass to the exchange processes. + */ + extraProcEnv?: Record<string, string>; } export interface ExchangeServiceInterface { @@ -1738,6 +1742,7 @@ export class ExchangeService implements ExchangeServiceInterface { "taler-exchange-httpd", ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr], `exchange-httpd-${this.name}`, + { ...process.env, ...(this.exchangeConfig.extraProcEnv ?? {}) }, ); await this.pingUntilAvailable(); diff --git a/packages/taler-harness/src/harness/topsConfig.ts b/packages/taler-harness/src/harness/topsConfig.ts @@ -0,0 +1,691 @@ +/* + 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 { + TalerCorebankApiClient, + TalerCoreBankHttpClient, + TalerExchangeHttpClient, + TalerMerchantInstanceHttpClient, + TalerProtocolDuration, + TalerWireGatewayHttpClient, +} from "@gnu-taler/taler-util"; +import { + createSyncCryptoApi, + WalletApiOperation, +} from "@gnu-taler/taler-wallet-core"; +import { CoinConfig, defaultCoinConfig } from "./denomStructures.js"; +import { KycTestEnv } from "./environments.js"; +import { + BankService, + ExchangeService, + getTestHarnessPaytoForLabel, + GlobalTestState, + HarnessExchangeBankAccount, + harnessHttpLib, + MerchantService, + setupDb, + WalletClient, + WalletService, +} from "./harness.js"; + +// TODO: +// * Use IBANs and libeufin-nexus +// to be closer to prod setup + +/** + * Configuration to emulate the TOPS setup in testing. + * + * Ideally, configuration from the TOPS setup + * can be copy+pasted here. + */ + +export const topsKycRulesConf = ` +[exchange] + +# Better enable KYC. +ENABLE_KYC = YES + +# Hard limits +[kyc-rule-withdraw-limit-monthly] +OPERATION_TYPE = WITHDRAW +NEXT_MEASURES = verboten +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:2500 +TIMEFRAME = "30 days" + +[kyc-rule-withdraw-limit-annually] +OPERATION_TYPE = WITHDRAW +NEXT_MEASURES = verboten +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:15000 +TIMEFRAME = "365 days" + +# Limit on merchant transactions +[kyc-rule-transaction-limit] +OPERATION_TYPE = TRANSACTION +NEXT_MEASURES = verboten +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:1000 +TIMEFRAME = "1 days" + +[kyc-rule-balance-limit] +OPERATION_TYPE = BALANCE +NEXT_MEASURES = verboten +EXPOSED = YES +# Note: Disabled, kept in case we ever want to impose a limit on wallet balances. +ENABLED = NO +THRESHOLD = CHF:1000 +TIMEFRAME = "1 days" + +# SMS identification limit on withdraw (voluntary rule) +[kyc-rule-withdraw-limit-low] +OPERATION_TYPE = WITHDRAW +NEXT_MEASURES = sms-registration +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:200 +TIMEFRAME = "30 days" + +# Deposit requires ToS acceptance, this way we ensure bank account is confirmed! +[kyc-rule-deposit-limit-zero] +OPERATION_TYPE = DEPOSIT +NEXT_MEASURES = accept-tos +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:0 +TIMEFRAME = "1 days" + +# Aggregation limits +[kyc-rule-deposit-limit-monthly] +OPERATION_TYPE = AGGREGATE +NEXT_MEASURES = kyx +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:2500 +TIMEFRAME = "30 days" + +[kyc-rule-deposit-limit-annually] +OPERATION_TYPE = AGGREGATE +NEXT_MEASURES = kyx +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:15000 +TIMEFRAME = "365 days" + +# P2P limits +[kyc-rule-p2p-limit-monthly] +OPERATION_TYPE = MERGE +NEXT_MEASURES = verboten +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:2500 +TIMEFRAME = "30 days" + +[kyc-rule-p2p-limit-annually] +OPERATION_TYPE = MERGE +NEXT_MEASURES = verboten +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:15000 +TIMEFRAME = "365 days" + +[kyc-rule-p2p-domestic-identification-requirement] +OPERATION_TYPE = MERGE +NEXT_MEASURES = sms-registration postal-registration +IS_AND_COMBINATOR = NO +EXPOSED = YES +ENABLED = YES +THRESHOLD = CHF:0 +TIMEFRAME = "30 days" + +# #################### KYC measures ####################### + +# Fallback measure on errors. +[kyc-measure-freeze-investigate] +CHECK_NAME = skip +PROGRAM = freeze-investigate +VOLUNTARY = NO +CONTEXT = {} + +[kyc-measure-sms-registration] +CHECK_NAME = sms-registration +PROGRAM = tops-sms-check +VOLUNTARY = YES +CONTEXT = {} + +[kyc-measure-postal-registration] +CHECK_NAME = postal-registration +PROGRAM = tops-postal-check +VOLUNTARY = YES +CONTEXT = {} + +[kyc-measure-accept-tos] +CHECK_NAME = form-accept-tos +PROGRAM = check-tos +CONTEXT = {"tos_url":"https://exchange.taler-ops.ch/terms","provider_name":"Taler Operations AG", "successor_measure":"accept-tos", "validity_years":10} +VOLUNTARY = NO + +[kyc-measure-kyx] +CHECK_NAME = form-vqf-902.1 +PROGRAM = tops-kyx-check +VOLUNTARY = NO +CONTEXT = {} + +# Form triggered via tops-check-controlling-entity after vqf-902.11 +[kyc-measure-form-vqf-902.9] +CHECK_NAME = form-vqf-902.9 +PROGRAM = preserve-investigate +VOLUNTARY = NO +CONTEXT = {} + +[kyc-measure-form-vqf-902.11] +CHECK_NAME = form-vqf-902.11 +PROGRAM = tops-check-controlling-entity +VOLUNTARY = NO +CONTEXT = {} + +# FIXME: #9825 +#[kyc-measure-form-vqf-902.12] +#CHECK_NAME = form-vqf-902.12 +#PROGRAM = preserve-investigate +#VOLUNTARY = NO +#CONTEXT = {} + +# FIXME: #9827 +#[kyc-measure-form-vqf-902.13] +#CHECK_NAME = form-vqf-902.13 +#PROGRAM = preserve-investigate +#VOLUNTARY = NO +#CONTEXT = {} + +# FIXME: #9826 +#[kyc-measure-form-vqf-902.15] +#CHECK_NAME = form-vqf-902.15 +#PROGRAM = preserve-investigate +#VOLUNTARY = NO +#CONTEXT = {} + +# ##################### KYC checks ########################### + +[kyc-check-form-info-internal-error] +TYPE = INFO +DESCRIPTION = "We encountered an internal error. Staff has been notified. Please be patient." +DESCRIPTION_I18N = {"de":"Interner Fehler. Mitarbeiter wurden informiert. Bitte warten."} +FALLBACK = default-investigate + +[kyc-check-form-info-investigation] +TYPE = INFO +DESCRIPTION = "Staff is checking your case. Please be patient." +DESCRIPTION_I18N = {"de":"Mitarbeiter prüfen ihren Fall. Bitte warten."} +FALLBACK = default-investigate + +[kyc-check-sms-registration] +TYPE = LINK +PROVIDER_ID = sms-challenger +DESCRIPTION = "Confirm Swiss mobile phone number via SMS TAN" +DESCRIPTION_I18N = {"de":"Schweizer Mobiltelefonnummer via SMS TAN bestätigen"} +OUTPUTS = "CONTACT_PHONE" +FALLBACK = default-investigate + +[kyc-check-email-registration] +TYPE = LINK +PROVIDER_ID = email-challenger +DESCRIPTION = "Confirm email address via TAN" +DESCRIPTION_I18N = {"de":"Email addresse via TAN bestätigen"} +OUTPUTS = "CONTACT_EMAIL" +FALLBACK = default-investigate + +[kyc-check-postal-registration] +TYPE = LINK +PROVIDER_ID = postal-challenger +DESCRIPTION = "Register Swiss postal address via TAN letter" +DESCRIPTION_I18N = {"de":"Schweizer Addresse via TAN Brief bestätigen"} +OUTPUTS = "CONTACT_NAME ADDRESS_LINES ADDRESS_COUNTRY" +FALLBACK = default-investigate + +# This check can be triggered by AML programs and/or AML officers, +# it do not appear directly in this configuration as it is triggered +# only indirectly. +[kyc-check-kycaid-individual] +TYPE = LINK +PROVIDER_ID = kycaid-individual +DESCRIPTION = "Provider personal identification data via KYCAID provider" +DESCRIPTION_I18N = {"de":"Persönliche Identifikation via KYCAID Service druchführen"} +OUTPUTS = "PERSON_FULL_NAME PERSON_DATE_OF_BIRTH PERSON_NATIONALITY_CC ADDRESS_STREET ADDRESS_TOWN_LOCATION ADDRESS_ZIPCODE ADDRESS_COUNTRY_CC PERSON_NATIONAL_ID_SCAN TAX_ID" +FALLBACK = default-investigate + +# This check can be triggered by AML programs and/or AML officers, +# it do not appear directly in this configuration as it is triggered +# only indirectly. +[kyc-check-kycaid-business] +TYPE = LINK +PROVIDER_ID = kycaid-business +DESCRIPTION = "Provide business identification via KYCAID provider" +DESCRIPTION_I18N = {"de":"Geschäftsidentifikation via KYCAID durchführen"} +# FIXME: correct output labels? FIXME: questionable we can get those from KYCAID... +# FIXME: lower case names are missing in GANA +OUTPUTS = "BUSINESS_NAME ADDRESS_STREET ADDRESS_TOWN_LOCATION ADDRESS_ZIPCODE ADDRESS_COUNTRY_CC company_identification_document power_of_atorney_document BUSINESS_REGISTRATION_ID business_registration_document registration_authority_name tops_controlling_owner_identifications" +FALLBACK = default-investigate + +# FIXME: consider moving these into the exchange default config! +[kyc-check-form-accept-tos] +TYPE = FORM +FORM_NAME = accept-tos +DESCRIPTION = "Accept Taler Operations terms of service" +DESCRIPTION_I18N = {"de":"Geschäftsbedingungen akzeptieren"} +# This form field must be set to the etag (!) of the accepted /terms! +OUTPUTS = ACCEPTED_TERMS_OF_SERVICE +FALLBACK = preserve-investigate + +[kyc-check-form-vqf-902.1] +TYPE = FORM +FORM_NAME = vqf_902_1_customer +DESCRIPTION = "Supply VQF form 902.1" +DESCRIPTION_I18N = {"de":"Formular VQF 902.1 hochladen"} +OUTPUTS = CUSTOMER_TYPE CUSTOMER_TYPE_VQF +# OPTIONAL: NAME, ADDRESS, ID DOCS, ETC. DEPENDING ON LEGAL ENTITY TYPE +# => aml program will decide on legal entity type between no more forms +# or vqf_902_9, 11, 12, 13, 15. => after that, AML officer +FALLBACK = preserve-investigate + +[kyc-check-form-vqf-902.9] +TYPE = FORM +# FIXME: #9863: needs "_customer" variant! +FORM_NAME = vqf_902_9 +DESCRIPTION = "Supply VQF form 902.9" +DESCRIPTION_I18N = {"de":"Formular VQF 902.9 hochladen"} +OUTPUTS = SUBMITTED_BY CONTRACTING_PARTY BENEFICIAL_OWNER_LIST +FALLBACK = preserve-investigate + +[kyc-check-form-vqf-902.11] +TYPE = FORM +FORM_NAME = vqf_902_11_customer +DESCRIPTION = "Supply VQF form 902.11" +DESCRIPTION_I18N = {"de":"Formular VQF 902.11 hochladen"} +OUTPUTS = SUBMITTED_BY CONTRACTING_PARTY CONTROL_REASON CONTROLLING_LIST THIRD_PARTY_OWNERSHIP +FALLBACK = preserve-investigate + +#[kyc-check-form-vqf-902.12] +#TYPE = FORM +# FIXME #9025: This form will not be supported for the TOPS MVP +#FORM_NAME = vqf_902_12 +#DESCRIPTION = "Supply VQF form 902.12" +#DESCRIPTION_I18N = {"de":"Formular VQF 902.12 hochladen"} +# FIXME: list correct outputs for each form here (and update GANA) +#OUTPUTS = LEGAL_ENTITY_TYPE +#FALLBACK = preserve-investigate + +#[kyc-check-form-vqf-902.13] +#TYPE = FORM +# FIXME: #9827 : This form will not be supported for the TOPS MVP +#FORM_NAME = vqf_902_13 +#DESCRIPTION = "Supply VQF form 902.13" +#DESCRIPTION_I18N = {"de":"Formular VQF 902.13 hochladen"} +# FIXME: list correct outputs for each form here (and update GANA) +#OUTPUTS = LEGAL_ENTITY_TYPE +#FALLBACK = preserve-investigate + +#[kyc-check-form-vqf-902.15] +#TYPE = FORM +# FIXME: #9826: This form will not be supported for the TOPS MVP +#FORM_NAME = vqf_902_15 +#DESCRIPTION = "Supply VQF form 902.15" +#DESCRIPTION_I18N = {"de":"Formular VQF 902.15 hochladen"} +# FIXME: list correct outputs for each form here (and update GANA) +#OUTPUTS = LEGAL_ENTITY_TYPE +#FALLBACK = preserve-investigate + +[kyc-measure-preserve-investigate] +TYPE = SKIP +CONTEXT = {} +PROGRAM = preserve-investigate + +[kyc-measure-default-investigate] +TYPE = SKIP +CONTEXT = {} +PROGRAM = default-investigate + + +# ##################### AML programs ######################### + +[aml-program-freeze-investigate] +DESCRIPTION = "Fallback measure on errors that freezes the account and asks AML staff to investigate the system failure." +COMMAND = taler-exchange-helper-measure-freeze +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-default-investigate] +DESCRIPTION = "Fallback measure on errors that keeps default rules on the account but asks AML staff to investigate the system failure." +COMMAND = taler-exchange-helper-measure-defaults-but-investigate +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-preserve-investigate] +DESCRIPTION = "Fallback measure on errors that preserves current rules on the account but asks AML staff to investigate the system failure." +COMMAND = taler-exchange-helper-measure-preserve-but-investigate +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-inform-investigate] +DESCRIPTION = "Measure that asks AML staff to investigate an account and informs the account owner about it." +COMMAND = taler-exchange-helper-measure-inform-investigate +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-challenger-postal-from-context] +DESCRIPTION = "Measure to validate a postal address given in the context. Optionally, a 'prog_name' given in the context can be used to automatically follow up with another AML program. By default, the AML program run after address validation is 'inform-investigate'" +COMMAND = taler-exchange-helper-measure-challenger-postal-context-check +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-challenger-sms-from-context] +DESCRIPTION = "Measure to validate an SMS phone number given in the context. Optionally, a 'prog_name' given in the context can be used to automatically follow up with another AML program. By default, the AML program run after address validation is 'inform-investigate'" +COMMAND = taler-exchange-helper-measure-challenger-sms-context-check +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-challenger-email-from-context] +DESCRIPTION = "Measure to validate an email address given in the context. Optionally, a 'prog_name' given in the context can be used to automatically follow up with another AML program. By default, the AML program run after address validation is 'inform-investigate'" +COMMAND = taler-exchange-helper-measure-challenger-email-context-check +ENABLED = YES +FALLBACK = freeze-investigate + + +# this program should require context 'tos_url' and 'provider_name' +# and require attribute "ACCEPTED_TERMS_OF_SERVICE" +[aml-program-check-tos] +DESCRIPTION = "AML program that enables functions after the ToS have been accepted." +COMMAND = taler-exchange-helper-measure-validate-accepted-tos +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-clear-measure-and-continue] +DESCRIPTION = "AML program that clears a measure 'clear_measure' and continues with another AML binary 'exec_name' with context 'next_context', all of which must be given in the context." +COMMAND = taler-exchange-helper-measure-clear-continue +ENABLED = YES +FALLBACK = freeze-investigate + + +[aml-program-preserve-set-expire-from-context] +DESCRIPTION = "Measure that preserves the current rules but sets them to expire based on the context. The successor measure to activate on expiration can also be specified in the context. Useful when AML staff merely wants to set an expiration date." +COMMAND = taler-exchange-helper-measure-preserve-set-expiration +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-preserve-set-expire-from-context] +DESCRIPTION = "Measure that modifies the current rules by combining them with those from the context. The expiration time and successor measure to activate on expiration can also be specified in the context. Useful when AML staff merely wants to update rules." +COMMAND = taler-exchange-helper-measure-update-from-context +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-tops-sms-check] +DESCRIPTION = "Program that checks that the user was able to receive an SMS at a Swiss mobile phone number. Enables receiving P2P payments by lifiting kyc-rule-p2p-domestic-identification-requirement and also lifts the kyc-rule-withdraw-limit-low. The new rules expire after 2 years." +COMMAND = taler-exchange-helper-measure-tops-sms-check +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-tops-postal-check] +DESCRIPTION = "Program that checks that the user was able to postal mail at a Swiss postal address. Enables receiving P2P payments by lifiting kyc-rule-p2p-domestic-identification-requirement and also lifts the kyc-rule-withdraw-limit-low. The new rules expire after 5 years." +COMMAND = taler-exchange-helper-measure-tops-postal-check +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-tops-kyx-check] +DESCRIPTION = "Program that determines what kind of KYC/KYB process should be run based on a first form supplied by the user. Determines the next checks to run. Always concludes by passing all results to an AML officer. Rules are preserved." +COMMAND = taler-exchange-helper-measure-tops-kyx-check +ENABLED = YES +FALLBACK = freeze-investigate + +[aml-program-tops-check-controlling-entity] +DESCRIPTION = "Program that checks if the 'Controlling entity 3rd persion' checkbox was set, and if so triggers the optional form VQF 902.9. Then in either case ensures we run the address validation logic. Always concludes by passing all results to an AML officer. Rules are preserved." +COMMAND = taler-exchange-helper-measure-tops-3rdparty-check +ENABLED = YES +FALLBACK = freeze-investigate +`; + +const topsProvidersTestConf = ` +[kyc-provider-postal-challenger] +LOGIC = oauth2 +KYC_OAUTH2_VALIDITY = 2 years +KYC_OAUTH2_AUTHORIZE_URL = http://localhost:6001/authorize#setup +KYC_OAUTH2_TOKEN_URL = http://localhost:6001/token +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_DEBUG_MODE = YES + +[kyc-provider-sms-challenger] +LOGIC = oauth2 +KYC_OAUTH2_VALIDITY = 2 years +KYC_OAUTH2_AUTHORIZE_URL = http://localhost:6002/authorize#setup +KYC_OAUTH2_TOKEN_URL = http://localhost:6002/token +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_DEBUG_MODE = YES + +[kyc-provider-email-challenger] +LOGIC = oauth2 +KYC_OAUTH2_VALIDITY = 2 years +KYC_OAUTH2_AUTHORIZE_URL = http://localhost:6003/authorize#setup +KYC_OAUTH2_TOKEN_URL = http://localhost:6003/token +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_DEBUG_MODE = YES + +[kyc-provider-kycaid-business] +LOGIC = kycaid +KYC_KYCAID_VALIDITY = forever +KYC_KYCAID_AUTH_TOKEN = test-kycaid-access-token +# FIXME: correct converter? business should differ! +KYC_KYCAID_CONVERTER_HELPER = taler-exchange-kyc-kycaid-converter.sh +KYC_KYCAID_FORM_ID = form_business +KYC_KYCAID_POST_URL = http://localhost:6004/done + +[kyc-provider-kycaid-individual] +LOGIC = kycaid +KYC_KYCAID_VALIDITY = forever +KYC_KYCAID_AUTH_TOKEN = test-kycaid-access-token +# FIXME: correct converter? business should differ! +KYC_KYCAID_CONVERTER_HELPER = taler-exchange-kyc-kycaid-converter.sh +KYC_KYCAID_FORM_ID = form_individual +KYC_KYCAID_POST_URL = http://localhost:6005/done +`; + +export async function createTopsEnvironment( + t: GlobalTestState, +): Promise<KycTestEnv> { + const db = await setupDb(t); + + let coinConfig: CoinConfig[]; + coinConfig = defaultCoinConfig.map((x) => x("CHF")); + + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "CHF", + database: db.connStr, + httpPort: 8082, + }); + + const exchange = ExchangeService.create(t, { + name: "testexchange-1", + currency: "CHF", + httpPort: 8081, + database: db.connStr, + // FIXME: This is a terrible way to configure the exchange, should be moved into config. + extraProcEnv: { + EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "v1", + EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_THRESHOLD: "CHF:0", + }, + }); + + let receiverName = "Exchange"; + let exchangeBankUsername = "exchange"; + let exchangeBankPassword = "mypw-password"; + let exchangePaytoUri = getTestHarnessPaytoForLabel(exchangeBankUsername); + + const wireGatewayApiBaseUrl = new URL( + `accounts/${exchangeBankUsername}/taler-wire-gateway/`, + bank.corebankApiBaseUrl, + ).href; + + await exchange.addBankAccount("1", { + wireGatewayAuth: { + username: exchangeBankUsername, + password: exchangeBankPassword, + }, + wireGatewayApiBaseUrl, + accountPaytoUri: exchangePaytoUri, + }); + + bank.setSuggestedExchange(exchange, exchangePaytoUri); + + await bank.start(); + + await bank.pingUntilAvailable(); + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "admin-password", + }, + }); + + await bankClient.registerAccountExtended({ + name: receiverName, + password: exchangeBankPassword, + username: exchangeBankUsername, + is_taler_exchange: true, + payto_uri: exchangePaytoUri, + }); + + exchange.addCoinConfigList(coinConfig); + + await exchange.modifyConfig(async (config) => { + config.loadFromString(topsKycRulesConf); + config.loadFromString(topsProvidersTestConf); + }); + + await exchange.start(); + + const cryptoApi = createSyncCryptoApi(); + const amlKeypair = await cryptoApi.createEddsaKeypair({}); + + await exchange.enableAmlAccount(amlKeypair.pub, "Alice"); + + const walletService = new WalletService(t, { + name: "wallet", + useInMemoryDb: true, + }); + await walletService.start(); + await walletService.pingUntilAvailable(); + + const walletClient = new WalletClient({ + name: "wallet", + unixPath: walletService.socketPath, + }); + await walletClient.connect(); + await walletClient.client.call(WalletApiOperation.InitWallet, { + config: { + testing: { + skipDefaults: true, + }, + }, + }); + + const merchant = await MerchantService.create(t, { + name: "testmerchant-1", + httpPort: 8083, + database: db.connStr, + }); + + merchant.addExchange(exchange); + + await merchant.start(); + + await merchant.addInstanceWithWireAccount({ + id: "admin", + name: "Default Instance", + paytoUris: [getTestHarnessPaytoForLabel("merchant-default")], + defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }), + }); + + await merchant.addInstanceWithWireAccount({ + id: "minst1", + name: "minst1", + paytoUris: [getTestHarnessPaytoForLabel("minst1")], + defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }), + }); + + const exchangeBankAccount: HarnessExchangeBankAccount = { + wireGatewayAuth: { + username: exchangeBankUsername, + password: exchangeBankPassword, + }, + accountPaytoUri: exchangePaytoUri, + wireGatewayApiBaseUrl, + }; + + t.logStep("env-setup-done"); + + const bankApi = new TalerCoreBankHttpClient( + bankClient.baseUrl, + harnessHttpLib, + ); + + const wireGatewayApi = new TalerWireGatewayHttpClient( + bankApi.getWireGatewayAPI( + exchangeBankAccount.wireGatewayAuth.username, + ).href, + { + httpClient: harnessHttpLib, + }, + ); + + const merchantApi = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + harnessHttpLib, + ); + + const exchangeApi = new TalerExchangeHttpClient(exchange.baseUrl, { + httpClient: harnessHttpLib, + }); + + return { + commonDb: db, + exchange, + amlKeypair, + walletClient, + walletService, + bankClient, + exchangeBankAccount, + merchant, + bankApi, + wireGatewayApi, + merchantApi, + exchangeApi, + }; +} diff --git a/packages/taler-harness/src/integrationtests/test-kyc-challenger.ts b/packages/taler-harness/src/integrationtests/test-kyc-challenger.ts @@ -0,0 +1,375 @@ +/* + This file is part of GNU Taler + (C) 2020 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 { + Logger, + NotificationType, + TransactionMajorState, + TransactionMinorState, + TransactionType, + codecForKycProcessClientInformation, + j2s, +} from "@gnu-taler/taler-util"; +import { readResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import * as http from "node:http"; +import { + configureCommonKyc, + createKycTestkudosEnvironment, +} from "../harness/environments.js"; +import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; + +const logger = new Logger("test-kyc.ts"); + +interface TestfakeKycService { + stop: () => 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 runTestfakeKycService(): Promise<TestfakeKycService> { + const server = http.createServer((req, res) => { + const requestUrl = req.url!; + logger.info(`kyc: got ${req.method} request, ${requestUrl}`); + + const [path, query] = splitInTwoAt(requestUrl, "?"); + + const qp = new URLSearchParams(query); + + if (path === "/oauth/v2/login") { + // 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({ + redirect_uri: redirUri.href, + }), + ); + } else if (path === "/oauth/v2/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", + }), + ); + }); + } else if (path === "/oauth/v2/info") { + logger.info("authorization header:", req.headers.authorization); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "success", + data: { + id: "Foobar", + first_name: "Alice", + last_name: "Abc", + birthdate: "2000-01-01", + }, + }), + ); + } 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(6666, () => resolve()); + }); + return { + stop() { + server.close(); + }, + }; +} + +export async function runKycChallengerTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, bankClient, exchange, merchant } = + await createKycTestkudosEnvironment(t, { + adjustExchangeConfig(config) { + config.setString("exchange", "enable_kyc", "yes"); + + configureCommonKyc(config); + + config.setString("KYC-RULE-R1", "operation_type", "withdraw"); + config.setString("KYC-RULE-R1", "enabled", "yes"); + config.setString("KYC-RULE-R1", "exposed", "yes"); + config.setString("KYC-RULE-R1", "is_and_combinator", "yes"); + config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:5"); + config.setString("KYC-RULE-R1", "timeframe", "1d"); + config.setString("KYC-RULE-R1", "next_measures", "M1"); + + config.setString("KYC-MEASURE-M1", "check_name", "C1"); + config.setString("KYC-MEASURE-M1", "context", "{}"); + config.setString("KYC-MEASURE-M1", "program", "P1"); + + config.setString("KYC-CHECK-C1", "type", "LINK"); + config.setString("KYC-CHECK-C1", "provider_id", "MYPROV"); + config.setString("KYC-CHECK-C1", "description", "my check!"); + config.setString("KYC-CHECK-C1", "description_i18n", "{}"); + config.setString("KYC-CHECK-C1", "outputs", "full_name birthdate"); + config.setString("KYC-CHECK-C1", "fallback", "FREEZE"); + + config.setString( + "AML-PROGRAM-P1", + "command", + "taler-exchange-helper-measure-test-form", + ); + config.setString("AML-PROGRAM-P1", "enabled", "true"); + config.setString( + "AML-PROGRAM-P1", + "description", + "test for full_name and birthdate", + ); + config.setString("AML-PROGRAM-P1", "description_i18n", "{}"); + config.setString("AML-PROGRAM-P1", "fallback", "FREEZE"); + + const myprov = "KYC-PROVIDER-MYPROV"; + config.setString(myprov, "logic", "oauth2"); + config.setString( + myprov, + "converter", + "taler-exchange-kyc-oauth2-test-converter.sh", + ); + config.setString(myprov, "kyc_oauth2_validity", "forever"); + config.setString( + myprov, + "kyc_oauth2_token_url", + "http://localhost:6666/oauth/v2/token", + ); + config.setString( + myprov, + "kyc_oauth2_authorize_url", + "http://localhost:6666/oauth/v2/login", + ); + config.setString( + myprov, + "kyc_oauth2_info_url", + "http://localhost:6666/oauth/v2/info", + ); + config.setString( + myprov, + "kyc_oauth2_converter_helper", + "taler-exchange-kyc-oauth2-test-converter.sh", + ); + config.setString(myprov, "kyc_oauth2_client_id", "taler-exchange"); + config.setString(myprov, "kyc_oauth2_client_secret", "exchange-secret"); + config.setString(myprov, "kyc_oauth2_post_url", "https://taler.net"); + + config.setString( + "kyc-legitimization-withdraw1", + "operation_type", + "withdraw", + ); + }, + }); + + const kycServer = await runTestfakeKycService(); + + // Withdraw digital cash into the wallet. + + const amount = "TESTKUDOS:20"; + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth({ + username: user.username, + password: user.password, + }); + + const wop = await bankClient.createWithdrawalOperation(user.username, amount); + + // Hand it to the wallet + + await walletClient.client.call( + WalletApiOperation.GetWithdrawalDetailsForUri, + { + talerWithdrawUri: wop.taler_withdraw_uri, + }, + ); + + // Withdraw + + const acceptResp = await walletClient.client.call( + WalletApiOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + }, + ); + + const withdrawalTxId = acceptResp.transactionId; + + // Confirm it + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: withdrawalTxId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }); + + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: wop.withdrawal_id, + }); + + const kycNotificationCond = walletClient.waitForNotificationCond((x) => { + if ( + x.type === NotificationType.TransactionStateTransition && + x.transactionId === withdrawalTxId && + x.newTxState.major === TransactionMajorState.Pending && + x.newTxState.minor === TransactionMinorState.KycRequired + ) { + return x; + } + return false; + }); + + const withdrawalDoneCond = walletClient.waitForNotificationCond( + (x) => + x.type === NotificationType.TransactionStateTransition && + x.transactionId === withdrawalTxId && + x.newTxState.major === TransactionMajorState.Done, + ); + + const kycNotif = await kycNotificationCond; + + logger.info("got kyc notification:", j2s(kycNotif)); + + const txState = await walletClient.client.call( + WalletApiOperation.GetTransactionById, + { + transactionId: withdrawalTxId, + }, + ); + + t.assertDeepEqual(txState.type, TransactionType.Withdrawal); + const paytoHash = txState.kycPaytoHash; + + t.assertTrue(!!txState.kycUrl); + t.assertTrue(!!paytoHash); + + // We now simulate the user interacting with the KYC service, + // which would usually done in the browser. + + const accessToken = txState.kycAccessToken; + t.assertTrue(!!accessToken); + + /** + * TODO: we should check this in another way that doesn't make the test + * to normally take 3 seconds on happy path + */ + const unexpectedNotification = + await walletClient.waitForNotificationCondOrTimeout((x) => { + if ( + x.type === NotificationType.TransactionStateTransition && + x.transactionId === withdrawalTxId && + x.newTxState.major === TransactionMajorState.Pending && + x.newTxState.minor === TransactionMinorState.WithdrawCoins + ) { + return x; + } + return false; + }, 3000); + + if (unexpectedNotification) { + throw Error(`unexpected notification ${j2s(unexpectedNotification)}`); + } + + const infoResp = await harnessHttpLib.fetch( + new URL(`kyc-info/${txState.kycAccessToken}`, exchange.baseUrl).href, + ); + + const clientInfo = await readResponseJsonOrThrow( + infoResp, + codecForKycProcessClientInformation(), + ); + + console.log(j2s(clientInfo)); + + const kycId = clientInfo.requirements.find((x) => x.id != null)?.id; + t.assertTrue(!!kycId); + + const startResp = await harnessHttpLib.fetch( + new URL(`kyc-start/${kycId}`, exchange.baseUrl).href, + { + method: "POST", + body: {}, + }, + ); + + logger.info(`kyc-start resp status: ${startResp.status}`); + logger.info(j2s(startResp.json())); + + // We need to "visit" the KYC proof URL at least once to trigger the exchange + // asking for the KYC status. + const proofUrl = new URL(`kyc-proof/MYPROV`, exchange.baseUrl); + proofUrl.searchParams.set("state", paytoHash); + proofUrl.searchParams.set("code", "code_is_ok"); + const proofHttpResp = await harnessHttpLib.fetch(proofUrl.href); + logger.info(`proof resp status ${proofHttpResp.status}`); + logger.info(`resp headers ${j2s(proofHttpResp.headers.toJSON())}`); + if ( + !(proofHttpResp.status >= 200 && proofHttpResp.status <= 299) && + proofHttpResp.status !== 303 + ) { + logger.error("kyc proof failed"); + logger.info(await proofHttpResp.text()); + t.assertTrue(false); + } + + // Now that KYC is done, withdrawal should finally succeed. + + await withdrawalDoneCond; + + kycServer.stop(); +} + +runKycChallengerTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-kyc.ts b/packages/taler-harness/src/integrationtests/test-kyc.ts @@ -1,375 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2020 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 { - Logger, - NotificationType, - TransactionMajorState, - TransactionMinorState, - TransactionType, - codecForKycProcessClientInformation, - j2s, -} from "@gnu-taler/taler-util"; -import { readResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import * as http from "node:http"; -import { - configureCommonKyc, - createKycTestkudosEnvironment, -} from "../harness/environments.js"; -import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; - -const logger = new Logger("test-kyc.ts"); - -interface TestfakeKycService { - stop: () => 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 runTestfakeKycService(): Promise<TestfakeKycService> { - const server = http.createServer((req, res) => { - const requestUrl = req.url!; - logger.info(`kyc: got ${req.method} request, ${requestUrl}`); - - const [path, query] = splitInTwoAt(requestUrl, "?"); - - const qp = new URLSearchParams(query); - - if (path === "/oauth/v2/login") { - // 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({ - redirect_uri: redirUri.href, - }), - ); - } else if (path === "/oauth/v2/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", - }), - ); - }); - } else if (path === "/oauth/v2/info") { - logger.info("authorization header:", req.headers.authorization); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - status: "success", - data: { - id: "Foobar", - first_name: "Alice", - last_name: "Abc", - birthdate: "2000-01-01", - }, - }), - ); - } 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(6666, () => resolve()); - }); - return { - stop() { - server.close(); - }, - }; -} - -export async function runKycTest(t: GlobalTestState) { - // Set up test environment - - const { walletClient, bankClient, exchange, merchant } = - await createKycTestkudosEnvironment(t, { - adjustExchangeConfig(config) { - config.setString("exchange", "enable_kyc", "yes"); - - configureCommonKyc(config); - - config.setString("KYC-RULE-R1", "operation_type", "withdraw"); - config.setString("KYC-RULE-R1", "enabled", "yes"); - config.setString("KYC-RULE-R1", "exposed", "yes"); - config.setString("KYC-RULE-R1", "is_and_combinator", "yes"); - config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:5"); - config.setString("KYC-RULE-R1", "timeframe", "1d"); - config.setString("KYC-RULE-R1", "next_measures", "M1"); - - config.setString("KYC-MEASURE-M1", "check_name", "C1"); - config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "P1"); - - config.setString("KYC-CHECK-C1", "type", "LINK"); - config.setString("KYC-CHECK-C1", "provider_id", "MYPROV"); - config.setString("KYC-CHECK-C1", "description", "my check!"); - config.setString("KYC-CHECK-C1", "description_i18n", "{}"); - config.setString("KYC-CHECK-C1", "outputs", "full_name birthdate"); - config.setString("KYC-CHECK-C1", "fallback", "FREEZE"); - - config.setString( - "AML-PROGRAM-P1", - "command", - "taler-exchange-helper-measure-test-form", - ); - config.setString("AML-PROGRAM-P1", "enabled", "true"); - config.setString( - "AML-PROGRAM-P1", - "description", - "test for full_name and birthdate", - ); - config.setString("AML-PROGRAM-P1", "description_i18n", "{}"); - config.setString("AML-PROGRAM-P1", "fallback", "FREEZE"); - - const myprov = "KYC-PROVIDER-MYPROV"; - config.setString(myprov, "logic", "oauth2"); - config.setString( - myprov, - "converter", - "taler-exchange-kyc-oauth2-test-converter.sh", - ); - config.setString(myprov, "kyc_oauth2_validity", "forever"); - config.setString( - myprov, - "kyc_oauth2_token_url", - "http://localhost:6666/oauth/v2/token", - ); - config.setString( - myprov, - "kyc_oauth2_authorize_url", - "http://localhost:6666/oauth/v2/login", - ); - config.setString( - myprov, - "kyc_oauth2_info_url", - "http://localhost:6666/oauth/v2/info", - ); - config.setString( - myprov, - "kyc_oauth2_converter_helper", - "taler-exchange-kyc-oauth2-test-converter.sh", - ); - config.setString(myprov, "kyc_oauth2_client_id", "taler-exchange"); - config.setString(myprov, "kyc_oauth2_client_secret", "exchange-secret"); - config.setString(myprov, "kyc_oauth2_post_url", "https://taler.net"); - - config.setString( - "kyc-legitimization-withdraw1", - "operation_type", - "withdraw", - ); - }, - }); - - const kycServer = await runTestfakeKycService(); - - // Withdraw digital cash into the wallet. - - const amount = "TESTKUDOS:20"; - const user = await bankClient.createRandomBankUser(); - bankClient.setAuth({ - username: user.username, - password: user.password, - }); - - const wop = await bankClient.createWithdrawalOperation(user.username, amount); - - // Hand it to the wallet - - await walletClient.client.call( - WalletApiOperation.GetWithdrawalDetailsForUri, - { - talerWithdrawUri: wop.taler_withdraw_uri, - }, - ); - - // Withdraw - - const acceptResp = await walletClient.client.call( - WalletApiOperation.AcceptBankIntegratedWithdrawal, - { - exchangeBaseUrl: exchange.baseUrl, - talerWithdrawUri: wop.taler_withdraw_uri, - }, - ); - - const withdrawalTxId = acceptResp.transactionId; - - // Confirm it - - await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { - transactionId: withdrawalTxId, - txState: { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.BankConfirmTransfer, - }, - }); - - await bankClient.confirmWithdrawalOperation(user.username, { - withdrawalOperationId: wop.withdrawal_id, - }); - - const kycNotificationCond = walletClient.waitForNotificationCond((x) => { - if ( - x.type === NotificationType.TransactionStateTransition && - x.transactionId === withdrawalTxId && - x.newTxState.major === TransactionMajorState.Pending && - x.newTxState.minor === TransactionMinorState.KycRequired - ) { - return x; - } - return false; - }); - - const withdrawalDoneCond = walletClient.waitForNotificationCond( - (x) => - x.type === NotificationType.TransactionStateTransition && - x.transactionId === withdrawalTxId && - x.newTxState.major === TransactionMajorState.Done, - ); - - const kycNotif = await kycNotificationCond; - - logger.info("got kyc notification:", j2s(kycNotif)); - - const txState = await walletClient.client.call( - WalletApiOperation.GetTransactionById, - { - transactionId: withdrawalTxId, - }, - ); - - t.assertDeepEqual(txState.type, TransactionType.Withdrawal); - const paytoHash = txState.kycPaytoHash; - - t.assertTrue(!!txState.kycUrl); - t.assertTrue(!!paytoHash); - - // We now simulate the user interacting with the KYC service, - // which would usually done in the browser. - - const accessToken = txState.kycAccessToken; - t.assertTrue(!!accessToken); - - /** - * TODO: we should check this in another way that doesn't make the test - * to normally take 3 seconds on happy path - */ - const unexpectedNotification = - await walletClient.waitForNotificationCondOrTimeout((x) => { - if ( - x.type === NotificationType.TransactionStateTransition && - x.transactionId === withdrawalTxId && - x.newTxState.major === TransactionMajorState.Pending && - x.newTxState.minor === TransactionMinorState.WithdrawCoins - ) { - return x; - } - return false; - }, 3000); - - if (unexpectedNotification) { - throw Error(`unexpected notification ${j2s(unexpectedNotification)}`); - } - - const infoResp = await harnessHttpLib.fetch( - new URL(`kyc-info/${txState.kycAccessToken}`, exchange.baseUrl).href, - ); - - const clientInfo = await readResponseJsonOrThrow( - infoResp, - codecForKycProcessClientInformation(), - ); - - console.log(j2s(clientInfo)); - - const kycId = clientInfo.requirements.find((x) => x.id != null)?.id; - t.assertTrue(!!kycId); - - const startResp = await harnessHttpLib.fetch( - new URL(`kyc-start/${kycId}`, exchange.baseUrl).href, - { - method: "POST", - body: {}, - }, - ); - - logger.info(`kyc-start resp status: ${startResp.status}`); - logger.info(j2s(startResp.json())); - - // We need to "visit" the KYC proof URL at least once to trigger the exchange - // asking for the KYC status. - const proofUrl = new URL(`kyc-proof/MYPROV`, exchange.baseUrl); - proofUrl.searchParams.set("state", paytoHash); - proofUrl.searchParams.set("code", "code_is_ok"); - const proofHttpResp = await harnessHttpLib.fetch(proofUrl.href); - logger.info(`proof resp status ${proofHttpResp.status}`); - logger.info(`resp headers ${j2s(proofHttpResp.headers.toJSON())}`); - if ( - !(proofHttpResp.status >= 200 && proofHttpResp.status <= 299) && - proofHttpResp.status !== 303 - ) { - logger.error("kyc proof failed"); - logger.info(await proofHttpResp.text()); - t.assertTrue(false); - } - - // Now that KYC is done, withdrawal should finally succeed. - - await withdrawalDoneCond; - - kycServer.stop(); -} - -runKycTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml.ts b/packages/taler-harness/src/integrationtests/test-tops-aml.ts @@ -0,0 +1,152 @@ +/* + This file is part of GNU Taler + (C) 2020 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, + j2s, + KycStatusLongPollingReason, + Logger, + parsePaytoUriOrThrow, + TalerExchangeHttpClient, + TalerMerchantInstanceHttpClient, +} from "@gnu-taler/taler-util"; +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"); + +export async function runTopsAmlTest(t: GlobalTestState) { + // Set up test environment + + const { + walletClient, + bankClient, + exchange, + amlKeypair, + merchant, + exchangeBankAccount, + wireGatewayApi, + } = await createTopsEnvironment(t); + + // 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; + + // 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; + } + + 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)); + } +} + +runTopsAmlTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -55,6 +55,7 @@ import { runKnownAccountsTest } from "./test-known-accounts.js"; import { runKycAmpFailureTest } from "./test-kyc-amp-failure.js"; import { runKycAmpTimeoutTest } from "./test-kyc-amp-timeout.js"; import { runKycBalanceWithdrawalTest } from "./test-kyc-balance-withdrawal.js"; +import { runKycChallengerTest } from "./test-kyc-challenger.js"; import { runKycDecisionAttrTest } from "./test-kyc-decision-attr.js"; import { runKycDecisionEventsTest } from "./test-kyc-decision-events.js"; import { runKycDecisionsTest } from "./test-kyc-decisions.js"; @@ -76,7 +77,6 @@ import { runKycSkipExpirationTest } from "./test-kyc-skip-expiration.js"; import { runKycThresholdWithdrawalTest } from "./test-kyc-threshold-withdrawal.js"; import { runKycTwoFormsTest } from "./test-kyc-two-forms.js"; import { runKycWithdrawalVerbotenTest } from "./test-kyc-withdrawal-verboten.js"; -import { runKycTest } from "./test-kyc.js"; import { runLibeufinBankTest } from "./test-libeufin-bank.js"; import { runMerchantCategoriesTest } from "./test-merchant-categories.js"; import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js"; @@ -119,6 +119,7 @@ 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 { runTermOfServiceFormatTest } from "./test-tos-format.js"; import { runWalletBackupBasicTest } from "./test-wallet-backup-basic.js"; import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend.js"; @@ -192,7 +193,7 @@ const allTests: TestMainFunction[] = [ runExchangeTimetravelTest, runFeeRegressionTest, runForcedSelectionTest, - runKycTest, + runKycChallengerTest, runExchangePurseTest, runExchangeDepositTest, runMerchantExchangeConfusionTest, @@ -318,6 +319,7 @@ const allTests: TestMainFunction[] = [ runAgeRestrictionsDepositTest, runKycDecisionEventsTest, runWalletDevexpFakeprotoverTest, + runTopsAmlTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/http-client/exchange.ts b/packages/taler-util/src/http-client/exchange.ts @@ -97,7 +97,6 @@ import { } from "../index.js"; import { TalerErrorCode } from "../taler-error-codes.js"; import { AbsoluteTime } from "../time.js"; -import { codecForEmptyObject } from "../types-taler-wallet.js"; export type TalerExchangeResultByMethod< prop extends keyof TalerExchangeHttpClient, @@ -857,8 +856,9 @@ export class TalerExchangeHttpClient { } /** - * https://docs.taler.net/core/api-exchange.html#get--kyc-check-$H_NORMALIZED_PAYTO + * Endpoint: GET /kyc-check/$H_NORMALIZED_PAYTO * + * https://docs.taler.net/core/api-exchange.html#get--kyc-check-$H_NORMALIZED_PAYTO */ async checkKycStatus( signingKey: EddsaPrivP | string, diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -375,6 +375,14 @@ export function stringifyReservePaytoUri( return `payto://${target}/${domainWithOptPort}${optPath}/${reservePub}`; } +export function parsePaytoUriOrThrow(s: string): PaytoUri | undefined { + const ret = parsePaytoUri(s); + if (!ret) { + throw Error("invalid payto URI"); + } + return ret; +} + /** * Parse a valid payto:// uri into a PaytoUri object * RFC 8905 diff --git a/packages/taler-util/src/talerconfig.ts b/packages/taler-util/src/talerconfig.ts @@ -605,6 +605,19 @@ export class Configuration { return this.sectionMap[secNorm]?.entries[optNorm]; } + /** + * Load configuration values from a string in the format + * of a configuration file. Directives are not allowed. + * + * + * Has the same effect as setting each option manually. + */ + loadFromString(s: string): void { + this.internalLoadFromString(s, false, { + banDirectives: true, + }); + } + setString(section: string, option: string, value: string): void { const sec = this.provideSection(section); sec.entries[option.toUpperCase()] = { @@ -715,7 +728,7 @@ export class Configuration { } private loadDefaultsFromDir(dirname: string): void { - const exists = nodejs_fs.existsSync(dirname) + const exists = nodejs_fs.existsSync(dirname); if (!exists) return; const files = nodejs_fs.readdirSync(dirname); for (const f of files) { diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -37,8 +37,6 @@ import { codecForEither, codecForMap, codecForPaytoString, - codecForTalerErrorDetail, - codecForTalerMerchantConfigResponse, codecForURN, codecOptionalDefault, strcmp, @@ -2000,8 +1998,13 @@ export interface KycProcessClientInformation { voluntary_measures?: KycRequirementInformation[]; } +declare const opaque_brand: unique symbol; + declare const opaque_kycReq: unique symbol; -export type KycRequirementInformationId = string & { [opaque_kycReq]: true }; + +export type KycRequirementInformationId = string & { + [opaque_brand]: typeof opaque_kycReq; +}; declare const opaque_formId: unique symbol; export type KycBuiltInFromId = string & { [opaque_formId]: true };