taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/balance.test.ts | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/balance.ts | 9+++++++--
Mpackages/taler-wallet-core/src/dev-experiments.test.ts | 149++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/dev-experiments.ts | 43++++++++++++++++++++++++++++++++++++++++---
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": {