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