Skip to content

Commit 09cf9b6

Browse files
fix: Stop button now sends correct CSRF token, fixing silent cancel failures (#157)
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. Fixes #94.
1 parent 7d2dccd commit 09cf9b6

File tree

2 files changed

+98
-3
lines changed

2 files changed

+98
-3
lines changed

src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,19 +326,24 @@ export default class extends Controller {
326326
}
327327

328328
try {
329-
const csrfInput = document.querySelector('input[name="_csrf_token"]') as HTMLInputElement | null;
329+
const form = this.cancelButtonTarget.closest("form");
330+
const csrfInput = form?.querySelector<HTMLInputElement>('input[name="_csrf_token"]') ?? null;
330331

331332
const formData = new FormData();
332333
if (csrfInput) {
333334
formData.append("_csrf_token", csrfInput.value);
334335
}
335336

336-
await fetch(cancelUrl, {
337+
const cancelResponse = await fetch(cancelUrl, {
337338
method: "POST",
338339
headers: { "X-Requested-With": "XMLHttpRequest" },
339340
body: formData,
340341
});
341342

343+
if (!cancelResponse.ok) {
344+
throw new Error(`Cancel request failed with status ${cancelResponse.status}`);
345+
}
346+
342347
// Don't stop polling — let the polling loop detect the cancelled status
343348
// and done chunk naturally, so all pre-cancellation output is displayed.
344349
} catch {

tests/frontend/integration/ChatBasedContentEditor/chat_editor_controller.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe("ChatBasedContentEditorController", () => {
9999
<input type="hidden" name="_csrf_token" value="csrf-123">
100100
<textarea data-chat-based-content-editor-target="instruction"></textarea>
101101
<button type="submit" data-chat-based-content-editor-target="submit">Make changes</button>
102-
<button type="button" data-chat-based-content-editor-target="cancelButton" class="hidden">Stop</button>
102+
<button type="button" data-chat-based-content-editor-target="cancelButton" data-action="click->chat-based-content-editor#handleCancel" class="hidden">Stop</button>
103103
</form>
104104
`;
105105

@@ -277,5 +277,95 @@ describe("ChatBasedContentEditorController", () => {
277277
}),
278278
);
279279
});
280+
281+
it("stop button sends correct CSRF token from AI form, not global", async () => {
282+
createChatEditorFixture();
283+
await waitForController();
284+
285+
const controller = application.getControllerForElementAndIdentifier(
286+
document.querySelector('[data-controller="chat-based-content-editor"]') as HTMLElement,
287+
"chat-based-content-editor",
288+
) as InstanceType<typeof ChatEditorController>;
289+
290+
// Set up a mock polling state
291+
const mockContainer = document.createElement("div");
292+
(controller as unknown as Record<string, unknown>).currentPollingState = {
293+
sessionId: "test-sess-123",
294+
container: mockContainer,
295+
lastId: 0,
296+
pollUrl: "/api/poll/test-sess-123",
297+
};
298+
299+
const fetchMock = vi.fn(async () => {
300+
return {
301+
ok: true,
302+
status: 200,
303+
json: async () => ({}),
304+
};
305+
});
306+
307+
vi.stubGlobal("fetch", fetchMock);
308+
309+
// Call handleCancel
310+
await controller.handleCancel();
311+
312+
// Verify cancel request was made
313+
expect(fetchMock).toHaveBeenCalledWith(
314+
"/api/cancel/test-sess-123",
315+
expect.objectContaining({
316+
method: "POST",
317+
headers: { "X-Requested-With": "XMLHttpRequest" },
318+
}),
319+
);
320+
321+
// Verify the CSRF token from the form was used
322+
const mockCalls = fetchMock.mock.calls as unknown as Array<[unknown, unknown]>;
323+
const cancelCall = mockCalls.find((call) => String(call[0]) === "/api/cancel/test-sess-123");
324+
if (cancelCall) {
325+
const formData = (cancelCall[1] as Record<string, unknown>)?.body as FormData | undefined;
326+
expect(formData?.get("_csrf_token")).toBe("csrf-123");
327+
}
328+
});
329+
330+
it("stop button re-enables on cancel failure and shows error state", async () => {
331+
createChatEditorFixture();
332+
await waitForController();
333+
334+
const controller = application.getControllerForElementAndIdentifier(
335+
document.querySelector('[data-controller="chat-based-content-editor"]') as HTMLElement,
336+
"chat-based-content-editor",
337+
) as InstanceType<typeof ChatEditorController>;
338+
339+
// Set up a mock polling state
340+
const mockContainer = document.createElement("div");
341+
(controller as unknown as Record<string, unknown>).currentPollingState = {
342+
sessionId: "test-sess-fail",
343+
container: mockContainer,
344+
lastId: 0,
345+
pollUrl: "/api/poll/test-sess-fail",
346+
};
347+
348+
const fetchMock = vi.fn(async () => {
349+
return {
350+
ok: false,
351+
status: 403,
352+
json: async () => ({ error: "Forbidden" }),
353+
};
354+
});
355+
356+
vi.stubGlobal("fetch", fetchMock);
357+
358+
// Get the cancel button and verify it's disabled
359+
const cancelButton = document.querySelector(
360+
"[data-chat-based-content-editor-target='cancelButton']",
361+
) as HTMLButtonElement;
362+
363+
// Call handleCancel
364+
await controller.handleCancel();
365+
366+
// The catch block should re-enable the button
367+
expect(cancelButton.disabled).toBe(false);
368+
expect(cancelButton.textContent).toBe(translations.stop);
369+
});
280370
});
281371
});

0 commit comments

Comments
 (0)