commit 6a30be60a21b92df9feef813ea4043f5c18bfd09 parent bb5f31892e4ebbf785513303e044f32af3a995b3 Author: Florian Dold <dold@taler.net> Date: Mon, 17 Aug 2026 22:14:39 +0200 taler-harness: strengthen integration test coverage Diffstat:
41 files changed, 683 insertions(+), 214 deletions(-)
diff --git a/packages/taler-harness/src/harness/tops.ts b/packages/taler-harness/src/harness/tops.ts @@ -1115,10 +1115,20 @@ export async function setupMeasuresTestEnvironment( console.log(`existing decision:`, j2s(decisionsResp)); const currDec = decisionsResp.records[0]; + let decisionTime = TalerProtocolTimestamp.now(); + if ( + currDec.decision_time.t_s !== "never" && + decisionTime.t_s !== "never" && + decisionTime.t_s <= currDec.decision_time.t_s + ) { + decisionTime = TalerProtocolTimestamp.fromSeconds( + currDec.decision_time.t_s + 1, + ); + } succeedOrThrow( await exchangeClient.makeAmlDesicion(officerAcc, { - decision_time: TalerProtocolTimestamp.now(), + decision_time: decisionTime, h_payto: merchantPaytoHash, justification: "bla", properties: currDec.properties || {}, @@ -1332,6 +1342,10 @@ async function doTriggerMeasure( }, ): Promise<{ currentDecision: AmlDecision }> { const { officerAcc, exchangeClient, merchantPaytoHash } = args; + // AML decision timestamps have second precision. Form programs can create + // a successor decision immediately before the officer triggers the next + // measure, so allow the clock to move to the next representable timestamp. + await waitMs(1100); const decisionsResp = succeedOrThrow( await exchangeClient.getAmlDecisions(officerAcc, { active: true, @@ -1342,6 +1356,7 @@ async function doTriggerMeasure( let toInvestigate: boolean; let properties; let rules: KycRule[]; + let previousDecisionTime: TalerProtocolTimestamp | undefined; if (decisionsResp.records.length == 0) { toInvestigate = false; @@ -1354,11 +1369,24 @@ async function doTriggerMeasure( toInvestigate = rec.to_investigate; properties = rec.properties ?? {}; rules = rec.limits.rules; + previousDecisionTime = rec.decision_time; + } + + let decisionTime = TalerProtocolTimestamp.now(); + if ( + previousDecisionTime?.t_s !== undefined && + previousDecisionTime.t_s !== "never" && + decisionTime.t_s !== "never" && + decisionTime.t_s <= previousDecisionTime.t_s + ) { + decisionTime = TalerProtocolTimestamp.fromSeconds( + previousDecisionTime.t_s + 1, + ); } succeedOrThrow( await exchangeClient.makeAmlDesicion(officerAcc, { - decision_time: TalerProtocolTimestamp.now(), + decision_time: decisionTime, h_payto: merchantPaytoHash, justification: "bla", properties: properties, diff --git a/packages/taler-harness/src/integrationtests/test-balance-prospective.ts b/packages/taler-harness/src/integrationtests/test-balance-prospective.ts @@ -19,6 +19,7 @@ */ import { j2s, + TalerErrorCode, TransactionMajorState, TransactionMinorState, } from "@gnu-taler/taler-util"; @@ -94,8 +95,8 @@ export async function runBalanceProspectiveTest(t: GlobalTestState) { }); { - const errRes = t.assertThrowsTalerErrorAsync(async () => { - const p2 = await walletClient.call( + const errRes = await t.assertThrowsTalerErrorAsync(async () => { + await walletClient.call( WalletApiOperation.InitiatePeerPushDebit, { partialContractTerms: { @@ -106,7 +107,11 @@ export async function runBalanceProspectiveTest(t: GlobalTestState) { ); }); - console.log(j2s(errRes)); + console.log(j2s(errRes.errorDetail)); + t.assertDeepEqual( + errRes.errorDetail.code, + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + ); } } diff --git a/packages/taler-harness/src/integrationtests/test-claim-loop.ts b/packages/taler-harness/src/integrationtests/test-claim-loop.ts @@ -204,9 +204,14 @@ export async function runClaimLoopTest(t: GlobalTestState) { body: { nonce: encodeCrock(getRandomBytes(32)) }, }); t.assertTrue(claimResp.type === "fail"); - t.assertTrue( - claimResp.case === - TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND, + t.assertDeepEqual(claimResp.response.status, HttpStatusCode.NotFound); + t.assertDeepEqual( + claimResp.case, + TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND, + ); + t.assertDeepEqual( + claimResp.detail?.code, + TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND, ); }, ); diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts @@ -240,7 +240,7 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { t.assertDeepEqual(exch1.exchanges.length, 2); } - const ex = walletClient.call( + const ex = await walletClient.call( WalletApiOperation.ListGlobalCurrencyExchanges, {}, ); diff --git a/packages/taler-harness/src/integrationtests/test-deposit-fault.ts b/packages/taler-harness/src/integrationtests/test-deposit-fault.ts @@ -22,9 +22,11 @@ * Imports. */ import { + HttpStatusCode, j2s, openPromise, TalerCorebankApiClient, + TransactionMajorState, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; @@ -183,16 +185,28 @@ export async function runDepositFaultTest(t: GlobalTestState) { }); allowDeposit = true; - await harnessHttpLib.fetch( + const replayResponse = await harnessHttpLib.fetch( new URL("/batch-deposit", faultyExchange.baseUrl).href, { method: "POST", body: caughtDepositBody, }, ); + t.assertDeepEqual(replayResponse.status, HttpStatusCode.Ok); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); await walletClient.call(WalletApiOperation.TestingWaitRefreshesFinal, {}); + + const abortedDeposit = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: depositResp.transactionId }, + ); + t.assertDeepEqual(abortedDeposit.txState.major, TransactionMajorState.Aborted); + + const balances = await walletClient.call(WalletApiOperation.GetBalances, {}); + // Aborting returns the deposit inputs through refresh. The two refresh + // fees are the only loss, even when the captured request is replayed. + t.assertAmountEquals(balances.balances[0].available, "TESTKUDOS:19.68"); } runDepositFaultTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-deposit-too-large.ts b/packages/taler-harness/src/integrationtests/test-deposit-too-large.ts @@ -18,8 +18,8 @@ import { AmountString, encodeCrock, getRandomBytes, - j2s, - TalerError, + HttpStatusCode, + TalerErrorCode, } from "@gnu-taler/taler-util"; import { CryptoDispatcher, @@ -61,8 +61,9 @@ const coinConfigList: CoinConfig[] = [ /** * Test deposit with a large number of coins. * - * In particular, this checks that the wallet properly - * splits deposits into batches with <=64 coins per batch. + * This exercises the exchange directly with a 100-coin batch and verifies + * that the exchange rejects a request above its 64-coin limit without + * becoming unavailable. * * Since we use an artificially large number of coins, this * test is a bit slower than other tests. @@ -82,8 +83,7 @@ export async function runDepositTooLargeTest(t: GlobalTestState) { const merchantPub = merchantPair.pub; const merchantPriv = merchantPair.priv; - try { - // Withdraw digital cash into the wallet. + // Withdraw digital cash without using the wallet database. const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http); @@ -109,6 +109,7 @@ export async function runDepositTooLargeTest(t: GlobalTestState) { console.log("waiting for longpoll request"); const resp = await longpollReq; console.log(`got response, status ${resp.status}`); + t.assertDeepEqual(resp.status, HttpStatusCode.Ok); console.log(exchangeInfo); @@ -137,29 +138,39 @@ export async function runDepositTooLargeTest(t: GlobalTestState) { const wireSalt = encodeCrock(getRandomBytes(16)); const contractTermsHash = encodeCrock(getRandomBytes(64)); - await depositCoinBatch({ - contractTermsHash, - merchantPriv, - wireSalt, - amounts, - coins, - cryptoApi, - exchangeBaseUrl: exchange.baseUrl, - http, - }); - } catch (e) { - if (e instanceof TalerError) { - console.log(e); - console.log(j2s(e.errorDetail)); - } else { - console.log(e); - } - } + const oversizedError = await t.assertThrowsTalerErrorAsync(() => + depositCoinBatch({ + contractTermsHash, + merchantPriv, + wireSalt, + amounts, + coins, + cryptoApi, + exchangeBaseUrl: exchange.baseUrl, + http, + }), + ); + t.assertDeepEqual( + oversizedError.errorDetail.code, + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + ); + t.assertDeepEqual( + (oversizedError.errorDetail as any).httpStatusCode, + HttpStatusCode.BadRequest, + ); + t.assertDeepEqual( + (oversizedError.errorDetail as any).errorResponse.code, + TalerErrorCode.GENERIC_PARAMETER_MALFORMED, + ); + t.assertDeepEqual( + (oversizedError.errorDetail as any).errorResponse.detail, + "coins", + ); { // Try downloading exchange info again to make // sure that exchange is still running and didn't crash! - const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http); + await downloadExchangeInfo(exchange.baseUrl, http); } } diff --git a/packages/taler-harness/src/integrationtests/test-deposit-twice.ts b/packages/taler-harness/src/integrationtests/test-deposit-twice.ts @@ -74,13 +74,8 @@ const coinConfigList: CoinConfig[] = topsValues.map( ); /** - * Test deposit with a large number of coins. - * - * In particular, this checks that the wallet properly - * splits deposits into batches with <=64 coins per batch. - * - * Since we use an artificially large number of coins, this - * test is a bit slower than other tests. + * Verify that a wallet can deposit to the same bank account again after + * receiving more funds through a peer-to-peer payment. */ export async function runDepositTwiceTest(t: GlobalTestState) { // Set up test environment @@ -202,7 +197,8 @@ export async function runDepositTwiceTest(t: GlobalTestState) { console.log(`BALANCES : ${j2s({ aliceBalance, bobBalance })}`); } - // I tried to do a deposite to the the same bank account from B again and get the error message + // The account used for the first deposit remains a valid destination after + // Bob receives more funds. const maxDepositResp = await bobWallet.call( WalletApiOperation.GetMaxDepositAmount, { @@ -212,6 +208,25 @@ export async function runDepositTwiceTest(t: GlobalTestState) { ); console.log(`DEPOSIT : ${j2s(maxDepositResp)}`); + t.assertAmountEquals(maxDepositResp.effectiveAmount, "TESTKUDOS:10"); + // The effective amount is the amount removed from the wallet. The raw + // amount is what reaches the bank account after the deposit fee. + t.assertAmountEquals(maxDepositResp.rawAmount, "TESTKUDOS:9.99"); + + const secondDeposit = await bobWallet.call( + WalletApiOperation.CreateDepositGroup, + { + amount: "TESTKUDOS:10", + depositPaytoUri: bobWithdrawRes.accountPaytoUri, + }, + ); + await bobWallet.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: secondDeposit.transactionId, + txState: { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.Track, + }, + }); { const aliceBalance = await aliceWallet.call( @@ -220,6 +235,7 @@ export async function runDepositTwiceTest(t: GlobalTestState) { ); const bobBalance = await bobWallet.call(WalletApiOperation.GetBalances, {}); console.log(`BALNACES : ${j2s({ aliceBalance, bobBalance })}`); + t.assertAmountEquals(bobBalance.balances[0].available, "TESTKUDOS:0"); } } diff --git a/packages/taler-harness/src/integrationtests/test-donau-idempotency.ts b/packages/taler-harness/src/integrationtests/test-donau-idempotency.ts @@ -251,6 +251,13 @@ export async function runDonauIdempotencyTest(t: GlobalTestState) { {}, ); console.log(j2s(statements)); + // Each donation must appear exactly once even though the wallet had to + // retry every blocked pay response and the merchant registration was sent + // twice. + t.assertDeepEqual(statements.statements.length, 1); + t.assertAmountEquals(statements.statements[0].total, "TESTKUDOS:10.52"); + t.assertDeepEqual(statements.statements[0].year, currentYear); + t.assertDeepEqual(statements.statements[0].legalDomain, "Bern"); } runDonauIdempotencyTest.suites = ["donau"]; diff --git a/packages/taler-harness/src/integrationtests/test-donau-multi.ts b/packages/taler-harness/src/integrationtests/test-donau-multi.ts @@ -189,9 +189,19 @@ export async function runDonauMultiTest(t: GlobalTestState) { t.assertDeepEqual(getRes.currentDonauInfo.donauBaseUrl, donau.baseUrl); } - for (let i = 0; i < 3; i++) { + const merchants = [ + { + client: merchantClient1, + token: merchantAdminAccessToken, + charityId: charityId1, + }, + { client: merchantClient2, token: tok2, charityId: charityId2 }, + ]; + + for (let i = 0; i < 4; i++) { + const selectedMerchant = merchants[i % merchants.length]; const orderResp = succeedOrThrow( - await merchantClient1.createOrder(merchantAdminAccessToken, { + await selectedMerchant.client.createOrder(selectedMerchant.token, { order: { version: OrderVersion.V1, summary: "Test Donation", @@ -214,8 +224,8 @@ export async function runDonauMultiTest(t: GlobalTestState) { console.log(`order resp: ${j2s(orderResp)}`); let orderStatus = succeedOrThrow( - await merchantClient1.getOrderDetails( - merchantAdminAccessToken, + await selectedMerchant.client.getOrderDetails( + selectedMerchant.token, orderResp.order_id, ), ); @@ -267,8 +277,8 @@ export async function runDonauMultiTest(t: GlobalTestState) { // Check if payment was successful. orderStatus = succeedOrThrow( - await merchantClient1.getOrderDetails( - merchantAdminAccessToken, + await selectedMerchant.client.getOrderDetails( + selectedMerchant.token, orderResp.order_id, ), ); @@ -281,6 +291,10 @@ export async function runDonauMultiTest(t: GlobalTestState) { {}, ); console.log(j2s(statements)); + t.assertDeepEqual(statements.statements.length, 1); + t.assertAmountEquals(statements.statements[0].total, "TESTKUDOS:36"); + t.assertDeepEqual(statements.statements[0].year, currentYear); + t.assertDeepEqual(statements.statements[0].legalDomain, "Bern"); } runDonauMultiTest.suites = ["donau"]; diff --git a/packages/taler-harness/src/integrationtests/test-exchange-purse.ts b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts @@ -26,6 +26,7 @@ import { encodeCrock, getRandomBytes, hash, + HttpStatusCode, j2s, PeerContractTerms, TalerError, @@ -195,6 +196,7 @@ export async function runExchangePurseTest(t: GlobalTestState) { method: "POST", body: reqBody, }); + t.assertDeepEqual(httpResp.status, HttpStatusCode.Ok); const respBody = await httpResp.json(); @@ -205,6 +207,7 @@ export async function runExchangePurseTest(t: GlobalTestState) { const mergeUrl = new URL(`purses/${pursePub}/merge`, exchange.baseUrl); mergeUrl.searchParams.set("timeout_ms", "300"); const statusResp = await http.fetch(mergeUrl.href, {}); + t.assertDeepEqual(statusResp.status, HttpStatusCode.Ok); const statusRespBody = await statusResp.json(); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-deposit-kycauth.ts b/packages/taler-harness/src/integrationtests/test-kyc-deposit-kycauth.ts @@ -205,10 +205,9 @@ export async function runKycDepositKycauthTest(t: GlobalTestState) { const depositW0 = await w0.call(WalletApiOperation.CreateDepositGroup, { amount: "TESTKUDOS:3" as AmountString, depositPaytoUri: wres.accountPaytoUri, - transactionId: depositTxId, }); - await w1.walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + await w0.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: depositW0.transactionId, txState: { major: TransactionMajorState.Finalizing, diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts @@ -17,18 +17,21 @@ /** * Imports. */ -import { Configuration, j2s } from "@gnu-taler/taler-util"; +import { + Amounts, + Configuration, + CreditDebitIndicator, + LimitOperationType, + MerchantAccountKycStatus, + succeedOrThrow, +} from "@gnu-taler/taler-util"; import { configureCommonKyc, createKycTestkudosEnvironmentFull, makeTestPaymentV2, withdrawViaBankV3, } from "../harness/environments.js"; -import { - doMerchantKycAuth, - GlobalTestState, - harnessHttpLib, -} from "../harness/harness.js"; +import { doMerchantKycAuth, GlobalTestState } from "../harness/harness.js"; function adjustExchangeConfig(config: Configuration) { configureCommonKyc(config); @@ -61,6 +64,7 @@ export async function runKycMerchantAggregateTest(t: GlobalTestState) { bank, exchangeBankAccount, merchantAdminAccessToken, + merchantApi, } = await createKycTestkudosEnvironmentFull(t, { adjustExchangeConfig }); // Withdraw digital cash into the wallet. @@ -107,18 +111,38 @@ export async function runKycMerchantAggregateTest(t: GlobalTestState) { }); t.logStep("start-request-kyc"); - const kycStatusUrl = new URL("private/kyc", merchant.makeInstanceBaseUrl()); - const resp = await harnessHttpLib.fetch(kycStatusUrl.href, { - headers: { - Authorization: `Bearer ${merchantAdminAccessToken}`, - }, - }); - - console.log(`mechant kyc status: ${resp.status}`); - - t.assertDeepEqual(resp.status, 200); - - console.log(j2s(await resp.json())); + const status = succeedOrThrow( + await merchantApi.getCurrentInstanceKycStatus(merchantAdminAccessToken), + ); + t.assertDeepEqual(status.kyc_data.length, 1); + t.assertDeepEqual( + status.kyc_data[0].status, + MerchantAccountKycStatus.READY, + ); + t.assertDeepEqual(status.kyc_data[0].limits?.length, 1); + t.assertAmountEquals( + status.kyc_data[0].limits![0].threshold, + "TESTKUDOS:5", + ); + t.assertDeepEqual( + status.kyc_data[0].limits![0].operation_type, + LimitOperationType.aggregate, + ); + + const merchantBalance = await bankClient.getAccountBalance( + "merchant-default", + ); + t.assertDeepEqual( + merchantBalance.balance.credit_debit_indicator, + CreditDebitIndicator.Credit, + ); + t.assertTrue( + Amounts.cmp( + Amounts.parseOrThrow(merchantBalance.balance.amount), + Amounts.parseOrThrow("TESTKUDOS:0"), + ) > 0, + "aggregate was not credited to the merchant bank account", + ); } runKycMerchantAggregateTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-form.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-form.ts @@ -65,6 +65,13 @@ COMMAND = taler-exchange-helper-measure-inform-investigate ENABLED = YES FALLBACK = freeze-investigate +# Successor produced by taler-exchange-helper-measure-inform-investigate. +[kyc-measure-inform-investigate] +CHECK_NAME = SKIP +PROGRAM = NONE +VOLUNTARY = NO +CONTEXT = {} + [kyc-check-form-gls-merchant-onboarding] TYPE = FORM FORM_NAME = gls-merchant-onboarding @@ -251,13 +258,71 @@ export async function runKycMerchantDepositFormTest(t: GlobalTestState) { "Content-Type": "application/json", }, body: { - full_name: "Alice Abc", - birthdate: "2000-01-01", + FORM_ID: "gls-merchant-onboarding", + FORM_VERSION: 1, }, }, ); console.log("resp status", uploadResp.status); + t.assertDeepEqual(uploadResp.status, 204); + + // The form program intentionally sends the account to AML investigation. + // Completing the form must clear the interactive requirement while keeping + // the configured soft deposit limit visible to the merchant. + const deadline = Date.now() + 30_000; + while (true) { + t.assertTrue(Date.now() < deadline, "timed out completing KYC form"); + const completedInfoResp = await harnessHttpLib.fetch( + new URL( + `kyc-info/${kycRespTwo.kyc_data[0].access_token}`, + exchange.baseUrl, + ).href, + ); + if (completedInfoResp.status === 202 || completedInfoResp.status === 204) { + await delayMs(250); + continue; + } + const completedInfo = await readResponseJsonOrThrow( + completedInfoResp, + codecForKycProcessClientInformation(), + ); + if (completedInfo.requirements.length === 0) { + break; + } + await delayMs(250); + } + + await merchant.runKyccheckOnce(); + const statusResp = await harnessHttpLib.fetch( + new URL("private/kyc", merchant.makeInstanceBaseUrl()).href, + { headers }, + ); + const status = await readSuccessResponseJsonOrThrow( + statusResp, + codecForAccountKycRedirects(), + ); + t.assertDeepEqual( + status.kyc_data[0].status, + MerchantAccountKycStatus.AWAITING_AML_REVIEW, + ); + t.assertTrue((status.kyc_data[0].limits?.length ?? 0) > 0); + + const orderResp = await harnessHttpLib.fetch( + new URL("private/orders", merchant.makeInstanceBaseUrl()).href, + { + method: "POST", + body: { + order: { + summary: "Order after KYC", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + } satisfies TalerMerchantApi.Order, + }, + headers, + }, + ); + t.assertDeepEqual(orderResp.status, 200); } runKycMerchantDepositFormTest.suites = ["wallet", "merchant", "kyc"]; diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-rewrite.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-rewrite.ts @@ -271,8 +271,22 @@ export async function runKycMerchantDepositRewriteTest(t: GlobalTestState) { return undefined; }); t.assertTrue(!!kycStatus); + t.assertDeepEqual( + kycStatus.kyc_data[0].status, + MerchantAccountKycStatus.READY, + ); + t.assertDeepEqual(kycStatus.kyc_data[0].limits?.length ?? 0, 0); logger.info(`kyc resp 3: ${j2s(kycStatus)}`); } + + succeedOrThrow( + await merchantApi.createOrder(merchantAdminAccessToken, { + order: { + summary: "Order after KYC rewrite", + amount: "TESTKUDOS:5", + }, + }), + ); } runKycMerchantDepositRewriteTest.suites = ["wallet", "merchant", "kyc"]; diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit.ts @@ -263,6 +263,10 @@ export async function runKycMerchantDepositTest(t: GlobalTestState) { ); logger.info(`kyc resp 3: ${j2s(parsedResp)}`); if ((parsedResp.kyc_data[0].limits?.length ?? 0) == 0) { + t.assertDeepEqual( + parsedResp.kyc_data[0].status, + MerchantAccountKycStatus.READY, + ); break; } @@ -288,6 +292,22 @@ export async function runKycMerchantDepositTest(t: GlobalTestState) { // https://bugs.gnunet.org/view.php?id=9892 await merchant.runKyccheckOnce(); } + + const finalOrderResp = await harnessHttpLib.fetch( + new URL("private/orders", merchant.makeInstanceBaseUrl()).href, + { + method: "POST", + body: { + order: { + summary: "Order after KYC", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + } satisfies TalerMerchantApi.Order, + }, + headers, + }, + ); + t.assertDeepEqual(finalOrderResp.status, 200); } runKycMerchantDepositTest.suites = ["wallet", "merchant", "kyc"]; diff --git a/packages/taler-harness/src/integrationtests/test-kyc-new-measure.ts b/packages/taler-harness/src/integrationtests/test-kyc-new-measure.ts @@ -35,7 +35,6 @@ import { configureCommonKyc, createKycTestkudosEnvironmentFull, postAmlDecision, - withdrawViaBankV3, } from "../harness/environments.js"; import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; @@ -114,20 +113,49 @@ export async function runKycNewMeasureTest(t: GlobalTestState) { }); // Withdraw digital cash into the wallet. + const bankUser = await bankClient.createRandomBankUser(); + bankClient.setAuth({ + username: bankUser.username, + password: bankUser.password, + }); + + async function withdrawFromSameBankAccount(amount: string) { + const operation = await bankClient.createWithdrawalOperation( + bankUser.username, + amount, + ); + await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, { + talerWithdrawUri: operation.taler_withdraw_uri, + }); + const accepted = await walletClient.call( + WalletApiOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: operation.taler_withdraw_uri, + }, + ); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: accepted.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }); + await bankClient.confirmWithdrawalOperation(bankUser.username, { + withdrawalOperationId: operation.withdrawal_id, + }); + return accepted.transactionId; + } + let kycPaytoHash: string | undefined; let accessToken: string | undefined; let firstTransaction: string | undefined; { - const wres = await withdrawViaBankV3(t, { - amount: "TESTKUDOS:20", - bankClient, - exchange, - walletClient, - }); + const transactionId = await withdrawFromSameBankAccount("TESTKUDOS:20"); await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { - transactionId: wres.transactionId as TransactionIdStr, + transactionId: transactionId as TransactionIdStr, txState: { major: TransactionMajorState.Pending, minor: TransactionMinorState.KycRequired, @@ -137,7 +165,7 @@ export async function runKycNewMeasureTest(t: GlobalTestState) { const txDetails = await walletClient.call( WalletApiOperation.GetTransactionById, { - transactionId: wres.transactionId, + transactionId, }, ); @@ -145,7 +173,7 @@ export async function runKycNewMeasureTest(t: GlobalTestState) { accessToken = txDetails.kycAccessToken; kycPaytoHash = txDetails.kycPaytoHash; - firstTransaction = wres.transactionId; + firstTransaction = transactionId; } t.assertTrue(!!accessToken); @@ -217,7 +245,7 @@ export async function runKycNewMeasureTest(t: GlobalTestState) { amlPub: amlKeypair.pub, exchangeBaseUrl: exchange.baseUrl, paytoHash: kycPaytoHash, - newMeasures: "m3", + newMeasures: "M3", newRules: { expiration_time: TalerProtocolTimestamp.never(), custom_measures: {}, @@ -246,44 +274,37 @@ export async function runKycNewMeasureTest(t: GlobalTestState) { t.assertDeepEqual(decisionsResp.status, 200); } - { - const wres = await withdrawViaBankV3(t, { - amount: "TESTKUDOS:21", - bankClient, - exchange, - walletClient, - }); - - await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { - transactionId: wres.transactionId as TransactionIdStr, - txState: { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.KycRequired, - }, - }); - - const txDetails = await walletClient.call( - WalletApiOperation.GetTransactionById, - { - transactionId: wres.transactionId, - }, - ); - console.log(j2s(txDetails)); - - const accessToken = txDetails.kycAccessToken; - t.assertTrue(!!accessToken); - + // The officer decision applies M3 immediately to this account. Observe + // that exact measure through the account's existing KYC access token. + const deadline = Date.now() + 30_000; + while (true) { + t.assertTrue(Date.now() < deadline, "timed out waiting for M3"); const infoResp = await harnessHttpLib.fetch( - new URL(`kyc-info/${txDetails.kycAccessToken}`, exchange.baseUrl).href, + new URL(`kyc-info/${accessToken}`, exchange.baseUrl).href, ); - + if (infoResp.status === 202 || infoResp.status === 204) { + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } const clientInfo = await readResponseJsonOrThrow( infoResp, codecForKycProcessClientInformation(), ); - - console.log("second withdrawal, clientInfo:"); + console.log("officer-applied M3, clientInfo:"); console.log(j2s(clientInfo)); + if (clientInfo.requirements.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } + t.assertDeepEqual(clientInfo.requirements.length, 1); + t.assertDeepEqual(clientInfo.requirements[0].form, "INFO"); + t.assertDeepEqual( + clientInfo.requirements[0].description, + "this is info c3", + ); + t.assertDeepEqual(clientInfo.requirements[0].context, undefined); + t.assertDeepEqual(clientInfo.requirements[0].id, undefined); + break; } } diff --git a/packages/taler-harness/src/integrationtests/test-kyc-new-measures-prog.ts b/packages/taler-harness/src/integrationtests/test-kyc-new-measures-prog.ts @@ -270,7 +270,9 @@ export async function runKycNewMeasuresProgTest(t: GlobalTestState) { await waitMs(2000); // Wait for the KYC program to run + const deadline = Date.now() + 30_000; while (true) { + t.assertTrue(Date.now() < deadline, "timed out waiting for new measures"); const infoResp = await harnessHttpLib.fetch( new URL(`kyc-info/${accessToken}`, exchange.baseUrl).href, ); @@ -305,11 +307,20 @@ export async function runKycNewMeasuresProgTest(t: GlobalTestState) { console.log(j2s(clientInfo)); - // Finally here we must see the officer defined form - t.assertDeepEqual(clientInfo?.requirements[0].context, { - // this is fixed by the aml program - WAT: "REALLY?", - }); + // Both custom measures returned by the AML program must survive. A test + // of only the first requirement would miss one being silently dropped. + t.assertDeepEqual(clientInfo?.requirements.length, 2); + for (const requirement of clientInfo?.requirements ?? []) { + t.assertDeepEqual(requirement.form, "dynamicform"); + t.assertTrue(!!requirement.id); + } + const contexts = (clientInfo?.requirements ?? []) + .map((requirement) => JSON.stringify(requirement.context)) + .sort(); + t.assertDeepEqual( + contexts, + [JSON.stringify({ WAT: "REALLY?" }), JSON.stringify({ infotype: "basic" })].sort(), + ); break; } diff --git a/packages/taler-harness/src/integrationtests/test-kyc-two-forms.ts b/packages/taler-harness/src/integrationtests/test-kyc-two-forms.ts @@ -224,6 +224,8 @@ export async function runKycTwoFormsTest(t: GlobalTestState) { const clientInfo = infoResp.body; t.assertDeepEqual(clientInfo?.requirements.length, 1); t.assertDeepEqual(clientInfo?.requirements[0].form, "secondform"); + t.assertTrue(!!clientInfo?.requirements[0].id); + latestFormId = clientInfo.requirements[0].id; } await waitMs(2000); @@ -238,6 +240,28 @@ export async function runKycTwoFormsTest(t: GlobalTestState) { t.assertDeepEqual(clientInfo?.requirements.length, 1); t.assertDeepEqual(clientInfo?.requirements[0].form, "secondform"); } + + { + t.logStep("Complete the second form"); + succeedOrThrow( + await exchangeApi.uploadKycForm(latestFormId, { + FORM_ID: "secondform", + FINAL: "done", + FORM_VERSION: 1, + }), + ); + + const deadline = Date.now() + 30_000; + while (true) { + t.assertTrue(Date.now() < deadline, "timed out completing second form"); + const infoResp = await exchangeApi.checkKycInfo(accessToken); + t.assertTrue(infoResp.type === "ok"); + if (infoResp.body.requirements.length === 0) { + break; + } + await waitMs(250); + } + } } runKycTwoFormsTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-bank-bad-wire-target.ts b/packages/taler-harness/src/integrationtests/test-merchant-bank-bad-wire-target.ts @@ -29,7 +29,8 @@ import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; import { GlobalTestState, waitMs } from "../harness/harness.js"; /** - * Test APIs related to merchant wire transfers. + * Verify that a merchant IBAN account is reported as unsupported when the + * exchange only supports x-taler-bank wire accounts. */ export async function runMerchantBankBadWireTargetTest(t: GlobalTestState) { // Set up test environment diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts @@ -127,6 +127,29 @@ export async function runMerchantInstancesDeleteTest(t: GlobalTestState) { t.assertTrue(res.type === "fail"); t.assertTrue(res.case === HttpStatusCode.Unauthorized); } + + // The administrator can soft-delete the instance. Management listings + // retain it for compliance, marked as deleted, while direct lookup treats + // it as unavailable. + { + const res = await merchantApi.deleteInstance(adminAccessToken, "myinst"); + t.assertTrue(res.type === "ok"); + + const listed = await merchantApi.listInstances(adminAccessToken); + t.assertTrue(listed.type === "ok"); + const listedById = new Map( + listed.body.instances.map((instance) => [instance.id, instance]), + ); + t.assertDeepEqual(listedById.get("admin")?.deleted, false); + t.assertDeepEqual(listedById.get("myinst")?.deleted, true); + + const deleted = await merchantApi.getInstanceDetails( + adminAccessToken, + "myinst", + ); + t.assertTrue(deleted.type === "fail"); + t.assertDeepEqual(deleted.response.status, HttpStatusCode.NotFound); + } } runMerchantInstancesDeleteTest.suites = ["merchant"]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts @@ -178,12 +178,6 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) { await check( `${adminInstBaseUrl}instances/myinst/private/orders`, - adminToken, - 401, - ); - - await check( - `${adminInstBaseUrl}instances/myinst/private/orders`, myInstToken, 200, ); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts b/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts @@ -24,6 +24,7 @@ import { TransactionMinorState, TransactionType, URL, + codecForMerchantOrderStatusPaid, codecForMerchantOrderStatusUnpaid, succeedOrThrow, } from "@gnu-taler/taler-util"; @@ -35,7 +36,8 @@ import { import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; /** - * Run test for basic, bank-integrated withdrawal. + * Verify public order-status long polling times out while unpaid and wakes + * with a paid response when the wallet completes the payment. */ export async function runMerchantLongpollingTest(t: GlobalTestState) { // Set up test environment @@ -171,24 +173,16 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) { const proposalTransactionId = preparePayResp.transactionId; - publicOrderStatusResp = await publicOrderStatusPromise; - - if (publicOrderStatusResp.status != 402) { - throw Error( - `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`, - ); - } - - pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - await publicOrderStatusResp.json(), - ); - const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, { transactionId: proposalTransactionId, choiceIndex: 0, }); t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done); + + publicOrderStatusResp = await publicOrderStatusPromise; + t.assertDeepEqual(publicOrderStatusResp.status, 200); + codecForMerchantOrderStatusPaid().decode(await publicOrderStatusResp.json()); } runMerchantLongpollingTest.suites = ["merchant"]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-refund-fees.ts b/packages/taler-harness/src/integrationtests/test-merchant-refund-fees.ts @@ -18,11 +18,13 @@ * Imports. */ import { + Amounts, j2s, succeedOrThrow, TalerMerchantInstanceHttpClient, TransactionMajorState, TransactionMinorState, + TransactionType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { @@ -58,6 +60,10 @@ export async function runMerchantRefundFeesTest(t: GlobalTestState) { bank, }); await wres.withdrawalFinishedCond; + const balanceBeforePayment = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); const merchantClient = new TalerMerchantInstanceHttpClient( merchant.makeInstanceBaseUrl(), @@ -103,6 +109,10 @@ export async function runMerchantRefundFeesTest(t: GlobalTestState) { major: TransactionMajorState.Done, }, }); + const balanceAfterPayment = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); const refundResp = succeedOrThrow( await merchantClient.addRefund( @@ -125,6 +135,42 @@ export async function runMerchantRefundFeesTest(t: GlobalTestState) { }); const bal = await walletClient.call(WalletApiOperation.GetBalances, {}); console.log(j2s(bal)); + const payment = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: prepResp.transactionId }, + ); + t.assertTrue(payment.type === TransactionType.Payment); + const transactions = await walletClient.call( + WalletApiOperation.GetTransactions, + {}, + ); + const refund = transactions.transactions.find( + (tx) => + tx.type === TransactionType.Refund && + tx.refundedTransactionId === prepResp.transactionId, + ); + t.assertTrue(refund?.type === TransactionType.Refund); + t.assertAmountEquals(refund.amountRaw, "TESTKUDOS:10"); + t.assertTrue( + Amounts.cmp( + Amounts.parseOrThrow(refund.amountEffective), + Amounts.parseOrThrow("TESTKUDOS:0"), + ) > 0, + ); + t.assertTrue( + Amounts.cmp( + Amounts.parseOrThrow(bal.balances[0].available), + Amounts.parseOrThrow(balanceAfterPayment.balances[0].available), + ) > 0, + "refund did not increase the wallet balance", + ); + t.assertTrue( + Amounts.cmp( + Amounts.parseOrThrow(bal.balances[0].available), + Amounts.parseOrThrow(balanceBeforePayment.balances[0].available), + ) <= 0, + "refund credited more than the pre-payment balance", + ); // This is the request that caused a merchant crash in // https://bugs.taler.net/n/11054 const orderStatus2 = succeedOrThrow( diff --git a/packages/taler-harness/src/integrationtests/test-merchant-reports.ts b/packages/taler-harness/src/integrationtests/test-merchant-reports.ts @@ -67,6 +67,14 @@ export async function runMerchantReportsTest(t: GlobalTestState) { merchant.makeInstanceBaseUrl(), ); + function assertPdf(reportBytes: Uint8Array) { + t.assertTrue(reportBytes.length > 1_000, "PDF report is unexpectedly small"); + t.assertDeepEqual( + new TextDecoder().decode(reportBytes.slice(0, 5)), + "%PDF-", + ); + } + { const reportBytes = succeedOrThrow( await instanceMgmtApi.getStatisticsReportPdf( @@ -74,6 +82,7 @@ export async function runMerchantReportsTest(t: GlobalTestState) { "transactions", ), ); + assertPdf(reportBytes); const f = t.testDir + `/report-0.pdf`; fs.writeFileSync(f, reportBytes); console.log(`written to ${f}`); @@ -105,6 +114,7 @@ export async function runMerchantReportsTest(t: GlobalTestState) { "transactions", ), ); + assertPdf(reportBytes); const f = t.testDir + `/report-1.pdf`; fs.writeFileSync(f, reportBytes); console.log(`written to ${f}`); @@ -136,6 +146,7 @@ export async function runMerchantReportsTest(t: GlobalTestState) { "transactions", ), ); + assertPdf(reportBytes); const f = t.testDir + `/report-2.pdf`; fs.writeFileSync(f, reportBytes); console.log(`written to ${f}`); @@ -180,6 +191,7 @@ export async function runMerchantReportsTest(t: GlobalTestState) { "transactions", ), ); + assertPdf(reportBytes); const f = t.testDir + `/report-3.pdf`; fs.writeFileSync(f, reportBytes); console.log(`written to ${f}`); @@ -191,6 +203,11 @@ export async function runMerchantReportsTest(t: GlobalTestState) { mime: "text/csv", }), ); + const csv = new TextDecoder().decode(reportBytes); + t.assertTrue(csv.length > 100, "CSV report is unexpectedly small"); + t.assertTrue(csv.includes("Test payment 1")); + t.assertTrue(csv.includes("Test payment 2")); + t.assertTrue(csv.includes("Test payment 3")); const f = t.testDir + `/report-4.csv`; fs.writeFileSync(f, reportBytes); console.log(`written to ${f}`); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation-two-bank-account.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation-two-bank-account.ts @@ -40,7 +40,8 @@ import { } from "../harness/tan-helper.js"; /** - * Do basic checks on instance management and authentication. + * Activate a self-provisioned instance with email/SMS MFA and verify that two + * distinct bank accounts survive the MFA-protected account-add flow. */ export async function runMerchantSelfProvisionActivationTwoBankAccountsTest( t: GlobalTestState, @@ -206,6 +207,13 @@ export async function runMerchantSelfProvisionActivationTwoBankAccountsTest( }, ), ); + + t.assertTrue(bankAccount.h_wire !== secondBankAccount.h_wire); + const accounts = succeedOrThrow(await instanceApi.listBankAccounts(token)); + const accountHashes = accounts.accounts.map((account) => account.h_wire); + t.assertDeepEqual(accounts.accounts.length, 2); + t.assertTrue(accountHashes.includes(bankAccount.h_wire)); + t.assertTrue(accountHashes.includes(secondBankAccount.h_wire)); } runMerchantSelfProvisionActivationTwoBankAccountsTest.suites = [ diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts @@ -42,7 +42,8 @@ import { export const logger = new Logger("test-merchant-self-provision-activation.ts"); /** - * Do basic checks on instance management and authentication. + * Activate a self-provisioned instance with email/SMS MFA and verify its + * authenticated contact details. */ export async function runMerchantSelfProvisionActivationTest( t: GlobalTestState, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-casing.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-casing.ts @@ -96,7 +96,7 @@ export async function runMerchantSelfProvisionCasingTest(t: GlobalTestState) { { // Special characters in instance name should not be allowed - t.assertThrowsTalerErrorAsync(async () => { + await t.assertThrowsTalerErrorAsync(async () => { await merchantClient.createInstanceSelfProvision({ ...instanceInfo, id: "löl", diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-forgot-password.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-forgot-password.ts @@ -36,7 +36,8 @@ import { } from "../harness/tan-helper.js"; /** - * The merchant should get the TAN code on request to be used to activate the account. + * A password reset requires MFA, invalidates the old password and produces a + * usable login token for the new password. */ export async function runMerchantSelfProvisionForgotPasswordTest( t: GlobalTestState, @@ -121,6 +122,16 @@ export async function runMerchantSelfProvisionForgotPasswordTest( }), ); + const oldPasswordLogin = await instanceApi.createAccessToken( + instanceInfo.id, + instanceInfo.auth.password, + { scope: LoginTokenScope.All }, + ); + t.assertTrue( + oldPasswordLogin.type === "fail", + "old password still worked after password reset", + ); + const mfa2 = alternativeOrThrow( await instanceApi.createAccessToken(instanceInfo.id, newPassword.password, { scope: LoginTokenScope.All, @@ -142,6 +153,10 @@ export async function runMerchantSelfProvisionForgotPasswordTest( }, ), ); + const instanceDetails = succeedOrThrow( + await instanceApi.getCurrentInstanceDetails(tk.access_token), + ); + t.assertDeepEqual(instanceDetails.email, instanceInfo.email); } runMerchantSelfProvisionForgotPasswordTest.suites = [ diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts @@ -37,8 +37,8 @@ import { import { GlobalTestState } from "../harness/harness.js"; /** - * Test that the merchant can change name and emails address but can't start kyc process - * before activating the account + * Test that a pending self-provisioned instance can update its email, but + * cannot log in (and therefore cannot start KYC) before account activation. */ export async function runMerchantSelfProvisionInactiveAccountPermissionsTest( t: GlobalTestState, @@ -111,6 +111,16 @@ export async function runMerchantSelfProvisionInactiveAccountPermissionsTest( merchantClient.httpLib, ); + const inactiveLogin = await instanceApi.createAccessToken( + instanceInfo.id, + instanceInfo.auth.password, + { scope: LoginTokenScope.All }, + ); + t.assertTrue( + inactiveLogin.type === "fail", + "pending instance unexpectedly allowed login before activation", + ); + succeedOrThrow(await merchantClient.sendChallenge(firstChallenge)); const res = await wait2FaCode(mfaConfig.email.path); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-tokenfamilies.ts b/packages/taler-harness/src/integrationtests/test-merchant-tokenfamilies.ts @@ -152,6 +152,7 @@ export async function runMerchantTokenfamiliesTest(t: GlobalTestState) { { merchant, exchange, + walletClient, }, ); @@ -248,6 +249,7 @@ export async function runMerchantTokenfamiliesTest(t: GlobalTestState) { ); console.log(`status: ${j2s(st)}`); + t.assertDeepEqual(st.order_status, "unpaid"); await waitMs(200); } @@ -344,6 +346,16 @@ export async function runMerchantTokenfamiliesTest(t: GlobalTestState) { major: TransactionMajorState.Done, }, }); + + const { discounts } = await walletClient.call( + WalletApiOperation.ListDiscounts, + {}, + ); + const issuedDiscount = discounts.find( + (discount) => discount.name === "discount1", + ); + t.assertTrue(issuedDiscount !== undefined); + t.assertDeepEqual(issuedDiscount.tokensAvailable, 3); } } diff --git a/packages/taler-harness/src/integrationtests/test-merchant-wire.ts b/packages/taler-harness/src/integrationtests/test-merchant-wire.ts @@ -154,7 +154,7 @@ export async function runMerchantWireTest(t: GlobalTestState) { t.assertDeepEqual(resp.incoming.length, 1); const det = resp.incoming[0]; t.assertTrue(det.expected_credit_amount != null); - t.assertAmountEquals(det.expected_credit_amount, "TESTKUDOS:4.89"); + t.assertAmountEquals(det.expected_credit_amount, "TESTKUDOS:4.98"); incomingAmount = det.expected_credit_amount; incomingWtid = det.wtid; incomingPayto = det.payto_uri; @@ -168,14 +168,6 @@ export async function runMerchantWireTest(t: GlobalTestState) { t.assertDeepEqual(resp.transfers.length, 0); } - { - const resp = succeedOrThrow( - await merchantClient.listConfirmedWireTransfers(merchantAdminAccessToken), - ); - console.log(j2s(resp)); - t.assertDeepEqual(resp.transfers.length, 0); - } - succeedOrThrow( await merchantClient.informWireTransfer(merchantAdminAccessToken, { credit_amount: incomingAmount, diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts @@ -21,6 +21,7 @@ import { Duration, TalerCorebankApiClient, TalerMerchantApi, + TransactionType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; @@ -40,7 +41,7 @@ import { } from "../harness/harness.js"; /** - * Run test for basic, bank-integrated withdrawal and payment. + * Verify that one payment can combine funds withdrawn from two exchanges. */ export async function runMultiExchangeTest(t: GlobalTestState) { // Set up test environment @@ -204,6 +205,23 @@ export async function runMultiExchangeTest(t: GlobalTestState) { await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const fundedBalances = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); + t.assertDeepEqual(fundedBalances.balances.length, 2); + t.assertDeepEqual( + fundedBalances.balances + .map((balance) => + "url" in balance.scopeInfo ? balance.scopeInfo.url : "", + ) + .sort(), + [exchangeOne.baseUrl, exchangeTwo.baseUrl].sort(), + ); + for (const balance of fundedBalances.balances) { + t.assertAmountEquals(balance.available, "TESTKUDOS:5.85"); + } + const order: TalerMerchantApi.Order = { summary: "Buy me!", amount: "TESTKUDOS:10", @@ -212,13 +230,25 @@ export async function runMultiExchangeTest(t: GlobalTestState) { console.log("making test payment"); - await makeTestPaymentV2(t, { + const payment = await makeTestPaymentV2(t, { walletClient, merchant, order, merchantAdminAccessToken: adminAccessToken, }); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + const paymentTx = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: payment.transactionId }, + ); + t.assertTrue(paymentTx.type === TransactionType.Payment); + t.assertDeepEqual( + paymentTx.scopes + .map((scope) => ("url" in scope ? scope.url : "")) + .sort(), + [exchangeOne.baseUrl, exchangeTwo.baseUrl].sort(), + ); } runMultiExchangeTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-otp.ts b/packages/taler-harness/src/integrationtests/test-otp.ts @@ -37,7 +37,7 @@ import { import { GlobalTestState } from "../harness/harness.js"; /** - * Run test for basic, bank-integrated withdrawal and payment. + * Verify merchant OTP device and template setup and an OTP-confirmed payment. */ export async function runOtpTest(t: GlobalTestState) { // Set up test environment diff --git a/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts b/packages/taler-harness/src/integrationtests/test-paivana-repurchase.ts @@ -36,6 +36,10 @@ const harnessHttpLib = createPlatformHttpLib({ }); export const logger = new Logger("test-paivana.ts"); +/** + * Verify that Paivana creates one payment and subsequently reuses it through + * the repurchase flow for the same protected URL. + */ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { // Set up test environment @@ -55,8 +59,8 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { const website = `${paivana.baseUrl}index.html`; - let times = 3; - while (times--) { + let originalPaymentId: string | undefined; + for (let iteration = 0; iteration < 3; iteration++) { logger.info("1) access denied, preparing Paivana payment"); { @@ -66,16 +70,16 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { ); await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { transactionId: templateStatus.transactionId, - txState: [ - { - major: TransactionMajorState.Failed, - minor: TransactionMinorState.Repurchase, - }, - { - major: TransactionMajorState.Dialog, - minor: TransactionMinorState.Proposed, - }, - ], + txState: + iteration === 0 + ? { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + } + : { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Repurchase, + }, }); const txDet = await walletClient.call( @@ -86,10 +90,12 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { ); t.assertDeepEqual(txDet.type, TransactionType.Payment); - if (txDet.txState.major === TransactionMajorState.Failed) { + if (iteration > 0) { + t.assertDeepEqual(txDet.txState.major, TransactionMajorState.Failed); const repurchaseTxId = txDet.repurchaseTransactionId; t.assertTrue(repurchaseTxId != null); + t.assertDeepEqual(repurchaseTxId, originalPaymentId); await walletClient.call( WalletApiOperation.TestingWaitTransactionState, @@ -101,6 +107,7 @@ export async function runPaivanaRepurchaseTest(t: GlobalTestState) { }, ); } else { + originalPaymentId = txDet.transactionId; await walletClient.call(WalletApiOperation.ConfirmPay, { transactionId: txDet.transactionId, choiceIndex: 0, diff --git a/packages/taler-harness/src/integrationtests/test-payment-zero.ts b/packages/taler-harness/src/integrationtests/test-payment-zero.ts @@ -21,10 +21,9 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { GlobalTestState } from "../harness/harness.js"; import { createSimpleTestkudosEnvironmentV3, - withdrawViaBankV3, makeTestPaymentV2, } from "../harness/environments.js"; -import { TransactionMajorState } from "@gnu-taler/taler-util"; +import { TransactionMajorState, TransactionType } from "@gnu-taler/taler-util"; /** * Run test for a payment for a "free" order with @@ -33,26 +32,11 @@ import { TransactionMajorState } from "@gnu-taler/taler-util"; export async function runPaymentZeroTest(t: GlobalTestState) { // Set up test environment - const { - walletClient, - bankClient, - exchange, - merchant, - merchantAdminAccessToken, - } = await createSimpleTestkudosEnvironmentV3(t); - - // First, make a "free" payment when we don't even have - // any money in the + const { walletClient, merchant, merchantAdminAccessToken } = + await createSimpleTestkudosEnvironmentV3(t); - // Withdraw digital cash into the wallet. - await withdrawViaBankV3(t, { - walletClient, - bankClient, - exchange, - amount: "TESTKUDOS:20", - }); - - await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const before = await walletClient.call(WalletApiOperation.GetTransactions, {}); + t.assertDeepEqual(before.transactions.length, 0); await makeTestPaymentV2(t, { walletClient, @@ -72,9 +56,11 @@ export async function runPaymentZeroTest(t: GlobalTestState) { {}, ); - for (const tr of transactions.transactions) { - t.assertDeepEqual(tr.txState.major, TransactionMajorState.Done); - } + t.assertDeepEqual(transactions.transactions.length, 1); + const payment = transactions.transactions[0]; + t.assertDeepEqual(payment.type, TransactionType.Payment); + t.assertDeepEqual(payment.txState.major, TransactionMajorState.Done); + t.assertAmountEquals(payment.amountRaw, "TESTKUDOS:0"); } runPaymentZeroTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-peer-repair.ts b/packages/taler-harness/src/integrationtests/test-peer-repair.ts @@ -20,7 +20,6 @@ import { AbsoluteTime, AmountString, - CoinStatus, Duration, NotificationType, TransactionMajorState, @@ -200,11 +199,6 @@ export async function runPeerRepairTest(t: GlobalTestState) { await withdraw2Res.withdrawalFinishedCond; - const coinsBeforeRepair = await wallet1.call(WalletApiOperation.DumpCoins, {}); - const freshBeforeRepair = coinsBeforeRepair.coins.filter( - (c) => c.coinStatus === CoinStatus.Fresh, - ).length; - const peerPushDebitReady2Cond = wallet1.waitForNotificationCond( (x) => x.type === NotificationType.TransactionStateTransition && @@ -215,18 +209,28 @@ export async function runPeerRepairTest(t: GlobalTestState) { await peerPushDebitReady2Cond; - // The re-selected coins have been deposited into the purse, so they must - // no longer be offered to another payment. - const coinsAfterRepair = await wallet1.call(WalletApiOperation.DumpCoins, {}); - const freshAfterRepair = coinsAfterRepair.coins.filter( - (c) => c.coinStatus === CoinStatus.Fresh, - ).length; - - console.log( - `fresh coins before repair: ${freshBeforeRepair}, after: ${freshAfterRepair}`, + const repairedDebit = await wallet1.call( + WalletApiOperation.GetTransactionById, + { transactionId: initResp2.transactionId }, ); + t.assertDeepEqual(repairedDebit.type, TransactionType.PeerPushDebit); + t.assertTrue(!!repairedDebit.talerUri); - t.assertTrue(freshAfterRepair < freshBeforeRepair); + const repairedCredit = await wallet2.call( + WalletApiOperation.PreparePeerPushCredit, + { talerUri: repairedDebit.talerUri }, + ); + await wallet2.call(WalletApiOperation.ConfirmPeerPushCredit, { + transactionId: repairedCredit.transactionId, + }); + await wallet2.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: repairedCredit.transactionId, + txState: { major: TransactionMajorState.Done }, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: initResp2.transactionId, + txState: { major: TransactionMajorState.Done }, + }); } runPeerRepairTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts @@ -24,6 +24,13 @@ import { setupMeasuresTestEnvironment } from "../harness/tops.js"; export const logger = new Logger("test-tops-aml-measures.ts"); +function assertPdf(t: GlobalTestState, data: Uint8Array, label: string): void { + const text = Buffer.from(data).toString("latin1"); + t.assertTrue(data.byteLength > 100, `${label} is unexpectedly small`); + t.assertTrue(text.startsWith("%PDF-"), `${label} is not a PDF`); + t.assertTrue(text.includes("%%EOF"), `${label} is truncated`); +} + interface FileField { CONTENTS: string; ENCODING: "base64"; @@ -116,6 +123,7 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { { limit: 100, order: "asc" }, ), ); + assertPdf(t, res, "initial AML attributes export"); const f = t.testDir + `/aml-file-initial.pdf`; fs.writeFileSync(f, res); console.log(`written to ${f}`); @@ -711,6 +719,7 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { { limit: 100, order: "asc" }, ), ); + assertPdf(t, res, "final AML attributes export"); const f = t.testDir + `/aml-file.pdf`; fs.writeFileSync(f, res); console.log(`written to ${f}`); @@ -721,6 +730,9 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { const res = succeedOrThrow( await exchangeClient.getAmlAccountsAsOtherFormat(officerAcc, "text/csv"), ); + const csv = Buffer.from(res).toString("utf8"); + t.assertTrue(csv.length > 20, "AML account CSV is empty"); + t.assertTrue(csv.includes("merchant-default")); const f = t.testDir + `/accounts.csv`; fs.writeFileSync(f, res); console.log(`written to ${f}`); @@ -733,6 +745,10 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { "application/json", ), ); + const jsonText = Buffer.from(res).toString("utf8"); + const json = JSON.parse(jsonText); + t.assertTrue(json !== null && typeof json === "object"); + t.assertTrue(jsonText.includes("merchant-default")); const f = t.testDir + `/accounts.json`; fs.writeFileSync(f, res); console.log(`written to ${f}`); @@ -745,6 +761,7 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { "application/vnd.ms-excel", ), ); + t.assertTrue(res.byteLength > 20, "AML account spreadsheet is empty"); const f = t.testDir + `/accounts.xls`; fs.writeFileSync(f, res); console.log(`written to ${f}`); diff --git a/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts b/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts @@ -182,6 +182,23 @@ export async function runTopsMerchantSwtKycauthTest(t: GlobalTestState) { kycResp.wire_instructions.length > 0, "expected at least one wire instruction", ); + const instruction = kycResp.wire_instructions[0]; + t.assertDeepEqual(instruction.amount, "CHF:0.01"); + t.assertTrue(instruction.target_payto.includes(bankAccountInfo.qrIban!)); + t.assertDeepEqual(instruction.subject.type, "CH_QR_BILL"); + + const creditPayto = new URL(instruction.target_payto); + creditPayto.searchParams.set("amount", instruction.amount); + if (instruction.subject.type !== "CH_QR_BILL") { + throw Error("expected a Swiss QR bill transfer subject"); + } + creditPayto.searchParams.set( + "ch-qrr", + instruction.subject.qr_reference_number, + ); + // Exercise the instruction against Nexus so an invalid QR-IBAN/reference + // combination cannot pass as a merely well-shaped response. + await nexus.fakeIncoming({ creditPayto: creditPayto.href }); } runTopsMerchantSwtKycauthTest.suites = ["tops", "libeufin"]; diff --git a/packages/taler-harness/src/integrationtests/test-tops-nexus-swt.ts b/packages/taler-harness/src/integrationtests/test-tops-nexus-swt.ts @@ -194,6 +194,15 @@ export async function runTopsNexusSwtTest(t: GlobalTestState) { (txDet.kycAuthTransferInfo?.transferOptionsExt[0]?.transferOptions.length ?? 0) > 0, ); + const kycTransferOpt = + txDet.kycAuthTransferInfo?.transferOptionsExt.flatMap( + (account) => account.transferOptions, + ).find((option) => option.type === "ch-qr-bill"); + t.assertTrue(kycTransferOpt?.type === "ch-qr-bill"); + t.assertDeepEqual(kycTransferOpt.qrReferenceNumber.length, 27); + // Submitting this payto URI proves that the generated Swiss QR transfer is + // accepted by the Nexus test bank, rather than merely checking it exists. + await nexus.fakeIncoming({ creditPayto: kycTransferOpt.paytoUri }); } runTopsNexusSwtTest.suites = ["tops", "libeufin"]; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts b/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts @@ -21,24 +21,27 @@ import { AmountString } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; -import { GlobalTestState, setupDb } from "../harness/harness.js"; +import { GlobalTestState, WalletCli } from "../harness/harness.js"; /** * Test that run-until-done of taler-wallet-cli terminates. */ export async function runWalletCliTerminationTest(t: GlobalTestState) { - const db = await setupDb(t); - const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")); - const { exchange, bankClient, walletClient } = + const { exchange, bankClient } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, {}); + const wallet = new WalletCli(t, "termination"); - await walletClient.call(WalletApiOperation.WithdrawTestBalance, { + await wallet.client.call(WalletApiOperation.WithdrawTestBalance, { corebankApiBaseUrl: bankClient.baseUrl, exchangeBaseUrl: exchange.baseUrl, amount: "TESTKUDOS:20" as AmountString, }); + await wallet.runUntilDone(); + + const balances = await wallet.client.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balances.balances[0].available, "TESTKUDOS:19.84"); } runWalletCliTerminationTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-network-availability.ts b/packages/taler-harness/src/integrationtests/test-wallet-network-availability.ts @@ -107,3 +107,5 @@ export async function runWalletNetworkAvailabilityTest(t: GlobalTestState) { // refresh should finish due to network being restored await refreshDoneCond; } + +runWalletNetworkAvailabilityTest.suites = ["wallet"];