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