taler-typescript-core

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

commit 43ff95dc1956d384164b75f30c84f16534de5c94
parent 8e57ab32d8bff66fdf7575abcdb1161832c54b2f
Author: Florian Dold <dold@taler.net>
Date:   Fri, 31 Jul 2026 15:47:40 +0200

harness: check that merchant instance names are case-insensitive

Covers the stored ID, case-only collisions, Basic auth, the URL path, the
canonical URLs in orders, and paying a URI that spells the instance
differently.

Diffstat:
Apackages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
2 files changed, 357 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts b/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts @@ -0,0 +1,355 @@ +/* + 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/> + */ + +/** + * Imports. + */ +import { + ConfirmPayResultType, + Duration, + HttpStatusCode, + MerchantAuthMethod, + succeedOrThrow, + TalerMerchantApi, + TalerMerchantInstanceHttpClient, + TalerMerchantManagementHttpClient, + TalerUriString, + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + createSimpleTestkudosEnvironmentV3, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { + getTestHarnessPaytoForLabel, + GlobalTestState, + harnessHttpLib, + MERCHANT_DEFAULT_LOGIN_SCOPE, +} from "../harness/harness.js"; + +/** + * The instance ID as the merchant operator spells it when creating the + * instance, the canonical (lower-case) form the backend stores it under, and + * a third spelling used to prove that lookups do not depend on the spelling + * that happens to be in the URL. + */ +const MIXED_ID = "MiXeDcAsE"; +const CANONICAL_ID = "mixedcase"; +const UPPER_ID = "MIXEDCASE"; + +const INSTANCE_PASSWORD = "i-am-mixedcase"; +const INSTANCE_NAME = "Mixed Case Business"; + +/** + * Merchant instance IDs are case-insensitive: the backend folds them to + * lower case when an instance is created and again on every lookup, so the + * lower-case form is the only identity that exists once the request is done. + * + * This test pins that behaviour down end to end, because it is easy to + * regress in a way that is invisible until a merchant happens to type a + * capital letter: + * + * - the stored ID is canonical, and an ID that differs only in case is a + * collision rather than a second instance (the merchant_id column has a + * plain UNIQUE constraint, so nothing but the folding prevents the + * duplicate), + * - every request path that carries the ID -- the URL, the Basic-auth + * username, the bearer token's instance -- accepts any spelling, + * - the URLs the backend hands out (merchant_base_url, taler://pay/) are + * canonical no matter which spelling the shop used to create the order, + * - and the wallet can still pay an order whose taler://pay/ URI carries a + * non-canonical instance segment, since the base URL it derives from the + * URI then disagrees with the signed contract terms. + */ +export async function runMerchantInstanceCasingTest(t: GlobalTestState) { + const { + bankClient, + walletClient, + exchange, + merchant, + merchantAdminAccessToken, + } = await createSimpleTestkudosEnvironmentV3(t); + + const managementClient = new TalerMerchantManagementHttpClient( + merchant.makeInstanceBaseUrl(), + ); + + const instancesBefore = succeedOrThrow( + await managementClient.listInstances(merchantAdminAccessToken), + ).instances.length; + + // Create the instance spelled in mixed case. addInstanceWithWireAccount + // also logs in via Basic auth and posts the wire account through + // /instances/$MIXED_ID/, so this already exercises two of the paths below. + await merchant.addInstanceWithWireAccount( + { + id: MIXED_ID, + name: INSTANCE_NAME, + paytoUris: [getTestHarnessPaytoForLabel("merchant-mixedcase")], + auth: { + method: MerchantAuthMethod.TOKEN, + password: INSTANCE_PASSWORD, + }, + }, + { adminAccessToken: merchantAdminAccessToken }, + ); + + { + // The backend stores and reports the canonical ID, not the spelling the + // operator used. + const r = succeedOrThrow( + await managementClient.listInstances(merchantAdminAccessToken), + ); + const ids = r.instances.map((i) => i.id); + console.log(`instances after creation: ${JSON.stringify(ids)}`); + t.assertDeepEqual(r.instances.length, instancesBefore + 1); + t.assertTrue(ids.includes(CANONICAL_ID)); + t.assertTrue(!ids.includes(MIXED_ID)); + t.assertTrue(!ids.includes(UPPER_ID)); + } + + { + // GET /management/instances/$ID resolves under every spelling, and all + // spellings name the same instance. + for (const spelling of [CANONICAL_ID, MIXED_ID, UPPER_ID]) { + const det = succeedOrThrow( + await managementClient.getInstanceDetails( + merchantAdminAccessToken, + spelling, + ), + ); + console.log(`management lookup of '${spelling}' -> '${det.name}'`); + t.assertDeepEqual(det.name, INSTANCE_NAME); + } + } + + { + // An instance whose ID differs only in case is the *same* instance, so + // creating it with different settings must conflict rather than produce + // a second instance. (Identical settings would be answered idempotently + // with 204, hence the different name.) + const resp = await managementClient.createInstance( + merchantAdminAccessToken, + { + id: UPPER_ID, + name: "Some Other Business", + address: {}, + jurisdiction: {}, + use_stefan: true, + default_pay_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ days: 1 }), + ), + default_wire_transfer_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ days: 1 }), + ), + auth: { + method: MerchantAuthMethod.TOKEN, + password: "some-other-password", + }, + }, + ); + t.assertTrue(resp.type === "fail"); + t.assertDeepEqual(resp.case, HttpStatusCode.Conflict); + + const r = succeedOrThrow( + await managementClient.listInstances(merchantAdminAccessToken), + ); + t.assertDeepEqual(r.instances.length, instancesBefore + 1); + } + + { + // The admin instance is reachable both at the root and, for older + // clients, under /instances/admin/ -- which the backend answers with a + // redirect to the root, so that instance management (every "default + // only" handler) stays reachable. That redirect keys on the instance + // segment, so it has to fold it like every other instance lookup: with a + // case-sensitive compare, /instances/Admin/ silently loses management. + const adminBaseUrl = merchant.makeInstanceBaseUrl(); + for (const spelling of ["admin", "Admin", "ADMIN"]) { + const url = `${adminBaseUrl}instances/${spelling}/management/instances`; + const resp = await harnessHttpLib.fetch(url, { + headers: { + Authorization: `Bearer ${merchantAdminAccessToken}`, + }, + }); + console.log(`GET ${url} -> ${resp.status}`); + t.assertDeepEqual(resp.status, HttpStatusCode.Ok); + } + } + + { + // The Basic-auth username on POST /private/token is an instance ID and + // must be folded like any other. A regression here logs the merchant + // out with a bare HTTP 401. + for (const spelling of [CANONICAL_ID, MIXED_ID, UPPER_ID]) { + const client = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(CANONICAL_ID), + ); + succeedOrThrow( + await client.createAccessToken( + spelling, + INSTANCE_PASSWORD, + MERCHANT_DEFAULT_LOGIN_SCOPE, + ), + ); + console.log(`login as '${spelling}' succeeded`); + } + } + + // A token minted through one spelling of the URL... + const { access_token: instanceToken } = succeedOrThrow( + await new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(UPPER_ID), + ).createAccessToken( + CANONICAL_ID, + INSTANCE_PASSWORD, + MERCHANT_DEFAULT_LOGIN_SCOPE, + ), + ); + + { + // ...is accepted under every other spelling, on both the public and the + // authenticated endpoints. + for (const spelling of [CANONICAL_ID, MIXED_ID, UPPER_ID]) { + const client = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(spelling), + ); + const cfg = succeedOrThrow(await client.getConfig()); + t.assertDeepEqual(cfg.currency, "TESTKUDOS"); + + const det = succeedOrThrow( + await client.getCurrentInstanceDetails(instanceToken), + ); + t.assertDeepEqual(det.name, INSTANCE_NAME); + console.log(`instance API reachable at /instances/${spelling}/`); + } + } + + // Fund the wallet before the payment part. + t.assertTrue(bankClient !== undefined); + await withdrawViaBankV3(t, { + walletClient, + exchange, + amount: "TESTKUDOS:20", + bankClient, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + // Create the order through the *mixed-case* base URL, the way a shop + // configured with a capitalised instance name would. + const orderClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(MIXED_ID), + ); + + const orderResp = succeedOrThrow( + await orderClient.createOrder(instanceToken, { + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + } satisfies TalerMerchantApi.Order, + }), + ); + + let orderStatus = succeedOrThrow( + await orderClient.getOrderDetails(instanceToken, orderResp.order_id), + ); + t.assertTrue(orderStatus.order_status === "unpaid"); + + const canonicalPayUri = orderStatus.taler_pay_uri; + console.log(`taler pay URI: ${canonicalPayUri}`); + + { + // The URI the backend advertises is canonical even though the order was + // created through the mixed-case URL: it is built from the instance's + // stored ID, not from the request URL. + t.assertTrue(canonicalPayUri.includes(`/instances/${CANONICAL_ID}/`)); + t.assertTrue(!canonicalPayUri.includes(MIXED_ID)); + t.assertTrue(!canonicalPayUri.includes(UPPER_ID)); + } + + // Now hand the wallet a URI with a non-canonical instance segment, as a + // QR code generated from a hand-written base URL would carry. The wallet + // must still claim, download and pay the order -- the base URL it derives + // from the URI will not match merchant_base_url in the signed contract + // terms, and it has to tolerate exactly that mismatch. + const uncanonicalPayUri = canonicalPayUri.replace( + `/instances/${CANONICAL_ID}/`, + `/instances/${UPPER_ID}/`, + ) as TalerUriString; + t.assertTrue(uncanonicalPayUri !== canonicalPayUri); + console.log(`paying via non-canonical URI: ${uncanonicalPayUri}`); + + const preparePayResult = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: uncanonicalPayUri, + }, + ); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: preparePayResult.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + + { + // The order is claimed now, so the backend hands out the contract terms + // it signed. Their merchant_base_url is the canonical one, which is + // what the wallet is expected to adopt as the authoritative base URL. + const claimed = succeedOrThrow( + await orderClient.getOrderDetails(instanceToken, orderResp.order_id), + ); + t.assertTrue(claimed.order_status === "claimed"); + const baseUrl = claimed.contract_terms.merchant_base_url; + console.log(`contract terms merchant_base_url: ${baseUrl}`); + t.assertTrue(baseUrl.includes(`/instances/${CANONICAL_ID}/`)); + t.assertTrue(!baseUrl.includes(UPPER_ID)); + } + + { + // Re-scanning the very same non-canonical URI must return the existing + // transaction. The purchase record now stores the canonical base URL + // from the contract terms, so a lookup that keys on the URI's spelling + // alone misses it and claims the order all over again. + const again = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: uncanonicalPayUri, + }, + ); + t.assertDeepEqual(again.transactionId, preparePayResult.transactionId); + } + + const confirmResp = await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: preparePayResult.transactionId, + choiceIndex: 0, + }); + t.assertDeepEqual(confirmResp.type, ConfirmPayResultType.Done); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + orderStatus = succeedOrThrow( + await orderClient.getOrderDetails(instanceToken, orderResp.order_id), + ); + t.assertDeepEqual(orderStatus.order_status, "paid"); +} + +runMerchantInstanceCasingTest.suites = ["merchant", "wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -118,6 +118,7 @@ import { runMerchantDepositLargeTest } from "./test-merchant-deposit-large.js"; import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js"; import { runMerchantExchangeDuplicateTest } from "./test-merchant-exchange-duplicate.js"; import { runMerchantTokenfamilyKeysTest } from "./test-merchant-tokenfamily-keys.js"; +import { runMerchantInstanceCasingTest } from "./test-merchant-instance-casing.js"; import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete.js"; import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js"; import { runMerchantInstancesTest } from "./test-merchant-instances.js"; @@ -298,6 +299,7 @@ const allTests: TestMainFunction[] = [ runMerchantExchangeConfusionTest, runMerchantExchangeDuplicateTest, runMerchantTokenfamilyKeysTest, + runMerchantInstanceCasingTest, runMerchantInstancesDeleteTest, runMerchantInstancesTest, runMerchantInstancesUrlsTest,