taler-typescript-core

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

commit 609b86c80ba012f2c5dc4a42ee2ef7e2dd8b4359
parent d99bfca8430d1b9f8bffc3c9d4d3206941042512
Author: Florian Dold <dold@taler.net>
Date:   Thu, 27 Aug 2026 16:03:03 +0200

taler-harness: test the /keys withdrawal filter

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

Diffstat:
Apackages/taler-harness/src/integrationtests/test-exchange-keys-withdraw-filter.ts | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
2 files changed, 318 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-exchange-keys-withdraw-filter.ts b/packages/taler-harness/src/integrationtests/test-exchange-keys-withdraw-filter.ts @@ -0,0 +1,316 @@ +/* + 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/> + */ + +import { + ExchangeKeysResponse, + TalerCorebankApiClient, + URL, + codecForExchangeKeysResponse, +} from "@gnu-taler/taler-util"; +import { applyTimeTravelV2 } from "../harness/environments.js"; +import { + BankService, + ExchangeService, + GlobalTestState, + getTestHarnessPaytoForLabel, + harnessHttpLib, + setupDb, +} from "../harness/harness.js"; + +interface FlatDenomination { + family: string; + id: string; + start: number; + expireWithdraw: number; + lost: boolean; +} + +interface KeysDownload { + body: ExchangeKeysResponse; + bodySize: number; + etag: string | undefined; +} + +function timestampSeconds(timestamp: { t_s: number | "never" }): number { + if (timestamp.t_s === "never") { + throw Error("denomination timestamp must be finite"); + } + return timestamp.t_s; +} + +function flattenDenominations(keys: ExchangeKeysResponse): FlatDenomination[] { + const result: FlatDenomination[] = []; + for (const group of keys.denominations) { + const ageMask = "age_mask" in group ? group.age_mask : 0; + const family = JSON.stringify([group.cipher, group.value, ageMask]); + for (const denom of group.denoms) { + result.push({ + family, + id: denom.master_sig, + start: timestampSeconds(denom.stamp_start), + expireWithdraw: timestampSeconds(denom.stamp_expire_withdraw), + lost: denom.lost ?? false, + }); + } + } + return result; +} + +function expectedWithdrawDenominations( + fullKeys: ExchangeKeysResponse, + now: number, +): Set<string> { + const byFamily = new Map<string, FlatDenomination[]>(); + for (const denom of flattenDenominations(fullKeys)) { + const family = byFamily.get(denom.family) ?? []; + family.push(denom); + byFamily.set(denom.family, family); + } + + const expected = new Set<string>(); + for (const family of byFamily.values()) { + for (const denom of family) { + if (denom.start <= now && now < denom.expireWithdraw) { + expected.add(denom.id); + } + } + const futureStarts = family + .filter((denom) => denom.start > now) + .map((denom) => denom.start); + if (futureStarts.length === 0) { + continue; + } + const nextStart = Math.min(...futureStarts); + for (const denom of family) { + if (denom.start === nextStart) { + expected.add(denom.id); + } + } + } + return expected; +} + +function assertWithdrawFilter( + t: GlobalTestState, + fullKeys: ExchangeKeysResponse, + filteredKeys: ExchangeKeysResponse, + now: number, +): void { + const expected = expectedWithdrawDenominations(fullKeys, now); + const actualDenoms = flattenDenominations(filteredKeys); + const actual = new Set(actualDenoms.map((denom) => denom.id)); + + t.assertDeepEqual([...actual].sort(), [...expected].sort()); + t.assertTrue(actual.size > 0); + t.assertDeepEqual( + timestampSeconds(filteredKeys.list_issue_date), + Math.min(...actualDenoms.map((denom) => denom.start)), + ); +} + +async function downloadKeys(url: string): Promise<KeysDownload> { + const response = await harnessHttpLib.fetch(url); + if (response.status !== 200) { + throw Error(`GET ${url} returned HTTP ${response.status}`); + } + const bodyText = await response.text(); + return { + body: codecForExchangeKeysResponse().decode(JSON.parse(bodyText)), + bodySize: bodyText.length, + etag: response.headers.get("etag") ?? undefined, + }; +} + +/** + * Verify the cached withdrawal-focused variant of the exchange's /keys API. + */ +export async function runExchangeKeysWithdrawFilterTest( + t: GlobalTestState, +): Promise<void> { + const db = await setupDb(t); + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "TESTKUDOS", + database: db.connStr, + httpPort: 8082, + }); + const exchange = ExchangeService.create(t, { + name: "testexchange-1", + currency: "TESTKUDOS", + httpPort: 8081, + database: db.connStr, + }); + + const exchangeBankUsername = "exchange"; + const exchangeBankPassword = "mypw-password"; + const exchangePaytoUri = getTestHarnessPaytoForLabel(exchangeBankUsername); + await exchange.addBankAccount("1", { + wireGatewayAuth: { + type: "basic", + username: exchangeBankUsername, + password: exchangeBankPassword, + }, + wireGatewayApiBaseUrl: new URL( + `accounts/${exchangeBankUsername}/taler-wire-gateway/`, + bank.corebankApiBaseUrl, + ).href, + accountPaytoUri: exchangePaytoUri, + }); + + await bank.start(); + await bank.pingUntilAvailable(); + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "admin-password", + }, + }); + await bankClient.registerAccountExtended({ + name: "Exchange", + password: exchangeBankPassword, + username: exchangeBankUsername, + is_taler_exchange: true, + payto_uri: exchangePaytoUri, + }); + + exchange.addCoinConfigList([ + { + cipher: "RSA", + rsaKeySize: 1024, + name: "filter-rsa", + value: "TESTKUDOS:1", + durationWithdraw: "7 days", + durationSpend: "30 days", + durationLegal: "60 days", + feeWithdraw: "TESTKUDOS:0.01", + feeDeposit: "TESTKUDOS:0.01", + feeRefresh: "TESTKUDOS:0.01", + feeRefund: "TESTKUDOS:0.01", + }, + { + cipher: "CS", + name: "filter-cs", + value: "TESTKUDOS:2", + durationWithdraw: "11 days", + durationSpend: "30 days", + durationLegal: "60 days", + feeWithdraw: "TESTKUDOS:0.01", + feeDeposit: "TESTKUDOS:0.01", + feeRefresh: "TESTKUDOS:0.01", + feeRefund: "TESTKUDOS:0.01", + }, + ]); + await exchange.modifyConfig(async (config) => { + for (const section of [ + "taler-exchange-secmod-rsa", + "taler-exchange-secmod-cs", + ]) { + config.setString(section, "lookahead_sign", "45 days"); + config.setString(section, "overlap_duration", "1 day"); + } + }); + + await exchange.start(); + const fullUrl = new URL("keys", exchange.baseUrl).href; + const filteredUrl = new URL("keys?denom_filter=withdraw", exchange.baseUrl) + .href; + + t.logStep("compare complete and withdrawal-focused responses"); + const initialNow = Date.now() / 1000; + const initialFull = await downloadKeys(fullUrl); + const initialFiltered = await downloadKeys(filteredUrl); + assertWithdrawFilter(t, initialFull.body, initialFiltered.body, initialNow); + t.assertTrue(initialFiltered.bodySize < initialFull.bodySize); + t.assertTrue( + flattenDenominations(initialFull.body).length > + flattenDenominations(initialFiltered.body).length, + ); + t.assertTrue(initialFiltered.etag !== undefined); + + const notModified = await harnessHttpLib.fetch(filteredUrl, { + headers: { "if-none-match": initialFiltered.etag }, + }); + t.assertDeepEqual(notModified.status, 304); + + t.logStep("validate query errors"); + const unknownFilter = await harnessHttpLib.fetch( + new URL("keys?denom_filter=unknown", exchange.baseUrl).href, + ); + t.assertDeepEqual(unknownFilter.status, 400); + const combinedFilters = await harnessHttpLib.fetch( + new URL("keys?denom_filter=withdraw&last_issue_date=1", exchange.baseUrl) + .href, + ); + t.assertDeepEqual(combinedFilters.status, 400); + + t.logStep("cross an overlapping rotation boundary"); + const overlapOffsetMs = 6.5 * 24 * 60 * 60 * 1000; + await applyTimeTravelV2(overlapOffsetMs, { exchange }); + const overlapFull = await downloadKeys(fullUrl); + const overlapFilteredResponse = await harnessHttpLib.fetch(filteredUrl, { + headers: { "if-none-match": initialFiltered.etag }, + }); + t.assertDeepEqual(overlapFilteredResponse.status, 200); + const overlapBodyText = await overlapFilteredResponse.text(); + const overlapFiltered = codecForExchangeKeysResponse().decode( + JSON.parse(overlapBodyText), + ); + assertWithdrawFilter( + t, + overlapFull.body, + overlapFiltered, + Date.now() / 1000 + overlapOffsetMs / 1000, + ); + t.assertTrue( + flattenDenominations(overlapFiltered).filter((denom) => + denom.family.includes('"RSA"'), + ).length >= 3, + ); + const overlapEtag = overlapFilteredResponse.headers.get("etag") ?? undefined; + t.assertTrue(overlapEtag !== undefined); + t.assertTrue(overlapEtag !== initialFiltered.etag); + + t.logStep("exclude withdrawal-expired denominations"); + const expiredOffsetMs = 12 * 24 * 60 * 60 * 1000; + await applyTimeTravelV2(expiredOffsetMs, { exchange }); + const expiredNow = Date.now() / 1000 + expiredOffsetMs / 1000; + const expiredFull = await downloadKeys(fullUrl); + const expiredFiltered = await downloadKeys(filteredUrl); + assertWithdrawFilter(t, expiredFull.body, expiredFiltered.body, expiredNow); + t.assertTrue( + flattenDenominations(expiredFull.body).some( + (denom) => denom.expireWithdraw <= expiredNow, + ), + ); + t.assertTrue( + flattenDenominations(expiredFiltered.body).every( + (denom) => denom.expireWithdraw > expiredNow || denom.start > expiredNow, + ), + ); + + t.logStep("retain selected lost denominations"); + await exchange.stop(); + await exchange.purgeSecmodKeys(); + await exchange.start(); + const lostFull = await downloadKeys(fullUrl); + const lostFiltered = await downloadKeys(filteredUrl); + assertWithdrawFilter(t, lostFull.body, lostFiltered.body, expiredNow); + t.assertTrue( + flattenDenominations(lostFiltered.body).some((denom) => denom.lost), + ); +} + +runExchangeKeysWithdrawFilterTest.suites = ["exchange"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -65,6 +65,7 @@ import { runExchangeDenomStorageTest } from "./test-exchange-denom-storage.js"; import { runExchangeDepositTest } from "./test-exchange-deposit.js"; import { runExchangeEphemeralTest } from "./test-exchange-ephemeral.js"; import { runExchangeKeysCherrypickTest } from "./test-exchange-keys-cherrypick.js"; +import { runExchangeKeysWithdrawFilterTest } from "./test-exchange-keys-withdraw-filter.js"; import { runExchangeKycAuthTest } from "./test-exchange-kyc-auth.js"; import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js"; import { runExchangeManagementTest } from "./test-exchange-management.js"; @@ -317,6 +318,7 @@ const allTests: TestMainFunction[] = [ runSimplePaymentTest, runExchangeManagementFaultTest, runExchangeKeysCherrypickTest, + runExchangeKeysWithdrawFilterTest, runExchangeDenomStorageTest, runExchangeTimetravelTest, runFeeRegressionTest,