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