commit a0258f14439129bec53ed718ffe1d9082117afcb
parent 50e616282b487c59ff56417f33559eceef3e4fab
Author: Florian Dold <dold@taler.net>
Date: Fri, 11 Sep 2026 21:34:34 +0200
merchant web UI: retain pending template IDs across token renewal
Scope pending template creation to the login generation, account, and
backend instead of the current token. Exclude renewed credentials from
the creation request key so an unchanged retry reuses the ID whose
response was lost and authenticates with the fresh token.
Continue discarding pending attempts on a new login, account change,
or backend change.
Issue: https://bugs.taler.net/n/10620
Diffstat:
4 files changed, 203 insertions(+), 6 deletions(-)
diff --git a/packages/taler-merchant-webui/src/api/hooks/useTemplates.test.tsx b/packages/taler-merchant-webui/src/api/hooks/useTemplates.test.tsx
@@ -0,0 +1,179 @@
+/*
+ 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/strict";
+import { test } from "node:test";
+import { render } from "preact";
+import { act } from "preact/test-utils";
+import { TemplateType, type TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useHttpLibForTesting } from "../client.js";
+import {
+ maybeRefreshLoginToken,
+ resetRecoveryBudget,
+} from "../tokenRefresh.js";
+import { useTemplates } from "./useTemplates.js";
+import {
+ captureSessionIdentity,
+ customBackendUrlStore,
+ session,
+ signIn,
+ signOut,
+} from "../../stores/session.js";
+import { updateDevSettings } from "../../stores/devSettings.js";
+import { webUiConfig } from "../../stores/webuiConfig.js";
+import { FakeHttpLib, noContent, ok } from "../../testing/fake-http.js";
+
+const backend = "https://backend.example.test/";
+const originalToken = "secret-token:old";
+const renewedToken = "secret-token:renewed";
+
+async function mountCreationHook() {
+ signOut();
+ customBackendUrlStore.clear();
+ webUiConfig.value = {};
+ updateDevSettings(() => ({}));
+ resetRecoveryBudget();
+ signIn("shop", originalToken, backend, {
+ expiresS: Math.floor(Date.now() / 1000) + 60,
+ refreshable: true,
+ scope: "spa:refreshable",
+ });
+
+ const occupied = new Set(["tmpl_coffee"]);
+ const writes: TalerMerchantApi.TemplateAddDetails[] = [];
+ const http = new FakeHttpLib()
+ .on(
+ "POST",
+ "/private/token",
+ ok({
+ access_token: renewedToken,
+ scope: "spa:refreshable",
+ refreshable: true,
+ expiration: { t_s: Math.floor(Date.now() / 1000) + 48 * 3600 },
+ }),
+ )
+ .handle((request) => {
+ if (!new URL(request.url).pathname.endsWith("/private/templates")) return;
+ if (request.method === "GET") {
+ return ok({
+ templates: [...occupied].map((id) => ({
+ template_id: id,
+ template_description: "Coffee",
+ })),
+ });
+ }
+ const body = request.body as TalerMerchantApi.TemplateAddDetails;
+ writes.push(body);
+ occupied.add(body.template_id);
+ if (writes.length === 1)
+ throw new TypeError("response lost after creation");
+ return noContent();
+ });
+ // This installs the HTTP fixture; it is not a component hook.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const restoreHttp = useHttpLibForTesting(http);
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let hook!: ReturnType<typeof useTemplates>;
+ function Probe() {
+ hook = useTemplates({ enabled: false });
+ return null;
+ }
+ const rerender = async () => {
+ await act(async () => render(<Probe />, container));
+ };
+ await rerender();
+ return {
+ http,
+ writes,
+ rerender,
+ create: (idMode: "automatic" | "manual" = "automatic") =>
+ hook.createTemplate(
+ idMode === "automatic" ? "tmpl_coffee" : "printed-code",
+ "Coffee",
+ { template_type: TemplateType.FIXED_ORDER, amount: "CHF:5" },
+ undefined,
+ { idMode },
+ ),
+ close: () => {
+ act(() => render(null, container));
+ container.remove();
+ restoreHttp();
+ signOut();
+ customBackendUrlStore.clear();
+ resetRecoveryBudget();
+ },
+ };
+}
+
+for (const mode of ["automatic", "manual"] as const) {
+ test(`an unchanged ${mode} creation retry survives real silent token renewal`, async () => {
+ const f = await mountCreationHook();
+ try {
+ await assert.rejects(f.create(mode), /response lost after creation/);
+ const before = captureSessionIdentity()!;
+ assert.equal(await maybeRefreshLoginToken(), "refreshed");
+ assert.equal(session.value.token, renewedToken);
+ assert.equal(captureSessionIdentity()!.generation, before.generation);
+ await f.rerender();
+
+ const expectedId =
+ mode === "automatic" ? "tmpl_coffee_1" : "printed-code";
+ assert.equal(await f.create(mode), expectedId);
+ assert.deepEqual(
+ f.writes.map((body) => body.template_id),
+ [expectedId, expectedId],
+ );
+ assert.deepEqual(f.writes[0], f.writes[1]);
+ const posts = f.http.requests.filter(
+ (request) =>
+ request.method === "POST" &&
+ request.url.endsWith("/private/templates"),
+ );
+ assert.deepEqual(
+ posts.map((request) => request.headers?.Authorization),
+ [`Bearer ${originalToken}`, `Bearer ${renewedToken}`],
+ );
+ } finally {
+ f.close();
+ }
+ });
+}
+
+for (const change of ["login", "account", "backend"] as const) {
+ test(`a new ${change} discards a previous session's pending template ID`, async () => {
+ const f = await mountCreationHook();
+ try {
+ await assert.rejects(f.create(), /response lost after creation/);
+ const before = captureSessionIdentity()!;
+ // Even identical credentials represent a new session after sign-in.
+ signIn(
+ change === "account" ? "other" : "shop",
+ originalToken,
+ change === "backend" ? "https://other.example.test/" : backend,
+ );
+ assert.notEqual(captureSessionIdentity()!.generation, before.generation);
+ await f.rerender();
+ assert.equal(await f.create(), "tmpl_coffee_2");
+ assert.deepEqual(
+ f.writes.map((body) => body.template_id),
+ ["tmpl_coffee_1", "tmpl_coffee_2"],
+ );
+ } finally {
+ f.close();
+ }
+ });
+}
diff --git a/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts b/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts
@@ -61,7 +61,14 @@ export function useTemplatePayUri(): (templateId: string) => string {
export function useTemplates({ enabled = true }: { enabled?: boolean } = {}) {
const token = session.value.token;
const config = getClientConfig();
- const identity = JSON.stringify(captureSessionIdentity());
+ const currentSession = captureSessionIdentity();
+ // Renewal rotates credentials within the same session. Only a new login,
+ // account, or backend should discard a pending creation attempt.
+ const identity = JSON.stringify([
+ currentSession?.generation,
+ currentSession?.account,
+ currentSession?.backendUrl,
+ ]);
const creation = useRef({ identity, creator: new TemplateCreator() });
if (creation.current.identity !== identity) {
creation.current = { identity, creator: new TemplateCreator() };
diff --git a/packages/taler-merchant-webui/src/api/templateCreation.test.ts b/packages/taler-merchant-webui/src/api/templateCreation.test.ts
@@ -199,7 +199,7 @@ for (const response of [
}
for (const idMode of ["automatic", "manual"] as const) {
- test(`an unchanged ${idMode} request reuses its ID after an uncertain POST`, async () => {
+ test(`an unchanged ${idMode} request reuses its ID with renewed credentials after an uncertain POST`, async () => {
const f = fixture();
let attempts = 0;
f.state.post = (req) => {
@@ -213,8 +213,18 @@ for (const idMode of ["automatic", "manual"] as const) {
};
const args = { ...f.args, idMode };
await assert.rejects(f.creator.create(args), /response lost/);
- assert.equal(await f.creator.create(args), "tmpl_coffee");
+ assert.equal(
+ await f.creator.create({
+ ...args,
+ token: "secret-token:renewed" as AccessToken,
+ }),
+ "tmpl_coffee",
+ );
assert.deepEqual(f.posted(), [body, body]);
+ assert.equal(
+ f.http.lastRequest!.headers?.Authorization,
+ "Bearer secret-token:renewed",
+ );
assert.equal(f.state.refreshes, 1);
});
}
@@ -242,7 +252,7 @@ test("an uncertain suffixed request remains pinned through a failed list refresh
);
});
-for (const change of ["contents", "account", "token", "mode"] as const) {
+for (const change of ["contents", "account", "mode"] as const) {
test(`changing ${change} starts a new allocation after an uncertain request`, async () => {
const f = fixture();
f.state.post = () => {
@@ -259,7 +269,6 @@ for (const change of ["contents", "account", "token", "mode"] as const) {
"https://backend.example.test/instances/other/",
f.http,
);
- if (change === "token") args.token = "secret-token:other" as AccessToken;
if (change === "mode") {
args.idMode = "manual";
await assert.rejects(f.creator.create(args), isTemplateIdConflict);
diff --git a/packages/taler-merchant-webui/src/api/templateCreation.ts b/packages/taler-merchant-webui/src/api/templateCreation.ts
@@ -53,7 +53,9 @@ export class TemplateCreator {
body: TalerMerchantApi.TemplateAddDetails;
refresh: () => Promise<unknown>;
}): Promise<string> {
- const requestKey = JSON.stringify([client.baseUrl, token, idMode, body]);
+ // The caller scopes this creator to a login session. Renewed credentials
+ // still retry the same operation, using the new token for authentication.
+ const requestKey = JSON.stringify([client.baseUrl, idMode, body]);
if (this.pending?.requestKey !== requestKey) this.pending = undefined;
// The backend treats identical POSTs as successful idempotent requests.