commit 4b71391d0baab19a2d3803007808a66eecd6fec7
parent b32f2b4e19aa6fb4cd6e074ac8bf1a2d13bcda5d
Author: Florian Dold <dold@taler.net>
Date: Tue, 1 Sep 2026 15:28:40 +0200
wallet-core: fake shopping links per balance scope
Diffstat:
4 files changed, 300 insertions(+), 7 deletions(-)
diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts
@@ -15,7 +15,12 @@
import assert from "node:assert";
import { test } from "node:test";
-import { Amounts, RefreshReason, ScopeType } from "@gnu-taler/taler-util";
+import {
+ Amounts,
+ RefreshReason,
+ ScopeType,
+ stringifyScopeInfo,
+} from "@gnu-taler/taler-util";
import {
getBalancesInsideTransaction,
getPaymentBalanceDetailsInTx,
@@ -50,6 +55,7 @@ function makeExchange(
function makeBalanceContext(
exchanges: WalletExchangeEntry[],
refreshGroups: WalletRefreshGroup[] = [],
+ shoppingUrlsByExchange: Record<string, string> = {},
): {
wex: WalletExecutionContext;
tx: WalletDbTransaction;
@@ -62,6 +68,7 @@ function makeBalanceContext(
currency: "TESTKUDOS",
masterPublicKey: `master-pub-${x.baseUrl}`,
auditors: [],
+ shoppingUrl: shoppingUrlsByExchange[x.baseUrl],
} as unknown as WalletExchangeDetails,
]),
);
@@ -214,6 +221,103 @@ test("zero current global balance remains visible without availability rows", as
assert.strictEqual(result.balances[0].available, "TESTKUDOS:0");
});
+test("fake shopping URLs override only their exact balance scopes", async () => {
+ const exchangeA = "https://shopping-a.example/";
+ const exchangeB = "https://shopping-b.example/";
+ const { wex, tx } = makeBalanceContext(
+ [makeExchange(exchangeA), makeExchange(exchangeB)],
+ [],
+ {
+ [exchangeA]: "https://real-shop-a.example/",
+ [exchangeB]: "https://real-shop-b.example/",
+ },
+ );
+ wex.ws.devExperimentState.fakeShoppingUrls = new Map([
+ [
+ stringifyScopeInfo({
+ type: ScopeType.Exchange,
+ currency: "TESTKUDOS",
+ url: exchangeA,
+ }),
+ ["https://fake-shop.example/first", "https://fake-shop.example/second"],
+ ],
+ [
+ stringifyScopeInfo({
+ type: ScopeType.Exchange,
+ currency: "TESTKUDOS",
+ url: exchangeB,
+ }),
+ [],
+ ],
+ ]);
+
+ const result = await getBalancesInsideTransaction(wex, tx);
+ const byUrl = new Map<string, (typeof result.balances)[number]>();
+ for (const balance of result.balances) {
+ if (balance.scopeInfo.type === ScopeType.Exchange) {
+ byUrl.set(balance.scopeInfo.url, balance);
+ }
+ }
+ assert.deepStrictEqual(byUrl.get(exchangeA)?.shoppingUrls, [
+ "https://fake-shop.example/first",
+ "https://fake-shop.example/second",
+ ]);
+ assert.deepStrictEqual(byUrl.get(exchangeB)?.shoppingUrls, []);
+});
+
+test("unmatched fake shopping URL scopes keep real URLs", async () => {
+ const exchangeBaseUrl = "https://shopping-real.example/";
+ const { wex, tx } = makeBalanceContext([makeExchange(exchangeBaseUrl)], [], {
+ [exchangeBaseUrl]: "https://real-shop.example/",
+ });
+ wex.ws.devExperimentState.fakeShoppingUrls = new Map([
+ [
+ stringifyScopeInfo({
+ type: ScopeType.Global,
+ currency: "TESTKUDOS",
+ }),
+ ["https://fake-global-shop.example/"],
+ ],
+ ]);
+
+ const result = await getBalancesInsideTransaction(wex, tx);
+ assert.deepStrictEqual(result.balances[0].shoppingUrls, [
+ "https://real-shop.example/",
+ ]);
+});
+
+test("fake shopping URLs can target a global scope", async () => {
+ const exchangeBaseUrl = "https://shopping-global.example/";
+ const { wex, tx } = makeBalanceContext([makeExchange(exchangeBaseUrl)], [], {
+ [exchangeBaseUrl]: "https://real-shop.example/",
+ });
+ tx.listGlobalCurrencyExchanges = async () => [
+ {
+ currency: "TESTKUDOS",
+ exchangeBaseUrl,
+ exchangeMasterPub: `master-pub-${exchangeBaseUrl}`,
+ },
+ ];
+ wex.ws.devExperimentState.fakeShoppingUrls = new Map([
+ [
+ stringifyScopeInfo({
+ type: ScopeType.Global,
+ currency: "TESTKUDOS",
+ }),
+ ["https://fake-global-shop.example/"],
+ ],
+ ]);
+
+ const result = await getBalancesInsideTransaction(wex, tx);
+ assert.deepStrictEqual(result.balances[0].scopeInfo, {
+ type: ScopeType.Global,
+ currency: "TESTKUDOS",
+ });
+ assert.deepStrictEqual(result.balances[0].shoppingUrls, [
+ "https://fake-global-shop.example/",
+ ]);
+});
+
test("balance scope inputs are loaded once per exchange", async () => {
const exchangeBaseUrl = "https://cached-scope.example/";
const exchange = makeExchange(exchangeBaseUrl);
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -76,6 +76,7 @@ import {
RefreshReason,
ScopeInfo,
ScopeType,
+ stringifyScopeInfo,
} from "@gnu-taler/taler-util";
import {
checkExchangeAccepted,
@@ -562,8 +563,12 @@ class BalancesStore {
flags.push(BalanceFlag.OutgoingKyc);
}
let shoppingUrls: string[];
- if (this.wex.ws.devExperimentState.fakeShoppingUrl != null) {
- shoppingUrls = [this.wex.ws.devExperimentState.fakeShoppingUrl];
+ const fakeShoppingUrls =
+ this.wex.ws.devExperimentState.fakeShoppingUrls?.get(
+ stringifyScopeInfo(v.scopeInfo),
+ );
+ if (fakeShoppingUrls != null) {
+ shoppingUrls = [...fakeShoppingUrls];
} else {
shoppingUrls = [...v.shoppingUrls];
}
diff --git a/packages/taler-wallet-core/src/dev-experiments.test.ts b/packages/taler-wallet-core/src/dev-experiments.test.ts
@@ -14,7 +14,12 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>.
*/
-import { TalerErrorCode } from "@gnu-taler/taler-util";
+import {
+ ScopeType,
+ TalerErrorCode,
+ stringifyScopeInfo,
+ stringifyScopeInfoShort,
+} from "@gnu-taler/taler-util";
import {
HeadersImpl,
type HttpRequestLibrary,
@@ -23,8 +28,10 @@ import {
import assert from "node:assert";
import { test } from "node:test";
import { WalletApiOperation } from "./wallet-api-types.js";
+import { WalletExecutionContext } from "./wallet.js";
import { DevExperimentHttpLib } from "./dev-experiments.js";
import {
+ applyDevExperiment,
configureDevExperimentApiError,
configureDevExperimentApiResponse,
takeDevExperimentApiError,
@@ -32,6 +39,25 @@ import {
type DevExperimentState,
} from "./dev-experiments.js";
+function fakeShoppingExperiment(params: Record<string, string> = {}): string {
+ const query = Object.entries(params)
+ .map(
+ ([key, value]) =>
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
+ )
+ .join("&");
+ return `taler://dev-experiment/fake-shopping-url${query ? `?${query}` : ""}`;
+}
+
+function makeDevExperimentContext(): WalletExecutionContext {
+ return {
+ ws: {
+ config: { testing: { devModeActive: true } },
+ devExperimentState: {},
+ },
+ } as WalletExecutionContext;
+}
+
function okResponse(url: string): HttpResponse {
return {
requestMethod: "POST",
@@ -81,6 +107,127 @@ test("fake protocol versions do not disable payment response blockers", async ()
assert.strictEqual(underlyingCalls, 1);
});
+test("fake shopping URL experiments retain independent scoped overrides", async () => {
+ const wex = makeDevExperimentContext();
+ const exchangeScope = {
+ type: ScopeType.Exchange,
+ currency: "TESTKUDOS",
+ url: "https://exchange.example/",
+ } as const;
+ const globalScope = {
+ type: ScopeType.Global,
+ currency: "TESTKUDOS",
+ } as const;
+ const exchangeKey = stringifyScopeInfo(exchangeScope);
+ const globalKey = stringifyScopeInfo(globalScope);
+
+ await applyDevExperiment(
+ wex,
+ fakeShoppingExperiment({
+ scope: stringifyScopeInfoShort(exchangeScope),
+ urls: JSON.stringify([
+ "https://shop.example/one",
+ "https://shop.example/two",
+ ]),
+ }),
+ );
+ await applyDevExperiment(
+ wex,
+ fakeShoppingExperiment({
+ scope: stringifyScopeInfoShort(globalScope),
+ urls: "[]",
+ }),
+ );
+ assert.deepStrictEqual(
+ wex.ws.devExperimentState.fakeShoppingUrls?.get(exchangeKey),
+ ["https://shop.example/one", "https://shop.example/two"],
+ );
+ assert.deepStrictEqual(
+ wex.ws.devExperimentState.fakeShoppingUrls?.get(globalKey),
+ [],
+ );
+
+ await applyDevExperiment(
+ wex,
+ fakeShoppingExperiment({
+ scope: stringifyScopeInfoShort(exchangeScope),
+ urls: JSON.stringify(["https://shop.example/replacement"]),
+ }),
+ );
+ assert.deepStrictEqual(
+ wex.ws.devExperimentState.fakeShoppingUrls?.get(exchangeKey),
+ ["https://shop.example/replacement"],
+ );
+
+ await applyDevExperiment(
+ wex,
+ fakeShoppingExperiment({
+ scope: stringifyScopeInfoShort(exchangeScope),
+ }),
+ );
+ assert.strictEqual(
+ wex.ws.devExperimentState.fakeShoppingUrls?.has(exchangeKey),
+ false,
+ );
+ assert.strictEqual(
+ wex.ws.devExperimentState.fakeShoppingUrls?.has(globalKey),
+ true,
+ );
+
+ await applyDevExperiment(wex, fakeShoppingExperiment());
+ assert.strictEqual(wex.ws.devExperimentState.fakeShoppingUrls?.size, 0);
+});
+
+test("fake shopping URL experiments reject invalid parameters", async () => {
+ const scope = stringifyScopeInfoShort({
+ type: ScopeType.Global,
+ currency: "TESTKUDOS",
+ });
+
+ await assert.rejects(
+ applyDevExperiment(
+ makeDevExperimentContext(),
+ fakeShoppingExperiment({ url: "https://shop.example/" }),
+ ),
+ /param 'url' is not supported/,
+ );
+ await assert.rejects(
+ applyDevExperiment(
+ makeDevExperimentContext(),
+ fakeShoppingExperiment({ urls: "[]" }),
+ ),
+ /param 'scope' is required/,
+ );
+ await assert.rejects(
+ applyDevExperiment(
+ makeDevExperimentContext(),
+ fakeShoppingExperiment({ scope: "/invalid", urls: "[]" }),
+ ),
+ /param 'scope' is invalid/,
+ );
+ await assert.rejects(
+ applyDevExperiment(
+ makeDevExperimentContext(),
+ fakeShoppingExperiment({ scope, urls: "not-json" }),
+ ),
+ /param 'urls' must be valid JSON/,
+ );
+ await assert.rejects(
+ applyDevExperiment(
+ makeDevExperimentContext(),
+ fakeShoppingExperiment({ scope, urls: JSON.stringify({}) }),
+ ),
+ /param 'urls' must be an array of strings/,
+ );
+ await assert.rejects(
+ applyDevExperiment(
+ makeDevExperimentContext(),
+ fakeShoppingExperiment({ scope, urls: JSON.stringify(["okay", 1]) }),
+ ),
+ /param 'urls' must be an array of strings/,
+ );
+});
+
test("API error experiments preserve details, queue, and occurrence counts", () => {
const state: DevExperimentState = {};
const first = {
diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts
@@ -36,6 +36,7 @@ import {
MerchantContractTermsV0,
MerchantContractVersion,
PeerContractTerms,
+ parseScopeInfoShort,
RefreshReason,
Result,
TalerDevExperimentUri,
@@ -48,6 +49,7 @@ import {
encodeCrock,
getRandomBytes,
j2s,
+ stringifyScopeInfo,
} from "@gnu-taler/taler-util";
import {
HeadersImpl,
@@ -119,7 +121,8 @@ export interface DevExperimentState {
}
>;
- fakeShoppingUrl?: string;
+ /** Shopping URL overrides keyed by canonical balance scope. */
+ fakeShoppingUrls?: Map<string, string[]>;
flagDisablePeerPayments?: boolean;
@@ -525,8 +528,42 @@ export async function applyDevExperiment(
return;
}
case "fake-shopping-url": {
- const url = parsedUri.query?.["url"];
- wex.ws.devExperimentState.fakeShoppingUrl = url;
+ const query = parsedUri.query ?? {};
+ if (Object.prototype.hasOwnProperty.call(query, "url")) {
+ throw Error("param 'url' is not supported");
+ }
+ const scopeRaw = query["scope"];
+ const urlsRaw = query["urls"];
+ if (scopeRaw == null) {
+ if (urlsRaw != null) {
+ throw Error("param 'scope' is required when setting URLs");
+ }
+ wex.ws.devExperimentState.fakeShoppingUrls?.clear();
+ return;
+ }
+ const scope = parseScopeInfoShort(scopeRaw);
+ if (!scope) {
+ throw Error("param 'scope' is invalid");
+ }
+ const scopeKey = stringifyScopeInfo(scope);
+ if (urlsRaw == null) {
+ wex.ws.devExperimentState.fakeShoppingUrls?.delete(scopeKey);
+ return;
+ }
+ let urls: unknown;
+ try {
+ urls = JSON.parse(urlsRaw);
+ } catch {
+ throw Error("param 'urls' must be valid JSON");
+ }
+ if (!Array.isArray(urls) || urls.some((url) => typeof url !== "string")) {
+ throw Error("param 'urls' must be an array of strings");
+ }
+ let overrides = wex.ws.devExperimentState.fakeShoppingUrls;
+ if (!overrides) {
+ overrides = wex.ws.devExperimentState.fakeShoppingUrls = new Map();
+ }
+ overrides.set(scopeKey, urls);
return;
}
case "flag-confirm-pay-no-wait": {