commit e5039a31a44caeff82cc51d62002b34aec48339c
parent 575e42a985efd857cac0ca824481b504b6cfbe34
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 23:44:16 +0100
taler-harness: cover ToS acceptance across exchange refreshes
Exercise repeated withdrawals and different document formats and
languages without accepting unchanged terms again. Check that a new
legal version requires acceptance and returns consistent version tags.
Issue: https://bugs.taler.net/n/11816
Diffstat:
2 files changed, 169 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tos-acceptance.ts b/packages/taler-harness/src/integrationtests/test-wallet-tos-acceptance.ts
@@ -0,0 +1,167 @@
+/*
+ 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 { ExchangeTosStatus } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as fs from "node:fs";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV4,
+} from "../harness/environments.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+
+/**
+ * Regression for https://bugs.taler.net/n/11816: reading the terms used to
+ * store the document ETag, while exchange updates stored the legal version.
+ * Refreshing therefore revoked acceptance of unchanged terms. Reopening them
+ * could return proposed status with equal accepted/current tags, leaving iOS
+ * asking for acceptance while hiding its Accept button.
+ */
+export async function runWalletTosAcceptanceTest(t: GlobalTestState) {
+ const tosDir = `${t.testDir}/tos`;
+ const variants = [
+ { language: "en", format: "text/plain", extension: "txt" },
+ { language: "de", format: "text/plain", extension: "txt" },
+ { language: "de", format: "text/markdown", extension: "md" },
+ ];
+ for (const version of ["v1", "v2"]) {
+ for (const variant of variants) {
+ const dir = `${tosDir}/${variant.language}`;
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(
+ `${dir}/${version}.${variant.extension}`,
+ `Terms ${version}: ${variant.language}, ${variant.format}`,
+ );
+ }
+ }
+
+ const { walletClient, exchange, bank } =
+ await createSimpleTestkudosEnvironmentV3(t, undefined, {
+ additionalExchangeConfig: (ex) => {
+ ex.changeConfig((config) => {
+ config.setString("exchange", "terms_etag", "v1");
+ config.setString("exchange", "terms_dir", tosDir);
+ });
+ },
+ });
+ const exchangeArgs = { exchangeBaseUrl: exchange.baseUrl };
+ const getTerms = (variant = variants[0]) =>
+ walletClient.call(WalletApiOperation.GetExchangeTos, {
+ ...exchangeArgs,
+ acceptedFormat: [variant.format],
+ acceptLanguage: variant.language,
+ });
+ const refresh = async () => {
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ ...exchangeArgs,
+ force: true,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ ...exchangeArgs,
+ forceUpdate: true,
+ });
+ };
+ const assertStatus = async (expected: ExchangeTosStatus) => {
+ const entry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ exchangeArgs,
+ );
+ t.assertDeepEqual(entry.tosStatus, expected);
+ };
+
+ const initial = await getTerms();
+ t.assertDeepEqual(initial.tosStatus, ExchangeTosStatus.Proposed);
+ await walletClient.call(
+ WalletApiOperation.SetExchangeTosAccepted,
+ exchangeArgs,
+ );
+ await assertStatus(ExchangeTosStatus.Accepted);
+ const firstWithdrawal = await withdrawViaBankV4(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+ await firstWithdrawal.withdrawalFinishedCond;
+
+ // Check the exchange entry BEFORE opening the document again: downloading
+ // it used to overwrite the metadata version and temporarily mask the bug.
+ await refresh();
+ await assertStatus(ExchangeTosStatus.Accepted);
+ const documentEtags = new Set<string>();
+ for (const variant of variants) {
+ const response = await harnessHttpLib.fetch(
+ new URL("terms", exchange.baseUrl).href,
+ {
+ headers: {
+ Accept: variant.format,
+ "Accept-Language": variant.language,
+ },
+ },
+ );
+ t.assertDeepEqual(response.status, 200);
+ t.assertDeepEqual(response.headers.get("taler-terms-version"), "v1");
+ const etag = response.headers.get("etag");
+ t.assertTrue(etag != null);
+ documentEtags.add(etag);
+
+ const terms = await getTerms(variant);
+ t.assertDeepEqual(
+ terms.content,
+ `Terms v1: ${variant.language}, ${variant.format}`,
+ );
+ t.assertDeepEqual(terms.tosStatus, ExchangeTosStatus.Accepted);
+ t.assertDeepEqual(terms.currentEtag, "v1");
+ t.assertDeepEqual(terms.acceptedEtag, terms.currentEtag);
+ await refresh();
+ await assertStatus(ExchangeTosStatus.Accepted);
+ }
+ t.assertDeepEqual(documentEtags.size, variants.length);
+
+ // A second withdrawal must work without another acceptance call.
+ const secondWithdrawal = await withdrawViaBankV4(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+ await secondWithdrawal.withdrawalFinishedCond;
+
+ // A new legal version must still require explicit acceptance.
+ await exchange.stop();
+ exchange.changeConfig((config) => {
+ config.setString("exchange", "terms_etag", "v2");
+ });
+ await exchange.start();
+ await refresh();
+ await assertStatus(ExchangeTosStatus.Proposed);
+ const changed = await getTerms();
+ t.assertDeepEqual(changed.tosStatus, ExchangeTosStatus.Proposed);
+ t.assertDeepEqual(changed.acceptedEtag, "v1");
+ t.assertDeepEqual(changed.currentEtag, "v2");
+ await walletClient.call(
+ WalletApiOperation.SetExchangeTosAccepted,
+ exchangeArgs,
+ );
+ await refresh();
+ await assertStatus(ExchangeTosStatus.Accepted);
+ const accepted = await getTerms();
+ t.assertDeepEqual(accepted.tosStatus, ExchangeTosStatus.Accepted);
+ t.assertDeepEqual(accepted.acceptedEtag, "v2");
+ t.assertDeepEqual(accepted.currentEtag, "v2");
+}
+
+runWalletTosAcceptanceTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -230,6 +230,7 @@ import { runWalletGenDbTest } from "./test-wallet-gendb.js";
import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js";
import { runWalletMailboxBasicTest } from "./test-wallet-mailbox-basic.js";
import { runWalletMissingTosTest } from "./test-wallet-missing-tos.js";
+import { runWalletTosAcceptanceTest } from "./test-wallet-tos-acceptance.js";
import { runWalletNetworkAvailabilityTest } from "./test-wallet-network-availability.js";
import { runWalletNotificationsTest } from "./test-wallet-notifications.js";
import { runWalletRefreshErrorsTest } from "./test-wallet-refresh-errors.js";
@@ -425,6 +426,7 @@ const allTests: TestMainFunction[] = [
runWithdrawalConversionTest,
runWithdrawalHugeTest,
runTermOfServiceFormatTest,
+ runWalletTosAcceptanceTest,
runPaymentExpiredTest,
runWalletGenDbTest,
runLibeufinBankTest,