taler-typescript-core

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

commit 9027bda3becfe59cde4e9db6f2ad06802746b2c8
parent 08d9950ffc0aa61eb602e136ae2f8c45befc533a
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 14:13:06 +0200

wallet-core: add deterministic API experiment results

Diffstat:
Mpackages/taler-wallet-core/src/dev-experiments.test.ts | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/dev-experiments.ts | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 31++++++++++++++++++++++++++++++-
3 files changed, 340 insertions(+), 3 deletions(-)

diff --git a/packages/taler-wallet-core/src/dev-experiments.test.ts b/packages/taler-wallet-core/src/dev-experiments.test.ts @@ -14,14 +14,23 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. */ +import { TalerErrorCode } from "@gnu-taler/taler-util"; import { HeadersImpl, - HttpRequestLibrary, - HttpResponse, + type HttpRequestLibrary, + type HttpResponse, } from "@gnu-taler/taler-util/http"; import assert from "node:assert"; import { test } from "node:test"; +import { WalletApiOperation } from "./wallet-api-types.js"; import { DevExperimentHttpLib } from "./dev-experiments.js"; +import { + configureDevExperimentApiError, + configureDevExperimentApiResponse, + takeDevExperimentApiError, + takeDevExperimentApiResponse, + type DevExperimentState, +} from "./dev-experiments.js"; function okResponse(url: string): HttpResponse { return { @@ -41,6 +50,10 @@ function okResponse(url: string): HttpResponse { }; } +function encoded(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + test("fake protocol versions do not disable payment response blockers", async () => { let underlyingCalls = 0; const underlying: HttpRequestLibrary = { @@ -67,3 +80,112 @@ test("fake protocol versions do not disable payment response blockers", async () assert.strictEqual(claimResponse.status, 500); assert.strictEqual(underlyingCalls, 1); }); + +test("API error experiments preserve details, queue, and occurrence counts", () => { + const state: DevExperimentState = {}; + const first = { + code: TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + hint: "claimed fixture", + orderId: "order-1", + nested: { preserved: true }, + }; + const second = { + code: TalerErrorCode.WALLET_NETWORK_ERROR, + hint: "network fixture", + requestUrl: "https://merchant.example/", + requestMethod: "POST", + }; + configureDevExperimentApiError( + state, + WalletApiOperation.PreparePayForUriV2, + encoded(first), + "2", + ); + configureDevExperimentApiError( + state, + WalletApiOperation.PreparePayForUriV2, + encoded(second), + ); + assert.deepEqual( + takeDevExperimentApiError(state, WalletApiOperation.PreparePayForUriV2), + first, + ); + assert.deepEqual( + takeDevExperimentApiError(state, WalletApiOperation.PreparePayForUriV2), + first, + ); + assert.deepEqual( + takeDevExperimentApiError(state, WalletApiOperation.PreparePayForUriV2), + second, + ); + assert.equal( + takeDevExperimentApiError(state, WalletApiOperation.PreparePayForUriV2), + undefined, + ); +}); + +test("API error experiments reject malformed and control-plane targets", () => { + const state: DevExperimentState = {}; + assert.throws(() => + configureDevExperimentApiError( + state, + "not-an-operation", + encoded({ code: 1 }), + ), + ); + assert.throws(() => + configureDevExperimentApiError( + state, + WalletApiOperation.ApplyDevExperiment, + encoded({ code: 1 }), + ), + ); + assert.throws(() => + configureDevExperimentApiError( + state, + WalletApiOperation.GetBalances, + encoded({ hint: "missing code" }), + ), + ); + assert.throws(() => + configureDevExperimentApiError( + state, + WalletApiOperation.GetBalances, + encoded({ code: 1 }), + "1x", + ), + ); +}); + +test("API response experiments preserve arbitrary responses and counts", () => { + const state: DevExperimentState = {}; + const response = { + transactionId: "txn:payment:fixture", + futureResponseField: { kept: true }, + }; + configureDevExperimentApiResponse( + state, + WalletApiOperation.PreparePayForUriV2, + encoded(response), + "2", + ); + assert.deepEqual( + takeDevExperimentApiResponse(state, WalletApiOperation.PreparePayForUriV2), + response, + ); + assert.deepEqual( + takeDevExperimentApiResponse(state, WalletApiOperation.PreparePayForUriV2), + response, + ); + assert.equal( + takeDevExperimentApiResponse(state, WalletApiOperation.PreparePayForUriV2), + undefined, + ); + assert.throws(() => + configureDevExperimentApiResponse( + state, + WalletApiOperation.ApplyDevExperiment, + encoded(response), + ), + ); +}); diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts @@ -41,6 +41,7 @@ import { TalerDevExperimentUri, TalerError, TalerErrorCode, + type TalerErrorDetail, TalerPreciseTimestamp, TalerUriAction, TalerUris, @@ -83,10 +84,18 @@ import { } from "./requests.js"; import { WalletExecutionContext } from "./wallet.js"; import { WithdrawTransactionContext } from "./withdraw.js"; +import { WalletApiOperation } from "./wallet-api-types.js"; const logger = new Logger("dev-experiments.ts"); export interface DevExperimentState { + /** Deterministic, queued wallet API failures for frontend integration tests. */ + apiErrors?: Map< + string, + Array<{ detail: TalerErrorDetail; remaining: number }> + >; + /** Deterministic, queued wallet API responses for frontend integration tests. */ + apiResponses?: Map<string, Array<{ response: unknown; remaining: number }>>; blockRefreshes?: boolean; /** Pretend that exchanges have no fees.*/ pretendNoFees?: boolean; @@ -240,6 +249,42 @@ export async function applyDevExperiment( wex.ws.devExperimentState.merchantDepositInsufficient = true; return; } + case "api-error": { + const operation = parsedUri.query?.["operation"]; + const encodedDetail = parsedUri.query?.["detail"]; + if (!operation || !encodedDetail) { + throw Error("api-error requires operation and detail parameters"); + } + configureDevExperimentApiError( + wex.ws.devExperimentState, + operation, + encodedDetail, + parsedUri.query?.["count"], + ); + return; + } + case "clear-api-errors": { + wex.ws.devExperimentState.apiErrors?.clear(); + return; + } + case "api-response": { + const operation = parsedUri.query?.["operation"]; + const encodedResponse = parsedUri.query?.["response"]; + if (!operation || !encodedResponse) { + throw Error("api-response requires operation and response parameters"); + } + configureDevExperimentApiResponse( + wex.ws.devExperimentState, + operation, + encodedResponse, + parsedUri.query?.["count"], + ); + return; + } + case "clear-api-responses": { + wex.ws.devExperimentState.apiResponses?.clear(); + return; + } case "start-tc": { const maybeFloatParam = (name: string) => { const val = parsedUri.query?.[name]; @@ -526,6 +571,147 @@ export async function applyDevExperiment( } } +/** Consume the next deterministic API error configured by a dev experiment. */ +export function takeDevExperimentApiError( + state: DevExperimentState, + operation: string, +): TalerErrorDetail | undefined { + const entries = state.apiErrors?.get(operation); + const current = entries?.[0]; + if (!entries || !current) return undefined; + current.remaining -= 1; + if (current.remaining === 0) entries.shift(); + if (entries.length === 0) state.apiErrors?.delete(operation); + return current.detail; +} + +export function configureDevExperimentApiError( + state: DevExperimentState, + operation: string, + encodedDetail: string, + countRaw?: string, +): void { + validateDevExperimentOperation(operation, "api-error"); + const count = parseDevExperimentCount(countRaw, "api-error"); + let detail: unknown; + try { + detail = JSON.parse(decodeBase64UrlText(encodedDetail)); + } catch (cause) { + throw Error( + `api-error detail is not valid base64url JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + if ( + !detail || + typeof detail !== "object" || + Array.isArray(detail) || + typeof (detail as { code?: unknown }).code !== "number" + ) { + throw Error("api-error detail must be an object with a numeric code"); + } + const queue = state.apiErrors ?? (state.apiErrors = new Map()); + const entries = queue.get(operation) ?? []; + entries.push({ detail: detail as TalerErrorDetail, remaining: count }); + queue.set(operation, entries); +} + +function validateDevExperimentOperation( + operation: string, + experiment: "api-error" | "api-response", +): void { + if ( + !Object.values(WalletApiOperation).includes(operation as WalletApiOperation) + ) { + throw Error(`unknown wallet API operation ${operation}`); + } + if ( + operation === WalletApiOperation.InitWallet || + operation === WalletApiOperation.SetWalletRunConfig || + operation === WalletApiOperation.ApplyDevExperiment + ) { + throw Error(`${experiment} may not target ${operation}`); + } +} + +function parseDevExperimentCount( + countRaw: string | undefined, + experiment: "api-error" | "api-response", +): number { + const count = countRaw === undefined ? 1 : Number.parseInt(countRaw, 10); + if ( + !Number.isSafeInteger(count) || + count <= 0 || + (countRaw !== undefined && String(count) !== countRaw) + ) { + throw Error(`${experiment} count must be a positive integer`); + } + return count; +} + +/** Consume the next deterministic API response configured by an experiment. */ +export function takeDevExperimentApiResponse( + state: DevExperimentState, + operation: string, +): unknown | undefined { + const entries = state.apiResponses?.get(operation); + const current = entries?.[0]; + if (!entries || !current) return undefined; + current.remaining -= 1; + if (current.remaining === 0) entries.shift(); + if (entries.length === 0) state.apiResponses?.delete(operation); + return current.response; +} + +export function configureDevExperimentApiResponse( + state: DevExperimentState, + operation: string, + encodedResponse: string, + countRaw?: string, +): void { + validateDevExperimentOperation(operation, "api-response"); + const count = parseDevExperimentCount(countRaw, "api-response"); + let response: unknown; + try { + response = JSON.parse(decodeBase64UrlText(encodedResponse)); + } catch (cause) { + throw Error( + `api-response response is not valid base64url JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + const queue = state.apiResponses ?? (state.apiResponses = new Map()); + const entries = queue.get(operation) ?? []; + entries.push({ response, remaining: count }); + queue.set(operation, entries); +} + +function decodeBase64UrlText(value: string): string { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) { + throw Error("invalid base64url alphabet"); + } + const unpadded = normalized.replace(/=+$/, ""); + if (unpadded.length % 4 === 1) throw Error("invalid base64url length"); + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const bytes: number[] = []; + let bits = 0; + let bitCount = 0; + for (const character of unpadded) { + const digit = alphabet.indexOf(character); + if (digit < 0) throw Error("invalid base64url character"); + bits = bits * 64 + digit; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + bytes.push(Math.floor(bits / 2 ** bitCount) & 0xff); + bits %= 2 ** bitCount; + } + } + return new TextDecoder("utf-8", { fatal: true }).decode( + Uint8Array.from(bytes), + ); +} + function getValFlag(parsedUri: TalerDevExperimentUri): boolean { const setVal = parsedUri.query?.["val"]; if (setVal == null) { diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -80,7 +80,12 @@ import { WalletDenomRef, } from "./db/transaction.js"; import { UnverifiedDenomError } from "./denomSelection.js"; -import { DevExperimentHttpLib, DevExperimentState } from "./dev-experiments.js"; +import { + DevExperimentHttpLib, + DevExperimentState, + takeDevExperimentApiError, + takeDevExperimentApiResponse, +} from "./dev-experiments.js"; import { OutdatedExchangeError, ReadyExchangeSummary, @@ -703,6 +708,30 @@ async function dispatchWalletCoreApiRequest( type: ObservabilityEventType.RequestStart, name: operation, }); + const injectedResponse = takeDevExperimentApiResponse( + ws.devExperimentState, + operation, + ); + if (injectedResponse !== undefined) { + const end = performanceNow(); + oc.observe({ + type: ObservabilityEventType.RequestFinishSuccess, + operation, + requestId: id, + durationMs: performanceDelta(start, end), + }); + return { + type: "response", + operation, + id, + result: injectedResponse, + }; + } + const injectedError = takeDevExperimentApiError( + ws.devExperimentState, + operation, + ); + if (injectedError) throw TalerError.fromUncheckedDetail(injectedError); const result = await dispatchRequestInternal( wex, operation as any,