deer-flow/frontend/tests/unit/components/workspace/input-box-send-gating.dom.test.tsx
hataa 71087f2f8e
feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063) (#5528)
* feat(authz): gate composer send on runs:create (Phase 4 follow-up, #4063)

Mirrors #5294's stop gating on the send side: both chat routes pass
canCreateRuns (from PERMISSIONS.RUNS_CREATE, lockstep with the backend
enum) into the shared composer. The gate sits at the top of
submitThreadMessage — the single choke point every composer entry
(submit button, Enter, goal-set-triggered run) funnels through — and
denies with a toast plus a rejected promise so PromptInput keeps the
text. The idle submit button is disabled and explains the boundary via
conditionally-spread aria-label/title (startTurnUnavailable, en/zh);
while streaming the button stays the runs:cancel stop affordance.

Also removes the unreachable kind === "stop" branch in handleSubmit
(flagged during #5294's review): the Enter path early-returns with the
streaming toast before the classifier runs.

* fix(frontend): reject denied goal starts before saving state

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-20 17:45:03 +08:00

306 lines
9.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { ReactNode } from "react";
import { toast } from "sonner";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { DEFAULT_LOCALE } from "@/core/i18n";
import { I18nProvider } from "@/core/i18n/context";
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
usePathname: () => "/workspace",
useSearchParams: () => new URLSearchParams(),
}));
// The composer's model selector is irrelevant to send gating; keep the
// react-query + network machinery out of the way entirely.
rs.mock("@/core/models/hooks", () => ({
useModels: () => ({
models: [],
tokenUsageEnabled: false,
isLoading: false,
isFetching: false,
error: null,
refetch: rs.fn(),
}),
}));
function getSubmitButton(container: HTMLElement): HTMLButtonElement {
const button = container.querySelector('button[type="submit"]');
if (!(button instanceof HTMLButtonElement)) {
throw new Error("submit button not rendered");
}
return button;
}
function typeText(container: HTMLElement, value: string): void {
const textarea = container.querySelector("textarea");
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error("composer textarea not rendered");
}
fireEvent.change(textarea, { target: { value } });
}
function renderComposer({
canCreateRuns,
onSubmit,
onPrepareThread,
onGoalChange,
isWelcomeMode = false,
}: {
canCreateRuns?: boolean;
onSubmit: () => void;
onPrepareThread?: () => void | Promise<void>;
onGoalChange?: () => void;
isWelcomeMode?: boolean;
}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const tree = (onSubmitProp: () => void): ReactNode => (
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider client={queryClient}>
<AuthProvider
initialUser={{
id: "user-1",
email: "user@example.test",
system_role: "user",
needs_setup: false,
oauth_provider: null,
}}
>
<ThreadContext.Provider
value={{ thread: { messages: [] } as never, isMock: true }}
>
<PromptInputProvider>
<InputBox
threadId="thread-1"
status="ready"
context={{ mode: "flash" } as never}
onSubmit={onSubmitProp}
canCreateRuns={canCreateRuns}
onPrepareThread={onPrepareThread}
onGoalChange={onGoalChange}
isWelcomeMode={isWelcomeMode}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>
);
return render(tree(onSubmit));
}
beforeEach(() => {
window.sessionStorage.clear();
});
afterEach(() => {
rs.restoreAllMocks();
rs.unstubAllGlobals();
cleanup();
});
function submitForm(container: HTMLElement, text: string) {
typeText(container, text);
const form = container.querySelector("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("composer form not rendered");
}
fireEvent.submit(form);
}
function mockCommandRequests(events: string[] = []) {
const fetchMock = rs.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const isCommand = url.endsWith("/goal") || url.endsWith("/compact");
if (isCommand) {
events.push(init?.method ?? "GET");
}
return new Response(
JSON.stringify(isCommand ? { goal: null, compacted: true } : {}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
},
);
rs.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("InputBox send gating (runs:create)", () => {
it.each([false, true])(
"rejects goal-set before thread preparation or goal writes (welcome=%s)",
async (isWelcomeMode) => {
const events: string[] = [];
mockCommandRequests(events);
const info = rs.spyOn(toast, "info");
const success = rs.spyOn(toast, "success");
const onSubmit = rs.fn();
const onPrepareThread = rs.fn();
const onGoalChange = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
onPrepareThread,
onGoalChange,
isWelcomeMode,
});
submitForm(container, "/goal finish all tests");
await waitFor(() =>
expect(info).toHaveBeenCalledWith(
expect.stringContaining("not permitted"),
),
);
expect(onPrepareThread).not.toHaveBeenCalled();
expect(events).toEqual([]);
expect(onSubmit).not.toHaveBeenCalled();
expect(onGoalChange).not.toHaveBeenCalled();
expect(success).not.toHaveBeenCalled();
expect(container.querySelector("textarea")?.value).toBe(
"/goal finish all tests",
);
},
);
it("preserves prepare, goal-save, and run-start ordering for an allowed role", async () => {
const events: string[] = [];
mockCommandRequests(events);
const onSubmit = rs.fn(() => {
events.push("submit");
});
const { container } = renderComposer({
canCreateRuns: true,
onSubmit,
onPrepareThread: () => {
events.push("prepare");
},
});
submitForm(container, "/goal finish all tests");
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(events).toEqual(["prepare", "PUT", "submit"]);
});
it.each([
["/goal", "GET"],
["/goal clear", "DELETE"],
["/compact", "POST"],
])("allows %s without runs:create", async (command, method) => {
const events: string[] = [];
mockCommandRequests(events);
const info = rs.spyOn(toast, "info");
const onSubmit = rs.fn();
const onPrepareThread = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
onPrepareThread,
});
submitForm(container, command);
await waitFor(() =>
expect(container.querySelector("textarea")?.value).toBe(""),
);
expect(events).toEqual([method]);
expect(onSubmit).not.toHaveBeenCalled();
expect(onPrepareThread).not.toHaveBeenCalled();
expect(info).not.toHaveBeenCalledWith(
expect.stringContaining("not permitted"),
);
});
it("disables the send affordance for a denied role and never fires onSubmit", () => {
const onSubmit = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
});
const submit = getSubmitButton(container);
expect(submit.disabled).toBe(true);
fireEvent.click(submit);
expect(onSubmit).not.toHaveBeenCalled();
});
it("blocks the Enter submit path for a denied role (handler-level gate)", async () => {
const onSubmit = rs.fn();
const { container } = renderComposer({
canCreateRuns: false,
onSubmit,
});
typeText(container, "hello");
const form = container.querySelector("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("composer form not rendered");
}
fireEvent.submit(form);
// PromptInput resolves the submit through a microtask chain (file
// conversion promise) before calling onSubmit; flush it so an
// ungated composer provably fires and this test is a real guard.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onSubmit).not.toHaveBeenCalled();
});
it("explains the permission boundary on the disabled affordance", () => {
const { container } = renderComposer({
canCreateRuns: false,
onSubmit: rs.fn(),
});
const submit = getSubmitButton(container);
expect(submit.getAttribute("aria-label")).toContain("not permitted");
expect(submit.title).toContain("not permitted");
});
it("keeps send enabled for an unresolved permission list (default)", async () => {
const onSubmit = rs.fn();
const { container } = renderComposer({ onSubmit });
const submit = getSubmitButton(container);
expect(submit.disabled).toBe(false);
typeText(container, "hello");
const form = container.querySelector("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("composer form not rendered");
}
fireEvent.submit(form);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
});
it("keeps the base Submit accessible name when send is not denied", () => {
// Mirror of the stop-gating regression: the aria-label must be spread
// conditionally, never explicitly-undefined, or it clobbers
// PromptInputSubmit's default aria-label="Submit".
renderComposer({ onSubmit: rs.fn() });
const submit = screen.getByRole("button", { name: "Submit" });
expect(submit.tagName).toBe("BUTTON");
});
});