From 9ec6018efef9b45ee42ccda33ed7093881534141 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 4 Sep 2020 02:20:20 +0530 Subject: test recoup, fix bug in reserve state machine, fix bug in recoup-refresh --- packages/taler-integrationtests/src/harness.ts | 44 +++++++++++ packages/taler-integrationtests/src/helpers.ts | 85 +++++++++++++++++++++- .../taler-integrationtests/src/test-payment.ts | 57 +++------------ packages/taler-wallet-core/src/db.ts | 2 +- .../taler-wallet-core/src/operations/recoup.ts | 9 ++- .../taler-wallet-core/src/operations/reserves.ts | 31 ++++++-- packages/taler-wallet-core/src/types/dbTypes.ts | 6 ++ .../taler-wallet-core/src/types/notifications.ts | 2 + .../taler-wallet-core/src/types/walletTypes.ts | 33 +++++++++ packages/taler-wallet-core/src/wallet.ts | 54 ++++++++++++-- 10 files changed, 261 insertions(+), 62 deletions(-) diff --git a/packages/taler-integrationtests/src/harness.ts b/packages/taler-integrationtests/src/harness.ts index cc30df618..dbb416b48 100644 --- a/packages/taler-integrationtests/src/harness.ts +++ b/packages/taler-integrationtests/src/harness.ts @@ -69,6 +69,9 @@ import { ApplyRefundRequest, codecForApplyRefundResponse, codecForAny, + CoinDumpJson, + ForceExchangeUpdateRequest, + ForceRefreshRequest, } from "taler-wallet-core"; import { URL } from "url"; import axios, { AxiosError } from "axios"; @@ -1077,6 +1080,23 @@ export class ExchangeService implements ExchangeServiceInterface { ); } + async revokeDenomination(denomPubHash: string) { + if (this.isRunning()) { + throw Error("exchange must be stopped when revoking denominations"); + } + await runCommand( + this.globalState, + "exchange-keyup", + "taler-exchange-keyup", + [ + "-c", this.configFilename, + ...this.timetravelArgArr, + "--revoke", + denomPubHash, + ], + ); + } + async start(): Promise { if (this.isRunning()) { throw Error("exchange is already running"); @@ -1540,6 +1560,14 @@ export class WalletCli { throw new OperationFailedError(resp.error); } + async dumpCoins(): Promise { + const resp = await this.apiRequest("dumpCoins", {}); + if (resp.type === "response") { + return codecForAny().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + async addExchange(req: AddExchangeRequest): Promise { const resp = await this.apiRequest("addExchange", req); if (resp.type === "response") { @@ -1548,6 +1576,22 @@ export class WalletCli { throw new OperationFailedError(resp.error); } + async forceUpdateExchange(req: ForceExchangeUpdateRequest): Promise { + const resp = await this.apiRequest("forceUpdateExchange", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async forceRefresh(req: ForceRefreshRequest): Promise { + const resp = await this.apiRequest("forceRefresh", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + async listExchanges(): Promise { const resp = await this.apiRequest("listExchanges", {}); if (resp.type === "response") { diff --git a/packages/taler-integrationtests/src/helpers.ts b/packages/taler-integrationtests/src/helpers.ts index d47d5f7b0..ca9b57abf 100644 --- a/packages/taler-integrationtests/src/helpers.ts +++ b/packages/taler-integrationtests/src/helpers.ts @@ -36,8 +36,9 @@ import { MerchantServiceInterface, BankApi, BankAccessApi, + MerchantPrivateApi, } from "./harness"; -import { AmountString } from "taler-wallet-core"; +import { AmountString, Duration, PreparePayResultType, ConfirmPayResultType, ContractTerms } from "taler-wallet-core"; import { FaultInjectedMerchantService } from "./faultInjection"; export interface SimpleTestEnvironment { @@ -280,3 +281,85 @@ export async function withdrawViaBank( const balApiResp = await wallet.apiRequest("getBalances", {}); t.assertTrue(balApiResp.type === "response"); } + +export async function applyTimeTravel( + timetravelDuration: Duration, + s: { + exchange?: ExchangeService; + merchant?: MerchantService; + wallet?: WalletCli; + }, +): Promise { + if (s.exchange) { + await s.exchange.stop(); + s.exchange.setTimetravel(timetravelDuration); + await s.exchange.start(); + await s.exchange.pingUntilAvailable(); + } + + if (s.merchant) { + await s.merchant.stop(); + s.merchant.setTimetravel(timetravelDuration); + await s.merchant.start(); + await s.merchant.pingUntilAvailable(); + } + + if (s.wallet) { + s.wallet.setTimetravel(timetravelDuration); + } +} + + +/** + * Make a simple payment and check that it succeeded. + */ +export async function makeTestPayment(t: GlobalTestState, args: { + merchant: MerchantServiceInterface, + wallet: WalletCli, + order: Partial, + instance?: string +}): Promise { + // Set up order. + + const { wallet, merchant } = args; + const instance = args.instance ?? "default"; + + const orderResp = await MerchantPrivateApi.createOrder(merchant, instance, { + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + }, + }); + + let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, { + orderId: orderResp.order_id, + }); + + t.assertTrue(orderStatus.order_status === "unpaid"); + + // Make wallet pay for the order + + const preparePayResult = await wallet.preparePay({ + talerPayUri: orderStatus.taler_pay_uri, + }); + + t.assertTrue( + preparePayResult.status === PreparePayResultType.PaymentPossible, + ); + + const r2 = await wallet.confirmPay({ + proposalId: preparePayResult.proposalId, + }); + + t.assertTrue(r2.type === ConfirmPayResultType.Done); + + // Check if payment was successful. + + orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, { + orderId: orderResp.order_id, + instance, + }); + + t.assertTrue(orderStatus.order_status === "paid"); +} \ No newline at end of file diff --git a/packages/taler-integrationtests/src/test-payment.ts b/packages/taler-integrationtests/src/test-payment.ts index a099e9f23..4f44fc146 100644 --- a/packages/taler-integrationtests/src/test-payment.ts +++ b/packages/taler-integrationtests/src/test-payment.ts @@ -20,14 +20,15 @@ import { runTest, GlobalTestState, - MerchantPrivateApi, - WalletCli, } from "./harness"; -import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers"; -import { PreparePayResultType } from "taler-wallet-core"; +import { + createSimpleTestkudosEnvironment, + withdrawViaBank, + makeTestPayment, +} from "./helpers"; /** - * Run test for basic, bank-integrated withdrawal. + * Run test for basic, bank-integrated withdrawal and payment. */ runTest(async (t: GlobalTestState) => { // Set up test environment @@ -43,45 +44,11 @@ runTest(async (t: GlobalTestState) => { await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" }); - // Set up order. - - const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", { - order: { - summary: "Buy me!", - amount: "TESTKUDOS:5", - fulfillment_url: "taler://fulfillment-success/thx", - }, - }); - - let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, { - orderId: orderResp.order_id, - }); - - t.assertTrue(orderStatus.order_status === "unpaid"); - - // Make wallet pay for the order - - const preparePayResult = await wallet.preparePay({ - talerPayUri: orderStatus.taler_pay_uri, - }); - - t.assertTrue( - preparePayResult.status === PreparePayResultType.PaymentPossible, - ); - - const r2 = await wallet.apiRequest("confirmPay", { - // FIXME: should be validated, don't cast! - proposalId: preparePayResult.proposalId, - }); - t.assertTrue(r2.type === "response"); - - // Check if payment was successful. - - orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, { - orderId: orderResp.order_id, - }); - - t.assertTrue(orderStatus.order_status === "paid"); + const order = { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + }; - await t.shutdown(); + await makeTestPayment(t, { wallet, merchant, order }); }); diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index d5ebdb6c5..b3203935e 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -8,7 +8,7 @@ import { IDBFactory, IDBDatabase } from "idb-bridge"; * with each major change. When incrementing the major version, * the wallet should import data from the previous version. */ -const TALER_DB_NAME = "taler-walletdb-v9"; +const TALER_DB_NAME = "taler-walletdb-v10"; /** * Current database minor version, should be incremented diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index 0e4ce18d3..91579f602 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -201,6 +201,7 @@ async function recoupWithdrawCoin( const currency = updatedCoin.currentAmount.currency; updatedCoin.currentAmount = Amounts.getZero(currency); updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; + updatedReserve.retryInfo = initRetryInfo(); await tx.put(Stores.coins, updatedCoin); await tx.put(Stores.reserves, updatedReserve); await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); @@ -253,7 +254,13 @@ async function recoupRefreshCoin( } await ws.db.runWithWriteTransaction( - [Stores.coins, Stores.reserves, Stores.recoupGroups, Stores.refreshGroups], + [ + Stores.coins, + Stores.denominations, + Stores.reserves, + Stores.recoupGroups, + Stores.refreshGroups, + ], async (tx) => { const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId); if (!recoupGroup) { diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts index 439eb34a6..a28c2e0cf 100644 --- a/packages/taler-wallet-core/src/operations/reserves.ts +++ b/packages/taler-wallet-core/src/operations/reserves.ts @@ -74,6 +74,7 @@ import { import { reconcileReserveHistory, summarizeReserveHistory, + ReserveHistorySummary, } from "../util/reserveHistoryUtil"; import { TransactionHandle } from "../util/query"; import { addPaytoQueryParams } from "../util/payto"; @@ -162,6 +163,7 @@ export async function createReserve( retryInfo: initRetryInfo(), lastError: undefined, currency: req.amount.currency, + requestedQuery: false, }; const reserveHistoryRecord: ReserveHistoryRecord = { @@ -285,13 +287,12 @@ export async function forceQueryReserve( // Only force status query where it makes sense switch (reserve.reserveStatus) { case ReserveRecordStatus.DORMANT: - case ReserveRecordStatus.WITHDRAWING: - case ReserveRecordStatus.QUERYING_STATUS: + reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; break; default: + reserve.requestedQuery = true; return; } - reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; reserve.retryInfo = initRetryInfo(); await tx.put(Stores.reserves, reserve); }); @@ -551,6 +552,7 @@ async function updateReserve( const balance = Amounts.parseOrThrow(reserveInfo.balance); const currency = balance.currency; + let updateSummary: ReserveHistorySummary | undefined; await ws.db.runWithWriteTransaction( [Stores.reserves, Stores.reserveUpdatedEvents, Stores.reserveHistory], async (tx) => { @@ -578,7 +580,7 @@ async function updateReserve( reserveInfo.history, ); - const summary = summarizeReserveHistory( + updateSummary = summarizeReserveHistory( reconciled.updatedLocalHistory, currency, ); @@ -591,16 +593,24 @@ async function updateReserve( reservePub: r.reservePub, timestamp: getTimestampNow(), amountReserveBalance: Amounts.stringify(balance), - amountExpected: Amounts.stringify(summary.awaitedReserveAmount), + amountExpected: Amounts.stringify(updateSummary.awaitedReserveAmount), newHistoryTransactions, reserveUpdateId, }; await tx.put(Stores.reserveUpdatedEvents, reserveUpdate); + logger.trace("setting reserve status to 'withdrawing' after query"); r.reserveStatus = ReserveRecordStatus.WITHDRAWING; r.retryInfo = initRetryInfo(); } else { - r.reserveStatus = ReserveRecordStatus.DORMANT; - r.retryInfo = initRetryInfo(false); + logger.trace("setting reserve status to 'dormant' after query"); + if (r.requestedQuery) { + r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; + r.requestedQuery = false; + r.retryInfo = initRetryInfo(); + } else { + r.reserveStatus = ReserveRecordStatus.DORMANT; + r.retryInfo = initRetryInfo(false); + } } r.lastSuccessfulStatusQuery = getTimestampNow(); hist.reserveTransactions = reconciled.updatedLocalHistory; @@ -609,7 +619,11 @@ async function updateReserve( await tx.put(Stores.reserveHistory, hist); }, ); - ws.notify({ type: NotificationType.ReserveUpdated }); + ws.notify({ type: NotificationType.ReserveUpdated, updateSummary }); + const reserve2 = await ws.db.get(Stores.reserves, reservePub); + if (reserve2) { + logger.trace(`after db transaction, reserve status is ${reserve2.reserveStatus}`); + } return { ready: true }; } @@ -782,6 +796,7 @@ async function depleteReserve( }); } } + logger.trace("setting reserve status to dormant after depletion"); newReserve.reserveStatus = ReserveRecordStatus.DORMANT; newReserve.retryInfo = initRetryInfo(false); diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index 30a562822..45c19cbd0 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -320,6 +320,12 @@ export interface ReserveRecord { reserveStatus: ReserveRecordStatus; + /** + * Was a reserve query requested? If so, query again instead + * of going into dormant status. + */ + requestedQuery: boolean; + /** * Time of the last successful status query. */ diff --git a/packages/taler-wallet-core/src/types/notifications.ts b/packages/taler-wallet-core/src/types/notifications.ts index 7d3795a6d..7a51f0d83 100644 --- a/packages/taler-wallet-core/src/types/notifications.ts +++ b/packages/taler-wallet-core/src/types/notifications.ts @@ -24,6 +24,7 @@ */ import { TalerErrorDetails } from "./walletTypes"; import { WithdrawalSource } from "./dbTypes"; +import { ReserveHistorySummary } from "../util/reserveHistoryUtil"; export enum NotificationType { CoinWithdrawn = "coin-withdrawn", @@ -126,6 +127,7 @@ export interface RefreshRefusedNotification { export interface ReserveUpdatedNotification { type: NotificationType.ReserveUpdated; + updateSummary?: ReserveHistorySummary; } export interface ReserveConfirmedNotification { diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index 5686ee61c..82f29c39d 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -691,6 +691,17 @@ export const codecForAddExchangeRequest = (): Codec => .property("exchangeBaseUrl", codecForString()) .build("AddExchangeRequest"); +export interface ForceExchangeUpdateRequest { + exchangeBaseUrl: string; +} + +export const codecForForceExchangeUpdateRequest = (): Codec< + AddExchangeRequest +> => + buildCodecForObject() + .property("exchangeBaseUrl", codecForString()) + .build("AddExchangeRequest"); + export interface GetExchangeTosRequest { exchangeBaseUrl: string; } @@ -870,3 +881,25 @@ export const codecForApplyRefundResponse = (): Codec => .property("pendingAtExchange", codecForBoolean()) .property("proposalId", codecForString()) .build("ApplyRefundResponse"); + +export interface SetCoinSuspendedRequest { + coinPub: string; + suspended: boolean; +} + +export const codecForSetCoinSuspendedRequest = (): Codec< + SetCoinSuspendedRequest +> => + buildCodecForObject() + .property("coinPub", codecForString()) + .property("suspended", codecForBoolean()) + .build("SetCoinSuspendedRequest"); + +export interface ForceRefreshRequest { + coinPubList: string[]; +} + +export const codecForForceRefreshRequest = (): Codec => + buildCodecForObject() + .property("coinPubList", codecForList(codecForString())) + .build("ForceRefreshRequest"); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 21de541e5..9666665a4 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -90,6 +90,9 @@ import { withdrawTestBalanceDefaults, codecForWithdrawTestBalance, codecForTestPayArgs, + codecForSetCoinSuspendedRequest, + codecForForceExchangeUpdateRequest, + codecForForceRefreshRequest, } from "./types/walletTypes"; import { Logger } from "./util/logging"; @@ -110,7 +113,11 @@ import { import { InternalWalletState } from "./operations/state"; import { createReserve } from "./operations/reserves"; -import { processRefreshGroup, createRefreshGroup, autoRefresh } from "./operations/refresh"; +import { + processRefreshGroup, + createRefreshGroup, + autoRefresh, +} from "./operations/refresh"; import { processWithdrawGroup } from "./operations/withdraw"; import { getPendingOperations } from "./operations/pending"; import { getBalances } from "./operations/balance"; @@ -268,7 +275,7 @@ export class Wallet { await processRecoupGroup(this.ws, pending.recoupGroupId, forceNow); break; case PendingOperationType.ExchangeCheckRefresh: - await autoRefresh(this.ws, pending.exchangeBaseUrl) + await autoRefresh(this.ws, pending.exchangeBaseUrl); break; default: assertUnreachable(pending); @@ -371,7 +378,8 @@ export class Wallet { } private async runRetryLoopImpl(): Promise { - while (!this.stopped) { + let iteration = 0; + for (; !this.stopped; iteration++) { const pending = await this.getPendingOperations({ onlyDue: true }); let numDueAndLive = 0; for (const p of pending.pendingOperations) { @@ -379,7 +387,9 @@ export class Wallet { numDueAndLive++; } } - if (numDueAndLive === 0) { + // Make sure that we run tasks that don't give lifeness at least + // one time. + if (iteration !== 0 && numDueAndLive === 0) { const allPending = await this.getPendingOperations({ onlyDue: false }); let numPending = 0; let numGivingLiveness = 0; @@ -406,11 +416,12 @@ export class Wallet { numPending, }); await Promise.race([timeout, this.latch.wait()]); - logger.trace("timeout done"); } else { // FIXME: maybe be a bit smarter about executing these // operations in parallel? - logger.trace(`running ${pending.pendingOperations.length} pending operations`); + logger.trace( + `running ${pending.pendingOperations.length} pending operations`, + ); for (const p of pending.pendingOperations) { try { await this.processOnePendingOperation(p); @@ -985,6 +996,11 @@ export class Wallet { await this.updateExchangeFromUrl(req.exchangeBaseUrl); return {}; } + case "forceUpdateExchange": { + const req = codecForForceExchangeUpdateRequest().decode(payload); + await this.updateExchangeFromUrl(req.exchangeBaseUrl, true); + return {}; + } case "listExchanges": { return await this.getExchanges(); } @@ -1054,6 +1070,32 @@ export class Wallet { const req = codecForConfirmPayRequest().decode(payload); return await this.confirmPay(req.proposalId, req.sessionId); } + case "dumpCoins": { + return await this.dumpCoins(); + } + case "setCoinSuspended": { + const req = codecForSetCoinSuspendedRequest().decode(payload); + await this.setCoinSuspended(req.coinPub, req.suspended); + return {}; + } + case "forceRefresh": { + const req = codecForForceRefreshRequest().decode(payload); + const coinPubs = req.coinPubList.map((x) => ({ coinPub: x })); + const refreshGroupId = await this.db.runWithWriteTransaction( + [Stores.refreshGroups, Stores.denominations, Stores.coins], + async (tx) => { + return await createRefreshGroup( + this.ws, + tx, + coinPubs, + RefreshReason.Manual, + ); + }, + ); + return { + refreshGroupId, + }; + } } throw OperationFailedError.fromCode( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, -- cgit v1.2.3