taler-typescript-core

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

commit 736778f85efd2cbe3208d24d486e4d9da69cac61
parent fd23369b92847616ca45ecff4446df2079f63001
Author: Florian Dold <dold@taler.net>
Date:   Sun, 19 Jul 2026 02:52:19 +0200

db: add a WalletDbTransaction conformance suite

Cases run against any backend via a DbTxRunner.

Diffstat:
Apackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 488+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/dbtx-conformance.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/dbtx.test.ts | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 674 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -0,0 +1,488 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Conformance cases for {@link WalletDbTransaction}. + * + * Each case states a property of the interface that any backend must satisfy. + * Where a case exists because a real bug was shipped, the case says so: those + * are the ones most worth keeping honest, because each of them passed both + * tsc and the unit tests at the time. + */ + +import { + AmountString, + TalerPreciseTimestamp, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { + ConfigRecordKey, + DbPreciseTimestamp, + DbProtocolTimestamp, + DenominationVerificationStatus, + RefundGroupStatus, + RefundItemStatus, + timestampPreciseToDb, + timestampProtocolToDb, + WalletDenomination, + WalletRefundItem, +} from "./db-common.js"; +import { ConformanceCase } from "./dbtx-conformance.js"; + +const ts = (seconds: number): DbProtocolTimestamp => + timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(seconds)); + +const tsPrecise = (seconds: number): DbPreciseTimestamp => + timestampPreciseToDb(TalerPreciseTimestamp.fromSeconds(seconds)); + +const amt = (s: string): AmountString => s as AmountString; + +function makeDenomination( + exchangeBaseUrl: string, + denomPubHash: string, + opts: { + familySerial?: number; + stampExpireWithdraw?: number; + isOffered?: boolean; + } = {}, +): WalletDenomination { + return { + exchangeBaseUrl, + denomPubHash, + denomPub: { cipher: 1, rsa_public_key: "dummy" } as any, + exchangeMasterPub: "master-pub", + currency: "TESTKUDOS", + value: amt("TESTKUDOS:1"), + denominationFamilySerial: opts.familySerial, + stampStart: ts(1000), + stampExpireWithdraw: ts(opts.stampExpireWithdraw ?? 100000), + stampExpireDeposit: ts(200000), + stampExpireLegal: ts(300000), + fees: { + feeDeposit: amt("TESTKUDOS:0.1"), + feeRefresh: amt("TESTKUDOS:0.1"), + feeRefund: amt("TESTKUDOS:0.1"), + feeWithdraw: amt("TESTKUDOS:0.1"), + }, + isOffered: opts.isOffered ?? true, + isRevoked: false, + isLost: false, + masterSig: "master-sig", + verificationStatus: DenominationVerificationStatus.VerifiedGood, + listIssueDate: ts(1000), + } as WalletDenomination; +} + +function makeRefundItem( + refundGroupId: string, + coinPub: string, + rtxid: number, +): WalletRefundItem { + return { + status: RefundItemStatus.Done, + refundGroupId, + executionTime: ts(5000), + obtainedTime: tsPrecise(5000), + refundAmount: amt("TESTKUDOS:1"), + coinPub, + rtxid, + }; +} + +export const conformanceCases: ConformanceCase[] = [ + // ---------------------------------------------------------------- basics + + { + name: "config: upsert then get round trips", + async run(t, runner) { + await runner.runTx(async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.MaterializedTransactionsVersion, + value: 7, + }); + }); + const got = await runner.runTx((tx) => + tx.getConfig(ConfigRecordKey.MaterializedTransactionsVersion), + ); + t.ok(got, "config record should exist"); + t.equal(got?.value, 7); + }, + }, + + { + name: "get on a missing key returns undefined, not a throw", + async run(t, runner) { + const got = await runner.runTx((tx) => tx.getCoin("no-such-coin-pub")); + t.equal(got, undefined); + }, + }, + + { + name: "contract terms: arbitrary JSON survives a round trip", + async run(t, runner) { + const raw = { + nested: { a: [1, 2, 3], b: null }, + unicode: "ünïcödé", + num: 1.5, + }; + await runner.runTx((tx) => + tx.upsertContractTerms({ h: "hash-1", contractTermsRaw: raw }), + ); + const got = await runner.runTx((tx) => tx.getContractTerms("hash-1")); + t.deepEqual(got?.contractTermsRaw, raw); + }, + }, + + // ------------------------------------------------- compound / array keys + + { + name: "denomination: compound primary key (exchange, denomPubHash)", + async run(t, runner) { + await runner.runTx(async (tx) => { + await tx.upsertDenomination(makeDenomination("https://e1/", "dph-a")); + await tx.upsertDenomination(makeDenomination("https://e2/", "dph-a")); + }); + const [d1, d2] = await runner.runTx(async (tx) => [ + await tx.getDenomination("https://e1/", "dph-a"), + await tx.getDenomination("https://e2/", "dph-a"), + ]); + t.ok(d1, "same hash under a different exchange must be a distinct row"); + t.ok(d2); + t.equal(d1?.exchangeBaseUrl, "https://e1/"); + t.equal(d2?.exchangeBaseUrl, "https://e2/"); + }, + }, + + { + name: "refund items by group are found (regression: array keyPath)", + // The IndexedDB index byRefundGroupId is declared with an ARRAY keyPath, + // so its keys are single-element arrays. Passing a bare string matched + // nothing, getRefundItemsByGroup silently returned [], and refunds ended + // in state "done" instead of "failed". It passed tsc and every unit test. + async run(t, runner) { + await runner.runTx(async (tx) => { + await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-1", 1)); + await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-2", 2)); + await tx.upsertRefundItem(makeRefundItem("grp-2", "coin-3", 3)); + }); + const items = await runner.runTx((tx) => + tx.getRefundItemsByGroup("grp-1"), + ); + t.equal(items.length, 2, "must find both items of the group"); + const other = await runner.runTx((tx) => + tx.getRefundItemsByGroup("grp-2"), + ); + t.equal(other.length, 1); + const none = await runner.runTx((tx) => + tx.getRefundItemsByGroup("grp-missing"), + ); + t.equal(none.length, 0); + }, + }, + + { + name: "refund item lookup by (coinPub, rtxid)", + async run(t, runner) { + await runner.runTx((tx) => + tx.upsertRefundItem(makeRefundItem("grp-3", "coin-9", 42)), + ); + const got = await runner.runTx((tx) => + tx.getRefundItemByCoinAndRtxid("coin-9", 42), + ); + t.ok(got, "compound index lookup must find the item"); + t.equal(got?.refundGroupId, "grp-3"); + const miss = await runner.runTx((tx) => + tx.getRefundItemByCoinAndRtxid("coin-9", 43), + ); + t.equal(miss, undefined, "wrong rtxid must not match"); + }, + }, + + // --------------------------------------------------- generated row ids + + { + name: "auto-increment stores return a usable generated id", + // upsertReserve/upsertRefundItem/upsertExchangeDetails/ + // upsertDenominationFamily must return the generated key: callers store it + // as a foreign key. A backend returning 0 or undefined would corrupt the + // exchange entry silently. + async run(t, runner) { + const id1 = await runner.runTx((tx) => + tx.upsertReserve({ reservePub: "rp-1", reservePriv: "rv-1" } as any), + ); + const id2 = await runner.runTx((tx) => + tx.upsertReserve({ reservePub: "rp-2", reservePriv: "rv-2" } as any), + ); + t.equal(typeof id1, "number"); + t.equal(typeof id2, "number"); + t.ok(id1 !== id2, "generated ids must be distinct"); + const back = await runner.runTx((tx) => tx.getReserve(id1)); + t.equal(back?.reservePub, "rp-1", "the returned id must address the row"); + }, + }, + + // ------------------------------------------------------- ordered scans + + { + name: "findDenominationByFamilyFromExpiry returns the first match", + async run(t, runner) { + await runner.runTx(async (tx) => { + // Same family, ascending expiry. The first two are not offered, so + // the predicate must skip them. + await tx.upsertDenomination( + makeDenomination("https://e/", "d-1", { + familySerial: 77, + stampExpireWithdraw: 10000, + isOffered: false, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "d-2", { + familySerial: 77, + stampExpireWithdraw: 20000, + isOffered: false, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "d-3", { + familySerial: 77, + stampExpireWithdraw: 30000, + }), + ); + }); + const found = await runner.runTx((tx) => + tx.findDenominationByFamilyFromExpiry(77, ts(0), (d) => d.isOffered), + ); + t.equal( + found?.denomPubHash, + "d-3", + "must return the first match in order", + ); + }, + }, + + { + name: "findDenominationByFamilyFromExpiry stops at the first match", + // Regression: this was briefly implemented as "fetch every non-expired + // denomination of the family, then filter". Families hold many + // denominations, so that turned a one-record read into a full range scan. + // The predicate is the only place we can observe how far the scan ran. + async run(t, runner) { + await runner.runTx(async (tx) => { + for (let i = 0; i < 20; i++) { + await tx.upsertDenomination( + makeDenomination("https://e/", `s-${i}`, { + familySerial: 88, + stampExpireWithdraw: 10000 + i * 1000, + }), + ); + } + }); + let examined = 0; + const before = runner.recordsRead?.(); + const found = await runner.runTx((tx) => + tx.findDenominationByFamilyFromExpiry(88, ts(0), () => { + examined++; + return true; + }), + ); + const after = runner.recordsRead?.(); + t.ok(found, "should find a denomination"); + t.equal(examined, 1, "the predicate must be consulted once"); + if (before !== undefined && after !== undefined) { + // The real regression was reading the whole family and filtering in + // JS: the predicate still ran once, so only the record count exposes + // it. Allow a small constant for cursor positioning. + t.ok( + after - before <= 3, + `must not scan the family: read ${after - before} records for one match`, + ); + } + }, + }, + + { + name: "findDenominationByFamilyFromExpiry respects the family boundary", + async run(t, runner) { + await runner.runTx(async (tx) => { + await tx.upsertDenomination( + makeDenomination("https://e/", "f1-a", { + familySerial: 101, + stampExpireWithdraw: 10000, + isOffered: false, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "f2-a", { + familySerial: 102, + stampExpireWithdraw: 20000, + }), + ); + }); + const found = await runner.runTx((tx) => + tx.findDenominationByFamilyFromExpiry(101, ts(0), (d) => d.isOffered), + ); + t.equal( + found, + undefined, + "must not spill into the next family when no match is found", + ); + }, + }, + + { + name: "findDenominationByFamilyFromExpiry honours the expiry lower bound", + async run(t, runner) { + await runner.runTx(async (tx) => { + await tx.upsertDenomination( + makeDenomination("https://e/", "old", { + familySerial: 111, + stampExpireWithdraw: 5000, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "new", { + familySerial: 111, + stampExpireWithdraw: 50000, + }), + ); + }); + const found = await runner.runTx((tx) => + tx.findDenominationByFamilyFromExpiry(111, ts(10000), () => true), + ); + t.equal(found?.denomPubHash, "new", "must skip records before the bound"); + }, + }, + + // ------------------------------------------------- transaction lifecycle + + { + name: "a throwing transaction rolls back its writes", + async run(t, runner) { + try { + await runner.runTx(async (tx) => { + await tx.upsertContractTerms({ + h: "rollback-hash", + contractTermsRaw: { x: 1 }, + }); + throw Error("deliberate abort"); + }); + t.fail("the transaction should have rethrown"); + } catch (e) { + // expected + } + const got = await runner.runTx((tx) => + tx.getContractTerms("rollback-hash"), + ); + t.equal(got, undefined, "writes before the throw must not be visible"); + }, + }, + + { + name: "writes are visible within the same transaction", + async run(t, runner) { + const got = await runner.runTx(async (tx) => { + await tx.upsertContractTerms({ + h: "same-tx", + contractTermsRaw: { y: 2 }, + }); + return await tx.getContractTerms("same-tx"); + }); + t.deepEqual(got?.contractTermsRaw, { y: 2 }); + }, + }, + + { + name: "scheduleOnCommit runs after the transaction, not during it", + async run(t, runner) { + const order: string[] = []; + await runner.runTx(async (tx) => { + tx.scheduleOnCommit(() => order.push("after-commit")); + order.push("in-tx"); + }); + t.deepEqual(order, ["in-tx", "after-commit"]); + }, + }, + + { + name: "scheduleOnCommit does not run when the transaction aborts", + async run(t, runner) { + let ran = false; + try { + await runner.runTx(async (tx) => { + tx.scheduleOnCommit(() => { + ran = true; + }); + throw Error("deliberate abort"); + }); + } catch (e) { + // expected + } + t.equal(ran, false, "commit hooks must not fire on a rolled-back tx"); + }, + }, + + { + name: "notify is safe to pass around unbound", + // Regression: notify was a prototype method reading this.tx. Call sites + // pass it as a bare function (applyNotifyTransition(tx.notify, ...)), which + // is well-typed but lost `this` and broke every transaction that used it. + async run(t, runner) { + await runner.runTx(async (tx) => { + const notify = tx.notify; + notify({ type: "balance-change" } as any); + }); + t.ok(true, "extracting notify and calling it must not throw"); + }, + }, + + // ------------------------------------------------------- delete semantics + + { + name: "deleting a missing row is a no-op, not an error", + async run(t, runner) { + await runner.runTx(async (tx) => { + await tx.deletePurchase("no-such-proposal"); + await tx.deleteDenomination("https://nope/", "no-such-hash"); + await tx.deleteRefundGroup("no-such-group"); + }); + t.ok(true, "deleting a missing row must not throw"); + }, + }, + + { + name: "upsert overwrites an existing row rather than duplicating it", + async run(t, runner) { + await runner.runTx(async (tx) => { + await tx.upsertDenomination( + makeDenomination("https://e-up/", "dup", { + stampExpireWithdraw: 111, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e-up/", "dup", { + stampExpireWithdraw: 222, + }), + ); + }); + const all = await runner.runTx((tx) => + tx.getDenominationsByExchange("https://e-up/"), + ); + t.equal(all.length, 1, "the second upsert must replace, not append"); + t.equal(all[0].stampExpireWithdraw, ts(222)); + }, + }, +]; diff --git a/packages/taler-wallet-core/src/dbtx-conformance.ts b/packages/taler-wallet-core/src/dbtx-conformance.ts @@ -0,0 +1,80 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Conformance suite for {@link WalletDbTransaction}. + * + * These tests describe the contract of the DAL, not the behaviour of any one + * backend. They are written against the interface and parameterised over a + * {@link DbTxRunner}, so the same suite runs against IdbWalletTransaction + * today and against a native sqlite3 implementation later. + * + * Anything backend-specific (how a transaction is opened, how the store is + * created) belongs in the runner, never in a test. + */ + +import { WalletDbTransaction } from "./dbtx.js"; + +/** + * Runs a callback inside one database transaction. + * + * An implementation under test supplies this; the suite never opens a + * database itself. + */ +export interface DbTxRunner { + /** Human-readable name of the implementation, used in test names. */ + name: string; + + /** Run f in a read-write transaction and return its result. */ + runTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T>; + + /** + * Total number of records the backend has read so far, if it can report it. + * + * Cases use this to assert that a query is bounded rather than scanning: + * counting callbacks is not enough, since a backend can materialise a whole + * range and still invoke a predicate only once. A runner that cannot report + * this returns undefined and the relevant cases skip the assertion. + */ + recordsRead?(): number | undefined; + + /** Release any resources. */ + close(): Promise<void>; +} + +/** + * A single conformance check. + * + * Kept as plain data so the suite can be enumerated, filtered and reported on + * per implementation, rather than being hard-wired into one test runner. + */ +export interface ConformanceCase { + name: string; + run(t: ConformanceAsserts, runner: DbTxRunner): Promise<void>; +} + +/** + * The assertions a case may use. + * + * Deliberately minimal and framework-agnostic so the suite does not depend on + * node:test, and can be driven from a harness or a browser if needed. + */ +export interface ConformanceAsserts { + equal(actual: unknown, expected: unknown, msg?: string): void; + deepEqual(actual: unknown, expected: unknown, msg?: string): void; + ok(value: unknown, msg?: string): void; + fail(msg: string): never; +} diff --git a/packages/taler-wallet-core/src/dbtx.test.ts b/packages/taler-wallet-core/src/dbtx.test.ts @@ -0,0 +1,106 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Runs the WalletDbTransaction conformance suite against every available + * implementation. + * + * Adding the sqlite3 implementation means adding one runner here; the cases + * themselves do not change. + */ + +import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; +import { CancellationToken } from "@gnu-taler/taler-util"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import assert from "node:assert"; +import { test } from "node:test"; + +import { openTalerDatabase, WalletIndexedDbStoresV1 } from "./db-indexeddb.js"; +import { DbAccessImpl } from "./query.js"; +import { conformanceCases } from "./dbtx-conformance-cases.js"; +import { ConformanceAsserts, DbTxRunner } from "./dbtx-conformance.js"; +import { IdbWalletTransaction } from "./dbtx-indexeddb.js"; + +const asserts: ConformanceAsserts = { + equal: (a, e, m) => assert.strictEqual(a, e, m), + deepEqual: (a, e, m) => assert.deepStrictEqual(a, e, m), + ok: (v, m) => assert.ok(v, m), + fail: (m) => assert.fail(m), +}; + +/** + * Runner for the IndexedDB implementation, on an in-memory sqlite-backed + * BridgeIDB. Each runner gets a fresh database so cases cannot leak into + * each other. + */ +async function makeIdbRunner(): Promise<DbTxRunner> { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename: ":memory:", + }); + backend.trackStats = true; + BridgeIDBFactory.enableTracing = false; + const idbFactory = new BridgeIDBFactory(backend); + const dbHandle = await openTalerDatabase(idbFactory as any, () => {}); + const db = new DbAccessImpl( + dbHandle, + WalletIndexedDbStoresV1, + {}, + CancellationToken.CONTINUE, + ); + return { + name: "IdbWalletTransaction", + async runTx<T>(f: (tx: any) => Promise<T>): Promise<T> { + return await db.runAllStoresReadWriteTx({}, async (mytx) => { + return await f(new IdbWalletTransaction(mytx)); + }); + }, + recordsRead() { + const st = backend.accessStats; + let n = 0; + for (const k of Object.keys(st.readItemsPerIndex)) { + n += st.readItemsPerIndex[k]; + } + for (const k of Object.keys(st.readItemsPerStore)) { + n += st.readItemsPerStore[k]; + } + return n; + }, + async close() { + dbHandle.close(); + }, + }; +} + +const runnerFactories: Array<() => Promise<DbTxRunner>> = [ + makeIdbRunner, + // A native sqlite3 implementation registers itself here. +]; + +for (const makeRunner of runnerFactories) { + for (const c of conformanceCases) { + test(`dbtx conformance: ${c.name}`, async () => { + const runner = await makeRunner(); + try { + await c.run(asserts, runner); + } finally { + await runner.close(); + } + }); + } +}