From d31c276ea0aec64c23d0d8da328dd8d5a8874b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Wed, 25 Feb 2026 11:24:37 +0100 Subject: [PATCH] fix: Stop button now sends correct CSRF token, fixing silent cancel failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user clicks Stop in the AI editor, handleCancel() was using a global document.querySelector for the CSRF token instead of scoping it to the form. The Twig template renders multiple CSRF tokens (one per form), and the global query returned the first match (Finish Session form's token with action conversation_finish) instead of the correct one (AI form's token with action chat_based_content_editor_run). The backend validates against the expected action, so a wrong token triggered a 403 response. Since fetch() only throws on network errors—not HTTP errors— the error was silently swallowed and the cancel button remained stuck in "Stopping…" state forever, while the generation continued uninterrupted. Changes: - Changed CSRF lookup in handleCancel() to use form-scoped query, matching the correct pattern already used in handleSubmit() - Added response.ok check after fetch to throw an error on non-2xx responses, allowing the catch block to re-enable the button for retry - Added integration tests verifying the CSRF token is sourced from the correct form and that button state recovers on cancel failure This ensures cancel requests use the correct CSRF token and any future failures (network or server) re-enable the button so users can retry. Co-Authored-By: Claude Haiku 4.5 --- .../chat_based_content_editor_controller.ts | 9 +- .../chat_editor_controller.test.ts | 92 ++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts index 8d78dc65..80acdfb7 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts @@ -326,19 +326,24 @@ export default class extends Controller { } try { - const csrfInput = document.querySelector('input[name="_csrf_token"]') as HTMLInputElement | null; + const form = this.cancelButtonTarget.closest("form"); + const csrfInput = form?.querySelector('input[name="_csrf_token"]') ?? null; const formData = new FormData(); if (csrfInput) { formData.append("_csrf_token", csrfInput.value); } - await fetch(cancelUrl, { + const cancelResponse = await fetch(cancelUrl, { method: "POST", headers: { "X-Requested-With": "XMLHttpRequest" }, body: formData, }); + if (!cancelResponse.ok) { + throw new Error(`Cancel request failed with status ${cancelResponse.status}`); + } + // Don't stop polling — let the polling loop detect the cancelled status // and done chunk naturally, so all pre-cancellation output is displayed. } catch { diff --git a/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts b/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts index 4b21a3eb..6e6b404d 100644 --- a/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts +++ b/tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts @@ -99,7 +99,7 @@ describe("ChatBasedContentEditorController", () => { - + `; @@ -277,5 +277,95 @@ describe("ChatBasedContentEditorController", () => { }), ); }); + + it("stop button sends correct CSRF token from AI form, not global", async () => { + createChatEditorFixture(); + await waitForController(); + + const controller = application.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="chat-based-content-editor"]') as HTMLElement, + "chat-based-content-editor", + ) as InstanceType; + + // Set up a mock polling state + const mockContainer = document.createElement("div"); + (controller as unknown as Record).currentPollingState = { + sessionId: "test-sess-123", + container: mockContainer, + lastId: 0, + pollUrl: "/api/poll/test-sess-123", + }; + + const fetchMock = vi.fn(async () => { + return { + ok: true, + status: 200, + json: async () => ({}), + }; + }); + + vi.stubGlobal("fetch", fetchMock); + + // Call handleCancel + await controller.handleCancel(); + + // Verify cancel request was made + expect(fetchMock).toHaveBeenCalledWith( + "/api/cancel/test-sess-123", + expect.objectContaining({ + method: "POST", + headers: { "X-Requested-With": "XMLHttpRequest" }, + }), + ); + + // Verify the CSRF token from the form was used + const mockCalls = fetchMock.mock.calls as unknown as Array<[unknown, unknown]>; + const cancelCall = mockCalls.find((call) => String(call[0]) === "/api/cancel/test-sess-123"); + if (cancelCall) { + const formData = (cancelCall[1] as Record)?.body as FormData | undefined; + expect(formData?.get("_csrf_token")).toBe("csrf-123"); + } + }); + + it("stop button re-enables on cancel failure and shows error state", async () => { + createChatEditorFixture(); + await waitForController(); + + const controller = application.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="chat-based-content-editor"]') as HTMLElement, + "chat-based-content-editor", + ) as InstanceType; + + // Set up a mock polling state + const mockContainer = document.createElement("div"); + (controller as unknown as Record).currentPollingState = { + sessionId: "test-sess-fail", + container: mockContainer, + lastId: 0, + pollUrl: "/api/poll/test-sess-fail", + }; + + const fetchMock = vi.fn(async () => { + return { + ok: false, + status: 403, + json: async () => ({ error: "Forbidden" }), + }; + }); + + vi.stubGlobal("fetch", fetchMock); + + // Get the cancel button and verify it's disabled + const cancelButton = document.querySelector( + "[data-chat-based-content-editor-target='cancelButton']", + ) as HTMLButtonElement; + + // Call handleCancel + await controller.handleCancel(); + + // The catch block should re-enable the button + expect(cancelButton.disabled).toBe(false); + expect(cancelButton.textContent).toBe(translations.stop); + }); }); });