commit bec18897cc7344c077be019b84d560d8716c4deb
parent c4ce5227b13fb241ed037f7d486aa91ef05c6a7b
Author: Sebastian <sebasjm@taler-systems.com>
Date: Mon, 27 Apr 2026 10:51:13 -0300
int test for #11356
Diffstat:
10 files changed, 406 insertions(+), 44 deletions(-)
diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts
@@ -53,6 +53,7 @@ import {
TalerMerchantInstanceHttpClient,
TalerProtocolTimestamp,
TalerWireGatewayHttpClient,
+ TemplateType,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
@@ -87,6 +88,7 @@ import {
MERCHANT_DEFAULT_LOGIN_SCOPE,
MerchantService,
MerchantServiceInterface,
+ PaivanaService,
setupDb,
setupSharedDb,
useLibeufinBank,
@@ -141,6 +143,7 @@ export interface SimpleTestEnvironmentNg3 {
exchange: ExchangeService;
exchangeBankAccount: HarnessExchangeBankAccount;
merchant: MerchantService;
+ paivana: PaivanaService;
merchantAdminAccessToken: AccessToken;
walletClient: WalletClient;
walletService: WalletService;
@@ -155,6 +158,8 @@ export interface EnvOptions {
*/
ageMaskSpec?: string;
+ paivanaWebsite?: string;
+
mixedAgeRestriction?: boolean;
skipWireFeeCreation?: boolean;
@@ -561,6 +566,11 @@ export async function createSimpleTestkudosEnvironmentV3(
? await LibeufinBankService.create(t, bc)
: await FakebankService.create(t, bc);
+ const paivana = await PaivanaService.create(t, {
+ destination: "https://grothoff.org/",
+ httpPort: 8088
+ })
+
const exchange = ExchangeService.create(t, {
name: "testexchange-1",
currency: "TESTKUDOS",
@@ -703,6 +713,22 @@ export async function createSimpleTestkudosEnvironmentV3(
},
);
+ if (opts.paivanaWebsite) {
+ const instanceUrl = merchant.makeInstanceBaseUrl("admin")
+ const api = new TalerMerchantInstanceHttpClient(instanceUrl);
+ const t = await api.addTemplate(merchantAdminAccessToken, {
+ template_id: "paivana",
+ template_description: "test paivana",
+ template_contract: {
+ template_type: TemplateType.PAIVANA,
+ choices: [{ amount: "TESTKUDOS:1" }],
+ website_regex: opts.paivanaWebsite,
+ }
+ })
+ paivana.setMerchant(instanceUrl, merchantAdminAccessToken)
+ await paivana.start()
+ }
+
console.log("setup done!");
return {
@@ -714,6 +740,7 @@ export async function createSimpleTestkudosEnvironmentV3(
walletService,
bank,
bankClient,
+ paivana,
exchangeBankAccount,
createBrowser: createBrowser(t),
};
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
@@ -580,6 +580,13 @@ export interface BankConfig {
overrideTestDir?: string;
}
+export interface PaivanaConfig {
+ destination: string;
+ httpPort: number;
+ secret?: string;
+ overrideTestDir?: string;
+}
+
export interface FakeBankConfig {
currency: string;
httpPort: number;
@@ -1157,6 +1164,143 @@ export interface BankServiceHandle {
changeConfig(f: (config: Configuration) => void): void;
}
+export interface PaivanaServiceHandle {
+ readonly paivanaApiBaseUrl: string;
+ readonly http: HttpRequestLibrary;
+
+ setMerchant(url: string, authToken: AccessToken): void;
+ start(): Promise<void>;
+ pingUntilAvailable(): Promise<void>;
+ stop(): Promise<void>;
+ changeConfig(f: (config: Configuration) => void): void;
+}
+
+/**
+ * Implementation of the bank service using the "taler-fakebank-run" tool.
+ */
+export class PaivanaService
+ implements PaivanaServiceHandle {
+ public proc: ProcessWrapper | undefined;
+ // public paivanaApiBaseUrl: string;
+
+
+ get paivanaApiBaseUrl() {
+ return this.baseUrl
+ }
+
+ constructor(
+ private globalState: GlobalTestState,
+ private config: PaivanaConfig,
+ private configFile: string,
+ ) { }
+
+ http = createPlatformHttpLib({ enableThrottling: false });
+
+
+ /**
+ */
+ static async create(
+ gc: GlobalTestState,
+ pc: PaivanaConfig,
+ ): Promise<PaivanaService> {
+ const config = new Configuration(ConfigSources["paivana"]);
+ const testDir = pc.overrideTestDir ?? gc.testDir;
+ setTalerPaths(config, testDir + "/talerhome");
+ const hostname = "localhost";
+ config.setString("paivana", "port", `${pc.httpPort}`);
+ config.setString("paivana", "serve", "tcp");
+ config.setString("paivana", "DESTINATION_BASE_URL", pc.destination);
+ config.setString("paivana", "SECRET", Math.random().toString().substring(2, 6));
+ config.setString(
+ "paivana",
+ "base_url",
+ `http://${hostname}:${pc.httpPort}/`,
+ );
+
+ const cfgFilename = testDir + "/paivana.conf";
+ config.writeTo(cfgFilename, { excludeDefaults: true });
+
+ return new PaivanaService(gc, pc, cfgFilename);
+ }
+
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ opts: { overridePath?: string },
+ ): PaivanaService {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = testDir + `/paivana.conf`;
+ const config = Configuration.load(
+ cfgFilename,
+ ConfigSources["paivana"],
+ );
+ const pc: PaivanaConfig = {
+ httpPort: config.getNumber("paivana", "port").required(),
+ destination: config.getString("paivana", "DESTINATION_BASE_URL").required(),
+ secret: config.getString("paivana", "secret").required(),
+ };
+ return new PaivanaService(gc, pc, cfgFilename);
+ }
+
+ changeConfig(f: (config: Configuration) => void) {
+ const config = Configuration.load(
+ this.configFile,
+ ConfigSources["paivana"],
+ );
+ f(config);
+ config.writeTo(this.configFile, { excludeDefaults: true });
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.config.httpPort}/`;
+ }
+
+
+ setMerchant(backendUrl: string, authToken: AccessToken): void {
+ const config = Configuration.load(
+ this.configFile,
+ ConfigSources["paivana"],
+ );
+ config.setString("paivana", "MERCHANT_BACKEND_URL", backendUrl);
+ config.setString("paivana", "MERCHANT_ACCESS_TOKEN", authToken);
+ config.writeTo(this.configFile, { excludeDefaults: true });
+
+ }
+ get port() {
+ return this.config.httpPort;
+ }
+
+ async start(): Promise<void> {
+ logger.info("starting paivana");
+ if (this.proc) {
+ logger.info("paivana already running, not starting again");
+ return;
+ }
+ this.proc = this.globalState.spawnService(
+ "paivana-httpd",
+ [
+ "-c",
+ this.configFile,
+ ],
+ "paivana",
+ );
+ await this.pingUntilAvailable();
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.config.httpPort}/`;
+ await pingProc(this.proc, url, "paivana");
+ }
+
+ async stop(): Promise<void> {
+ const paivanaProc = this.proc;
+ if (paivanaProc) {
+ paivanaProc.proc.kill("SIGTERM");
+ await paivanaProc.wait();
+ this.proc = undefined;
+ }
+ }
+}
+
export type BankService = BankServiceHandle;
export const BankService = useLibeufinBank
? LibeufinBankService
diff --git a/packages/taler-harness/src/integrationtests/test-fee-regression.ts b/packages/taler-harness/src/integrationtests/test-fee-regression.ts
@@ -35,6 +35,7 @@ import {
GlobalTestState,
HarnessExchangeBankAccount,
MerchantService,
+ PaivanaService,
getTestHarnessPaytoForLabel,
setupDb,
} from "../harness/harness.js";
@@ -68,6 +69,11 @@ export async function createMyTestkudosEnvironment(
database: db.connStr,
});
+ const paivana = await PaivanaService.create(t, {
+ destination: "https://grothoff.org/",
+ httpPort: 8088
+ })
+
let receiverName = "Exchange";
let exchangeBankUsername = "exchange";
let exchangeBankPassword = "mypw-password";
@@ -199,6 +205,7 @@ export async function createMyTestkudosEnvironment(
walletService,
bankClient,
bank,
+ paivana,
createBrowser: createBrowser(t),
exchangeBankAccount,
};
diff --git a/packages/taler-harness/src/integrationtests/test-paivana.ts b/packages/taler-harness/src/integrationtests/test-paivana.ts
@@ -0,0 +1,164 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020-2024 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ bytesToString,
+ ConfirmPayResultType,
+ Logger,
+ PreparePayResultType,
+ Result,
+ sha256,
+ stringToBytes,
+ TalerUriAction,
+ TalerUris,
+ WalletNotification
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3
+} from "../harness/environments.js";
+import { GlobalTestState } from "../harness/harness.js";
+
+const harnessHttpLib = createPlatformHttpLib({
+ enableThrottling: false,
+});
+export const logger = new Logger("test-paivana.ts");
+
+export async function runPaivanaTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ walletClient,
+ bankClient,
+ exchange,
+ paivana,
+ } = await createSimpleTestkudosEnvironmentV3(t, undefined, {
+ paivanaWebsite: "/.*.html" // block all html pages
+ });
+
+ const notifs: WalletNotification[] = [];
+
+ walletClient.addNotificationListener((x) => {
+ notifs.push(x);
+ });
+
+ const withdrawalRes = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawalRes.withdrawalFinishedCond;
+
+ const website = `${paivana.baseUrl}index.html`
+
+ const firstRequest = await harnessHttpLib.fetch(website);
+ const templateURI = firstRequest.headers.get("paivana");
+ t.assertTrue(!!templateURI);
+
+ const uri = Result.unpack(TalerUris.fromString(templateURI))
+ t.assertTrue(uri.type === TalerUriAction.PayTemplate)
+
+ const cur_time = Math.floor(Date.now() / 1000);// + 60*60*24;
+ const nonce = Array.from(new Uint8Array(32))
+ .map(b => b.toString(16).padStart(2, '0')).join('');
+
+ const sha = bytesToString(sha256(stringToBytes(nonce + website + cur_time)))
+ const hash = base64Url(sha)
+ const paivanaId = (`${cur_time}-${hash}`)
+
+
+ logger.info("PAIVANA TEMPLATE ID::", paivanaId);
+ const PAIVANA_POLL_BASE = `${website}/.well-known/paivana/sessions/${encodeURIComponent(paivanaId)}`;
+
+ { // Check if we magically have access (guess what, no)
+ const res = await harnessHttpLib.fetch(`${PAIVANA_POLL_BASE}`);
+ // const res = await harnessHttpLib.fetch(`${PAIVANA_POLL_BASE}?timeout_ms=1000`);
+ t.assertTrue(res.status === 402)
+ }
+ logger.info("access denied, we need to pay");
+
+ { // Pay the access to the site
+ const newTemplate = TalerUris.createTalerPayTemplate(
+ uri.merchantBaseUrl,
+ uri.templateId,
+ {
+ fulfillmenURL: encodeURIComponent(website),
+ sessionId: encodeURIComponent(paivanaId)
+ }
+ );
+ const talerPayTemplateUri = TalerUris.toString(newTemplate);
+ logger.info("pay template", newTemplate, talerPayTemplateUri);
+
+ const templateStatus = await walletClient.call(WalletApiOperation.PreparePayForTemplate, { talerPayTemplateUri })
+ t.assertTrue(templateStatus.status === PreparePayResultType.PaymentPossible)
+ const talerPayUri = templateStatus.talerUri
+ logger.info("pay order", talerPayUri);
+
+ const payStatus = await walletClient.call(WalletApiOperation.PreparePayForUri, { talerPayUri });
+ t.assertTrue(payStatus.status === PreparePayResultType.PaymentPossible)
+ logger.info("transaction", payStatus.transactionId);
+
+ const payment = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: payStatus.transactionId,
+ });
+ t.assertTrue(payment.type === ConfirmPayResultType.Done)
+ logger.info("paid", payment.contractTerms.fulfillment_url);
+ }
+
+ logger.info("checking paivana state again",);
+ let order_id;
+ { // Check if we have access
+ const res = await harnessHttpLib.fetch(`${PAIVANA_POLL_BASE}?timeout_ms=1000`);
+ t.assertTrue(res.status === 200)
+ try { order_id = (await res.json()).order_id ?? undefined; } catch (_) { }
+ }
+ t.assertTrue(order_id !== undefined)
+ logger.info(`---- ORDER ID ${order_id}`);
+
+ const res = await harnessHttpLib.fetch(`${website}/.well-known/paivana`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ order_id, nonce, cur_time, website }),
+ });
+
+ console.log(res)
+ // TODO: the test is incomplete
+
+ // TODO: verify cookie
+}
+
+function base64Url(str: string) {
+ return Buffer
+ .from(str, "utf8")
+ .toString("base64")
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+
+}
+
+function waitMs(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+runPaivanaTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-revocation.ts b/packages/taler-harness/src/integrationtests/test-revocation.ts
@@ -36,6 +36,7 @@ import {
GlobalTestState,
HarnessExchangeBankAccount,
MerchantService,
+ PaivanaService,
WalletCli,
WalletClient,
delayMs,
@@ -90,6 +91,10 @@ async function createTestEnvironment(
httpPort: 8083,
database: db.connStr,
});
+ const paivana = await PaivanaService.create(t, {
+ destination: "https://grothoff.org/",
+ httpPort: 8088
+ })
let receiverName = "Exchange";
let exchangeBankUsername = "exchange";
@@ -181,6 +186,7 @@ async function createTestEnvironment(
commonDb: db,
exchange,
merchant,
+ paivana,
walletClient,
merchantAdminAccessToken: adminAccessToken,
walletService,
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -224,6 +224,7 @@ import { runWithdrawalHugeTest } from "./test-withdrawal-huge.js";
import { runWithdrawalIdempotentTest } from "./test-withdrawal-idempotent.js";
import { runWithdrawalManualTest } from "./test-withdrawal-manual.js";
import { runWithdrawalPrepareTest } from "./test-withdrawal-prepare.js";
+import { runPaivanaTest } from "./test-paivana.js";
/**
* Test runner.
@@ -432,6 +433,7 @@ const allTests: TestMainFunction[] = [
runWireMetadataTest,
runMerchantPaytoReuseTest,
runPreparedTransferTest,
+ runPaivanaTest
];
export interface TestRunSpec {
diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts
@@ -35,6 +35,8 @@ export * from "./i18n.js";
export * from "./iban.js";
export * from "./invariants.js";
export * from "./kdf.js";
+export {sha256} from "./sha256.js";
+
export * from "./libtool-version.js";
export * from "./logging.js";
export * from "./longpool-queue.js";
diff --git a/packages/taler-util/src/talerconfig.ts b/packages/taler-util/src/talerconfig.ts
@@ -133,6 +133,13 @@ export const ConfigSources = {
baseConfigVarname: "DONAU_BASE_CONFIG",
prefixVarname: "DONAU_PREFIX",
} satisfies ConfigSource,
+ ["paivana"]: {
+ projectName: "paivana",
+ componentName: "paivana",
+ installPathBinary: "paivana-config",
+ baseConfigVarname: "PAIVANA_BASE_CONFIG",
+ prefixVarname: "PAIVANA_PREFIX",
+ } satisfies ConfigSource,
} satisfies ConfigSourceDef;
const defaultConfigSource: ConfigSource = ConfigSources["taler-exchange"];
diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts
@@ -296,10 +296,14 @@ export namespace TalerUris {
if (p.sourceBaseUrl) result.push(["sourceBaseUrl", p.sourceBaseUrl]);
return result;
}
+ case TalerUriAction.PayTemplate: {
+ if (p.fulfillmentUrl) result.push(["fulfillment_url", p.fulfillmentUrl]);
+ if (p.sessionId) result.push(["session_id", p.sessionId]);
+ return result;
+ }
case TalerUriAction.Refund:
case TalerUriAction.PayPush:
case TalerUriAction.PayPull:
- case TalerUriAction.PayTemplate:
case TalerUriAction.Restore:
case TalerUriAction.DevExperiment:
case TalerUriAction.AddExchange: {
@@ -347,9 +351,7 @@ export namespace TalerUris {
*/
switch (p.type) {
case TalerUriAction.Withdraw:
- return `/${asHost(p.bankIntegrationApiBaseUrl)}${
- p.withdrawalOperationId
- }`;
+ return `/${asHost(p.bankIntegrationApiBaseUrl)}${p.withdrawalOperationId}`;
case TalerUriAction.Pay:
return `/${asHost(p.merchantBaseUrl)}${p.orderId}/${p.sessionId}`;
case TalerUriAction.Refund:
@@ -406,47 +408,47 @@ export namespace TalerUris {
export type InvalidTargetPathDetail =
| {
- uriType: TalerUriAction.Pay;
- }
+ uriType: TalerUriAction.Pay;
+ }
| {
- uriType: TalerUriAction.Withdraw;
- }
+ uriType: TalerUriAction.Withdraw;
+ }
| {
- uriType: TalerUriAction.Refund;
- pos: 0;
- }
+ uriType: TalerUriAction.Refund;
+ pos: 0;
+ }
| {
- uriType: TalerUriAction.Refund;
- pos: 1;
- }
+ uriType: TalerUriAction.Refund;
+ pos: 1;
+ }
| {
- uriType: TalerUriAction.PayPull;
- pos: 0;
- }
+ uriType: TalerUriAction.PayPull;
+ pos: 0;
+ }
| {
- uriType: TalerUriAction.PayPush;
- pos: 0;
- }
+ uriType: TalerUriAction.PayPush;
+ pos: 0;
+ }
| {
- uriType: TalerUriAction.PayTemplate;
- pos: 0;
- }
+ uriType: TalerUriAction.PayTemplate;
+ pos: 0;
+ }
| {
- uriType: TalerUriAction.WithdrawExchange;
- pos: 0;
- }
+ uriType: TalerUriAction.WithdrawExchange;
+ pos: 0;
+ }
| {
- uriType: TalerUriAction.WithdrawExchange;
- pos: 1;
- }
+ uriType: TalerUriAction.WithdrawExchange;
+ pos: 1;
+ }
| {
- uriType: TalerUriAction.AddExchange;
- pos: 0;
- }
+ uriType: TalerUriAction.AddExchange;
+ pos: 0;
+ }
| {
- uriType: TalerUriAction.AddContact;
- pos: 0;
- };
+ uriType: TalerUriAction.AddContact;
+ pos: 0;
+ };
export function fromString(
s: string,
@@ -457,17 +459,17 @@ export namespace TalerUris {
| ResultError<TalerUriParseError.UNSUPPORTED, { uriType: string }>
| ResultError<TalerUriParseError.INCOMPLETE, { uriType: string }>
| ResultError<
- TalerUriParseError.COMPONENTS_LENGTH,
- { uriType: TalerUriAction }
- >
+ TalerUriParseError.COMPONENTS_LENGTH,
+ { uriType: TalerUriAction }
+ >
| ResultError<
- TalerUriParseError.INVALID_TARGET_PATH,
- InvalidTargetPathDetail
- >
+ TalerUriParseError.INVALID_TARGET_PATH,
+ InvalidTargetPathDetail
+ >
| ResultError<
- TalerUriParseError.INVALID_PARAMETER,
- { uriType: TalerUriAction; name: string }
- > {
+ TalerUriParseError.INVALID_PARAMETER,
+ { uriType: TalerUriAction; name: string }
+ > {
// check prefix
let isHttp = false;
const prefixCheck = opts.ignoreUppercase ? s.toLowerCase() : s;
diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts
@@ -113,6 +113,7 @@ export function useComponentState({
def?.amount !== undefined ? Amounts.parseOrThrow(def.amount) : undefined;
const defaultSummary = def?.summary;
+ // we need to get the currency from somewhere
const zero = fixedAmount
? Amounts.zeroOfAmount(fixedAmount)
: cfg.currency !== undefined