From 13f0442736479fb6ea8d1ecc7311cdac354a4de5 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 12 Feb 2023 19:30:59 +0100 Subject: [PATCH] harness: finish kyc test We mock the KYC gateway now, use the new notification-based wallet API and the test is not experimental anymore. --- packages/taler-harness/src/harness/harness.ts | 6 +- packages/taler-harness/src/harness/helpers.ts | 19 ++ .../src/integrationtests/test-kyc.ts | 199 +++++++++++++++--- packages/taler-util/src/notifications.ts | 10 +- packages/taler-wallet-cli/src/index.ts | 6 +- .../src/operations/deposits.ts | 40 +++- .../src/operations/withdraw.ts | 22 +- packages/taler-wallet-core/src/remote.ts | 30 +-- 8 files changed, 275 insertions(+), 57 deletions(-) diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index 3659ea538..275592091 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -2028,9 +2028,9 @@ export class WalletClient { return getClientFromRemoteWallet(this.remoteWallet); } - waitForNotificationCond( - cond: (n: WalletNotification) => boolean, - ): Promise { + waitForNotificationCond( + cond: (n: WalletNotification) => T | undefined | false, + ): Promise { return this.waiter.waitForNotificationCond(cond); } } diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts index 59a37e4b8..4c2ca80a7 100644 --- a/packages/taler-harness/src/harness/helpers.ts +++ b/packages/taler-harness/src/harness/helpers.ts @@ -53,9 +53,14 @@ import { MerchantServiceInterface, setupDb, WalletCli, + WalletClient, + WalletService, WithAuthorization, } from "./harness.js"; +/** + * @deprecated + */ export interface SimpleTestEnvironment { commonDb: DbInfo; bank: BankService; @@ -65,6 +70,20 @@ export interface SimpleTestEnvironment { wallet: WalletCli; } +/** + * Improved version of the simple test environment, + * with the daemonized wallet. + */ +export interface SimpleTestEnvironmentNg { + commonDb: DbInfo; + bank: BankService; + exchange: ExchangeService; + exchangeBankAccount: HarnessExchangeBankAccount; + merchant: MerchantService; + walletClient: WalletClient; + walletService: WalletService; +} + export interface EnvOptions { /** * If provided, enable age restrictions with the specified age mask string. diff --git a/packages/taler-harness/src/integrationtests/test-kyc.ts b/packages/taler-harness/src/integrationtests/test-kyc.ts index c652c86fa..b08db66f7 100644 --- a/packages/taler-harness/src/integrationtests/test-kyc.ts +++ b/packages/taler-harness/src/integrationtests/test-kyc.ts @@ -17,7 +17,13 @@ /** * Imports. */ -import { Duration } from "@gnu-taler/taler-util"; +import { Duration, j2s, NotificationType } from "@gnu-taler/taler-util"; +import { + BankAccessApi, + BankApi, + NodeHttpLib, + WalletApiOperation, +} from "@gnu-taler/taler-wallet-core"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { BankService, @@ -26,20 +32,17 @@ import { GlobalTestState, MerchantService, setupDb, - WalletCli, + WalletClient, + WalletService, } from "../harness/harness.js"; -import { - withdrawViaBank, - makeTestPayment, - EnvOptions, - SimpleTestEnvironment, -} from "../harness/helpers.js"; +import { EnvOptions, SimpleTestEnvironmentNg } from "../harness/helpers.js"; +import * as http from "node:http"; export async function createKycTestkudosEnvironment( t: GlobalTestState, coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")), opts: EnvOptions = {}, -): Promise { +): Promise { const db = await setupDb(t); const bank = await BankService.create(t, { @@ -117,11 +120,11 @@ export async function createKycTestkudosEnvironment( config.setString( myprov, "kyc_oauth2_info_url", - "http://localhost:6666/oauth/v2/login", + "http://localhost:6666/oauth/v2/info", ); 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.com"); + config.setString(myprov, "kyc_oauth2_post_url", "https://taler.net"); config.setString( "kyc-legitimization-withdraw1", @@ -167,40 +170,186 @@ export async function createKycTestkudosEnvironment( ), }); - console.log("setup done!"); + const walletService = new WalletService(t, { + name: "wallet", + useInMemoryDb: true, + }); + await walletService.start(); + await walletService.pingUntilAvailable(); - const wallet = new WalletCli(t); + const walletClient = new WalletClient({ + unixPath: walletService.socketPath, + onNotification(n) { + console.log("got notification", n); + }, + }); + await walletClient.connect(); + await walletClient.client.call(WalletApiOperation.InitWallet, { + skipDefaults: true, + }); + + console.log("setup done!"); return { commonDb: db, exchange, merchant, - wallet, + walletClient, + walletService, bank, exchangeBankAccount, }; } +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 { + const server = http.createServer((req, res) => { + const requestUrl = req.url!; + console.log(`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 redirUri = new URL(qp.get("redirect_uri")!); + redirUri.searchParams.set("code", "code_is_ok"); + 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", () => { + console.log("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") { + console.log("authorization header:", req.headers.authorization); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "success", + data: { + id: "foobar", + }, + }), + ); + } else { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code: 1, message: "bad request" })); + } + }); + await new Promise((resolve, reject) => { + server.listen(6666, () => resolve()); + }); + return { + stop() { + server.close(); + }, + }; +} + export async function runKycTest(t: GlobalTestState) { // Set up test environment - const { wallet, bank, exchange, merchant } = + const { walletClient, bank, exchange, merchant } = await createKycTestkudosEnvironment(t); + const kycServer = await runTestfakeKycService(); + // Withdraw digital cash into the wallet. - await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" }); + const amount = "TESTKUDOS:20"; + const user = await BankApi.createRandomBankUser(bank); + const wop = await BankAccessApi.createWithdrawalOperation(bank, user, amount); - const order = { - summary: "Buy me!", - amount: "TESTKUDOS:5", - fulfillment_url: "taler://fulfillment-success/thx", - }; + // Hand it to the wallet - await makeTestPayment(t, { wallet, merchant, order }); - await wallet.runUntilDone(); + await walletClient.client.call( + WalletApiOperation.GetWithdrawalDetailsForUri, + { + talerWithdrawUri: wop.taler_withdraw_uri, + }, + ); + + // Withdraw + + const kycNotificationCond = walletClient.waitForNotificationCond((x) => { + if (x.type === NotificationType.WithdrawalKycRequested) { + return x; + } + return false; + }); + + const withdrawalDoneCond = walletClient.waitForNotificationCond( + (x) => x.type === NotificationType.WithdrawGroupFinished, + ); + + await walletClient.client.call( + WalletApiOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + }, + ); + + // Confirm it + + await BankApi.confirmWithdrawalOperation(bank, user, wop); + + const kycNotif = await kycNotificationCond; + + console.log("got kyc notification:", j2s(kycNotif)); + + // We now simulate the user interacting with the KYC service, + // which would usually done in the browser. + + const httpClient = new NodeHttpLib(); + const kycServerResp = await httpClient.get(kycNotif.kycUrl); + const kycLoginResp = await kycServerResp.json(); + console.log("kyc server resp:", j2s(kycLoginResp)); + const kycProofUrl = kycLoginResp.redirect_uri; + const proofHttpResp = await httpClient.get(kycProofUrl); + console.log("proof resp status", proofHttpResp.status); + console.log("resp headers", proofHttpResp.headers.toJSON()); + + // Now that KYC is done, withdrawal should finally succeed. + + await withdrawalDoneCond; + + kycServer.stop(); } runKycTest.suites = ["wallet"]; -// See bugs.taler.net/n/7599 -runKycTest.experimental = true; diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts index bc1c4b71f..9d3ca32b0 100644 --- a/packages/taler-util/src/notifications.ts +++ b/packages/taler-util/src/notifications.ts @@ -62,6 +62,7 @@ export enum NotificationType { PendingOperationProcessed = "pending-operation-processed", ProposalRefused = "proposal-refused", ReserveRegisteredWithBank = "reserve-registered-with-bank", + WithdrawalKycRequested = "withdrawal-kyc-requested", DepositOperationError = "deposit-operation-error", } @@ -117,6 +118,12 @@ export interface RefreshMeltedNotification { type: NotificationType.RefreshMelted; } +export interface WithdrawalKycRequested { + type: NotificationType.WithdrawalKycRequested; + transactionId: string; + kycUrl: string; +} + export interface RefreshRevealedNotification { type: NotificationType.RefreshRevealed; } @@ -285,4 +292,5 @@ export type WalletNotification = | ProposalRefusedNotification | ReserveRegisteredWithBankNotification | ReserveNotYetFoundNotification - | PayOperationSuccessNotification; + | PayOperationSuccessNotification + | WithdrawalKycRequested; diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 6641dab09..7e942ede7 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -228,9 +228,9 @@ export interface WalletContext { * Return a promise that resolves after the wallet has emitted a notification * that meets the criteria of the "cond" predicate. */ - waitForNotificationCond( - cond: (n: WalletNotification) => boolean, - ): Promise; + waitForNotificationCond( + cond: (n: WalletNotification) => T | false | undefined, + ): Promise; } async function createLocalWallet( diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index e97738b55..4ff6a65cd 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -38,8 +38,10 @@ import { hashTruncate32, hashWire, HttpStatusCode, + j2s, Logger, MerchantContractTerms, + NotificationType, parsePaytoUri, PayCoinSelection, PrepareDepositRequest, @@ -61,7 +63,7 @@ import { TransactionStatus, } from "../db.js"; import { TalerError } from "../errors.js"; -import { checkKycStatus } from "../index.js"; +import { checkWithdrawalKycStatus, KycPendingInfo, KycUserType } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { OperationAttemptResult } from "../util/retries.js"; @@ -80,6 +82,40 @@ import { getTotalRefreshCost } from "./refresh.js"; */ const logger = new Logger("deposits.ts"); + +export async function checkDepositKycStatus( + ws: InternalWalletState, + exchangeUrl: string, + kycInfo: KycPendingInfo, + userType: KycUserType, +): Promise { + const url = new URL( + `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, + exchangeUrl, + ); + logger.info(`kyc url ${url.href}`); + const kycStatusReq = await ws.http.fetch(url.href, { + method: "GET", + }); + if (kycStatusReq.status === HttpStatusCode.Ok) { + logger.warn("kyc requested, but already fulfilled"); + return; + } else if (kycStatusReq.status === HttpStatusCode.Accepted) { + const kycStatus = await kycStatusReq.json(); + logger.info(`kyc status: ${j2s(kycStatus)}`); + // FIXME: This error code is totally wrong + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, + { + kycUrl: kycStatus.kyc_url, + }, + `KYC check required for deposit`, + ); + } else { + throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`); + } +} + /** * @see {processDepositGroup} */ @@ -162,7 +198,7 @@ export async function processDepositGroup( const paytoHash = encodeCrock( hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")), ); - await checkKycStatus( + await checkDepositKycStatus( ws, perm.exchange_url, { paytoHash, requirementRow }, diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 5c9854c0f..28754c77e 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -1250,12 +1250,7 @@ export async function processWithdrawalGroup( if (numKycRequired > 0) { if (kycInfo) { - await checkKycStatus( - ws, - withdrawalGroup.exchangeBaseUrl, - kycInfo, - "individual", - ); + await checkWithdrawalKycStatus(ws, withdrawalGroup, kycInfo, "individual"); return { type: OperationAttemptResultType.Pending, result: undefined, @@ -1293,12 +1288,13 @@ export async function processWithdrawalGroup( }; } -export async function checkKycStatus( +export async function checkWithdrawalKycStatus( ws: InternalWalletState, - exchangeUrl: string, + wg: WithdrawalGroupRecord, kycInfo: KycPendingInfo, userType: KycUserType, ): Promise { + const exchangeUrl = wg.exchangeBaseUrl; const url = new URL( `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, exchangeUrl, @@ -1307,12 +1303,20 @@ export async function checkKycStatus( const kycStatusReq = await ws.http.fetch(url.href, { method: "GET", }); - logger.warn("kyc requested, but already fulfilled"); if (kycStatusReq.status === HttpStatusCode.Ok) { + logger.warn("kyc requested, but already fulfilled"); return; } else if (kycStatusReq.status === HttpStatusCode.Accepted) { const kycStatus = await kycStatusReq.json(); logger.info(`kyc status: ${j2s(kycStatus)}`); + ws.notify({ + type: NotificationType.WithdrawalKycRequested, + kycUrl: kycStatus.kyc_url, + transactionId: makeTransactionId( + TransactionType.Withdrawal, + wg.withdrawalGroupId, + ), + }); throw TalerError.fromDetail( TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, { diff --git a/packages/taler-wallet-core/src/remote.ts b/packages/taler-wallet-core/src/remote.ts index 2628fea07..bc0be9d30 100644 --- a/packages/taler-wallet-core/src/remote.ts +++ b/packages/taler-wallet-core/src/remote.ts @@ -145,9 +145,14 @@ export function getClientFromRemoteWallet( export interface WalletNotificationWaiter { notify(wn: WalletNotification): void; - waitForNotificationCond( - cond: (n: WalletNotification) => boolean, - ): Promise; + waitForNotificationCond( + cond: (n: WalletNotification) => T | false | undefined, + ): Promise; +} + +interface NotificationCondEntry { + condition: (n: WalletNotification) => T | false | undefined; + promiseCapability: OpenedPromise; } /** @@ -157,22 +162,19 @@ export interface WalletNotificationWaiter { export function makeNotificationWaiter(): WalletNotificationWaiter { // Bookkeeping for waiting on notification conditions let nextCondIndex = 1; - const condMap: Map< - number, - { - condition: (n: WalletNotification) => boolean; - promiseCapability: OpenedPromise; - } - > = new Map(); + const condMap: Map> = new Map(); function onNotification(n: WalletNotification) { condMap.forEach((cond, condKey) => { - if (cond.condition(n)) { - cond.promiseCapability.resolve(); + const res = cond.condition(n); + if (res) { + cond.promiseCapability.resolve(res); } }); } - function waitForNotificationCond(cond: (n: WalletNotification) => boolean) { - const promCap = openPromise(); + function waitForNotificationCond( + cond: (n: WalletNotification) => T | false | undefined, + ) { + const promCap = openPromise(); condMap.set(nextCondIndex++, { condition: cond, promiseCapability: promCap,