taler-typescript-core

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

commit ab661ad8494eec1829074edfe77bdd22170777f8
parent e9a199544f564e4acc38397c20ffef09ef6548a8
Author: Florian Dold <dold@taler.net>
Date:   Thu,  6 Aug 2026 20:33:29 +0200

harness: check the migration to the native schema

wallet-dbcheck replays the recorded database through the migration as well and
must find the same balances and transactions on the other side; wallet-dbgen
now also records the stores a wallet fills from the side, which nothing in the
integration test touches.

Issue: https://bugs.taler.net/n/11718

Diffstat:
MMakefile | 5++++-
Mpackages/taler-harness/src/index.ts | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 166 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile @@ -129,6 +129,9 @@ install-tools: gana: ./contrib/gana_update.sh +# The recorded databases are replayed twice: as they are, and through the +# in-place migration to the native schema, which has to produce a wallet that +# answers identically. .PHONY: check-migration check-migration: - taler-harness advanced wallet-dbcheck contrib/wallet-testdata/wallet-dbgen-0.9.4-dev.8 + taler-harness advanced wallet-dbcheck --native-migration contrib/wallet-testdata/wallet-dbgen-0.9.4-dev.8 diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -18,12 +18,14 @@ * Imports. */ import { + AbsoluteTime, AccessToken, AmountJson, AmountString, Amounts, BalancesResponse, Configuration, + ContactEntry, DonauHttpClient, Duration, EddsaPrivP, @@ -45,6 +47,8 @@ import { TalerUris, TemplateType, TokenAuth, + TransactionMajorState, + TransactionMinorState, TransactionsResponse, createRFC8959AccessTokenEncoded, createRFC8959AccessTokenPlain, @@ -69,6 +73,7 @@ import { } from "@gnu-taler/taler-util/http"; import { CryptoDispatcher, + inspectWalletDbPath, SynchronousCryptoWorkerFactoryPlain, WalletApiOperation, } from "@gnu-taler/taler-wallet-core"; @@ -93,9 +98,11 @@ import { createWalletDaemonWithClient, } from "./harness/environments.js"; import { + ExchangeService, GlobalTestState, WalletClient, delayMs, + getTestHarnessPaytoForLabel, runTestWithState, waitMs, } from "./harness/harness.js"; @@ -476,6 +483,9 @@ advancedCli help: "Check a wallet database (used for migration testing).", }) .requiredArgument("indir", clk.STRING) + .flag("nativeMigration", ["--native-migration"], { + help: "Also check the database after migrating it to the native schema.", + }) .action(async (args) => { const indir = args.walletDbcheck.indir; if (!fs.existsSync(indir)) { @@ -526,9 +536,158 @@ advancedCli // instead of from the DB file. await doDbChecks(t, freshWalletClient, indir); + if (args.walletDbcheck.nativeMigration) { + // The same corpus again, this time through the in-place migration to + // the native schema. A separate copy of the file: the migration + // rewrites the database it is given, and the checks above must have + // seen the unmigrated one. + const migratedDbPath = `${testRootDir}/wallet-migrated.sqlite3`; + fs.cpSync(origWalletDbPath, migratedDbPath); + + const before = await inspectWalletDbPath(migratedDbPath); + if (before.kind !== "indexeddb") { + throw Error( + `the test database is ${before.kind}, so migrating it would not` + + ` test anything`, + ); + } + + const { walletClient: migratedClient, walletService: migratedService } = + await createWalletDaemonWithClient(t, { + name: "wallet-migrated", + overrideDbPath: migratedDbPath, + config: { + testing: { skipDefaults: true }, + features: { migrateNativeDb: true }, + }, + }); + await migratedService.pingUntilAvailable(); + + // Same assertions as for the database the wallet did not migrate: the + // migration is only correct if the wallet on the other side of it is + // indistinguishable. + await doDbChecks(t, migratedClient, indir); + + // Stop before looking at the file: the checks above ran against the + // running wallet, and what is on disk is the other half of the claim. + await migratedService.stop(); + const after = await inspectWalletDbPath(migratedDbPath); + if (after.kind !== "native" || after.migration?.status !== "complete") { + throw Error( + `the wallet did not migrate the database: it is ${after.kind}` + + ` with migration status ${after.migration?.status ?? "none"}`, + ); + } + if (after.migration.backupStatus !== "retained") { + throw Error( + `the pre-migration database was not retained (backup is` + + ` ${after.migration.backupStatus ?? "absent"})`, + ); + } + console.log( + `migrated database checks passed (${after.migration.recordsCopied}` + + ` records)`, + ); + } + await t.shutdown(); }); +/** + * Add the records the integration test never produces. + * + * RunIntegrationTestV2 already covers withdrawals, payments, refunds, + * refreshes, peer-to-peer payments in both directions and a deposit, so + * repeating those adds nothing. What it never touches are the stores a + * wallet fills from the side: known bank accounts, contacts, and a + * transaction that ends aborted rather than done. A recorded database is + * worth what it contains, and these are the records a migration is least + * likely to have been tried on. + * + * Everything here reaches a terminal state, so replaying the recorded + * database later cannot produce different transactions than were recorded. + */ +async function generateSideRecords( + t: GlobalTestState, + deps: { + walletClient: WalletClient; + exchange: ExchangeService; + }, +): Promise<void> { + const { walletClient, exchange } = deps; + + console.log("dbgen: known bank accounts"); + await walletClient.call(WalletApiOperation.AddBankAccount, { + paytoUri: getTestHarnessPaytoForLabel("dbgen-savings"), + label: "Savings", + currencies: ["TESTKUDOS"], + }); + await walletClient.call(WalletApiOperation.AddBankAccount, { + paytoUri: "payto://iban/DE75512108001245126199?receiver-name=Dbgen", + label: "IBAN", + currencies: ["TESTKUDOS", "EUR"], + }); + + console.log("dbgen: contacts"); + for (const contact of [ + { + alias: "dbgen@example.com", + aliasType: "email", + petname: "Dbgen Mail", + mailboxBaseUri: "https://mailbox.example.com", + mailboxAddress: "DBGENPKEY", + source: "dbgen", + }, + { + alias: "@dbgen", + aliasType: "social", + petname: "", + mailboxBaseUri: "https://mailbox.example.com", + mailboxAddress: "DBGENSOCIALPKEY", + source: "dbgen", + }, + ] satisfies ContactEntry[]) { + await walletClient.call(WalletApiOperation.AddContact, { contact }); + } + + console.log("dbgen: aborted peer-pull-credit"); + { + // An invoice nobody pays, called off by the wallet that wrote it: a + // transaction whose records exist and whose state is terminal without + // ever having been done. + const credit = await walletClient.call( + WalletApiOperation.InitiatePeerPullCredit, + { + exchangeBaseUrl: exchange.baseUrl, + partialContractTerms: { + summary: "dbgen aborted invoice", + amount: "TESTKUDOS:1" as AmountString, + purse_expiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 2 }), + ), + ), + }, + }, + ); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: credit.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Ready, + }, + }); + await walletClient.call(WalletApiOperation.AbortTransaction, { + transactionId: credit.transactionId, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: credit.transactionId, + txState: { major: TransactionMajorState.Aborted }, + }); + } +} + advancedCli .subcommand("walletDbgen", "wallet-dbgen", { help: "Generate a wallet test database (to be used for migration testing).", @@ -566,6 +725,9 @@ advancedCli // the wallet drives the merchant calls, so its auth has to be right. merchantAuthToken: merchantAdminAccessToken, }); + + await generateSideRecords(t, { walletClient, exchange }); + await walletClient.call( WalletApiOperation.TestingWaitTransactionsFinal, {},