deer-flow/frontend/tests/unit/components/workspace/input-box-stop-gating.dom.test.tsx
hataa ec0ac474c4
feat(authz): gate thread-delete and run-cancel UI on effective permissions (Phase 4, #4063) (#5294)
* feat(authz): gate thread-delete and run-cancel UI on effective permissions (Phase 4, #4063)

Consume the effective route permissions surfaced by #5228 so the UI hides
actions the caller's role cannot perform:

- threads:delete hides the sidebar thread-row Delete menu item and the
  sidecar panel delete button (every useDeleteThread consumer)
- runs:cancel disables the composer stop affordance; all three stop entry
  points converge on one check inside handleStopStreaming

hasPermission treats an absent/null/unresolved permission list as
permissive, so a mixed old-backend/new-frontend deploy never hides actions
the caller can still perform. The Gateway @require_permission guards
remain the single enforcement point.

* fix(authz): review follow-ups for stop gating (comment accuracy, a11y, tests)

- Correct the defense-in-depth comment: the submit-button click is the
  only live entry into handleStopStreaming (handleSubmit returns early
  with the pleaseWaitStreaming toast while streaming, so the kind==="stop"
  branch is unreachable); the handler gate stays as future-proofing.
- Explain the disabled stop affordance with aria-label + title (Radix
  tooltips don't fire on disabled buttons), with en-US/zh-CN strings.
- Add the composer stop-gating DOM tests (disabled + onStop never fires +
  permissive default) and the sidebar delete-menu gating tests, so all
  gated surfaces carry wiring tests.

* fix(authz): stop conditional aria-label from stripping the submit name

The stop-gating follow-up (1612855b) explained the disabled stop
affordance with aria-label/title but passed explicitly-undefined
values in the non-denied case. PromptInputSubmit declares its default
aria-label="Submit" before {...props}, so the undefined key landed in
the spread and clobbered the default: React omits the attribute
entirely and the submit control lost its accessible name in every
state, which broke the sidecar e2e layout helper (it locates the
button by its "Submit" label).

Spread the attributes conditionally so they only attach when
stopDenied, and lock the invariant with a DOM test asserting the base
"Submit" name survives when stop is not denied (mutation-verified:
reverting the conditional spread turns the new test red).

* test(authz): drop unused rerenderWith helper, guard accessible name by role query

Address the review nit on the stop-gating DOM tests: the
rerenderWith helper was never called, and a second render() would
append a composer instead of updating the first one anyway — drop it
(the sidecar-delete-gating tests already demonstrate the correct
rerender pattern if a granted->denied flip test is ever needed).

Also resolve the accessible-name regression guard through
getByRole("button", { name: "Submit" }) so it fails exactly the way
e2e and assistive tech consume the control (mutation-verified: the
explicitly-undefined aria-label form turns it red).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 10:40:54 +08:00

136 lines
4.5 KiB
TypeScript

import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
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 stop 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 renderComposer({
canStopStreaming,
onStop,
}: {
canStopStreaming?: boolean;
onStop: () => void;
}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const tree = (onStopProp: () => 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="streaming"
context={{ mode: "flash" } as never}
onStop={onStopProp}
canStopStreaming={canStopStreaming}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>
);
return render(tree(onStop));
}
afterEach(() => {
rs.restoreAllMocks();
cleanup();
});
describe("InputBox stop gating (runs:cancel)", () => {
it("disables the stop affordance for a denied role and never fires onStop", () => {
const onStop = rs.fn();
const { container } = renderComposer({ canStopStreaming: false, onStop });
const submit = getSubmitButton(container);
expect(submit.disabled).toBe(true);
fireEvent.click(submit);
expect(onStop).not.toHaveBeenCalled();
});
it("explains the permission boundary on the disabled affordance", () => {
const { container } = renderComposer({
canStopStreaming: false,
onStop: rs.fn(),
});
const submit = getSubmitButton(container);
expect(submit.getAttribute("aria-label")).toContain("not permitted");
expect(submit.title).toContain("not permitted");
});
it("keeps stop enabled for an unresolved permission list (default)", () => {
const onStop = rs.fn();
const { container } = renderComposer({ onStop });
const submit = getSubmitButton(container);
expect(submit.disabled).toBe(false);
fireEvent.click(submit);
expect(onStop).toHaveBeenCalledTimes(1);
});
it("keeps the base Submit accessible name when stop is not denied", () => {
// Regression: passing an explicitly-undefined aria-label clobbered
// PromptInputSubmit's default aria-label="Submit" via JSX spread,
// stripping the submit control's accessible name in every
// non-denied state (e2e locates the button by that name). Query by
// role + name so the assertion resolves the accessible name the
// same way e2e and assistive tech do, not via the raw attribute.
renderComposer({ onStop: rs.fn() });
// getByRole throws when no button exposes the "Submit" accessible
// name, which is exactly the regression being guarded.
const submit = screen.getByRole("button", { name: "Submit" });
expect(submit.tagName).toBe("BUTTON");
});
});