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:
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,
{},