taler-typescript-core

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

commit 3e76afae1ed7e858ce39a806dee83c6edb275154
parent 2be510b4ef1062ce9679910c341177c28e4447b1
Author: Florian Dold <dold@taler.net>
Date:   Thu,  3 Sep 2026 18:54:58 +0200

harness: fail tests on unexpected service diagnostics

Diffstat:
Mpackages/taler-harness/README.md | 38++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/harness/environments.ts | 34++++++++++++++++++++++++----------
Mpackages/taler-harness/src/harness/harness-donau.ts | 22++++++++++++++++++++++
Mpackages/taler-harness/src/harness/harness.ts | 573+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Apackages/taler-harness/src/harness/log-diagnostics.test.ts | 300+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/harness/tops.ts | 8--------
Mpackages/taler-harness/src/index.ts | 6+++++-
Mpackages/taler-harness/src/integrationtests/kyc-form-withdrawal-helper.ts | 1-
Mpackages/taler-harness/src/integrationtests/kyc-threshold-withdrawal-helper.ts | 1-
Mpackages/taler-harness/src/integrationtests/merchant-kyc-auth-helper.ts | 1-
Mpackages/taler-harness/src/integrationtests/test-account-restrictions.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/test-coinsel-legacy-2024.ts | 19++++++++++++++++++-
Mpackages/taler-harness/src/integrationtests/test-denom-lost-complex.ts | 24++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-denom-lost.ts | 10++++++++++
Mpackages/taler-harness/src/integrationtests/test-denom-revoked-balance.ts | 14++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-denom-unoffered.ts | 26++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-deposit-merge.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/test-deposit-too-large.ts | 10++++++++++
Mpackages/taler-harness/src/integrationtests/test-donau-charity-management.ts | 3+--
Mpackages/taler-harness/src/integrationtests/test-donau-idempotency.ts | 3+--
Mpackages/taler-harness/src/integrationtests/test-donau-keychange.ts | 3+--
Mpackages/taler-harness/src/integrationtests/test-donau-minus-t.ts | 3+--
Mpackages/taler-harness/src/integrationtests/test-donau-multi.ts | 5++---
Mpackages/taler-harness/src/integrationtests/test-donau.ts | 3+--
Mpackages/taler-harness/src/integrationtests/test-exchange-currency-change.ts | 28++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-exchange-denom-storage.ts | 12+++++++++++-
Mpackages/taler-harness/src/integrationtests/test-exchange-ephemeral.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts | 10++++++++++
Mpackages/taler-harness/src/integrationtests/test-exchange-management-fault.ts | 6+++---
Mpackages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts | 10++++++++++
Mpackages/taler-harness/src/integrationtests/test-forced-selection.ts | 11++++++++++-
Mpackages/taler-harness/src/integrationtests/test-kyc-amp-failure.ts | 15+++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-amp-timeout.ts | 7+++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal-change-manual.ts | 8+++++++-
Mpackages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts | 5+++--
Mpackages/taler-harness/src/integrationtests/test-kyc-deposit-aggregate-implicit-auth.ts | 1-
Mpackages/taler-harness/src/integrationtests/test-kyc-deposit-aggregate.ts | 1-
Mpackages/taler-harness/src/integrationtests/test-kyc-deposit-deposit.ts | 1-
Mpackages/taler-harness/src/integrationtests/test-kyc-deposit-kycauth.ts | 3+--
Mpackages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts | 3+--
Mpackages/taler-harness/src/integrationtests/test-kyc-fail-recover-simple.ts | 7+++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-form-bad-measure.ts | 8+++++++-
Mpackages/taler-harness/src/integrationtests/test-kyc-form-validation.ts | 7+++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts | 1-
Mpackages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-form.ts | 12++++++++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-merchant-deposit.ts | 12++++++++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-new-measure.ts | 2--
Mpackages/taler-harness/src/integrationtests/test-kyc-peer-pull.ts | 1-
Mpackages/taler-harness/src/integrationtests/test-kyc-peer-push.ts | 1-
Mpackages/taler-harness/src/integrationtests/test-kyc-two-forms.ts | 29++++++++++++++++++++++++++++-
Mpackages/taler-harness/src/integrationtests/test-kyc-wallet-deposit-abort.ts | 12++++++++++++
Mpackages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts | 11+++++++++++
Mpackages/taler-harness/src/integrationtests/test-libeufin-bank.ts | 71+++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/taler-harness/src/integrationtests/test-libeufin-conversion.ts | 104+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/taler-harness/src/integrationtests/test-merchant-acctsel.ts | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts | 21+++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-exchange-duplicate.ts | 41+++++++++++++++++++++++++++++++++++++----
Mpackages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts | 21+++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-instances.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-refund-api.ts | 12++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts | 12++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts | 22++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-sessions.ts | 12++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts | 16++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-templates.ts | 12++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-tokenfamilies.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-webui-bootstrap.ts | 26++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-webui-kyc-swap.ts | 14++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-webui-mfa.ts | 35+++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts | 14++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-multiexchange.ts | 42++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-payment-deleted.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/test-payment-expired.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/test-payment-unclaim.ts | 10++++++++++
Mpackages/taler-harness/src/integrationtests/test-peer-pull.ts | 11++++++++++-
Mpackages/taler-harness/src/integrationtests/test-peer-repair.ts | 26++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-refund-gone.ts | 11+++++++++++
Mpackages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts | 10++++++++++
Mpackages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts | 24++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-tops-aml-measures.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts | 4++++
Mpackages/taler-harness/src/integrationtests/test-wallet-balance.ts | 16++++++++++++++--
Mpackages/taler-harness/src/integrationtests/test-wallet-dd48.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts | 10++++++++++
Mpackages/taler-harness/src/integrationtests/test-wallet-progress-token.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts | 26++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-wallet-refresh-redenominate.ts | 24++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-wallet-tokens.ts | 9+++++++++
Mpackages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts | 9++++++---
Mpackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts | 4++++
Mpackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth.ts | 11++++++++++-
Mpackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts | 59++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-tos.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts | 29+++++++++++++++++++++--------
Mpackages/taler-harness/src/integrationtests/test-wallet-withdrawal-redenominate.ts | 25+++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-wire-metadata.ts | 28+++++++++++++++++++++-------
Mpackages/taler-harness/src/integrationtests/test-withdrawal-amount.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-withdrawal-conflict.ts | 7++++++-
Mpackages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-withdrawal-fees.ts | 4++--
Mpackages/taler-harness/src/integrationtests/test-withdrawal-huge.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-withdrawal-manual.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-withdrawal-shorten.ts | 37++++++++++++++++++++-----------------
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-harness/src/stagefright/merchant-webui.ts | 2+-
Mpackages/taler-util/src/bank-api-client.ts | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-util/src/twrpc-impl.node.ts | 7++++++-
Mpackages/taler-util/src/types-donau.ts | 8++++++--
Mpackages/taler-util/src/types-taler-corebank.ts | 27+++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-exchange.ts | 8++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 37+++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/common.ts | 2+-
Mpackages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts | 7+++++++
Mpackages/taler-wallet-core/src/db/query.ts | 19+++++++++++++++----
Mpackages/taler-wallet-core/src/dev-experiments.ts | 6+++---
Mpackages/taler-wallet-core/src/exchanges.ts | 16+++++++++++-----
Mpackages/taler-wallet-core/src/pay-merchant.ts | 2+-
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 4++--
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 4++--
Mpackages/taler-wallet-core/src/wallet.ts | 2+-
Mpackages/taler-wallet-core/src/withdraw.ts | 22++++++++++++++++++----
124 files changed, 2389 insertions(+), 358 deletions(-)

diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md @@ -119,6 +119,44 @@ globals still share a process, however. Thus this mode deliberately provides less isolation than the default; rerun a failure without `--reuse-worker` before treating it as a product regression. +### Service warnings and errors + +After every integration test, the harness parses structured `WARN`, `WARNING`, +and `ERROR` records from that test's `*-stderr.log` files (`WARN` is normalized +to `WARNING`). This includes the Taler/GNUnet layout, Libeufin's Kotlin logging +layout, and diagnostics with a case-insensitive `ERROR:`, `WARNING:`, or +`WARN:` severity prefix. Any +unexpected record fails the test. Text such as `ERROR` in an INFO or TRACE +message does not count: the parser uses a structured severity field or prefix, +not a substring search. + +First investigate a diagnostic and fix its cause or severity. If a test +deliberately exercises the condition, declare an exact, bounded expectation +near that scenario: + +```ts +t.allowLog({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + message: "Access denied: password does not match", + maxOccurrences: 1, + justification: "the test verifies rejection of an incorrect password", +}); +``` + +`file` and `message` may be exact strings or anchored regular expressions. +Every expectation requires a positive finite occurrence limit and a non-empty +justification. Catch-all file patterns, unanchored expressions, and global or +sticky expressions are rejected. Keep dynamic expressions as narrow as +possible and never add an expectation merely to make the suite green. + +Diagnostics are printed even in quiet mode and retained in `results.json` with +their file and line number. The runner also performs a parent-side scan when a +worker crashes or times out, after giving the child time to flush its logs. +Those entries are labeled as postmortem diagnostics rather than unexpected: +the parent cannot recover expectations that existed only in the terminated +worker, while the crash or timeout already makes the test fail. + ## Headless Web Integration test 1) First you need the browsers that you are going to use to test diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts @@ -473,8 +473,6 @@ export async function createSimpleTestkudosEnvironmentV3( accountRestrictions: opts.accountRestrictions, }; - await exchange.addBankAccount("1", exchangeBankAccount); - bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri); if (opts.additionalBankConfig) { @@ -484,11 +482,17 @@ export async function createSimpleTestkudosEnvironmentV3( await bank.pingUntilAvailable(); + const bankAdminAuth = bank.getAdminAuth(); const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { - auth: { - username: "admin", - password: "admin-password", - }, + auth: + bankAdminAuth.type === "bearer" + ? { + username: "admin", + password: "admin-password", + token: bankAdminAuth.token, + } + : bankAdminAuth, + preferBearerAuth: bank instanceof LibeufinBankService, }); await bankClient.registerAccountExtended({ @@ -499,6 +503,19 @@ export async function createSimpleTestkudosEnvironmentV3( payto_uri: exchangePaytoUri, }); + if (bank instanceof LibeufinBankService) { + const coreBankClient = new TalerCoreBankHttpClient(bank.corebankApiBaseUrl); + exchangeBankAccount.wireGatewayBearerToken = succeedOrThrow( + await coreBankClient.createAccessToken( + exchangeBankUsername, + exchangeBankAccount.wireGatewayAuth, + { scope: "wiregateway" }, + ), + ).access_token; + } + + await exchange.addBankAccount("1", exchangeBankAccount); + const ageMaskSpec = opts.ageMaskSpec; if (ageMaskSpec) { @@ -837,10 +854,7 @@ export async function withdrawViaBankV3( const user = await bankClient.createRandomBankUser(); const bankClient2 = new TalerCorebankApiClient(bankClient.baseUrl); - bankClient2.setAuth({ - username: user.username, - password: user.password, - }); + bankClient2.setAuth(user); const wop = await bankClient2.createWithdrawalOperation( user.username, diff --git a/packages/taler-harness/src/harness/harness-donau.ts b/packages/taler-harness/src/harness/harness-donau.ts @@ -15,6 +15,7 @@ */ import { + AccessToken, ConfigSources, Configuration, DonauHttpClient, @@ -111,6 +112,7 @@ export class DonauService { config.setString("donau", "port", `${e.httpPort}`); config.setString("donau", "legal_domain", e.domain); config.setString("donau", "expire_legal_years", "5"); + config.setString("donau", "admin_bearer_token", "secret-token:secret"); config.setString("donaudb-postgres", "config", e.database); @@ -167,6 +169,10 @@ export class DonauService { return `http://${host}:${this.donauConfig.httpPort}/`; } + get adminAccessToken(): AccessToken { + return "secret-token:secret" as AccessToken; + } + isRunning(): boolean { return !!this.donauHttpProc; } @@ -228,6 +234,22 @@ export class DonauService { await this.dbinit(); } + for (const message of [ + "Configuration specifies invalid value for option `TERMS_DIR' in section `donau': Could not open directory", + "Terms of service not configured", + "Configuration specifies invalid value for option `PRIVACY_DIR' in section `donau': Could not open directory", + "Privacy policy not configured", + ]) { + this.globalState.allowLog({ + file: `donau-httpd-${this.name}-stderr.log`, + level: "WARNING", + message, + maxOccurrences: 1, + justification: + "the generic harness Donau intentionally has no legal documents; legal-document tests provide their own configuration", + }); + } + this.helperCryptoEddsaProc = this.globalState.spawnService( "donau-secmod-eddsa", ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr], diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -135,6 +135,172 @@ class CommandError extends Error { interface Env { [index: string]: string | undefined; } + +export type ServiceLogLevel = "WARNING" | "ERROR"; + +export interface ServiceLogEntry { + file: string; + line: number; + timestamp: string; + component: string; + level: ServiceLogLevel; + message: string; +} + +type ServiceLogPattern = string | RegExp; + +export interface AllowedServiceLog { + file: ServiceLogPattern; + level: ServiceLogLevel; + message: ServiceLogPattern; + maxOccurrences: number; + justification: string; +} + +interface CountedAllowedServiceLog extends AllowedServiceLog { + occurrences: number; +} + +const structuredServiceLogPattern = + /^(\S+)\s+(\S+)\s+(WARN(?:ING)?|ERROR)\s+(.*)$/; +const kotlinRequestServiceLogPattern = + /^(\S+)\s+\S+\s+(WARN(?:ING)?|ERROR)\s+(\S+)\s+-\s+(.*)$/; +const kotlinStructuredServiceLogPattern = + /^(\S+)\s+(WARN(?:ING)?|ERROR)\s+(\S+)\s+-\s+(.*)$/; +const prefixedServiceLogPattern = /^(WARN(?:ING)?|ERROR):\s*(.*)$/i; + +function hasTopLevelAlternation(source: string): boolean { + let escaped = false; + let inCharacterClass = false; + let groupDepth = 0; + for (const character of source) { + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === "[") { + inCharacterClass = true; + continue; + } + if (character === "]" && inCharacterClass) { + inCharacterClass = false; + continue; + } + if (inCharacterClass) { + continue; + } + if (character === "(") { + groupDepth++; + } else if (character === ")") { + groupDepth--; + } else if (character === "|" && groupDepth === 0) { + return true; + } + } + return false; +} + +function validateServiceLogPattern( + pattern: ServiceLogPattern, + fieldName: string, +): void { + if (typeof pattern === "string") { + if (pattern.length === 0) { + throw Error(`${fieldName} must not be empty`); + } + return; + } + if (!pattern.source.startsWith("^") || !pattern.source.endsWith("$")) { + throw Error(`${fieldName} regular expression must be anchored`); + } + if (hasTopLevelAlternation(pattern.source)) { + throw Error( + `${fieldName} regular expression must not use top-level alternation`, + ); + } + if (/\(\?(?:[=!]|<[=!])/.test(pattern.source)) { + throw Error(`${fieldName} regular expression must not use lookarounds`); + } + if (pattern.global || pattern.sticky) { + throw Error(`${fieldName} regular expression must not be global or sticky`); + } + const domainSamples = fieldName.includes("file") + ? [ + "bank-stderr.log", + "exchange-httpd-testexchange-1-stderr.log", + "wallet-wallet-stderr.log", + ] + : ["request failed", "configuration is missing", "serialization retry 123"]; + if (domainSamples.every((sample) => pattern.test(sample))) { + throw Error(`${fieldName} regular expression must not be a catch-all`); + } +} + +function matchesServiceLogPattern( + pattern: ServiceLogPattern, + value: string, +): boolean { + return typeof pattern === "string" ? pattern === value : pattern.test(value); +} + +export function readServiceLogEntries(testDir: string): ServiceLogEntry[] { + const entries: ServiceLogEntry[] = []; + const filenames = fs + .readdirSync(testDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith("-stderr.log")) + .map((entry) => entry.name) + .sort(); + + for (const filename of filenames) { + const contents = fs.readFileSync(path.join(testDir, filename), "utf-8"); + const lines = contents.split(/\r?\n/); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + const conventionalMatch = structuredServiceLogPattern.exec(line); + const kotlinRequestMatch = kotlinRequestServiceLogPattern.exec(line); + const kotlinMatch = kotlinStructuredServiceLogPattern.exec(line); + const prefixedMatch = prefixedServiceLogPattern.exec(line); + if ( + !conventionalMatch && + !kotlinRequestMatch && + !kotlinMatch && + !prefixedMatch + ) { + continue; + } + const timestamp = + (kotlinRequestMatch ?? conventionalMatch ?? kotlinMatch)?.[1] ?? ""; + const component = + kotlinRequestMatch?.[3] ?? + conventionalMatch?.[2] ?? + kotlinMatch?.[3] ?? + "<stderr>"; + const rawLevel = + kotlinRequestMatch?.[2] ?? + conventionalMatch?.[3] ?? + kotlinMatch?.[2] ?? + prefixedMatch![1]; + const message = + kotlinRequestMatch?.[4] ?? + conventionalMatch?.[4] ?? + kotlinMatch?.[4] ?? + prefixedMatch![2]; + entries.push({ + file: filename, + line: lineIndex + 1, + timestamp, + component, + level: rawLevel.toUpperCase() === "ERROR" ? "ERROR" : "WARNING", + message, + }); + } + } + return entries; +} /** * Run a shell command, return stdout. */ @@ -160,7 +326,7 @@ export async function sh( } }); const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`); - const stderrLog = fs.createWriteStream(stderrLogFileName, { + const stderrLog = t.createLogWriteStream(stderrLogFileName, { flags: "a", }); proc.stderr.pipe(stderrLog); @@ -237,7 +403,7 @@ export async function runCommand( } }); const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`); - const stderrLog = fs.createWriteStream(stderrLogFileName, { + const stderrLog = t.createLogWriteStream(stderrLogFileName, { flags: "a", }); proc.stderr.pipe(stderrLog); @@ -302,6 +468,9 @@ export class GlobalTestState { servers: http.Server[]; inShutdown: boolean = false; stepSet: Set<string> = new Set(); + private logStreamCompletions: Promise<void>[] = []; + private logStreamFailures: Error[] = []; + private allowedServiceLogs: CountedAllowedServiceLog[] = []; spanStack: string[] = []; @@ -311,6 +480,87 @@ export class GlobalTestState { this.servers = []; } + createLogWriteStream( + filename: fs.PathLike, + options?: Parameters<typeof fs.createWriteStream>[1], + ): fs.WriteStream { + const stream = fs.createWriteStream(filename, options); + this.logStreamCompletions.push( + new Promise<void>((resolve) => { + stream.once("close", resolve); + stream.once("error", (error) => { + this.logStreamFailures.push(error); + resolve(); + }); + }), + ); + return stream; + } + + allowLog(allowance: AllowedServiceLog): void { + validateServiceLogPattern(allowance.file, "allowed log file"); + validateServiceLogPattern(allowance.message, "allowed log message"); + if ( + !Number.isSafeInteger(allowance.maxOccurrences) || + allowance.maxOccurrences <= 0 + ) { + throw Error("allowed log maxOccurrences must be a positive integer"); + } + if (allowance.justification.trim().length === 0) { + throw Error("allowed log justification must not be empty"); + } + this.allowedServiceLogs.push({ ...allowance, occurrences: 0 }); + } + + allowLogs(args: { + file: ServiceLogPattern; + level: ServiceLogLevel; + messages: Array<[ServiceLogPattern, number]>; + justification: string; + }): void { + for (const [message, maxOccurrences] of args.messages) { + this.allowLog({ + file: args.file, + level: args.level, + message, + maxOccurrences, + justification: args.justification, + }); + } + } + + readUnexpectedServiceLogs(): ServiceLogEntry[] { + const unexpected: ServiceLogEntry[] = []; + for (const allowance of this.allowedServiceLogs) { + allowance.occurrences = 0; + } + for (const entry of readServiceLogEntries(this.testDir)) { + const allowance = this.allowedServiceLogs.find( + (candidate) => + candidate.occurrences < candidate.maxOccurrences && + candidate.level === entry.level && + matchesServiceLogPattern(candidate.file, entry.file) && + matchesServiceLogPattern(candidate.message, entry.message), + ); + if (allowance) { + allowance.occurrences++; + } else { + unexpected.push(entry); + } + } + return unexpected; + } + + private async waitForLogStreams(): Promise<void> { + await Promise.all(this.logStreamCompletions); + if (this.logStreamFailures.length > 0) { + throw new AggregateError( + this.logStreamFailures, + "failed to write one or more harness log files", + ); + } + } + async runSpanAsync( spanName: string, block: () => Promise<void>, @@ -453,12 +703,12 @@ export class GlobalTestState { } }); const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`; - const stdoutLog = fs.createWriteStream(stdoutLogFileName, { + const stdoutLog = this.createLogWriteStream(stdoutLogFileName, { flags: "a", autoClose: true, }); const stderrLogFileName = this.testDir + `/${logName}-stderr.log`; - const stderrLog = fs.createWriteStream(stderrLogFileName, { + const stderrLog = this.createLogWriteStream(stderrLogFileName, { flags: "a", autoClose: true, }); @@ -473,6 +723,7 @@ export class GlobalTestState { async shutdown(): Promise<void> { if (this.inShutdown) { + await this.waitForLogStreams(); return; } if (shouldLingerInTest()) { @@ -485,7 +736,7 @@ export class GlobalTestState { s.close(); s.removeAllListeners(); } - for (const p of this.procs) { + for (const p of this.procs.toReversed()) { if (p.proc.exitCode == null) { logger.trace(`killing process ${p.proc.pid}`); p.proc.kill("SIGTERM"); @@ -493,6 +744,7 @@ export class GlobalTestState { logger.trace(`done waiting for ${p.proc.pid}`); } } + await this.waitForLogStreams(); } /** @@ -672,6 +924,12 @@ export interface HarnessExchangeBankAccount { accountPaytoUri: PaytoString; wireGatewayApiBaseUrl: string; wireGatewayAuth: BasicAuth; + /** Bearer credential preferred by LibEuFin over compatibility basic auth. */ + wireGatewayBearerToken?: AccessToken; + /** Whether wirewatch should import incoming transfers for this account. */ + enableCredit?: boolean; + /** Whether the exchange may send outgoing transfers from this account. */ + enableDebit?: boolean; conversionUrl?: string; preparedTransferUrl?: string; /** @@ -1173,6 +1431,21 @@ export class LibeufinBankService `libeufin-bank edit-account -c "${this.configFile}" admin --debit_threshold=${this.bankConfig.currency}:1000`, ); + const adminToken = ( + await sh( + this.globalTestState, + "libeufin-bank-admin-token", + `libeufin-bank create-token -c "${this.configFile}" --user admin --scope readwrite`, + ) + ).trim() as AccessToken; + if (!adminToken.startsWith("secret-token:")) { + throw Error("libeufin-bank create-token returned an invalid admin token"); + } + this.adminAuth = { + type: "bearer", + token: adminToken, + }; + this.proc = this.globalTestState.spawnService( "libeufin-bank", ["serve", "-c", this.configFile], @@ -1182,24 +1455,6 @@ export class LibeufinBankService const bankClient = new TalerCoreBankHttpClient(this.corebankApiBaseUrl); // Check version succeedOrThrow(await bankClient.getConfig()); - // Create admin token - const token = succeedOrThrow( - await bankClient.createAccessToken( - "admin", - { - type: "basic", - username: "admin", - password: "admin-password", - }, - { - scope: "readwrite", - }, - ), - ); - this.adminAuth = { - type: "bearer", - token: token.access_token, - }; // Register accounts for (const acc of this.accounts) { succeedOrThrow( @@ -1437,6 +1692,7 @@ export class ExchangeService implements ExchangeServiceInterface { private currentTimetravelOffsetMs: number | undefined; private exchangeBankAccounts: HarnessExchangeBankAccount[] = []; + private exchangeBankAccountNames: string[] = []; setTimetravel(tMs: number | undefined): void { if (this.isRunning()) { @@ -1596,6 +1852,7 @@ export class ExchangeService implements ExchangeServiceInterface { "${TALER_DATA_HOME}/exchange/revocations", ); config.setString("exchange", "max_keys_caching", "forever"); + config.setString("exchange", "default_p2p_expiration", "4 weeks"); config.setString("exchange", "db", "postgres"); config.setString( "exchange-offline", @@ -1610,6 +1867,11 @@ export class ExchangeService implements ExchangeServiceInterface { ); config.setString("exchange", "serve", "tcp"); config.setString("exchange", "port", `${e.httpPort}`); + config.setString( + "exchange", + "shopping_url", + `http://${hostname}:${e.httpPort}/`, + ); config.setString("exchangedb-postgres", "config", e.database); @@ -1721,6 +1983,7 @@ export class ExchangeService implements ExchangeServiceInterface { exchangeBankAccount: HarnessExchangeBankAccount, ): Promise<void> { this.exchangeBankAccounts.push(exchangeBankAccount); + this.exchangeBankAccountNames.push(localName); const config = Configuration.load( this.configFilename, ConfigSources["taler-exchange"], @@ -1735,36 +1998,57 @@ export class ExchangeService implements ExchangeServiceInterface { "payto_uri", exchangeBankAccount.accountPaytoUri, ); - config.setString(`exchange-account-${localName}`, "enable_credit", "yes"); - config.setString(`exchange-account-${localName}`, "enable_debit", "yes"); config.setString( - `exchange-accountcredentials-${localName}`, - "wire_gateway_url", - exchangeBankAccount.wireGatewayApiBaseUrl, - ); - config.setString( - `exchange-accountcredentials-${localName}`, - "wire_gateway_auth_method", - "basic", + `exchange-account-${localName}`, + "enable_credit", + exchangeBankAccount.enableCredit === false ? "no" : "yes", ); config.setString( - `exchange-accountcredentials-${localName}`, - "username", - exchangeBankAccount.wireGatewayAuth.username, + `exchange-account-${localName}`, + "enable_debit", + exchangeBankAccount.enableDebit === false ? "no" : "yes", ); config.setString( `exchange-accountcredentials-${localName}`, - "password", - exchangeBankAccount.wireGatewayAuth.password, + "wire_gateway_url", + exchangeBankAccount.wireGatewayApiBaseUrl, ); + if (exchangeBankAccount.wireGatewayBearerToken) { + config.setString( + `exchange-accountcredentials-${localName}`, + "wire_gateway_auth_method", + "bearer", + ); + config.setString( + `exchange-accountcredentials-${localName}`, + "token", + exchangeBankAccount.wireGatewayBearerToken, + ); + } else { + config.setString( + `exchange-accountcredentials-${localName}`, + "wire_gateway_auth_method", + "basic", + ); + config.setString( + `exchange-accountcredentials-${localName}`, + "username", + exchangeBankAccount.wireGatewayAuth.username, + ); + config.setString( + `exchange-accountcredentials-${localName}`, + "password", + exchangeBankAccount.wireGatewayAuth.password, + ); + } config.writeTo(this.configFilename, { excludeDefaults: true }); } exchangeHttpProc: ProcessWrapper | undefined; - exchangeWirewatchProc: ProcessWrapper | undefined; + exchangeWirewatchProcs: ProcessWrapper[] = []; - exchangeTransferProc: ProcessWrapper | undefined; - exchangeAggregatorProc: ProcessWrapper | undefined; + exchangeTransferProcs: ProcessWrapper[] = []; + exchangeAggregatorProcs: ProcessWrapper[] = []; helperCryptoRsaProc: ProcessWrapper | undefined; helperCryptoEddsaProc: ProcessWrapper | undefined; @@ -1787,7 +2071,7 @@ export class ExchangeService implements ExchangeServiceInterface { } isRunning(): boolean { - return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc; + return this.exchangeWirewatchProcs.length > 0 || !!this.exchangeHttpProc; } /** @@ -1796,26 +2080,23 @@ export class ExchangeService implements ExchangeServiceInterface { * Useful for some tests. */ async stopWirewatch(): Promise<void> { - const wirewatch = this.exchangeWirewatchProc; - if (wirewatch) { + for (const wirewatch of this.exchangeWirewatchProcs) { wirewatch.proc.kill("SIGTERM"); await wirewatch.wait(); - this.exchangeWirewatchProc = undefined; } + this.exchangeWirewatchProcs = []; } async stopAggregator(): Promise<void> { - const agg = this.exchangeAggregatorProc; - if (agg) { + for (const agg of this.exchangeAggregatorProcs) { agg.proc.kill("SIGTERM"); await agg.wait(); - this.exchangeAggregatorProc = undefined; } + this.exchangeAggregatorProcs = []; } async startWirewatch(): Promise<void> { - const wirewatch = this.exchangeWirewatchProc; - if (wirewatch) { + if (this.exchangeWirewatchProcs.length > 0) { logger.warn("wirewatch already running"); } else { this.internalCreateWirewatchProc(); @@ -1823,24 +2104,21 @@ export class ExchangeService implements ExchangeServiceInterface { } async stop(): Promise<void> { - const wirewatch = this.exchangeWirewatchProc; - if (wirewatch) { + for (const wirewatch of this.exchangeWirewatchProcs) { wirewatch.proc.kill("SIGTERM"); await wirewatch.wait(); - this.exchangeWirewatchProc = undefined; } - const aggregatorProc = this.exchangeAggregatorProc; - if (aggregatorProc) { + this.exchangeWirewatchProcs = []; + for (const aggregatorProc of this.exchangeAggregatorProcs) { aggregatorProc.proc.kill("SIGTERM"); await aggregatorProc.wait(); - this.exchangeAggregatorProc = undefined; } - const transferProc = this.exchangeTransferProc; - if (transferProc) { + this.exchangeAggregatorProcs = []; + for (const transferProc of this.exchangeTransferProcs) { transferProc.proc.kill("SIGTERM"); await transferProc.wait(); - this.exchangeTransferProc = undefined; } + this.exchangeTransferProcs = []; const httpd = this.exchangeHttpProc; if (httpd) { httpd.proc.kill("SIGTERM"); @@ -2086,32 +2364,68 @@ export class ExchangeService implements ExchangeServiceInterface { } private internalCreateWirewatchProc() { - this.exchangeWirewatchProc = this.globalState.spawnService( - "taler-exchange-wirewatch", - [ - "-c", - this.configFilename, - "--longpoll-timeout=5s", - ...this.timetravelArgArr, - ], - `exchange-wirewatch-${this.name}`, + this.exchangeWirewatchProcs = this.workerAccounts().map((account) => + this.globalState.spawnService( + "taler-exchange-wirewatch", + [ + "-c", + this.configFilename, + "--longpoll-timeout=5s", + ...(account ? ["-a", account.section] : []), + ...this.timetravelArgArr, + ], + `exchange-wirewatch-${this.name}${account ? `-${account.name}` : ""}`, + ), ); } private internalCreateAggregatorProc() { - this.exchangeAggregatorProc = this.globalState.spawnService( - "taler-exchange-aggregator", - ["-c", this.configFilename, ...this.timetravelArgArr], - `exchange-aggregator-${this.name}`, - ); + this.exchangeAggregatorProcs = !this.exchangeBankAccounts.some( + (account) => account.enableDebit !== false, + ) + ? [] + : [ + this.globalState.spawnService( + "taler-exchange-aggregator", + ["-c", this.configFilename, ...this.timetravelArgArr], + `exchange-aggregator-${this.name}`, + ), + ]; } private internalCreateTransferProc() { - this.exchangeTransferProc = this.globalState.spawnService( - "taler-exchange-transfer", - ["-c", this.configFilename, ...this.timetravelArgArr], - `exchange-transfer-${this.name}`, - ); + this.exchangeTransferProcs = !this.exchangeBankAccounts.some( + (account) => account.enableDebit !== false, + ) + ? [] + : [ + this.globalState.spawnService( + "taler-exchange-transfer", + ["-c", this.configFilename, ...this.timetravelArgArr], + `exchange-transfer-${this.name}`, + ), + ]; + } + + private workerAccounts(): Array< + { name: string; section: string } | undefined + > { + if (this.exchangeBankAccountNames.length === 0) { + return []; + } + if (this.exchangeBankAccountNames.length === 1) { + return this.exchangeBankAccounts[0].enableCredit === false + ? [] + : [undefined]; + } + return this.exchangeBankAccountNames + .map((name, index) => ({ + name, + section: `exchange-account-${name}`, + enableCredit: this.exchangeBankAccounts[index].enableCredit !== false, + })) + .filter((account) => account.enableCredit) + .map(({ name, section }) => ({ name, section })); } async dbinit() { @@ -2168,6 +2482,22 @@ export class ExchangeService implements ExchangeServiceInterface { { ...process.env, ...(this.exchangeConfig.extraProcEnv ?? {}) }, ); + for (const message of [ + "Configuration specifies invalid value for option `TERMS_DIR' in section `exchange': Could not open directory", + "Terms of service not configured", + "Configuration specifies invalid value for option `PRIVACY_DIR' in section `exchange': Could not open directory", + "Privacy policy not configured", + ]) { + this.globalState.allowLog({ + file: `exchange-httpd-${this.name}-stderr.log`, + level: "WARNING", + message, + maxOccurrences: 1, + justification: + "the generic harness exchange intentionally has no legal documents; legal-document tests provide their own configuration", + }); + } + await this.pingUntilAvailable(); { @@ -2542,6 +2872,25 @@ export class MerchantService implements MerchantServiceInterface { await this.dbinit(); } + for (const message of [ + "Configuration fails to specify option `TERMS_ETAG' in section `merchant'!", + "Terms of service not configured", + "Configuration fails to specify option `PRIVACY_ETAG' in section `merchant'!", + "Privacy policy not configured", + "Configuration specifies invalid value for option `PHONE_REGEX' in section `merchant': no restrictions on phone number specified", + "Configuration specifies invalid value for option `HELPER_SMS' in section `merchant': no helper specified", + "Configuration specifies invalid value for option `HELPER_EMAIL' in section `merchant': no helper specified", + ]) { + this.globalState.allowLog({ + file: `merchant-httpd-${this.merchantConfig.name}-stderr.log`, + level: "WARNING", + message, + maxOccurrences: 1, + justification: + "the generic harness merchant intentionally omits legal documents and optional MFA channels; tests of those features configure them explicitly", + }); + } + this.procHttpd = this.globalState.spawnService( "taler-merchant-httpd", [ @@ -2781,6 +3130,15 @@ export interface TestRunResult { todoBugUrl?: string; reason?: string; + + unexpectedLogEntries?: ServiceLogEntry[]; + + /** + * Diagnostics recovered by the parent after a worker crash or timeout. + * The parent cannot classify these against expectations registered only in + * the terminated worker. + */ + postmortemLogEntries?: ServiceLogEntry[]; } export async function runTestWithState( @@ -2793,13 +3151,20 @@ export async function runTestWithState( const p = openPromise(); let status: TestStatus; + let reason: string | undefined; + let fatalShutdownStarted = false; const handleSignal = (s: string) => { + if (fatalShutdownStarted) { + return; + } + fatalShutdownStarted = true; logger.warn( `**** received fatal process event (${s}), terminating test ${testName}`, ); - gc.shutdownSync(); - process.exit(1); + // Keep the worker alive until children exit and their pipes close. The + // parent performs a postmortem scan after this process exits. + void gc.shutdown().finally(() => process.exit(1)); }; process.on("SIGINT", handleSignal); @@ -2866,6 +3231,7 @@ export async function runTestWithState( let steps = `${gc.testDir}/steps.txt`; fs.appendFileSync(steps, `FAIL ${(e as any).message}\n`); status = "fail"; + reason = e instanceof Error ? e.message : String(e); } finally { try { await gc.shutdown(); @@ -2876,11 +3242,36 @@ export async function runTestWithState( process.removeListener("uncaughtException", handleUncaughtException); } } + let unexpectedLogEntries: ServiceLogEntry[] | undefined; + if (!shouldLingerInTest()) { + try { + const entries = gc.readUnexpectedServiceLogs(); + if (entries.length > 0) { + unexpectedLogEntries = entries; + if (status === "pass") { + status = "fail"; + reason = "unexpected WARNING/ERROR entries in service logs"; + } + } + } catch (error) { + const scanFailure = `service log scan failed: ${ + error instanceof Error ? error.message : String(error) + }`; + if (status === "pass") { + status = "fail"; + reason = scanFailure; + } else { + reason = reason ? `${reason}; ${scanFailure}` : scanFailure; + } + } + } const afterMs = new Date().getTime(); return { name: testName, timeSec: (afterMs - startMs) / 1000, status, + reason, + unexpectedLogEntries, }; } @@ -3219,24 +3610,10 @@ export class WalletCli { this.dbfile, "run-until-done", ], - ); - } - - async runPending(): Promise<void> { - await runCommand( - this.globalTestState, - `wallet-${this.name}`, - "taler-wallet-cli", - [ - "--no-throttle", - "--skip-defaults", - "-LTRACE", - ...this.timetravelArgArr, - "--wallet-db", - this.dbfile, - "advanced", - "run-pending", - ], + { + ...process.env, + TALER_WALLET_CLI_ENABLE: "legacy", + }, ); } } diff --git a/packages/taler-harness/src/harness/log-diagnostics.test.ts b/packages/taler-harness/src/harness/log-diagnostics.test.ts @@ -0,0 +1,300 @@ +/* + 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 assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test, type TestContext } from "node:test"; +import { + GlobalTestState, + readServiceLogEntries, + runTestWithState, +} from "./harness.js"; + +function makeTestDir(t: TestContext): string { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-logs-")); + t.after(() => fs.rmSync(testDir, { recursive: true, force: true })); + return testDir; +} + +test("service log parser reads only structured stderr diagnostics", (t) => { + const testDir = makeTestDir(t); + fs.writeFileSync( + path.join(testDir, "b-stderr.log"), + [ + "2026-09-03T10:00:00.000Z worker.ts TRACE stored task with ERROR", + "2026-09-03T10:00:00.001Z worker.ts ERROR failed request", + "2026-09-03T10:00:00.002Z worker.ts WARN short warning spelling", + "03-Sep-2026T13:47:42.238 ERROR io.ktor.server.Application - unhandled request", + "03-Sep-2026T13:47:42.239 WARN libeufin-bank - deprecated authentication", + "ERROR: helper rejected input", + "03-Sep-2026T13:47:42.240 REQUEST123 WARN libeufin-bank-auth - password authentication", + "warning: lowercase severity prefix", + "continuation of the error", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(testDir, "a-stderr.log"), + "2026-09-03T12:00:00.000000+0200 daemon-42 WARNING retrying\n", + ); + fs.writeFileSync( + path.join(testDir, "ignored-stdout.log"), + "2026-09-03T10:00:00.002Z worker.ts ERROR not stderr\n", + ); + fs.writeFileSync( + path.join(testDir, "harness.log"), + "2026-09-03T10:00:00.003Z harness.ts ERROR not a service log\n", + ); + + assert.deepEqual(readServiceLogEntries(testDir), [ + { + file: "a-stderr.log", + line: 1, + timestamp: "2026-09-03T12:00:00.000000+0200", + component: "daemon-42", + level: "WARNING", + message: "retrying", + }, + { + file: "b-stderr.log", + line: 2, + timestamp: "2026-09-03T10:00:00.001Z", + component: "worker.ts", + level: "ERROR", + message: "failed request", + }, + { + file: "b-stderr.log", + line: 3, + timestamp: "2026-09-03T10:00:00.002Z", + component: "worker.ts", + level: "WARNING", + message: "short warning spelling", + }, + { + file: "b-stderr.log", + line: 4, + timestamp: "03-Sep-2026T13:47:42.238", + component: "io.ktor.server.Application", + level: "ERROR", + message: "unhandled request", + }, + { + file: "b-stderr.log", + line: 5, + timestamp: "03-Sep-2026T13:47:42.239", + component: "libeufin-bank", + level: "WARNING", + message: "deprecated authentication", + }, + { + file: "b-stderr.log", + line: 6, + timestamp: "", + component: "<stderr>", + level: "ERROR", + message: "helper rejected input", + }, + { + file: "b-stderr.log", + line: 7, + timestamp: "03-Sep-2026T13:47:42.240", + component: "libeufin-bank-auth", + level: "WARNING", + message: "password authentication", + }, + { + file: "b-stderr.log", + line: 8, + timestamp: "", + component: "<stderr>", + level: "WARNING", + message: "lowercase severity prefix", + }, + ]); +}); + +test("allowed service logs are justified, anchored, and bounded", (t) => { + const testDir = makeTestDir(t); + const state = new GlobalTestState({ testDir }); + + assert.throws( + () => + state.allowLog({ + file: /stderr\.log$/, + level: "WARNING", + message: /^expected$/, + maxOccurrences: 1, + justification: "intentional negative path", + }), + /must be anchored/, + ); + assert.throws( + () => + state.allowLog({ + file: /^service-stderr\.log$|anything$/, + level: "WARNING", + message: /^expected$/, + maxOccurrences: 1, + justification: "the second branch is not anchored at the start", + }), + /top-level alternation/, + ); + assert.throws( + () => + state.allowLog({ + file: /^(?!bank-stderr\.log$).*$/, + level: "WARNING", + message: /^expected$/, + maxOccurrences: 1, + justification: "negative lookahead creates an effective catch-all", + }), + /lookarounds/, + ); + assert.throws( + () => + state.allowLog({ + file: "service-stderr.log", + level: "WARNING", + message: "expected", + maxOccurrences: 1, + justification: " ", + }), + /justification/, + ); + assert.throws( + () => + state.allowLog({ + file: /^.*-stderr\.log$/, + level: "WARNING", + message: /^expected$/, + maxOccurrences: 1, + justification: "too broad despite being bounded", + }), + /catch-all/, + ); + assert.throws( + () => + state.allowLog({ + file: "service-stderr.log", + level: "WARNING", + message: /^.*$/, + maxOccurrences: 1, + justification: "too broad despite being bounded", + }), + /catch-all/, + ); + assert.throws( + () => + state.allowLog({ + file: "service-stderr.log", + level: "WARNING", + message: /^expected$/g, + maxOccurrences: 1, + justification: "global expressions have stateful matching", + }), + /global or sticky/, + ); + assert.throws( + () => + state.allowLog({ + file: "service-stderr.log", + level: "WARNING", + message: "expected", + maxOccurrences: 0, + justification: "an unbounded or empty allowance is invalid", + }), + /positive integer/, + ); + + state.allowLog({ + file: "service-stderr.log", + level: "WARNING", + message: "expected", + maxOccurrences: 1, + justification: "the test intentionally exercises one rejected request", + }); + fs.writeFileSync( + path.join(testDir, "service-stderr.log"), + [ + "2026-09-03T10:00:00.000Z service.ts WARNING expected", + "2026-09-03T10:00:00.001Z service.ts WARNING expected", + "", + ].join("\n"), + ); + + const expectedUnexpected = [ + { + file: "service-stderr.log", + line: 2, + timestamp: "2026-09-03T10:00:00.001Z", + component: "service.ts", + level: "WARNING", + message: "expected", + }, + ]; + assert.deepEqual(state.readUnexpectedServiceLogs(), expectedUnexpected); + assert.deepEqual(state.readUnexpectedServiceLogs(), expectedUnexpected); +}); + +test("unexpected service diagnostics fail an otherwise passing test", async (t) => { + const testDir = makeTestDir(t); + const result = await runTestWithState( + new GlobalTestState({ testDir }), + async () => { + fs.writeFileSync( + path.join(testDir, "service-stderr.log"), + "2026-09-03T10:00:00.000Z service.ts ERROR broken\n", + ); + }, + "unexpected-service-log", + ); + + assert.equal(result.status, "fail"); + assert.equal( + result.reason, + "unexpected WARNING/ERROR entries in service logs", + ); + assert.equal(result.unexpectedLogEntries?.length, 1); +}); + +test("service output is flushed before post-test scanning", async (t) => { + const testDir = makeTestDir(t); + const result = await runTestWithState( + new GlobalTestState({ testDir }), + async (state) => { + state.allowLog({ + file: "child-stderr.log", + level: "WARNING", + message: "expected child warning", + maxOccurrences: 1, + justification: "this test verifies flushing of an intentional warning", + }); + const child = state.spawnService( + process.execPath, + ["-e", "console.error('now component WARNING expected child warning')"], + "child", + ); + await child.wait(); + }, + "service-log-flush", + ); + + assert.equal(result.status, "pass"); + assert.equal(result.unexpectedLogEntries, undefined); +}); diff --git a/packages/taler-harness/src/harness/tops.ts b/packages/taler-harness/src/harness/tops.ts @@ -197,19 +197,11 @@ CONTEXT = {} [kyc-measure-inform-investigate] CHECK_NAME = form-info-investigation -# It's an INFO, so the program will never run, but we still -# must specify one. Maybe make PROGRAM not required for -# INFO-checks? #9874 -PROGRAM = preserve-investigate VOLUNTARY = YES CONTEXT = {} [kyc-measure-inform-internal-error] CHECK_NAME = form-info-internal-error -# It's an INFO, so the program will never run, but we still -# must specify one. Maybe make PROGRAM not required for -# INFO-checks? #9874 -PROGRAM = preserve-investigate VOLUNTARY = YES CONTEXT = {} diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -113,7 +113,10 @@ import { import { AML_PROGRAM_FAIL_RECOVER } from "./integrationtests/test-kyc-fail-recover-simple.js"; import { AML_PROGRAM_TEST_KYC_NEW_MEASURES_PROG } from "./integrationtests/test-kyc-new-measures-prog.js"; import { AML_PROGRAM_FROM_ATTRIBUTES_TO_CONTEXT } from "./integrationtests/test-kyc-skip-expiration.js"; -import { AML_PROGRAM_NEXT_MEASURE_FORM } from "./integrationtests/test-kyc-two-forms.js"; +import { + AML_PROGRAM_FINISH_TWO_FORMS, + AML_PROGRAM_NEXT_MEASURE_FORM, +} from "./integrationtests/test-kyc-two-forms.js"; import { getTestInfo, runTests } from "./integrationtests/testrunner.js"; import { lintExchangeDeployment, lintExchangeUrl } from "./lint.js"; import { @@ -2267,6 +2270,7 @@ const allAmlPrograms: TalerKycAml.AmlProgramDefinition[] = [ }, AML_PROGRAM_FROM_ATTRIBUTES_TO_CONTEXT, AML_PROGRAM_NEXT_MEASURE_FORM, + AML_PROGRAM_FINISH_TWO_FORMS, AML_PROGRAM_FAIL_RECOVER, AML_PROGRAM_TEST_KYC_NEW_MEASURES_PROG, ]; diff --git a/packages/taler-harness/src/integrationtests/kyc-form-withdrawal-helper.ts b/packages/taler-harness/src/integrationtests/kyc-form-withdrawal-helper.ts @@ -44,7 +44,6 @@ function adjustExchangeConfig(config: Configuration): void { config.setString("KYC-MEASURE-M1", "program", "P1"); config.setString("KYC-MEASURE-M2", "check_name", "C2"); config.setString("KYC-MEASURE-M2", "context", "{}"); - config.setString("KYC-MEASURE-M2", "program", "NONE"); config.setString( "AML-PROGRAM-P1", "command", diff --git a/packages/taler-harness/src/integrationtests/kyc-threshold-withdrawal-helper.ts b/packages/taler-harness/src/integrationtests/kyc-threshold-withdrawal-helper.ts @@ -43,7 +43,6 @@ function adjustExchangeConfig(config: Configuration): void { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); diff --git a/packages/taler-harness/src/integrationtests/merchant-kyc-auth-helper.ts b/packages/taler-harness/src/integrationtests/merchant-kyc-auth-helper.ts @@ -95,7 +95,6 @@ export function configureMerchantInfoDepositKyc(config: Configuration): void { config.setString("KYC-RULE-R1", "next_measures", "M1"); config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "P1"); config.setString("KYC-MEASURE-FM", "check_name", "SKIP"); config.setString("KYC-MEASURE-FM", "context", "{}"); config.setString("KYC-MEASURE-FM", "program", "P1"); diff --git a/packages/taler-harness/src/integrationtests/test-account-restrictions.ts b/packages/taler-harness/src/integrationtests/test-account-restrictions.ts @@ -44,6 +44,15 @@ const logger = new Logger("test-account-restrictions.ts"); * Test for credit/debit account restrictions. */ export async function runAccountRestrictionsTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: "no account acceptable by the exchange", + maxOccurrences: 1, + justification: + "the test deliberately requests a deposit to an account rejected by the exchange's configured account restriction", + }); + // Set up test environment const { walletClient, bankClient, exchange } = diff --git a/packages/taler-harness/src/integrationtests/test-coinsel-legacy-2024.ts b/packages/taler-harness/src/integrationtests/test-coinsel-legacy-2024.ts @@ -82,7 +82,7 @@ async function withdrawAndPay( }); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); await walletClient.call(WalletApiOperation.WithdrawTestBalance, { @@ -128,6 +128,23 @@ async function withdrawAndPay( * not. */ export async function runCoinselLegacy2024Test(t: GlobalTestState) { + t.allowLog({ + file: /^wallet-(?:default|legacy)-stderr\.log$/, + level: "WARNING", + message: "using forced denom selection", + maxOccurrences: 2, + justification: + "the comparison deliberately forces the same one-large/four-small withdrawal composition in both wallets so only their payment coin-selection algorithms differ", + }); + t.allowLog({ + file: "wallet-legacy-stderr.log", + level: "WARNING", + message: "using non-default coin selection: legacy-2024", + maxOccurrences: 1, + justification: + "the test explicitly starts one comparison wallet with the legacy-2024 coin-selection algorithm", + }); + const env = await createSimpleTestkudosEnvironmentV2(t); const legacySpent = await withdrawAndPay(t, { diff --git a/packages/taler-harness/src/integrationtests/test-denom-lost-complex.ts b/packages/taler-harness/src/integrationtests/test-denom-lost-complex.ts @@ -42,6 +42,30 @@ import { * where exchange doesn't offer any good denomination for a while. */ export async function runDenomLostComplexTest(t: GlobalTestState) { + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + messages: [ + [/^redenomination required for withdrawal [0-9A-Z]+$/, 1], + ["unable to download exchange keys for http://localhost:8081/", 1], + [ + /^marking denomination [0-9A-Z]+ of http:\/\/localhost:8081\/ as lost$/, + 7, + ], + ], + justification: + "the test deliberately stops the exchange, deletes its denomination keys, and verifies withdrawal recovery after all seven advertised denominations become unusable", + }); + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "ERROR", + message: + /^Assertion failed at \.\.\/src\/exchange\/taler-exchange-httpd_post-withdraw\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "The test deliberately purges the denomination helper's private keys while leaving the denomination advertised, then verifies wallet recovery after key regeneration.", + }); + // Set up test environment const { walletClient, bankClient, exchangeBankAccount, exchange } = diff --git a/packages/taler-harness/src/integrationtests/test-denom-lost.ts b/packages/taler-harness/src/integrationtests/test-denom-lost.ts @@ -28,6 +28,16 @@ import { GlobalTestState } from "../harness/harness.js"; * Run test for refreshe after a payment. */ export async function runDenomLostTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + /^marking denomination [A-Z0-9]+ of http:\/\/localhost:8081\/ as lost$/, + maxOccurrences: 7, + justification: + "the test deliberately removes the exchange's original seven denominations and verifies the wallet's lost-denomination accounting", + }); + // Set up test environment const { bank, walletClient, exchange } = diff --git a/packages/taler-harness/src/integrationtests/test-denom-revoked-balance.ts b/packages/taler-harness/src/integrationtests/test-denom-revoked-balance.ts @@ -54,6 +54,20 @@ const initialAmount = "TESTKUDOS:20" as AmountString; * The exchange is configured without fees, so every amount below is exact. */ export async function runDenomRevokedBalanceTest(t: GlobalTestState) { + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + messages: [ + [/^denomination [A-Z0-9]+ is a loss$/, 1], + [ + /^processRecoup failed: TalerError: \{"code":7005,.*"requestUrl":"http:\/\/localhost:8081\/coins\/[A-Z0-9]+\/recoup".*"httpStatusCode":404,.*"code":1001,.*"detail":"recoup"\}\}$/, + 1, + ], + ], + justification: + "the test revokes one denomination and exercises the currently disabled recoup endpoint (exchange FIXME 9828), then verifies the resulting lost balance", + }); + const { walletClient, bankClient, exchange } = await createSimpleTestkudosEnvironmentV3( t, diff --git a/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts @@ -38,6 +38,32 @@ const logger = new Logger("test-denom-unoffered"); export async function runDenomUnofferedTest(t: GlobalTestState) { // Set up test environment + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-melt\.c:\d+\.$/, + maxOccurrences: 2, + justification: + "the test purges the exchange denomination database and verifies recovery after the wallet attempts refresh with two now-unknown denominations", + }); + t.allowLog({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + message: "Deposit operation failed with HTTP code 404/1005", + maxOccurrences: 1, + justification: + "the test deliberately pays with a denomination removed from the exchange and asserts that the payment fails", + }); + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: /^denomination [A-Z0-9]+ is a loss$/, + maxOccurrences: 4, + justification: + "the test deliberately removes denominations after withdrawal and verifies the wallet's accounting for the four affected coins", + }); + const { walletClient, bankClient, diff --git a/packages/taler-harness/src/integrationtests/test-deposit-merge.ts b/packages/taler-harness/src/integrationtests/test-deposit-merge.ts @@ -36,6 +36,15 @@ import { } from "../harness/harness.js"; export async function runDepositMergeTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: "exception in refresh session: {", + maxOccurrences: 9, + justification: + "the test repeatedly restarts the exchange to advance time while up to nine refresh attempts from its three deposits are active, and verifies that they recover after the planned outages", + }); + // Set up test environment const { walletClient, bankClient, exchange } = diff --git a/packages/taler-harness/src/integrationtests/test-deposit-too-large.ts b/packages/taler-harness/src/integrationtests/test-deposit-too-large.ts @@ -48,6 +48,16 @@ import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; * test is a bit slower than other tests. */ export async function runDepositTooLargeTest(t: GlobalTestState) { + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-batch-deposit\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "The test deliberately sends one 100-coin batch above the exchange's 64-coin protocol limit and verifies the 400 response.", + }); + // Set up test environment const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t); diff --git a/packages/taler-harness/src/integrationtests/test-donau-charity-management.ts b/packages/taler-harness/src/integrationtests/test-donau-charity-management.ts @@ -18,7 +18,6 @@ * Imports. */ import { - AccessToken, CharityRequest, DonauHttpClient, encodeCrock, @@ -60,7 +59,7 @@ export async function runDonauCharityManagementTest(t: GlobalTestState) { const merchantPub = encodeCrock(getRandomBytes(32)); - const tok: AccessToken = "secret-token:secret" as AccessToken; + const tok = donau.adminAccessToken; const createBody = { charity_name: "Test", diff --git a/packages/taler-harness/src/integrationtests/test-donau-idempotency.ts b/packages/taler-harness/src/integrationtests/test-donau-idempotency.ts @@ -18,7 +18,6 @@ * Imports. */ import { - AccessToken, AmountString, DonauHttpClient, j2s, @@ -94,7 +93,7 @@ export async function runDonauIdempotencyTest(t: GlobalTestState) { const currentYear = new Date().getFullYear(); const charityResp = succeedOrThrow( - await donauClient.createCharity("" as AccessToken, { + await donauClient.createCharity(donau.adminAccessToken, { charity_pub: merchantPub, current_year: currentYear, max_per_year: "TESTKUDOS:1000", diff --git a/packages/taler-harness/src/integrationtests/test-donau-keychange.ts b/packages/taler-harness/src/integrationtests/test-donau-keychange.ts @@ -18,7 +18,6 @@ * Imports. */ import { - AccessToken, AmountString, ConfirmPayResultType, DonauHttpClient, @@ -92,7 +91,7 @@ export async function runDonauKeychangeTest(t: GlobalTestState) { const currentYear = new Date().getFullYear(); const charityResp = succeedOrThrow( - await donauClient.createCharity("" as AccessToken, { + await donauClient.createCharity(donau.adminAccessToken, { charity_pub: merchantPub, current_year: currentYear, max_per_year: "TESTKUDOS:1000", diff --git a/packages/taler-harness/src/integrationtests/test-donau-minus-t.ts b/packages/taler-harness/src/integrationtests/test-donau-minus-t.ts @@ -18,7 +18,6 @@ * Imports. */ import { - AccessToken, ConfirmPayResultType, DonauHttpClient, j2s, @@ -90,7 +89,7 @@ export async function runDonauMinusTTest(t: GlobalTestState) { const currentYear = new Date().getFullYear(); const charityResp = succeedOrThrow( - await donauClient.createCharity("" as AccessToken, { + await donauClient.createCharity(donau.adminAccessToken, { charity_pub: merchantPub, current_year: currentYear, max_per_year: "TESTKUDOS:1000", diff --git a/packages/taler-harness/src/integrationtests/test-donau-multi.ts b/packages/taler-harness/src/integrationtests/test-donau-multi.ts @@ -18,7 +18,6 @@ * Imports. */ import { - AccessToken, ConfirmPayResultType, DonauHttpClient, Duration, @@ -96,7 +95,7 @@ export async function runDonauMultiTest(t: GlobalTestState) { const currentYear = new Date().getFullYear(); const charityResp1 = succeedOrThrow( - await donauClient.createCharity("" as AccessToken, { + await donauClient.createCharity(donau.adminAccessToken, { charity_pub: merchantPub1, current_year: currentYear, max_per_year: "TESTKUDOS:1000", @@ -145,7 +144,7 @@ export async function runDonauMultiTest(t: GlobalTestState) { console.log(`pub2: ${merchantPub2}`); const charityResp2 = succeedOrThrow( - await donauClient.createCharity("" as AccessToken, { + await donauClient.createCharity(donau.adminAccessToken, { charity_pub: merchantPub2, current_year: currentYear, max_per_year: "TESTKUDOS:1000", diff --git a/packages/taler-harness/src/integrationtests/test-donau.ts b/packages/taler-harness/src/integrationtests/test-donau.ts @@ -18,7 +18,6 @@ * Imports. */ import { - AccessToken, AmountString, ConfirmPayResultType, DonauHttpClient, @@ -92,7 +91,7 @@ export async function runDonauTest(t: GlobalTestState) { const currentYear = new Date().getFullYear(); const charityResp = succeedOrThrow( - await donauClient.createCharity("" as AccessToken, { + await donauClient.createCharity(donau.adminAccessToken, { charity_pub: merchantPub, current_year: currentYear, max_per_year: "TESTKUDOS:1000", diff --git a/packages/taler-harness/src/integrationtests/test-exchange-currency-change.ts b/packages/taler-harness/src/integrationtests/test-exchange-currency-change.ts @@ -45,6 +45,16 @@ import { export async function runExchangeCurrencyChangeTest( t: GlobalTestState, ): Promise<void> { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + "exchange http://localhost:8081/ changed its key set (master public key changed)", + maxOccurrences: 1, + justification: + "the test replaces the exchange at the same URL with one using another currency and verifies that the wallet detects the incompatible key set", + }); + const { walletClient, exchange, bankClient, exchangeBankAccount } = await createSimpleTestkudosEnvironmentV3(t); @@ -76,6 +86,24 @@ export async function runExchangeCurrencyChangeTest( httpPort: 8081, database: db2.connStr, }); + await exchange2.modifyConfig(async (config) => { + config.setString("currency-kudostwo", "enabled", "yes"); + config.setString("currency-kudostwo", "name", "Kudos Two"); + config.setString("currency-kudostwo", "code", "KUDOSTWO"); + config.setString("currency-kudostwo", "fractional_input_digits", "2"); + config.setString("currency-kudostwo", "fractional_normal_digits", "2"); + config.setString( + "currency-kudostwo", + "fractional_trailing_zero_digits", + "2", + ); + config.setString("currency-kudostwo", "alt_unit_names", '{"0":"K2"}'); + config.setString( + "currency-kudostwo", + "common_amounts", + "KUDOSTWO:5 KUDOSTWO:10 KUDOSTWO:25 KUDOSTWO:50", + ); + }); await exchange2.addBankAccount("1", exchangeBankAccount); exchange2.addCoinConfigList(defaultCoinConfig.map((x) => x("KUDOSTWO"))); diff --git a/packages/taler-harness/src/integrationtests/test-exchange-denom-storage.ts b/packages/taler-harness/src/integrationtests/test-exchange-denom-storage.ts @@ -40,6 +40,16 @@ import { GlobalTestState } from "../harness/harness.js"; export async function runExchangeDenomStorageTest( t: GlobalTestState, ): Promise<void> { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + /^marking denomination [A-Z0-9]+ of http:\/\/localhost:8081\/ as lost$/, + maxOccurrences: 7, + justification: + "the test advances the exchange until its original seven denominations disappear from /keys and verifies that the wallet preserves the required records", + }); + // Set up test environment const { walletClient, bankClient, exchange, merchant } = @@ -124,7 +134,7 @@ export async function runExchangeDenomStorageTest( }); await freshWalletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); await freshWalletClient.call(WalletApiOperation.TestingWaitExchangeReady, { exchangeBaseUrl: exchange.baseUrl, diff --git a/packages/taler-harness/src/integrationtests/test-exchange-ephemeral.ts b/packages/taler-harness/src/integrationtests/test-exchange-ephemeral.ts @@ -78,7 +78,7 @@ export async function runExchangeEphemeralTest(t: GlobalTestState) { await t.runSpanAsync("ephemeral entry removed on cold start", async () => { await w1.walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, ephemeral: true, }); const entry = await w1.walletClient.call( diff --git a/packages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts b/packages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts @@ -61,6 +61,16 @@ interface KeysRequestRecord { export async function runExchangeKeysCherrypickTest( t: GlobalTestState, ): Promise<void> { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + /^marking denomination [A-Z0-9]+ of http:\/\/localhost:8091\/ as lost$/, + maxOccurrences: 7, + justification: + "the test replaces the exchange key set after time travel and verifies handling of the seven prior denominations omitted from the new response", + }); + // Set up test environment const db = await setupDb(t); diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts b/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts @@ -147,7 +147,7 @@ export async function runExchangeManagementFaultTest( // Try before fault is injected await wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: faultyExchange.baseUrl, + uri: faultyExchange.baseUrl, }); exchangesList = await wallet.client.call( @@ -197,7 +197,7 @@ export async function runExchangeManagementFaultTest( const err1 = await t.assertThrowsTalerErrorAsync(() => wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: faultyExchange.baseUrl, + uri: faultyExchange.baseUrl, }), ); @@ -245,7 +245,7 @@ export async function runExchangeManagementFaultTest( const err2 = await t.assertThrowsTalerErrorAsync(() => wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: faultyExchange.baseUrl, + uri: faultyExchange.baseUrl, }), ); diff --git a/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts b/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts @@ -43,6 +43,16 @@ import { export async function runExchangeMasterPubChangeTest( t: GlobalTestState, ): Promise<void> { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + "exchange http://localhost:8081/ changed its key set (master public key changed)", + maxOccurrences: 1, + justification: + "the test replaces the exchange at the same URL under a new master key and verifies that the wallet records the change", + }); + // Set up test environment const { walletClient, exchange, bankClient, exchangeBankAccount } = diff --git a/packages/taler-harness/src/integrationtests/test-forced-selection.ts b/packages/taler-harness/src/integrationtests/test-forced-selection.ts @@ -26,13 +26,22 @@ import { createSimpleTestkudosEnvironmentV2 } from "../harness/environments.js"; * Run test for forced denom/coin selection. */ export async function runForcedSelectionTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: "using forced denom selection", + maxOccurrences: 3, + justification: + "the test explicitly invokes the testing-only forced denomination selector for three withdrawals", + }); + // Set up test environment const { walletClient, bank, exchange, merchant, merchantAdminAccessToken } = await createSimpleTestkudosEnvironmentV2(t); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); await walletClient.call(WalletApiOperation.WithdrawTestBalance, { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-amp-failure.ts b/packages/taler-harness/src/integrationtests/test-kyc-amp-failure.ts @@ -67,6 +67,21 @@ function adjustExchangeConfig(config: Configuration) { * - Test making decisions with default rules. */ export async function runKycAmpFailureTest(t: GlobalTestState) { + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "ERROR", + message: "I am a failed KYC program, oh my!", + maxOccurrences: 1, + justification: + "the test deliberately launches the harness AML helper in its named fail mode and verifies non-zero-program handling", + }); + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [["AML program p1 returned non-zero status 3/1", 1]], + justification: + "the test deliberately runs an AML program that exits unsuccessfully and verifies failure handling", + }); // Set up test environment // FIXME: Reduced test environment without merchant suffices diff --git a/packages/taler-harness/src/integrationtests/test-kyc-amp-timeout.ts b/packages/taler-harness/src/integrationtests/test-kyc-amp-timeout.ts @@ -67,6 +67,13 @@ function adjustExchangeConfig(config: Configuration) { * - Test making decisions with default rules. */ export async function runKycAmpTimeoutTest(t: GlobalTestState) { + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [["AML program p1 ran into timeout", 1]], + justification: + "the test deliberately runs an AML program beyond MAX_AML_PROGRAM_RUNTIME and verifies timeout handling", + }); // Set up test environment // FIXME: Reduced test environment without merchant suffices diff --git a/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal-change-manual.ts b/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal-change-manual.ts @@ -43,7 +43,6 @@ function adjustExchangeConfig(config: Configuration): void { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); @@ -53,6 +52,13 @@ function adjustExchangeConfig(config: Configuration): void { export async function runKycBalanceWithdrawalChangeManualTest( t: GlobalTestState, ) { + t.allowLogs({ + file: "merchant-kyccheck-testmerchant-1-stderr.log", + level: "WARNING", + messages: [["Exchange did not responded to /kyc-check request!", 2]], + justification: + "the test deliberately stops the exchange while changing its KYC configuration, so two scheduled merchant checks observe the outage", + }); // Set up test environment const { walletClient, bankClient, exchange, exchangeBankAccount, bank } = diff --git a/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts @@ -61,7 +61,6 @@ function adjustExchangeConfig(config: Configuration): void { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); @@ -136,7 +135,9 @@ export async function runKycBalanceWithdrawalTest(t: GlobalTestState) { t.assertDeepEqual( exchangeEntry.walletKycRequestedThreshold, - "TESTKUDOS:10", + // The wallet asks the exchange to authorize its expected balance, not + // merely the lower limit that this withdrawal crossed. + "TESTKUDOS:19.84", ); const kycReservePub = exchangeEntry.walletKycReservePub; diff --git a/packages/taler-harness/src/integrationtests/test-kyc-deposit-aggregate-implicit-auth.ts b/packages/taler-harness/src/integrationtests/test-kyc-deposit-aggregate-implicit-auth.ts @@ -44,7 +44,6 @@ function adjustExchangeConfig(config: Configuration): void { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-deposit-aggregate.ts b/packages/taler-harness/src/integrationtests/test-kyc-deposit-aggregate.ts @@ -46,7 +46,6 @@ function adjustExchangeConfig(config: Configuration): void { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-deposit-deposit.ts b/packages/taler-harness/src/integrationtests/test-kyc-deposit-deposit.ts @@ -54,7 +54,6 @@ function adjustExchangeConfig(config: Configuration) { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-deposit-kycauth.ts b/packages/taler-harness/src/integrationtests/test-kyc-deposit-kycauth.ts @@ -35,7 +35,6 @@ function adjustExchangeConfig(config: Configuration) { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); @@ -69,7 +68,7 @@ export async function runKycDepositKycauthTest(t: GlobalTestState) { await wres.withdrawalFinishedCond; await w1.walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); await w1.walletClient.call(WalletApiOperation.SetExchangeTosAccepted, { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts b/packages/taler-harness/src/integrationtests/test-kyc-exchange-wallet.ts @@ -49,7 +49,6 @@ export async function runKycExchangeWalletTest(t: GlobalTestState) { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); @@ -58,7 +57,7 @@ export async function runKycExchangeWalletTest(t: GlobalTestState) { }); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); await walletClient.call(WalletApiOperation.StartExchangeWalletKyc, { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-fail-recover-simple.ts b/packages/taler-harness/src/integrationtests/test-kyc-fail-recover-simple.ts @@ -116,6 +116,13 @@ function adjustExchangeConfig(config: Configuration) { } export async function runKycFailRecoverSimpleTest(t: GlobalTestState) { + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [["AML program p1 returned non-zero status 3/1", 1]], + justification: + "the test deliberately makes its AML program exit unsuccessfully and verifies fallback and later recovery", + }); // Set up test environment const { exchangeApi } = await createKycTestkudosEnvironmentFull(t, { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-form-bad-measure.ts b/packages/taler-harness/src/integrationtests/test-kyc-form-bad-measure.ts @@ -51,7 +51,6 @@ function adjustExchangeConfig(config: Configuration) { config.setString("KYC-MEASURE-M2", "check_name", "C2"); config.setString("KYC-MEASURE-M2", "context", "{}"); - config.setString("KYC-MEASURE-M2", "program", "NONE"); config.setString( "AML-PROGRAM-P1", @@ -81,6 +80,13 @@ function adjustExchangeConfig(config: Configuration) { } export async function runKycFormBadMeasureTest(t: GlobalTestState) { + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [["Expected form `test', got `full_name_and_birthdate'", 1]], + justification: + "the test deliberately uploads a different form than the configured measure and verifies rejection", + }); // Set up test environment const { walletClient, bankClient, exchange } = diff --git a/packages/taler-harness/src/integrationtests/test-kyc-form-validation.ts b/packages/taler-harness/src/integrationtests/test-kyc-form-validation.ts @@ -113,6 +113,13 @@ function adjustExchangeConfig(config: Configuration) { } export async function runKycFormValidationTest(t: GlobalTestState) { + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [["Expected form `firstform', got `does-not-exist-123'", 1]], + justification: + "the test deliberately uploads an unknown form identifier and verifies rejection", + }); // Set up test environment const { exchangeApi } = await createKycTestkudosEnvironmentFull(t, { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts @@ -46,7 +46,6 @@ function adjustExchangeConfig(config: Configuration) { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-form.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-form.ts @@ -48,6 +48,18 @@ function adjustExchangeConfig(config: Configuration) { } export async function runKycMerchantDepositFormTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "Exchange `http://localhost:8081/' 1 wire methods are not applicable to this order", + 1, + ], + ], + justification: + "the test deliberately attempts order creation before the merchant's bank account completes the required KYC form", + }); // Set up test environment const { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit.ts @@ -54,6 +54,18 @@ function adjustExchangeConfig(config: Configuration) { } export async function runKycMerchantDepositTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "Exchange `http://localhost:8081/' 1 wire methods are not applicable to this order", + 1, + ], + ], + justification: + "the test deliberately attempts order creation before the merchant bank account satisfies exchange KYC", + }); // Set up test environment const { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-new-measure.ts b/packages/taler-harness/src/integrationtests/test-kyc-new-measure.ts @@ -63,11 +63,9 @@ export async function runKycNewMeasureTest(t: GlobalTestState) { config.setString("KYC-MEASURE-M2", "check_name", "C2"); config.setString("KYC-MEASURE-M2", "context", "{}"); - config.setString("KYC-MEASURE-M2", "program", "P2"); config.setString("KYC-MEASURE-M3", "check_name", "C3"); config.setString("KYC-MEASURE-M3", "context", "{}"); - config.setString("KYC-MEASURE-M3", "program", "P2"); config.setString( "AML-PROGRAM-P1", diff --git a/packages/taler-harness/src/integrationtests/test-kyc-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-kyc-peer-pull.ts @@ -55,7 +55,6 @@ export async function runKycPeerPullTest(t: GlobalTestState) { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-peer-push.ts b/packages/taler-harness/src/integrationtests/test-kyc-peer-push.ts @@ -54,7 +54,6 @@ export async function runKycPeerPushTest(t: GlobalTestState) { config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "my check!"); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-two-forms.ts b/packages/taler-harness/src/integrationtests/test-kyc-two-forms.ts @@ -67,6 +67,23 @@ export const AML_PROGRAM_NEXT_MEASURE_FORM: TalerKycAml.AmlProgramDefinition = { requiredContext: [], }; +export const AML_PROGRAM_FINISH_TWO_FORMS: TalerKycAml.AmlProgramDefinition = { + name: "TWO_FORMS_DONE", + logic: async () => ({ + to_investigate: false, + properties: {}, + events: [], + new_rules: { + expiration_time: { t_s: 1 }, + rules: [], + custom_measures: {}, + }, + }), + requiredAttributes: [], + requiredInputs: [], + requiredContext: [], +}; + function adjustExchangeConfig(config: Configuration) { configureCommonKyc(config); @@ -84,7 +101,7 @@ function adjustExchangeConfig(config: Configuration) { config.setString("KYC-MEASURE-M2", "check_name", "C2"); config.setString("KYC-MEASURE-M2", "context", "{}"); - config.setString("KYC-MEASURE-M2", "program", "NONE"); + config.setString("KYC-MEASURE-M2", "program", "P2"); config.setString("KYC-MEASURE-M3", "check_name", "C3"); config.setString("KYC-MEASURE-M3", "context", "{}"); @@ -104,6 +121,16 @@ function adjustExchangeConfig(config: Configuration) { config.setString("AML-PROGRAM-P1", "description_i18n", "{}"); config.setString("AML-PROGRAM-P1", "fallback", "FREEZE"); + config.setString( + "AML-PROGRAM-P2", + "command", + "taler-harness aml-program run-program --name TWO_FORMS_DONE", + ); + config.setString("AML-PROGRAM-P2", "enabled", "true"); + config.setString("AML-PROGRAM-P2", "description", "finish both forms"); + config.setString("AML-PROGRAM-P2", "description_i18n", "{}"); + config.setString("AML-PROGRAM-P2", "fallback", "FREEZE"); + config.setString("KYC-CHECK-C1", "type", "FORM"); config.setString("KYC-CHECK-C1", "form_name", "firstform"); config.setString("KYC-CHECK-C1", "description", "starting check!"); diff --git a/packages/taler-harness/src/integrationtests/test-kyc-wallet-deposit-abort.ts b/packages/taler-harness/src/integrationtests/test-kyc-wallet-deposit-abort.ts @@ -80,6 +80,18 @@ function adjustExchangeConfig(config: Configuration) { } export async function runKycWalletDepositAbortTest(t: GlobalTestState) { + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [ + [ + "External protocol violation detected at ../src/exchange/taler-exchange-httpd_post-coins-COIN_PUB-refund.c:262.", + 1, + ], + ], + justification: + "aborting the KYC-blocked deposit deliberately asks the exchange to refund a coin whose deposit was never recorded and verifies the wallet recovery path", + }); // Set up test environment const { bankClient, exchange, walletClient } = diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts @@ -1040,6 +1040,9 @@ export async function runLibeufinBankWebuiMoneyFlowsTest(t: GlobalTestState) { // conversion is enabled, so activate it only after the standard environment // has registered that account. const libeufinBank = bank as LibeufinBankService; + // The bank restart below is deliberate setup, not a wirewatch failure. + // Stop the poller first so it never observes the temporary outage. + await exchange.stopWirewatch(); await libeufinBank.stop(); const nexus = await LibeufinNexusService.create(t, { currency: "FOO", @@ -1051,8 +1054,16 @@ export async function runLibeufinBankWebuiMoneyFlowsTest(t: GlobalTestState) { config.setString("libeufin-bank", "ALLOW_CONVERSION", "yes"); config.setString("libeufin-bank", "FIAT_CURRENCY", "FOO"); config.setString("libeufin-bank", "WIRE_TRANSFER_FEES", "TESTKUDOS:0.1"); + config.setString("currency-foo", "enabled", "yes"); + config.setString("currency-foo", "name", "Foo"); + config.setString("currency-foo", "code", "FOO"); + config.setString("currency-foo", "fractional_input_digits", "2"); + config.setString("currency-foo", "fractional_normal_digits", "2"); + config.setString("currency-foo", "fractional_trailing_zero_digits", "2"); + config.setString("currency-foo", "alt_unit_names", '{"0":"FOO"}'); }); await libeufinBank.start({ noReset: true }); + await exchange.startWirewatch(); const user = "webui-money"; const password = "webui-money-password"; const api = new TalerCoreBankHttpClient(bank.corebankApiBaseUrl); diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts @@ -20,6 +20,8 @@ import { CreditDebitIndicator, Logger, + succeedOrThrow, + TalerCoreBankHttpClient, TalerCorebankApiClient, TalerWireGatewayHttpClient, TransactionMajorState, @@ -34,6 +36,7 @@ import { createWalletDaemonWithClient } from "../harness/environments.js"; import { ExchangeService, GlobalTestState, + HarnessExchangeBankAccount, LibeufinBankService, MerchantService, getTestHarnessPaytoForLabel, @@ -80,7 +83,7 @@ export async function runLibeufinBankTest(t: GlobalTestState) { logger.info("creating bank account for the exchange"); - exchange.addBankAccount("1", { + const exchangeBankAccount: HarnessExchangeBankAccount = { wireGatewayApiBaseUrl, wireGatewayAuth: { type: "basic", @@ -88,7 +91,7 @@ export async function runLibeufinBankTest(t: GlobalTestState) { password: exchangeBankPw, }, accountPaytoUri: exchangePayto, - }); + }; bank.setSuggestedExchange(exchange); @@ -96,6 +99,40 @@ export async function runLibeufinBankTest(t: GlobalTestState) { await bank.pingUntilAvailable(); + const adminUser = "admin"; + const adminPassword = "admin-password"; + const adminAuth = bank.getAdminAuth(); + if (adminAuth.type !== "bearer") { + throw Error("libeufin-bank did not provide an admin bearer token"); + } + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: adminUser, + password: adminPassword, + token: adminAuth.token, + }, + preferBearerAuth: true, + }); + + // Wirewatch starts with the exchange, so its bank account must already + // exist and accept the configured credentials. + await bankClient.registerAccountExtended({ + name: "Exchange", + password: exchangeBankPw, + username: exchangeBankUsername, + is_taler_exchange: true, + payto_uri: exchangePayto, + }); + const coreBankClient = new TalerCoreBankHttpClient(bank.corebankApiBaseUrl); + exchangeBankAccount.wireGatewayBearerToken = succeedOrThrow( + await coreBankClient.createAccessToken( + exchangeBankUsername, + exchangeBankAccount.wireGatewayAuth, + { scope: "wiregateway" }, + ), + ).access_token; + await exchange.addBankAccount("1", exchangeBankAccount); + exchange.addOfferedCoins(defaultCoinConfig); await exchange.start(); @@ -128,30 +165,8 @@ export async function runLibeufinBankTest(t: GlobalTestState) { console.log("setup done!"); - const adminUser = "admin"; - const adminPassword = "admin-password"; - - const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { - auth: { - username: adminUser, - password: adminPassword, - }, - }); - - // register exchange bank account - await bankClient.registerAccountExtended({ - name: "Exchange", - password: exchangeBankPw, - username: exchangeBankUsername, - is_taler_exchange: true, - payto_uri: exchangePayto, - }); - const bankUser = await bankClient.registerAccount("user1", "password1"); - bankClient.setAuth({ - username: "user1", - password: "password1", - }); + bankClient.setAuth(bankUser); // Make sure that registering twice results in a 409 Conflict // { @@ -180,11 +195,7 @@ export async function runLibeufinBankTest(t: GlobalTestState) { ); await wireGatewayApiAdminClient.addIncoming({ - auth: { - type: "basic", - username: adminUser, - password: adminPassword, - }, + auth: adminAuth, body: { amount: "TESTKUDOS:115", debit_account: bankUser.accountPaytoUri, diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-conversion.ts b/packages/taler-harness/src/integrationtests/test-libeufin-conversion.ts @@ -32,6 +32,7 @@ import { createWalletDaemonWithClient } from "../harness/environments.js"; import { ExchangeService, GlobalTestState, + HarnessExchangeBankAccount, LibeufinBankService, LibeufinNexusService, MerchantService, @@ -87,7 +88,7 @@ export async function runLibeufinConversionTest(t: GlobalTestState) { logger.info("creating bank account for the exchange"); - exchange.addBankAccount("1", { + const exchangeBankAccount: HarnessExchangeBankAccount = { wireGatewayApiBaseUrl, wireGatewayAuth: { type: "basic", @@ -96,18 +97,7 @@ export async function runLibeufinConversionTest(t: GlobalTestState) { }, accountPaytoUri: exchangePayto, conversionUrl: new URL("conversion-info/", bank.baseUrl).href, - }); - - exchange.addBankAccount("conv", { - wireGatewayApiBaseUrl, - wireGatewayAuth: { - type: "basic", - username: exchangeBankUsername, - password: exchangeBankPw, - }, - accountPaytoUri: exchangePayto, - conversionUrl: new URL("conversion-info/", bank.baseUrl).href, - }); + }; bank.setSuggestedExchange(exchange); @@ -115,6 +105,38 @@ export async function runLibeufinConversionTest(t: GlobalTestState) { await bank.pingUntilAvailable(); + const adminUser = "admin"; + const adminPassword = "admin-password"; + const adminAuth = bank.getAdminAuth(); + if (adminAuth.type !== "bearer") { + throw Error("libeufin-bank did not provide an admin bearer token"); + } + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: adminUser, + password: adminPassword, + token: adminAuth.token, + }, + preferBearerAuth: true, + }); + await bankClient.registerAccountExtended({ + name: "exchange", + password: exchangeBankPw, + username: exchangeBankUsername, + is_taler_exchange: true, + payto_uri: exchangePayto, + }); + const bankUser = await bankClient.registerAccount("user1", "password1"); + const cbc = new TalerCoreBankHttpClient(bank.baseUrl); + exchangeBankAccount.wireGatewayBearerToken = succeedOrThrow( + await cbc.createAccessToken( + exchangeBankUsername, + exchangeBankAccount.wireGatewayAuth, + { scope: "wiregateway" }, + ), + ).access_token; + await exchange.addBankAccount("1", exchangeBankAccount); + exchange.addOfferedCoins(defaultCoinConfig); await exchange.start(); @@ -147,46 +169,9 @@ export async function runLibeufinConversionTest(t: GlobalTestState) { console.log("setup done!"); - const adminUser = "admin"; - const adminPassword = "admin-password"; - - const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { - auth: { - username: adminUser, - password: adminPassword, - }, - }); - - // register exchange bank account - await bankClient.registerAccountExtended({ - name: "exchange", - password: exchangeBankPw, - username: exchangeBankUsername, - is_taler_exchange: true, - payto_uri: exchangePayto, - }); - - await bankClient.registerAccount("user1", "password1"); - bankClient.setAuth({ - username: "user1", - password: "password1", - }); - - const cbc = new TalerCoreBankHttpClient(bank.baseUrl); - const adminTokResp = succeedOrThrow( - await cbc.createAccessToken( - adminUser, - { - type: "basic", - username: adminUser, - password: adminPassword, - }, - { - scope: "readwrite", - }, - ), - ); + bankClient.setAuth(bankUser); + await exchange.stopWirewatch(); await bank.stop(); // libeufin needs the exchange account to be created *before* conversion @@ -194,11 +179,24 @@ export async function runLibeufinConversionTest(t: GlobalTestState) { bank.changeConfig((conf) => { conf.setString("libeufin-bank", "allow_conversion", "yes"); conf.setString("libeufin-bank", "fiat_currency", "FOO"); + conf.setString("currency-foo", "enabled", "yes"); + conf.setString("currency-foo", "name", "Foo"); + conf.setString("currency-foo", "code", "FOO"); + conf.setString("currency-foo", "fractional_input_digits", "2"); + conf.setString("currency-foo", "fractional_normal_digits", "2"); + conf.setString("currency-foo", "fractional_trailing_zero_digits", "2"); + conf.setString("currency-foo", "alt_unit_names", '{"0":"FOO"}'); }); await bank.start({ noReset: true, }); + await exchange.startWirewatch(); + + const conversionAdminAuth = bank.getAdminAuth(); + if (conversionAdminAuth.type !== "bearer") { + throw Error("libeufin-bank did not provide an admin bearer token"); + } const cc = new TalerBankConversionHttpClient( bank.baseUrl + `conversion-info/`, @@ -208,7 +206,7 @@ export async function runLibeufinConversionTest(t: GlobalTestState) { await cc.updateConversionRate( { type: "bearer", - token: adminTokResp.access_token, + token: conversionAdminAuth.token, }, { cashin_fee: "TESTKUDOS:0", diff --git a/packages/taler-harness/src/integrationtests/test-merchant-acctsel.ts b/packages/taler-harness/src/integrationtests/test-merchant-acctsel.ts @@ -22,6 +22,7 @@ import { j2s, PaytoString, succeedOrThrow, + TalerCorebankApiClient, TalerMerchantInstanceHttpClient, TransactionMajorState, TransactionMinorState, @@ -30,6 +31,7 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { createWalletDaemonWithClient } from "../harness/environments.js"; import { + BankService, ExchangeService, GlobalTestState, MerchantService, @@ -37,9 +39,38 @@ import { } from "../harness/harness.js"; export async function runMerchantAcctselTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "Exchange `http://localhost:8081/' 1 wire methods are not applicable to this order", + 1, + ], + [ + "Exchange `http://localhost:8181/' 1 wire methods are not applicable to this order", + 1, + ], + ], + justification: + "each account-selection scenario deliberately excludes the exchange using the other wire method", + }); // Set up test environment const db = await setupDb(t); + const exchange2Db = await setupDb(t, { nameSuffix: "exchange2" }); + + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "TESTKUDOS", + database: db.connStr, + httpPort: 8082, + }); + await bank.start(); + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { username: "admin", password: "admin-password" }, + }); const { walletClient } = await createWalletDaemonWithClient(t, { name: "wallet", @@ -62,11 +93,14 @@ export async function runMerchantAcctselTest(t: GlobalTestState) { await exchange1.addBankAccount("1", { accountPaytoUri: paytoXtb, - wireGatewayApiBaseUrl: "http://localhost:8082/", + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, wireGatewayAuth: { type: "basic", password: "test", - username: "test", + username: "exchange", }, }); @@ -74,19 +108,35 @@ export async function runMerchantAcctselTest(t: GlobalTestState) { name: "testexchange-2", currency: "TESTKUDOS", httpPort: 8181, - database: db.connStr, + database: exchange2Db.connStr, }); await exchange2.addBankAccount("1", { accountPaytoUri: paytoIban, - wireGatewayApiBaseUrl: "http://localhost:8082/", + wireGatewayApiBaseUrl: new URL( + "accounts/exchange-iban/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, wireGatewayAuth: { type: "basic", password: "test", - username: "test", + username: "exchange-iban", }, }); + for (const [username, paytoUri] of [ + ["exchange", paytoXtb], + ["exchange-iban", paytoIban], + ] as const) { + await bankClient.registerAccountExtended({ + name: username, + password: "test", + username, + is_taler_exchange: true, + payto_uri: paytoUri, + }); + } + exchange2.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); const merchant = await MerchantService.create(t, { diff --git a/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts b/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts @@ -177,6 +177,27 @@ export async function createConfusedMerchantTestkudosEnvironment( * but sending coins from the same exchange with a different URL. */ export async function runMerchantExchangeConfusionTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + ["Exchange `http://localhost:9081/' not configured", 1], + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_post-orders-ORDER_ID-pay.c:1926.", + 1, + ], + ], + justification: + "the test deliberately gives the merchant an unconfigured proxy exchange URL and verifies that payment is rejected", + }); + t.allowLog({ + file: "wallet-default-stderr.log", + level: "WARNING", + message: "exchange base URL mismatch", + maxOccurrences: 3, + justification: + "the test deliberately proxies one exchange under a conflicting URL and exercises the wallet's mismatch handling for three affected coins", + }); // Set up test environment const { diff --git a/packages/taler-harness/src/integrationtests/test-merchant-exchange-duplicate.ts b/packages/taler-harness/src/integrationtests/test-merchant-exchange-duplicate.ts @@ -22,6 +22,7 @@ import { j2s, PaytoString, succeedOrThrow, + TalerCorebankApiClient, TalerExchangeHttpClient, TalerMerchantInstanceHttpClient, TransactionMajorState, @@ -31,6 +32,7 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { createWalletDaemonWithClient } from "../harness/environments.js"; import { + BankService, ExchangeService, GlobalTestState, MerchantService, @@ -52,6 +54,18 @@ export async function runMerchantExchangeDuplicateTest(t: GlobalTestState) { const db = await setupDb(t); + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "TESTKUDOS", + database: db.connStr, + httpPort: 8082, + }); + await bank.start(); + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { username: "admin", password: "admin-password" }, + }); + const { walletClient } = await createWalletDaemonWithClient(t, { name: "wallet", }); @@ -74,24 +88,43 @@ export async function runMerchantExchangeDuplicateTest(t: GlobalTestState) { await exchange1.addBankAccount("1", { accountPaytoUri: paytoXtb1, - wireGatewayApiBaseUrl: "http://localhost:8082/", + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, wireGatewayAuth: { type: "basic", password: "test", - username: "test", + username: "exchange", }, }); await exchange1.addBankAccount("2", { accountPaytoUri: paytoXtb2, - wireGatewayApiBaseUrl: "http://localhost:8082/", + wireGatewayApiBaseUrl: new URL( + "accounts/exchange-two/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, wireGatewayAuth: { type: "basic", password: "test", - username: "test", + username: "exchange-two", }, }); + for (const [username, paytoUri] of [ + ["exchange", paytoXtb1], + ["exchange-two", paytoXtb2], + ] as const) { + await bankClient.registerAccountExtended({ + name: username, + password: "test", + username, + is_taler_exchange: true, + payto_uri: paytoUri, + }); + } + const merchant = await MerchantService.create(t, { name: "testmerchant-1", httpPort: 8083, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts b/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts @@ -77,6 +77,27 @@ const INSTANCE_NAME = "Mixed Case Business"; * URI then disagrees with the signed contract terms. */ export async function runMerchantInstanceCasingTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "Client used deprecated '/instances/admin/' path. Redirecting to modern path", + 3, + ], + ], + justification: + "the test deliberately exercises three legacy instance-path casing variants and verifies their redirects", + }); + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + /^base URL mismatch between taler:\/\/ URI and contract terms: http:\/\/localhost:8083\/instances\/MIXEDCASE\/ \/ http:\/\/localhost:8083\/instances\/mixedcase\/$/, + maxOccurrences: 1, + justification: + "the test changes only the case of the instance segment in one payment URI and verifies that the wallet follows the canonical signed contract URL", + }); const { bankClient, walletClient, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts @@ -127,6 +127,58 @@ async function checkMerchantCategories( * Do basic checks on instance management and authentication. */ export async function runMerchantInstancesTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-exchangekeyupdate-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "Failed to receive /keys response from exchange http://localhost:8081/", + 4, + ], + ], + justification: + "the instance-routing test intentionally configures but does not start its exchange because no payment operation is exercised", + }); + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + ["Access denied: password does not match", 2], + [ + "Client used deprecated '/instances/admin/' path. Redirecting to modern path", + 3, + ], + [ + "Credentials provided are 0 which are insufficient for access to `instances-write'", + 1, + ], + [ + "Credentials provided are 0 which are insufficient for access to `orders-read'", + 1, + ], + ["Endpoint `/instances/foo/private/instances' not known", 1], + ["Endpoint `/instances/myinst/private/instances' not known", 1], + ["Endpoint `/private/instances' not known", 1], + [ + "Endpoint `/private/instances/admin/instances/admin/config' not known", + 1, + ], + ["Endpoint `/private/instances/myinst/config' not known", 1], + ["Endpoint `/private/instances/myinst/orders' not known", 1], + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_auth.c:724.", + 2, + ], + ["Failed to find required permissions for scope 0", 4], + ["Instance for `/instances/config' not known", 1], + ["Instance for `/instances/foo/config' not known", 1], + ["Login failed", 2], + ["Permission check failed: scope 0 not understood", 4], + ["Trying deprecated secret-token:password API authN", 2], + ], + justification: + "the test's route/authentication matrix deliberately probes deprecated, malformed, unknown, unauthenticated, and wrongly authenticated instance URLs", + }); // Set up test environment const db = await setupDb(t); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts b/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts @@ -133,6 +133,18 @@ async function testRefundApi( /** Test refund status for both fulfillment URL and message orders. */ export async function runMerchantRefundApiTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_get-orders-ORDER_ID.c:934.", + 2, + ], + ], + justification: + "the refund API test deliberately requests order status with two wrong contract hashes and verifies both forbidden responses", + }); const { walletClient, bankClient, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts @@ -37,6 +37,18 @@ import { export async function runMerchantSelfProvisionActivationTest( t: GlobalTestState, ) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "External protocol violation detected at ../src/json/json_helper.c:1650.", + 1, + ], + ], + justification: + "the activation test deliberately supplies malformed contact data and verifies input validation", + }); const instanceInfo = { id: "MYUPPERCASEINSTANCE", name: "My awesome instance", diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts @@ -43,6 +43,28 @@ import { GlobalTestState } from "../harness/harness.js"; export async function runMerchantSelfProvisionInactiveAccountPermissionsTest( t: GlobalTestState, ) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + ["Instance for `/instances/self-instance/private/token' not known", 1], + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_post-management-instances.c:206.", + 2, + ], + ["Access denied: password does not match", 1], + ["Password provided does not match credentials for self-instance", 1], + ["Basic authentication failed!", 1], + ["Failed to find required permissions for scope 0", 1], + ["Permission check failed: scope 0 not understood", 1], + [ + "Credentials provided are 0 which are insufficient for access to `token-refresh'", + 1, + ], + ], + justification: + "the test verifies that inactive and incorrectly authenticated self-provisioned accounts cannot gain token permissions", + }); // Set up test environment const instanceInfo = { diff --git a/packages/taler-harness/src/integrationtests/test-merchant-sessions.ts b/packages/taler-harness/src/integrationtests/test-merchant-sessions.ts @@ -32,6 +32,18 @@ import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; * unknown-to-paid long-poll transition. */ export async function runMerchantSessionsTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_get-sessions-SESSION_ID.c:180.", + 1, + ], + ], + justification: + "the session test intentionally submits an invalid fulfillment signature and checks that it is rejected", + }); const { bankClient, walletClient, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts @@ -187,6 +187,22 @@ async function testPublicOrderScenario( /** Exercise every public-order endpoint branch for both token policies. */ export async function runMerchantSpecPublicOrdersTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_get-orders-ORDER_ID.c:728.", + 4, + ], + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_get-orders-ORDER_ID.c:904.", + 4, + ], + ], + justification: + "the public-order matrix deliberately supplies mismatching contract hashes and claim tokens for each supported order mode", + }); const { bankClient, exchange, merchant, merchantAdminAccessToken } = await createSimpleTestkudosEnvironmentV3(t); const merchantBaseUrl = merchant.makeInstanceBaseUrl(); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-templates.ts b/packages/taler-harness/src/integrationtests/test-merchant-templates.ts @@ -32,6 +32,18 @@ import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; import { GlobalTestState } from "../harness/harness.js"; export async function runMerchantTemplatesTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "External protocol violation detected at ../src/backend/taler-merchant-httpd_post-templates-TEMPLATE_ID.c:1248.", + 1, + ], + ], + justification: + "the template test deliberately submits inconsistent editable defaults and verifies the protocol error", + }); // Set up test environment const { merchant, merchantAdminAccessToken } = diff --git a/packages/taler-harness/src/integrationtests/test-merchant-tokenfamilies.ts b/packages/taler-harness/src/integrationtests/test-merchant-tokenfamilies.ts @@ -45,6 +45,15 @@ import { import { GlobalTestState, waitMs } from "../harness/harness.js"; export async function runMerchantTokenfamiliesTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + /^marking denomination [0-9A-Z]+ of http:\/\/localhost:8081\/ as lost$/, + maxOccurrences: 7, + justification: + "the test advances the wallet and exchange by 120 days to expire a 90-day token; all seven denominations from the pre-jump withdrawal consequently leave the offered key set", + }); let { merchant, walletClient, bank, merchantAdminAccessToken, exchange } = await createSimpleTestkudosEnvironmentV3( t, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-bootstrap.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-bootstrap.ts @@ -18,6 +18,32 @@ import { startStaticServerMerchantWebui } from "../harness/webui-server.js"; /** Browser-level coverage for the only unauthenticated management mutation. */ export async function runMerchantWebuiBootstrapTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-bootstrap-merchant-stderr.log", + level: "WARNING", + messages: [ + ["Failed to find required permissions for scope 0", 1], + ["Permission check failed: scope 0 not understood", 1], + [ + "Credentials provided are 0 which are insufficient for access to `instances-write'", + 1, + ], + ], + justification: + "the bootstrap UI first probes protected instance management without credentials before offering bootstrap", + }); + t.allowLogs({ + file: "merchant-exchangekeyupdate-bootstrap-merchant-stderr.log", + level: "WARNING", + messages: [ + [ + "Failed to receive /keys response from exchange http://localhost:8081/", + 6, + ], + ], + justification: + "the isolated bootstrap fixture must name a trusted exchange but deliberately does not start one because bootstrap never uses it", + }); const db = await setupDb(t); // The merchant requires a trusted exchange in its deployment config even // though bootstrap itself neither contacts nor provisions that exchange. diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-kyc-swap.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-kyc-swap.ts @@ -42,6 +42,20 @@ import { startStaticServerMerchantWebui } from "../harness/webui-server.js"; * HARNESS_SCREENSHOTS=1 additionally keeps every UI checkpoint as a PNG. */ export async function runMerchantWebuiKycSwapTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + ["Failed to find required permissions for scope 0", 1], + ["Permission check failed: scope 0 not understood", 1], + [ + "Credentials provided are 0 which are insufficient for access to `instances-write'", + 1, + ], + ], + justification: + "the WebUI onboarding flow deliberately probes instance management before the administrator signs in", + }); const env = await createTopsEnvironment(t, { adjustExchangeConfig: (config) => { config.setString("exchange", "KYC_SWAP_TOS_ACCEPTANCE", "YES"); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-mfa.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-mfa.ts @@ -107,6 +107,41 @@ async function runMerchantWebuiMfaTestImpl( t: GlobalTestState, passwordResetOnly: boolean, ) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "Failed to find required permissions for scope 0", + passwordResetOnly ? 2 : 1, + ], + [ + "Permission check failed: scope 0 not understood", + passwordResetOnly ? 2 : 1, + ], + [ + "Credentials provided are 0 which are insufficient for access to `instances-write'", + 1, + ], + ["Access denied: password does not match", 1], + ...(passwordResetOnly + ? ([ + [ + "Password provided does not match credentials for password-reset-user", + 1, + ], + ["Basic authentication failed!", 1], + [ + "Credentials provided are 0 which are insufficient for access to `token-refresh'", + 1, + ], + ] as Array<[string, number]>) + : []), + ], + justification: passwordResetOnly + ? "the password-reset test verifies rejection of an incorrect old password and the resulting lack of token-refresh permission" + : "the MFA test verifies rejection of unauthenticated instance management and an incorrect password", + }); const { merchant, merchantAdminAccessToken } = await createSimpleTestkudosEnvironmentV3(t); diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts @@ -1286,6 +1286,20 @@ async function testServerDetailsAndSignOut( * Main integration test runner. */ export async function runMerchantWebuiSimpleTest(t: GlobalTestState) { + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + ["Failed to find required permissions for scope 0", 2], + ["Permission check failed: scope 0 not understood", 2], + [ + "Credentials provided are 0 which are insufficient for access to `instances-write'", + 2, + ], + ], + justification: + "the WebUI test verifies both the unauthenticated landing flow and the post-sign-out access check", + }); const db = await setupDb(t); const bankAccountInfo: NexusBankAccountInfo = { diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts @@ -59,6 +59,45 @@ import { */ export async function runMultiExchangeTest(t: GlobalTestState) { // Set up test environment + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-melt\.c:\d+\.$/, + maxOccurrences: 9, + justification: + "the test aborts a payment after one exchange deposited its nine selected coins, so their precautionary refresh melts encounter spent balances", + }); + t.allowLogs({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + messages: [ + [ + "Request to `http://localhost:9281/batch-deposit' was expected to return a body of type `application/json', got `(null)'", + 2, + ], + ["Deposit operation failed with HTTP code 500/10", 2], + ], + justification: + "the fault proxy deliberately returns two headerless HTTP 500 responses from the second exchange to exercise partial multi-exchange payment abort", + }); + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "ERROR", + messages: [ + [ + /^melt request for TESTKUDOS:0\.09 failed in refresh group [0-9A-Z]+ due to conflict$/, + 8, + ], + [ + /^melt request for TESTKUDOS:3\.99 failed in refresh group [0-9A-Z]+ due to conflict$/, + 1, + ], + ], + justification: + "the aborted first-exchange deposit leaves eight small and one large selected coin spent, which the wallet records as terminal refresh conflicts", + }); + const dbDefault = await setupDb(t); const dbExchangeTwo = await setupDb(t, { @@ -428,3 +467,6 @@ export async function runMultiExchangeTest(t: GlobalTestState) { } runMultiExchangeTest.suites = ["wallet"]; +// The test intentionally drives nine conflict/recovery cycles through two +// exchanges; loaded builders can legitimately need more than the default minute. +runMultiExchangeTest.timeoutMs = 120_000; diff --git a/packages/taler-harness/src/integrationtests/test-payment-deleted.ts b/packages/taler-harness/src/integrationtests/test-payment-deleted.ts @@ -36,6 +36,15 @@ import { GlobalTestState } from "../harness/harness.js"; * Test behavior when an order is deleted while the wallet is paying for it. */ export async function runPaymentDeletedTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: "pay transaction aborted, merchant deleted the order", + maxOccurrences: 1, + justification: + "the test deletes an order after the wallet claims it and verifies the resulting payment abort", + }); + // Set up test environment const { diff --git a/packages/taler-harness/src/integrationtests/test-payment-expired.ts b/packages/taler-harness/src/integrationtests/test-payment-expired.ts @@ -48,6 +48,15 @@ import { GlobalTestState } from "../harness/harness.js"; * refreshes allocated coins and thus raises the balance again. */ export async function runPaymentExpiredTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: "pay transaction aborted, order expired", + maxOccurrences: 1, + justification: + "the test deliberately lets the claimed merchant order expire and verifies that the wallet aborts it", + }); + // Set up test environment const { diff --git a/packages/taler-harness/src/integrationtests/test-payment-unclaim.ts b/packages/taler-harness/src/integrationtests/test-payment-unclaim.ts @@ -26,6 +26,16 @@ import { import { GlobalTestState } from "../harness/harness.js"; export async function runPaymentUnclaimTest(t: GlobalTestState) { + t.allowLog({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/backend\/taler-merchant-httpd_get-orders-ORDER_ID\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "After the second wallet claims the unclaimed order, the first wallet deliberately polls once with the superseded contract hash to detect that handover.", + }); + const { walletClient: firstWallet, bankClient, diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-pull.ts @@ -55,6 +55,15 @@ const stLostRace = [ * Run a test for basic peer-pull payments. */ export async function runPeerPullTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: "Amount for peer-pull-credit payment too low", + maxOccurrences: 1, + justification: + "the test explicitly checks a zero-value peer-pull quote and asserts that it selects no coins", + }); + const [ { walletClient: wallet1, bankClient, exchange }, { walletClient: wallet2 }, @@ -120,7 +129,7 @@ export async function runPeerPullTest(t: GlobalTestState) { t.logStep("P2P pull amount logic"); { await wallet1.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); const [checkfive, checkzero] = await Promise.all([ wallet1.call(WalletApiOperation.CheckPeerPullCredit, { diff --git a/packages/taler-harness/src/integrationtests/test-peer-repair.ts b/packages/taler-harness/src/integrationtests/test-peer-repair.ts @@ -39,6 +39,32 @@ import { export async function runPeerRepairTest(t: GlobalTestState) { // Set up test environment + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [ + [ + /^Coin [0-9A-Z]+ has insufficient balance for purse deposit of amount TESTKUDOS:3\.01$/, + 2, + ], + [ + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-melt\.c:\d+\.$/, + 1, + ], + ], + justification: + "the test rolls the sender wallet back across a completed peer payment and verifies repair after the restored wallet reuses already-spent coins", + }); + t.allowLog({ + file: "wallet-w1-stderr.log", + level: "ERROR", + message: + /^melt request for TESTKUDOS:0\.99 failed in refresh group [0-9A-Z]+ due to conflict$/, + maxOccurrences: 1, + justification: + "the rollback scenario deliberately reaches one zero-balance refresh conflict before newly withdrawn coins allow reselection", + }); + const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t); let allW1Notifications: WalletNotification[] = []; diff --git a/packages/taler-harness/src/integrationtests/test-refund-gone.ts b/packages/taler-harness/src/integrationtests/test-refund-gone.ts @@ -39,6 +39,17 @@ import { GlobalTestState } from "../harness/harness.js"; * can claim it. */ export async function runRefundGoneTest(t: GlobalTestState) { + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + messages: [ + ["exception in refresh session: {", 1], + ["created refresh group with zero coins", 1], + ], + justification: + "the test deliberately time-travels beyond the refunded coin's lifetime and verifies that the refund and its empty refresh group fail", + }); + // Set up test environment const { diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts @@ -48,6 +48,16 @@ import { * Basic time travel test. */ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-w1-stderr.log", + level: "WARNING", + message: + /^marking denomination [A-Z0-9]+ of http:\/\/localhost:8081\/ as lost$/, + maxOccurrences: 32, + justification: + "the test advances through four denomination-expiration periods and verifies automatic refresh, causing the wallet to retire the 32 denominations it observed", + }); + // Set up test environment const db = await setupDb(t); diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts b/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts @@ -36,6 +36,30 @@ import { GlobalTestState } from "../harness/harness.js"; export async function runTimetravelWithdrawTest(t: GlobalTestState) { // Set up test environment + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-withdraw\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "the test advances the exchange before the wallet and verifies recovery from exactly one withdrawal using an exchange-expired denomination", + }); + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + messages: [ + [ + /^marking denomination [A-Z0-9]+ of http:\/\/localhost:8081\/ as lost$/, + 7, + ], + [/^no representative denom for family with serial=\d+$/, 7], + [/^redenomination required for withdrawal [A-Z0-9]+$/, 1], + ], + justification: + "the test advances beyond the current seven denomination families and verifies that the pending withdrawal is redenominated", + }); + const { walletClient, bankClient, exchange, merchant } = await createSimpleTestkudosEnvironmentV3(t); diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-measures.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-measures.ts @@ -25,6 +25,42 @@ import { isFrozen, setupMeasuresTestEnvironment } from "../harness/tops.js"; * Test that invokes all measures defined for the TOPS deployment. */ export async function runTopsAmlMeasuresTest(t: GlobalTestState) { + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "ERROR", + messages: [ + [ + "Form data lacks required attribute `IDENTITY_CONTRACTING_PARTNER' for KYC check `form-vqf-902.11'", + 1, + ], + ["Measure `foobar' not found", 1], + ["Unexpected legal entity 'TRUST'", 1], + ["Unexpected legal entity 'FOO'", 1], + ], + justification: + "The test deliberately submits one incomplete form, one unknown measure, one currently unsupported TRUST entity, and one invalid FOO entity, then asserts their failure behavior.", + }); + t.allowLogs({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + messages: [ + [ + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-kyc-upload-ID\.c:\d+\.$/, + 1, + ], + [ + /^External protocol violation detected at \.\.\/src\/kyclogic\/kyclogic_api\.c:\d+\.$/, + 1, + ], + [ + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-aml-OFFICER_PUB-decision\.c:\d+\.$/, + 1, + ], + ], + justification: + "These are the server-side protocol diagnostics paired with the two deliberately malformed requests asserted below.", + }); + // Setup is done, now the real testing can start! const { diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts @@ -235,7 +235,7 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { createPdfAttachment("john-passport"), CUSTOMER_IS_SOLE_PROPRIETOR: false, CORRESPONDENCE_LANGUAGE: "de", - CUSTOMER_TYPE_VQF: "NATURAL", + CUSTOMER_TYPE_VQF: "NATURAL_PERSON", }); } diff --git a/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts b/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts @@ -103,6 +103,10 @@ export async function runTopsMerchantSwtKycauthTest(t: GlobalTestState) { username: "exchange-test", password: "exchange-test", }, + // This test only asks the merchant for a Swiss wire instruction. No bank + // transaction is imported or sent by the exchange. + enableCredit: false, + enableDebit: false, preparedTransferUrl: nexus.preparedTransferApiBaseUrl, }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts @@ -140,6 +140,7 @@ async function createMyEnvironment( exchange.addOfferedCoins(defaultCoinConfig); await exchange.start(); + await exchange.enableAccount("payto://void/foo?receiver-name=Foo"); const exchange2 = ExchangeService.create(t, { currency: "TESTKUDOS", @@ -150,9 +151,10 @@ async function createMyEnvironment( exchange2.addOfferedCoins(defaultCoinConfig); - await exchange.enableAccount("payto://void/foo?receiver-name=Foo"); - await exchange2.start(); + await exchange2.enableAccount( + "payto://iban/CH9300762011623852957?receiver-name=Other", + ); merchant.addExchange(exchange); merchant.addExchange(exchange2); @@ -203,6 +205,16 @@ async function createMyEnvironment( export async function runWalletBalanceTest(t: GlobalTestState) { // Set up test environment + t.allowLog({ + file: "merchant-httpd-testmerchant-1-stderr.log", + level: "WARNING", + message: + /^Exchange `http:\/\/localhost:9080\/' 1 wire methods are not applicable to this order$/, + maxOccurrences: 1, + justification: + "the test configures a second, healthy IBAN exchange while deliberately creating a void-target order to test receiver-specific balance diagnostics", + }); + const { merchant, walletClient, diff --git a/packages/taler-harness/src/integrationtests/test-wallet-dd48.ts b/packages/taler-harness/src/integrationtests/test-wallet-dd48.ts @@ -135,7 +135,7 @@ export async function runWalletDd48Test(t: GlobalTestState) { }); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, ephemeral: true, }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts @@ -31,6 +31,15 @@ import { GlobalTestState, setupDb } from "../harness/harness.js"; * Test how the wallet handles an expired denomination. */ export async function runWalletDenomExpireTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: /^denomination [A-Z0-9]+ is a loss$/, + maxOccurrences: 7, + justification: + "the test deliberately lets all seven withdrawn denominations expire without refreshing and verifies that their value is reported as lost", + }); + // Set up test environment await setupDb(t); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts @@ -48,6 +48,16 @@ import { export async function runWalletExchangeUpdateTest( t: GlobalTestState, ): Promise<void> { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: + "exchange http://localhost:8081/ changed its key set (master public key changed)", + maxOccurrences: 2, + justification: + "the test replaces the exchange at the same URL and triggers two explicit updates to verify master-key change handling", + }); + // Set up test environment const db = await setupDb(t); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-progress-token.ts b/packages/taler-harness/src/integrationtests/test-wallet-progress-token.ts @@ -109,7 +109,7 @@ export async function runWalletProgressTokenTest(t: GlobalTestState) { // failures injected below only hit the ToS download and not the exchange // update, which retries on its own schedule. await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); // --------------------------------------------------------------------- diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts @@ -44,6 +44,32 @@ const coinCommon = { export async function runWalletRefreshErrorsTest(t: GlobalTestState) { // Set up test environment + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-melt\.c:\d+\.$/, + maxOccurrences: 2, + justification: + "the test deliberately restores stale coin state and starts a duplicate refresh, producing two overspending melt requests", + }); + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "ERROR", + messages: [ + [ + /^melt request for TESTKUDOS:3 failed in refresh group [0-9A-Z]+ due to conflict$/, + 1, + ], + [ + /^melt request for TESTKUDOS:5 failed in refresh group [0-9A-Z]+ due to conflict$/, + 1, + ], + ], + justification: + "the test verifies that the wallet reaches a final failed state for stale and duplicate refresh conflicts", + }); + const coinConfigList: CoinConfig[] = [ { ...coinCommon, diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-redenominate.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh-redenominate.ts @@ -40,6 +40,30 @@ import { GlobalTestState } from "../harness/harness.js"; export async function runWalletRefreshRedenominateTest(t: GlobalTestState) { // Set up test environment + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + messages: [ + [ + /^marking denomination [0-9A-Z]+ of http:\/\/localhost:8081\/ as lost$/, + 7, + ], + ["handling melt Gone response from exchange", 1], + ], + justification: + "the test blocks refresh, time-travels past all seven denomination keys, and verifies recovery from the exchange's MELT_GONE response", + }); + + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-melt\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "the test time-travels past a blocked refresh denomination and verifies the wallet's MELT_GONE recovery path", + }); + const { walletClient, bankClient, diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts @@ -47,6 +47,15 @@ import { GlobalTestState } from "../harness/harness.js"; const logger = new Logger("test-wallet-tokens.ts"); export async function runWalletTokensTest(t: GlobalTestState) { + t.allowLog({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + message: "not confirming payment, insufficient tokens", + maxOccurrences: 1, + justification: + "the test deliberately confirms a choice without the required token and asserts that confirmation is rejected", + }); + let { bankClient, exchange, diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts @@ -128,10 +128,13 @@ export async function runWalletWebUiDemoTest(t: GlobalTestState) { await page.getByText("Museum admission", { exact: true }).waitFor(); await page.goto(`${demoUrl}#/transactions`); await page.getByRole("heading", { name: "History" }).waitFor(); + // The seeded demo data already contains a completed City Museum + // payment. Select the newly-created dialog transaction by state instead + // of depending on the history's ordering of two identically named rows. await page - .getByRole("button", { name: /City Museum/ }) - .first() - .click(); + .getByLabel("Filter transaction state") + .selectOption({ label: "In progress" }); + await page.getByRole("button", { name: /City Museum/ }).click(); await page.getByRole("heading", { name: "City Museum" }).waitFor(); await page.getByText("Payment options", { exact: true }).waitFor(); await page.getByRole("button", { name: "Pay CHF:5.10" }).click(); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts @@ -116,6 +116,10 @@ export async function runWalletWebUiPwaDepositKycAuthSwissTest( await exchange.addBankAccount("nexus-manual", { ...wireGateway, accountPaytoUri: manualPayto, + // Both wire methods use the same Nexus account and history endpoint. The + // QR account imports its credits; this second entry only exposes the + // ordinary IBAN withdrawal option. + enableCredit: false, }); exchange.addCoinConfigList(defaultCoinConfig.map((coin) => coin("CHF"))); await exchange.modifyConfig(async (config) => { diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth.ts @@ -39,7 +39,6 @@ function adjustExchangeConfig(config: Configuration): void { config.setString("KYC-RULE-R1", "next_measures", "M1"); config.setString("KYC-MEASURE-M1", "check_name", "C1"); config.setString("KYC-MEASURE-M1", "context", "{}"); - config.setString("KYC-MEASURE-M1", "program", "NONE"); config.setString("KYC-CHECK-C1", "type", "INFO"); config.setString("KYC-CHECK-C1", "description", "PWA deposit check"); config.setString("KYC-CHECK-C1", "fallback", "FREEZE"); @@ -58,6 +57,16 @@ async function visibleTestkudosBalance(page: Page): Promise<number> { /** Exercise the distinct deposit KYC authentication bank-transfer state. */ export async function runWalletWebUiPwaDepositKycAuthTest(t: GlobalTestState) { + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-coins-COIN_PUB-refund\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "the browser scenario deliberately aborts its first KYC-gated deposit, causing the wallet to refund a coin the exchange has not seen", + }); + const { bankClient, bank, wireGatewayApi } = await createKycTestkudosEnvironmentFull(t, { adjustExchangeConfig }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts @@ -10,7 +10,6 @@ import fs from "node:fs"; import path from "node:path"; import { - AccessToken, DonauHttpClient, OrderOutputType, OrderVersion, @@ -75,7 +74,7 @@ export async function runWalletWebUiPwaDonauTest(t: GlobalTestState) { const donauClient = new DonauHttpClient(donau.baseUrl); const currentYear = new Date().getFullYear(); const charity = succeedOrThrow( - await donauClient.createCharity("" as AccessToken, { + await donauClient.createCharity(donau.adminAccessToken, { charity_pub: merchantDetails.merchant_pub, current_year: currentYear, max_per_year: "TESTKUDOS:1000", @@ -424,15 +423,26 @@ export async function runWalletWebUiPwaDonauTest(t: GlobalTestState) { confirmPayRequests.at(-1)?.responseType, "response", ); - if (await submitted.isVisible()) await page.reload(); - await page.getByText("Payment complete", { exact: true }).waitFor(); - const paidOrder = succeedOrThrow( - await merchantClient.getOrderDetails( - merchantAdminAccessToken, - order.order_id, - ), - ); + // The submitted screen is shown while the wallet finishes the + // deposits asynchronously. Wait for the merchant's authoritative + // state instead of sampling it immediately after ConfirmPay returns. + const paymentDeadline = Date.now() + 30_000; + let paidOrder; + do { + paidOrder = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + order.order_id, + ), + ); + if (paidOrder.order_status === "paid") break; + await delayMs(100); + } while (Date.now() < paymentDeadline); t.assertDeepEqual(paidOrder.order_status, "paid"); + // "Payment submitted" is a valid terminal screen for this route: + // it explicitly sends the user to transaction history for progress. + // The merchant state above and the donation statement below verify + // completion without depending on an in-place UI transition. }, ); @@ -443,9 +453,32 @@ export async function runWalletWebUiPwaDonauTest(t: GlobalTestState) { await page .getByRole("heading", { name: "Donation statements" }) .waitFor(); - await page - .getByRole("img", { name: `Donation statement for ${currentYear}` }) - .waitFor({ timeout: 30_000 }); + const statementImage = page.getByRole("img", { + name: `Donation statement for ${currentYear}`, + }); + const emptyStatement = page.getByText( + "No donation statement is available yet.", + ); + const statementDeadline = Date.now() + 30_000; + while (true) { + // Let the in-flight query finish before deciding to reload. An + // immediate reload here continually aborted successful responses. + await statementImage.or(emptyStatement).waitFor(); + if (await statementImage.isVisible()) { + break; + } + if (Date.now() >= statementDeadline) { + throw Error("donation statement was not generated"); + } + // The statement route fetches once when it mounts. Receipt deposit + // finishes asynchronously after payment, so remount the route while + // waiting for the authoritative Donau statement to appear. + await delayMs(250); + await page.reload(); + await page + .getByRole("heading", { name: "Donation statements" }) + .waitFor(); + } await page.getByText("TESTKUDOS:5", { exact: true }).waitFor(); await page.getByTestId("taler-qr-logo").waitFor(); }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-tos.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-tos.ts @@ -49,7 +49,7 @@ export async function runWalletWebUiPwaPeerTosTest(t: GlobalTestState) { }, }); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); await walletClient.call(WalletApiOperation.SetExchangeTosAccepted, { exchangeBaseUrl: exchange.baseUrl, diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts @@ -904,14 +904,23 @@ async function runPwaWithdrawalTest( .getByText(/^TESTKUDOS:/) .last() .waitFor(); - const order = succeedOrThrow( - await merchantClient.getOrderDetails( - merchantAdminAccessToken, - ageOrder.order_id, - ), - ); - if (order.order_status !== "paid" || order.refund_pending) { - throw Error("browser wallet did not collect the merchant refund"); + // The wallet can render its completed refund before the merchant's + // asynchronous refund-confirmation callback clears refund_pending. + const refundDeadline = Date.now() + 30_000; + while (true) { + const order = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + ageOrder.order_id, + ), + ); + if (order.order_status === "paid" && !order.refund_pending) { + break; + } + if (Date.now() >= refundDeadline) { + throw Error("browser wallet did not collect the merchant refund"); + } + await new Promise((resolve) => setTimeout(resolve, 100)); } }); @@ -973,6 +982,10 @@ async function runPwaWithdrawalTest( await stage.step( "block on an exchange key change and resume", async (page) => { + // Unload the PWA while the replacement exchange is initialized. + // Otherwise its background refresh requests can reach the new + // HTTP daemon before the offline tool has enabled its wire account. + await page.goto("about:blank"); await exchange.stop(); const replacementDb = await setupDb(t, { nameSuffix: "webui-key-change", diff --git a/packages/taler-harness/src/integrationtests/test-wallet-withdrawal-redenominate.ts b/packages/taler-harness/src/integrationtests/test-wallet-withdrawal-redenominate.ts @@ -34,6 +34,31 @@ import { GlobalTestState } from "../harness/harness.js"; export async function runWalletWithdrawalRedenominateTest(t: GlobalTestState) { // Set up test environment + t.allowLogs({ + file: "wallet-wallet-stderr.log", + level: "WARNING", + messages: [ + [/^redenomination required for withdrawal [0-9A-Z]+$/, 2], + [/^no representative denom for family with serial=\d+$/, 7], + [ + /^marking denomination [0-9A-Z]+ of http:\/\/localhost:8081\/ as lost$/, + 7, + ], + ], + justification: + "the test corrupts one selection and then time-travels past seven denomination families to verify two-stage withdrawal redenomination", + }); + + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-withdraw\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "the test time-travels beyond denomination validity and verifies withdrawal redenomination after the expired-denomination response", + }); + const { walletClient, bankClient, diff --git a/packages/taler-harness/src/integrationtests/test-wire-metadata.ts b/packages/taler-harness/src/integrationtests/test-wire-metadata.ts @@ -91,8 +91,6 @@ export async function runWireMetadataTest(t: GlobalTestState) { accountPaytoUri: exchangePaytoUri, }; - await exchange.addBankAccount("1", exchangeBankAccount); - exchange.addOfferedCoins(defaultCoinConfig); bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri); @@ -108,6 +106,14 @@ export async function runWireMetadataTest(t: GlobalTestState) { is_taler_exchange: true, payto_uri: exchangePaytoUri, }); + exchangeBankAccount.wireGatewayBearerToken = succeedOrThrow( + await bankClient.createAccessToken( + exchangeBankUsername, + exchangeBankAccount.wireGatewayAuth, + { scope: "wiregateway" }, + ), + ).access_token; + await exchange.addBankAccount("1", exchangeBankAccount); await exchange.start(); @@ -190,10 +196,13 @@ export async function runWireMetadataTest(t: GlobalTestState) { }), ); - const { walletClient } = await createWalletDaemonWithClient(t, { - name: "wallet", - persistent: true, - }); + const { walletClient, walletService } = await createWalletDaemonWithClient( + t, + { + name: "wallet", + persistent: true, + }, + ); const wres = await withdrawViaBankV4(t, { walletClient, @@ -257,6 +266,12 @@ export async function runWireMetadataTest(t: GlobalTestState) { t.assertDeepEqual(orderStatus.order_status, "paid"); } + // The wallet is only needed to create the payment. Keeping it alive while + // the exchange jumps forward two weeks would make its background refresh + // logic react to denominations that this metadata test intentionally leaves + // behind. + await walletService.stop(); + await applyTimeTravelV2( Duration.toMilliseconds( Duration.fromSpec({ @@ -266,7 +281,6 @@ export async function runWireMetadataTest(t: GlobalTestState) { { merchant, exchange, - walletClient, }, ); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-amount.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-amount.ts @@ -52,7 +52,7 @@ export async function runWithdrawalAmountTest(t: GlobalTestState) { const user = await bankClient.createRandomBankUser(); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); logger.info("starting AcceptManualWithdrawal request"); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-conflict.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-conflict.ts @@ -36,7 +36,7 @@ import { GlobalTestState } from "../harness/harness.js"; export async function runWithdrawalConflictTest(t: GlobalTestState) { // Set up test environment const [ - { walletClient: wallet1, bankClient, bank }, + { walletClient: wallet1, bankClient, bank, exchange }, { walletClient: wallet2 }, ] = await Promise.all([ createSimpleTestkudosEnvironmentV3(t), @@ -67,6 +67,10 @@ export async function runWithdrawalConflictTest(t: GlobalTestState) { // Make sure both wallets go into the state where they need // to register the reserve info with the bank. + // The bank outage is the condition under test. Pause wirewatch while the + // bank is deliberately unavailable, so that the exchange does not turn the + // expected connection failures into unrelated service errors. + await exchange.stopWirewatch(); await bank.stop(); await Promise.all([ @@ -102,6 +106,7 @@ export async function runWithdrawalConflictTest(t: GlobalTestState) { ]); await bank.start(); + await exchange.startWirewatch(); // One wallet will succeed, another one will have an aborted transaction. // Order is non-determinstic. diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts @@ -233,7 +233,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { const user = await bankClient.createRandomBankUser(); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); const infoRes = await walletClient.call( diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts @@ -82,7 +82,7 @@ export async function runWithdrawalFakebankTest(t: GlobalTestState) { const wallet = new WalletCli(t); await wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); await wallet.client.call(WalletApiOperation.WithdrawTestBalance, { diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts @@ -137,7 +137,7 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { const wallet = new WalletCli(t); await wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); const amount = "TESTKUDOS:7.5"; @@ -175,7 +175,7 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { t.logStep("Complete all pending operations"); - await wallet.runPending(); + await wallet.runUntilDone(); t.logStep("Withdraw (AKA select)"); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts @@ -132,7 +132,7 @@ export async function runWithdrawalHugeTest(t: GlobalTestState) { ); await wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); // Results in about 1K coins withdrawn diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts @@ -55,7 +55,7 @@ export async function runWithdrawalManualTest(t: GlobalTestState) { const user = await bankClient.createRandomBankUser(); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); const tStart = AbsoluteTime.now(); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-shorten.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-shorten.ts @@ -21,7 +21,7 @@ import { AmountString, j2s, succeedOrThrow, - TalerCorebankApiClient, + TalerCoreBankHttpClient, TalerExchangeHttpClient, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -80,26 +80,29 @@ export async function runWithdrawalShortenTest(t: GlobalTestState) { preparedTransferUrl: prepBaseUrl, }; - await exchange.addBankAccount("1", exchangeBankAccount); - bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri); await bank.start(); - const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { - auth: { - username: "admin", - password: "admin-password", - }, - }); + const bankClient = new TalerCoreBankHttpClient(bank.corebankApiBaseUrl); - await bankClient.registerAccountExtended({ - name: receiverName, - password: exchangeBankPassword, - username: exchangeBankUsername, - is_taler_exchange: true, - payto_uri: exchangePaytoUri, - }); + succeedOrThrow( + await bankClient.createAccount(bank.getAdminAuth(), { + name: receiverName, + password: exchangeBankPassword, + username: exchangeBankUsername, + is_taler_exchange: true, + payto_uri: exchangePaytoUri, + }), + ); + exchangeBankAccount.wireGatewayBearerToken = succeedOrThrow( + await bankClient.createAccessToken( + exchangeBankUsername, + exchangeBankAccount.wireGatewayAuth, + { scope: "wiregateway" }, + ), + ).access_token; + await exchange.addBankAccount("1", exchangeBankAccount); exchange.addOfferedCoins(defaultCoinConfig); @@ -117,7 +120,7 @@ export async function runWithdrawalShortenTest(t: GlobalTestState) { }); await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchange.baseUrl, + uri: exchange.baseUrl, }); const wres = await walletClient.call( diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -27,6 +27,7 @@ import * as path from "node:path"; import url from "node:url"; import { GlobalTestState, + readServiceLogEntries, runTestWithState, shouldLingerInTest, TestRunResult, @@ -735,6 +736,25 @@ class ReusableTestWorker { } } +async function waitForWorkerClose( + worker: child_process.ChildProcess, +): Promise<void> { + if (worker.exitCode !== null || worker.signalCode !== null) { + return; + } + await new Promise<void>((resolve) => { + const timeout = setTimeout(resolve, 5_000); + worker.once("close", () => { + clearTimeout(timeout); + resolve(); + }); + worker.once("error", () => { + clearTimeout(timeout); + resolve(); + }); + }); +} + export async function runTests(spec: TestRunSpec) { validateTestMetadata(allTests); if (spec.reuseWorker && shouldLingerInTest()) { @@ -945,6 +965,7 @@ export async function runTests(spec: TestRunSpec) { }); let result: TestRunResult; + let needsPostMortemLogScan = false; try { result = await token.racePromise(resultPromise); @@ -958,6 +979,7 @@ export async function runTests(spec: TestRunSpec) { }; currentChild.kill("SIGTERM"); reusableWorker = undefined; + needsPostMortemLogScan = true; } else if (e instanceof Error) { result = { status: "fail", @@ -967,6 +989,7 @@ export async function runTests(spec: TestRunSpec) { }; currentChild.kill("SIGTERM"); reusableWorker = undefined; + needsPostMortemLogScan = true; } else { currentChild.kill("SIGTERM"); reusableWorker = undefined; @@ -975,6 +998,35 @@ export async function runTests(spec: TestRunSpec) { } } + if (needsPostMortemLogScan) { + await waitForWorkerClose(currentChild); + try { + result.postmortemLogEntries = readServiceLogEntries(testDir); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + result.reason = `${result.reason}; service log scan failed: ${detail}`; + } + } + + if (result.unexpectedLogEntries?.length) { + console.log("-- unexpected service log entries --"); + for (const entry of result.unexpectedLogEntries) { + console.log( + `${entry.file}:${entry.line}: ${entry.component} ${entry.level} ${entry.message}`, + ); + } + console.log("-- end unexpected service log entries --"); + } + if (result.postmortemLogEntries?.length) { + console.log("-- service log entries recovered after worker failure --"); + for (const entry of result.postmortemLogEntries) { + console.log( + `${entry.file}:${entry.line}: ${entry.component} ${entry.level} ${entry.message}`, + ); + } + console.log("-- end recovered service log entries --"); + } + if (isTodo) { result.todo = true; result.todoBugUrl = todoBugUrl; @@ -1004,7 +1056,15 @@ export async function runTests(spec: TestRunSpec) { console.log("-- end --"); } - console.log(`parent: got result ${JSON.stringify(result)}`); + const { unexpectedLogEntries, postmortemLogEntries, ...resultSummary } = + result; + console.log( + `parent: got result ${JSON.stringify({ + ...resultSummary, + unexpectedLogEntryCount: unexpectedLogEntries?.length ?? 0, + postmortemLogEntryCount: postmortemLogEntries?.length ?? 0, + })}`, + ); testResults.push(result); diff --git a/packages/taler-harness/src/stagefright/merchant-webui.ts b/packages/taler-harness/src/stagefright/merchant-webui.ts @@ -1162,7 +1162,7 @@ export async function runStagefrightMerchantWebui( } await walletClient.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: exchangeUrl, + uri: exchangeUrl, }); await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts @@ -23,10 +23,12 @@ */ import { AmountString, + AccessToken, buildCodecForObject, Codec, codecForAny, codecForString, + codecForTokenSuccessResponse, encodeCrock, getRandomBytes, HttpStatusCode, @@ -66,6 +68,7 @@ export interface BankAccountBalanceResponse { export interface BankUser { username: string; password: string; + token?: AccessToken; accountPaytoUri: PaytoString; } @@ -81,10 +84,22 @@ const codecForWithdrawalOperationInfo = (): Codec<WithdrawalOperationInfo> => .build("WithdrawalOperationInfo"); export interface BankAccessApiClientArgs { - auth?: { username: string; password: string }; + auth?: BankAccessAuth; + /** + * Obtain bearer tokens with password authentication before accessing an + * account. The token endpoint explicitly supports password authentication, + * while using passwords on the other LibEuFin endpoints is deprecated. + */ + preferBearerAuth?: boolean; httpClient?: HttpRequestLibrary; } +export interface BankAccessAuth { + username: string; + password: string; + token?: AccessToken; +} + export interface ConfirmWithdrawalArgs { withdrawalOperationId: string; } @@ -104,14 +119,52 @@ export class TalerCorebankApiClient { this.httpLib = args.httpClient ?? createPlatformHttpLib(); } - setAuth(auth: { username: string; password: string }) { + setAuth(auth: BankAccessAuth) { this.args.auth = auth; } - private makeAuthHeader(): Record<string, string> { + private async createBearerToken( + username: string, + password: string, + ): Promise<AccessToken> { + const url = new URL( + `accounts/${pathSegment(username)}/token`, + this.baseUrl, + ); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + body: { + scope: "readwrite", + }, + headers: authHeaders({ + type: "basic", + username, + password, + }), + }); + const result = await readSuccessResponseJsonOrThrow( + resp, + codecForTokenSuccessResponse(), + ); + return result.access_token; + } + + private async makeAuthHeader(): Promise<Record<string, string>> { if (!this.args.auth) { return {}; } + if (this.args.preferBearerAuth && !this.args.auth.token) { + this.args.auth.token = await this.createBearerToken( + this.args.auth.username, + this.args.auth.password, + ); + } + if (this.args.auth.token) { + return authHeaders({ + type: "bearer", + token: this.args.auth.token, + }); + } return authHeaders({ type: "basic", username: this.args.auth.username, @@ -124,7 +177,7 @@ export class TalerCorebankApiClient { ): Promise<BankAccountBalanceResponse> { const url = new URL(`accounts/${pathSegment(username)}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { - headers: this.makeAuthHeader(), + headers: await this.makeAuthHeader(), }); return readSuccessResponseJsonOrThrow(resp, codecForAny()); } @@ -144,7 +197,7 @@ export class TalerCorebankApiClient { payto_uri: target, }, headers: { - ...this.makeAuthHeader(), + ...(await this.makeAuthHeader()), "Content-Type": "application/json", }, }); @@ -161,7 +214,7 @@ export class TalerCorebankApiClient { const resp = await this.httpLib.fetch(reqUrl.href, { method: "GET", headers: { - ...this.makeAuthHeader(), + ...(await this.makeAuthHeader()), }, }); @@ -176,7 +229,7 @@ export class TalerCorebankApiClient { const resp = await this.httpLib.fetch(url.href, { method: "POST", body: req, - headers: this.makeAuthHeader(), + headers: await this.makeAuthHeader(), }); if ( @@ -211,7 +264,7 @@ export class TalerCorebankApiClient { password, name: username, }, - headers: this.makeAuthHeader(), + headers: await this.makeAuthHeader(), }); if ( resp.status !== 200 && @@ -228,14 +281,16 @@ export class TalerCorebankApiClient { }, ); } + const token = this.args.preferBearerAuth + ? await this.createBearerToken(username, password) + : undefined; + // FIXME: Corebank should directly return this info! const infoUrl = new URL(`accounts/${pathSegment(username)}`, this.baseUrl); const infoResp = await this.httpLib.fetch(infoUrl.href, { - headers: authHeaders({ - type: "basic", - username, - password, - }), + headers: token + ? authHeaders({ type: "bearer", token }) + : authHeaders({ type: "basic", username, password }), }); // FIXME: Validate! const acctInfo: TalerCorebankApi.AccountData = @@ -243,6 +298,7 @@ export class TalerCorebankApiClient { return { password, username, + token, accountPaytoUri: acctInfo.payto_uri, }; } @@ -266,7 +322,7 @@ export class TalerCorebankApiClient { body: { amount, }, - headers: this.makeAuthHeader(), + headers: await this.makeAuthHeader(), }); return readSuccessResponseJsonOrThrow( resp, @@ -286,7 +342,7 @@ export class TalerCorebankApiClient { const resp = await this.httpLib.fetch(url.href, { method: "POST", body: {}, - headers: this.makeAuthHeader(), + headers: await this.makeAuthHeader(), }); logger.info(`confirm response status ${resp.status}`); @@ -309,7 +365,7 @@ export class TalerCorebankApiClient { const resp = await this.httpLib.fetch(url.href, { method: "POST", body: {}, - headers: this.makeAuthHeader(), + headers: await this.makeAuthHeader(), }); await readSuccessResponseJsonOrThrow(resp, codecForAny()); } @@ -325,7 +381,7 @@ export class TalerCorebankApiClient { const resp = await this.httpLib.fetch(url.href, { method: "POST", body: {}, - headers: this.makeAuthHeader(), + headers: await this.makeAuthHeader(), }); await expectSuccessResponseOrThrow(resp); } diff --git a/packages/taler-util/src/twrpc-impl.node.ts b/packages/taler-util/src/twrpc-impl.node.ts @@ -155,7 +155,12 @@ export async function runRpcServer(args: RpcServerArgs): Promise<void> { }); sock.on("error", (err) => { - logger.error(`connection error: ${err}`); + const code = (err as NodeJS.ErrnoException).code; + if (code === "ECONNRESET" || code === "EPIPE") { + logger.info(`RPC peer disconnected: ${err}`); + } else { + logger.error(`connection error: ${err}`); + } }); function processLine(line: Uint8Array) { diff --git a/packages/taler-util/src/types-donau.ts b/packages/taler-util/src/types-donau.ts @@ -65,6 +65,9 @@ export interface DonauVersionResponse { // Financial domain by this Donau. legal_domain: string; + + // Build identifier, when provided by the deployment. + build_version?: string; } export const codecForDonauVersionResponse = (): Codec<DonauVersionResponse> => @@ -73,6 +76,7 @@ export const codecForDonauVersionResponse = (): Codec<DonauVersionResponse> => .property("name", codecForConstString("donau")) .property("currency", codecForString()) .property("legal_domain", codecForString()) + .property("build_version", codecOptional(codecForString())) .build("DonauApi.DonauVersionResponse"); /** @@ -93,7 +97,7 @@ export interface DonauKeysResponse { version: string; // Financial domain this Donau operates for. - //domain: string; + legal_domain: string; // The Donau's base URL. base_url: string; @@ -204,7 +208,7 @@ export const codecForDonauKeysResponse = (): Codec<DonauKeysResponse> => .property("version", codecForString()) .property("base_url", codecForString()) .property("currency", codecForString()) - //.property("domain", codecForString()) + .property("legal_domain", codecForString()) .property("signkeys", codecForAny()) .property("donation_units", codecForList(codecForDonationUnitKeyGroup())) //.property("currency_fraction_digits", codecForNumber()) diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts @@ -66,6 +66,16 @@ export interface IntegrationConfig { name: "taler-bank-integration"; implementation?: string; + + // Optional capabilities advertised by the fakebank implementation on its + // integration endpoint as well as on the core-bank endpoint. + allow_conversion?: boolean; + allow_registrations?: boolean; + allow_deletions?: boolean; + allow_edit_name?: boolean; + allow_edit_cashout_payto_uri?: boolean; + default_debit_threshold?: AmountString; + supported_tan_channels?: TanChannel[]; } export interface TalerCorebankConfigResponse { @@ -757,6 +767,23 @@ export const codecForIntegrationBankConfig = (): Codec<IntegrationConfig> => .property("currency", codecForString()) .property("currency_specification", codecForCurrencySpecificiation()) .property("implementation", codecOptional(codecForString())) + .property("allow_conversion", codecOptional(codecForBoolean())) + .property("allow_registrations", codecOptional(codecForBoolean())) + .property("allow_deletions", codecOptional(codecForBoolean())) + .property("allow_edit_name", codecOptional(codecForBoolean())) + .property("allow_edit_cashout_payto_uri", codecOptional(codecForBoolean())) + .property("default_debit_threshold", codecOptional(codecForAmountString())) + .property( + "supported_tan_channels", + codecOptional( + codecForList( + codecForEither( + codecForConstString(TanChannel.SMS), + codecForConstString(TanChannel.EMAIL), + ), + ), + ), + ) .build("TalerCorebankApi.IntegrationConfig"); export const codecForCoreBankConfig = (): Codec<TalerCorebankConfigResponse> => diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -1881,6 +1881,9 @@ export interface ExchangeVersionResponse { // @since v18, may become mandatory in the future. implementation?: string; + // Implementation build identifier, when exposed by the exchange. + build_version?: string; + // Currency supported by this exchange, given // as a currency code ("USD" or "EUR"). currency: string; @@ -2534,6 +2537,7 @@ export const codecForExchangeConfig = (): Codec<ExchangeVersionResponse> => .property("version", codecForString()) .property("name", codecForConstString("taler-exchange")) .property("implementation", codecOptional(codecForURN())) + .property("build_version", codecOptional(codecForString())) .property("currency", codecForString()) .property("currency_specification", codecForCurrencySpecificiation()) .property( @@ -3055,6 +3059,9 @@ interface DepositDoubleSpendError { // EdDSA public key of a coin being double-spent. coin_pub: EddsaPublicKey; + + // Hash of the denomination key for the conflicting coin. + h_denom_pub?: HashCodeString; } interface PurseCreateConflict { code: TalerErrorCode.EXCHANGE_PURSE_CREATE_CONFLICTING_META_DATA; @@ -3159,6 +3166,7 @@ export const codecForDepositDoubleSpendError = .property("code", codecForNumber()) .property("hint", codecForString()) .property("coin_pub", codecForString()) + .property("h_denom_pub", codecOptional(codecForString())) .build("DepositDoubleSpendError"); export const codecForPurseCreateConflict = (): Codec<PurseCreateConflict> => buildCodecForObject<PurseCreateConflict>() diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -1562,6 +1562,10 @@ export interface CoinPaySig { export interface StatusPaid { type?: "paid"; + // Fulfillment target associated with the paid order, when provided by the + // merchant so the wallet can continue after confirming payment. + fulfillment_url?: string; + // Was the payment refunded (even partially, via refund or abort)? refunded: boolean; @@ -2848,6 +2852,9 @@ export interface CheckPaymentPaidResponse { // 0 if there were no issues. exchange_code: number; + // Legacy name for the exchange error code. + exchange_ec?: number; + // HTTP status code returned by the exchange when we asked for // information to track the wire transfer for this purchase. // 0 if there were no issues. @@ -3538,6 +3545,19 @@ export interface WalletTemplateDetailsResponse { // This parameter is optional. // Since protocol **v13**. required_currency?: string; + + // Merchant identity and usable exchanges were added in merchant protocol + // v41 so the wallet can validate and complete a template payment. + merchant?: MerchantInfo; + merchant_pub?: EddsaPublicKey; + exchange_candidates?: TemplateExchangeCandidate[]; +} + +export interface TemplateExchangeCandidate { + base_url: string; + currency: string; + master_pub: EddsaPublicKey; + wire_methods: string[]; } export interface TemplateDetails { @@ -4436,6 +4456,7 @@ export const codecForStatusPaid = (): Codec<StatusPaid> => .property("refund_pending", codecForBoolean()) .property("refund_taken", codecForAmountString()) .property("refunded", codecForBoolean()) + .property("fulfillment_url", codecOptional(codecForString())) // The order-status response is discriminated by HTTP status, not by a // body field; some merchants don't send `type` at all. .property("type", codecOptional(codecForConstString("paid"))) @@ -4915,6 +4936,7 @@ export const codecForCheckPaymentPaidResponse = .property("wired", codecForBoolean()) .property("deposit_total", codecForAmountString()) .property("exchange_code", codecForNumber()) + .property("exchange_ec", codecOptional(codecForNumber())) .property("exchange_http_status", codecForNumber()) .property("exchange_hc", codecOptional(codecForNumber())) .property("refund_amount", codecForAmountString()) @@ -5223,6 +5245,21 @@ export const codecForWalletTemplateDetails = codecOptional(codecForTemplateContractDetailsDefaults()), ) .property("required_currency", codecOptional(codecForString())) + .property("merchant", codecOptional(codecForMerchantInfo())) + .property("merchant_pub", codecOptional(codecForEddsaPublicKey())) + .property( + "exchange_candidates", + codecOptional( + codecForList( + buildCodecForObject<TemplateExchangeCandidate>() + .property("base_url", codecForString()) + .property("currency", codecForString()) + .property("master_pub", codecForEddsaPublicKey()) + .property("wire_methods", codecForList(codecForString())) + .build("TalerMerchantApi.TemplateExchangeCandidate"), + ), + ), + ) .build("TalerMerchantApi.WalletTemplateDetails"); export const codecForWebhookSummaryResponse = diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -997,7 +997,7 @@ export function requireExchangeTosAcceptedOrThrow( exchange: ReadyExchangeSummary, ): void { if (wex.ws.refcntIgnoreTos > 0) { - logger.warn( + logger.info( `ignoring ToS acceptance state, refcntIgnoreTos=${wex.ws.refcntIgnoreTos}`, ); return; diff --git a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts @@ -293,6 +293,13 @@ export class CryptoDispatcher { } const currentWorkItem = ws.currentWorkItem; if (!currentWorkItem) { + // terminateWorkers() rejects and clears in-flight work before asking the + // worker to terminate. A response already queued by the worker can still + // arrive during that shutdown window and is not a protocol violation. + if (this.stopped) { + logger.trace("ignoring crypto worker response after dispatcher stop"); + return; + } logger.error("unsolicited response from worker"); return; } diff --git a/packages/taler-wallet-core/src/db/query.ts b/packages/taler-wallet-core/src/db/query.ts @@ -44,6 +44,7 @@ import { Logger, openPromise, safeStringifyException, + TalerError, WalletNotification, } from "@gnu-taler/taler-util"; @@ -641,8 +642,13 @@ function runTx<Arg, Res>( internalContext.isAborted = true; internalContext.abortExn = abortExn; unregisterOnCancelled(); - logger.error(msg); - logger.error(`${stack.stack ?? stack}`); + if (transactionException instanceof TalerError) { + logger.info(msg); + logger.info(`${stack.stack ?? stack}`); + } else { + logger.error(msg); + logger.error(`${stack.stack ?? stack}`); + } // When the abort was caused by the transaction function throwing, // reject with *that* exception rather than the wrapper. Wrapping it // replaced a specific, actionable error (say "insufficient balance") @@ -681,8 +687,13 @@ function runTx<Arg, Res>( logger.warn("got AbortError, transaction was aborted"); } else { transactionException = e; - logger.error(`Transaction failed: ${safeStringifyException(e)}`); - logger.error(`${stack.stack ?? stack}`); + if (e instanceof TalerError) { + logger.info(`Transaction failed: ${safeStringifyException(e)}`); + logger.info(`${stack.stack ?? stack}`); + } else { + logger.error(`Transaction failed: ${safeStringifyException(e)}`); + logger.error(`${stack.stack ?? stack}`); + } try { tx.abort(); } catch (abortErr) { diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts @@ -1167,17 +1167,17 @@ export class DevExperimentHttpLib implements HttpRequestLibrary { } } if (this.devExperimentState.blockPayResponse) { - logger.warn(`have blockPayResponse`); + logger.info(`have blockPayResponse`); logger.info(`endsWithPay: ${url.endsWith("/pay")}`); if (method === "post" && url.endsWith("/pay")) { - logger.warn(`blocking /pay response`); + logger.info(`blocking /pay response`); const realResp = await this.underlyingLib.fetch(url, opt); return mockInternalServerError(realResp); } } if (this.devExperimentState.blockClaimResponse) { if (method === "post" && url.endsWith("/claim")) { - logger.warn(`blocking /claim response`); + logger.info(`blocking /claim response`); // The claim is not sent on at all: the merchant would hand the order // to our nonce, and every further attempt would then be a repeated // claim instead of a first one. diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -240,7 +240,9 @@ async function getExchangeRecordsInternal( ): Promise<WalletExchangeDetails | undefined> { const r = knownExchange ?? (await tx.getExchange(exchangeBaseUrl)); if (!r) { - logger.warn(`no exchange found for ${exchangeBaseUrl}`); + // This lookup is intentionally optional: transaction rendering and claim + // handover can ask about an exchange before it has been added locally. + logger.info(`no exchange found for ${exchangeBaseUrl}`); return; } const dp = r.detailsPointer; @@ -2440,12 +2442,12 @@ export async function updateExchangeFromUrlHandler( cherryPickDate, ); } catch (e) { - logger.warn(`unable to download exchange keys for ${exchangeBaseUrl}`); // If keys download fails, check if there's a migration. // Only if the migration target is reachable, migrate there! const plan = wex.ws.exchangeMigrationPlan.get(exchangeBaseUrl); if (plan) { - logger.warn( + logger.info(`unable to download exchange keys for ${exchangeBaseUrl}`); + logger.info( `trying migration from ${exchangeBaseUrl} to ${plan.newExchangeBaseUrl}`, ); const newExchangeClient = walletExchangeClient( @@ -2469,6 +2471,7 @@ export async function updateExchangeFromUrlHandler( }); return TaskRunResult.finished(); } + logger.warn(`unable to download exchange keys for ${exchangeBaseUrl}`); throw e; } @@ -4867,8 +4870,10 @@ export type BalanceThresholdCheckResult = } | { result: "violation"; - /** Threshold that needs to be requested to proceed. */ + /** Balance limit crossed by the expected balance. */ nextThreshold: AmountString; + /** Expected balance that needs to be authorized to proceed. */ + requiredBalance: AmountString; walletKycStatus: ExchangeWalletKycStatus | undefined; walletKycAccessToken: string | undefined; }; @@ -4930,7 +4935,7 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold( } else if (reserveRec.status === ReserveRecordStatus.Done) { // We don't know what the next threshold is, but we've passed *some* KYC // check. We don't have enough information, so we allow the balance increase. - logger.warn( + logger.info( `No next balance threshold, assuming balance KYC is okay`, ); return { @@ -4972,6 +4977,7 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold( return { result: "violation", nextThreshold: limViolated, + requiredBalance: Amounts.stringify(balExpected), walletKycStatus: reserveRec?.status ? getKycStatusFromReserveStatus(reserveRec.status) : undefined, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1783,7 +1783,7 @@ async function processDownloadProposal( // FIXME: Adjust this to account for refunds, don't count as repurchase // if original order is refunded. if (repurchase) { - logger.warn("repurchase detected"); + logger.info("repurchase detected"); p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected; p.repurchaseProposalId = repurchase.proposalId; await startPayReplay(wex, tx, repurchase.proposalId, p.downloadSessionId); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -959,7 +959,7 @@ async function processPeerPullCreditCreatePurse( if (kycCheckRes.result === "violation") { // Do this before we transition so that the exchange is already in the right state. await handleStartExchangeWalletKyc(wex, { - amount: kycCheckRes.nextThreshold, + amount: kycCheckRes.requiredBalance, exchangeBaseUrl: pullIni.exchangeBaseUrl, }); await wex.runWalletDbTx(async (tx) => { @@ -1213,7 +1213,7 @@ async function processPeerPullCreditBalanceKyc( return checkRes; } await handleStartExchangeWalletKyc(ctx.wex, { - amount: checkRes.nextThreshold, + amount: checkRes.requiredBalance, exchangeBaseUrl, }); return undefined; diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -1066,7 +1066,7 @@ async function processPendingMerge( if (kycCheckRes.result === "violation") { // Do this before we transition so that the exchange is already in the right state. await handleStartExchangeWalletKyc(wex, { - amount: kycCheckRes.nextThreshold, + amount: kycCheckRes.requiredBalance, exchangeBaseUrl: peerInc.exchangeBaseUrl, }); await ctx.wex.runWalletDbTx(async (tx) => { @@ -1555,7 +1555,7 @@ async function processPeerPushCreditBalanceKyc( return checkRes; } await handleStartExchangeWalletKyc(ctx.wex, { - amount: checkRes.nextThreshold, + amount: checkRes.requiredBalance, exchangeBaseUrl, }); return undefined; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -1459,7 +1459,7 @@ export class InternalWalletState { this._http = this.httpFactory(newConfig); if (this.config.testing.devModeActive) { - logger.warn("using dev experiment http lib"); + logger.info("using dev experiment http lib"); this._http = new DevExperimentHttpLib(this.http, this.devExperimentState); } } diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -1088,7 +1088,7 @@ async function processWithdrawalGroupBalanceKyc( return checkRes; } await handleStartExchangeWalletKyc(ctx.wex, { - amount: checkRes.nextThreshold, + amount: checkRes.requiredBalance, exchangeBaseUrl, }); return undefined; @@ -1406,7 +1406,10 @@ export async function getWithdrawableDenomsTx( if (denom) { relevantDenoms.push(denom); } else { - logger.warn(`no representative denom for family with serial=${fpSerial}`); + // Families outlive their individual denominations. Having no currently + // withdrawable representative is therefore expected around key rotation + // and while recovering from denomination loss. + logger.info(`no representative denom for family with serial=${fpSerial}`); logger.info(`family details: ${j2s(fam)}`); } } @@ -2776,6 +2779,14 @@ async function redenominateWithdrawal( coinIndex += sel.count; } + if (candidates.length === 0) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, + { exchangeBaseUrl }, + "no withdrawal denominations available during re-denomination", + ); + } + const newSel = selectWithdrawalDenominations( remaining.toJson(), candidates, @@ -2863,7 +2874,7 @@ async function processWithdrawalGroupPendingReady( if (kycCheckRes.result === "violation") { // Do this before we transition so that the exchange is already in the right state. await handleStartExchangeWalletKyc(wex, { - amount: kycCheckRes.nextThreshold, + amount: kycCheckRes.requiredBalance, exchangeBaseUrl, }); await ctx.wex.runWalletDbTx(async (tx) => { @@ -3823,7 +3834,10 @@ async function processReserveBankStatus( ); } - if (reservePoll.status === "rejected") { + if ( + reservePoll.status === "rejected" && + !wex.cancellationToken.isCancelled + ) { // Until the bank confirms the transfer, exchange failures are secondary: // keep them out of the transaction's single user-visible retry record. logger.warn(