mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-13 15:40:00 +00:00
* 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>
This commit is contained in:
parent
3aa1ac477d
commit
ec0ac474c4
@ -36,6 +36,8 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
|
||||
import { useBrowserControlEnabled } from "@/core/features";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@ -63,6 +65,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_name } = useParams<{
|
||||
@ -457,6 +461,7 @@ export default function AgentChatPage() {
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
canStopStreaming={canStopStreaming}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
|
||||
@ -36,6 +36,8 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
|
||||
import { useBrowserControlEnabled } from "@/core/features";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@ -71,6 +73,8 @@ import { useThreadChat } from "./use-thread-chat";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@ -549,6 +553,7 @@ export default function ChatPage() {
|
||||
onPrepareThread={ensureProjectThread}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
canStopStreaming={canStopStreaming}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@ -300,6 +300,7 @@ export function InputBox({
|
||||
onPrepareThread,
|
||||
onSubmit,
|
||||
onStop,
|
||||
canStopStreaming = true,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@ -356,6 +357,13 @@ export function InputBox({
|
||||
options?: InputBoxSubmitOptions,
|
||||
) => void | Promise<void>;
|
||||
onStop?: () => void;
|
||||
/**
|
||||
* Whether the caller's role holds `runs:cancel` (RFC #4063 Phase 4).
|
||||
* Defaults to true so callers that don't resolve permissions (pre-Phase-4
|
||||
* backends, storybook) keep today's behavior; the Gateway route guard
|
||||
* stays the enforcement point.
|
||||
*/
|
||||
canStopStreaming?: boolean;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
@ -1163,6 +1171,14 @@ export function InputBox({
|
||||
);
|
||||
|
||||
const handleStopStreaming = useCallback(() => {
|
||||
// Roles denied runs:cancel must not interrupt the in-progress turn —
|
||||
// the Gateway would 403 the cancel anyway. The submit-button click is
|
||||
// the only live entry point today (handleSubmit returns early with the
|
||||
// pleaseWaitStreaming toast while streaming), but gate in the handler
|
||||
// as defense-in-depth so any future stop path is covered too.
|
||||
if (!canStopStreaming) {
|
||||
return;
|
||||
}
|
||||
// Mark the in-progress turn as user-interrupted so the next
|
||||
// streaming->ready transition does not suggest follow-ups for it.
|
||||
stoppedByUserRef.current = true;
|
||||
@ -1170,7 +1186,7 @@ export function InputBox({
|
||||
setFollowupsHidden(true);
|
||||
setFollowupsLoading(false);
|
||||
onStop?.();
|
||||
}, [onStop]);
|
||||
}, [canStopStreaming, onStop]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
@ -1367,6 +1383,9 @@ export function InputBox({
|
||||
const isComposerDisabled = disabled === true;
|
||||
const isMockThread = isMock === true;
|
||||
const composerLocked = isComposerDisabled || polishingInput;
|
||||
// A denied runs:cancel role sees a disabled stop affordance, not a removed
|
||||
// one — the composer must still show that a turn is in flight.
|
||||
const stopDenied = status === "streaming" && !canStopStreaming;
|
||||
const inputPolishUndoAvailable =
|
||||
!polishingInput &&
|
||||
inputPolishUndo !== null &&
|
||||
@ -2739,9 +2758,21 @@ export function InputBox({
|
||||
</ModelSelector>
|
||||
<PromptInputSubmit
|
||||
className="rounded-full"
|
||||
disabled={composerLocked}
|
||||
disabled={composerLocked || stopDenied}
|
||||
variant="outline"
|
||||
status={status}
|
||||
// A bare disabled stop square reads as a broken composer;
|
||||
// explain the permission boundary (native title, since a
|
||||
// Radix tooltip won't fire on a disabled button). Spread
|
||||
// conditionally: an explicitly-undefined aria-label would
|
||||
// clobber PromptInputSubmit's default aria-label="Submit"
|
||||
// and strip the submit control's accessible name.
|
||||
{...(stopDenied
|
||||
? {
|
||||
"aria-label": t.inputBox.stopStreamingUnavailable,
|
||||
title: t.inputBox.stopStreamingUnavailable,
|
||||
}
|
||||
: {})}
|
||||
onClick={(e) => {
|
||||
if (status === "streaming") {
|
||||
e.preventDefault();
|
||||
|
||||
@ -47,6 +47,8 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
|
||||
import { writeTextToClipboard } from "@/core/clipboard";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useProjects } from "@/core/projects";
|
||||
@ -98,6 +100,8 @@ export function ThreadSidebarItem({
|
||||
recentThreadId?: string | undefined;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
|
||||
@ -372,11 +376,15 @@ export function ThreadSidebarItem({
|
||||
onNewProject={() => setNewProjectDialogOpen(true)}
|
||||
onMoveProject={handleMoveProject}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleDelete}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
{canDeleteThreads && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleDelete}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@ -48,6 +48,8 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
buildHumanInputResponseText,
|
||||
@ -146,6 +148,8 @@ function promptMessageFiles(message: PromptInputMessage) {
|
||||
|
||||
export function SidecarPanel({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE);
|
||||
const sidecar = useSidecar();
|
||||
const { thread: parentThread } = useParentThread();
|
||||
const [localSettings] = useLocalSettings();
|
||||
@ -539,7 +543,7 @@ export function SidecarPanel({ className }: { className?: string }) {
|
||||
: t.sidecar.noContext}
|
||||
</div>
|
||||
</div>
|
||||
{hasSidecarThread && (
|
||||
{hasSidecarThread && canDeleteThreads && (
|
||||
<Tooltip content={t.sidecar.delete}>
|
||||
<Button
|
||||
aria-label={t.sidecar.delete}
|
||||
|
||||
30
frontend/src/core/auth/permissions.ts
Normal file
30
frontend/src/core/auth/permissions.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { User } from "./types";
|
||||
|
||||
/**
|
||||
* Route permissions surfaced by `GET /api/v1/auth/me` (RFC #4063 Phase 4).
|
||||
* Kept in lockstep with `Permissions` in `backend/app/gateway/authz.py`.
|
||||
*/
|
||||
export const PERMISSIONS = {
|
||||
THREADS_DELETE: "threads:delete",
|
||||
RUNS_CANCEL: "runs:cancel",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Whether the current user may perform a route-permission-gated action.
|
||||
*
|
||||
* The permission list is advisory UI state, not enforcement: a missing list
|
||||
* (pre-Phase-4 backend, or a credential that never resolved permissions —
|
||||
* serialized as `null`) or a not-yet-loaded user is treated as permissive so
|
||||
* a mixed old-backend/new-frontend deploy never hides actions the caller can
|
||||
* still perform. The Gateway's `@require_permission` route guards remain the
|
||||
* single enforcement point.
|
||||
*/
|
||||
export function hasPermission(
|
||||
user: Pick<User, "permissions"> | null | undefined,
|
||||
permission: string,
|
||||
): boolean {
|
||||
if (user?.permissions == null) {
|
||||
return true;
|
||||
}
|
||||
return user.permissions.includes(permission);
|
||||
}
|
||||
@ -8,6 +8,11 @@ export const userSchema = z.object({
|
||||
system_role: z.enum(["admin", "user"]),
|
||||
needs_setup: z.boolean().optional().default(false),
|
||||
oauth_provider: z.string().nullable().optional().default(null),
|
||||
// Effective route permissions (RFC #4063 Phase 4). Optional + nullable:
|
||||
// absent = pre-Phase-4 backend, null = credential-creation response that
|
||||
// never resolved them — both are consumed as "permissive, unresolved", see
|
||||
// hasPermission() in ./permissions.
|
||||
permissions: z.array(z.string()).nullable().optional(),
|
||||
});
|
||||
|
||||
export type User = Omit<z.infer<typeof userSchema>, "oauth_provider"> & {
|
||||
|
||||
@ -327,6 +327,8 @@ export const enUS: Translations = {
|
||||
},
|
||||
],
|
||||
pleaseWaitStreaming: "Please wait for the current response to finish.",
|
||||
stopStreamingUnavailable:
|
||||
"Stopping the running turn is not permitted for your role.",
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
|
||||
@ -245,6 +245,7 @@ export interface Translations {
|
||||
}
|
||||
)[];
|
||||
pleaseWaitStreaming: string;
|
||||
stopStreamingUnavailable: string;
|
||||
};
|
||||
|
||||
// Sidebar
|
||||
|
||||
@ -306,6 +306,7 @@ export const zhCN: Translations = {
|
||||
},
|
||||
],
|
||||
pleaseWaitStreaming: "请等待当前响应完成。",
|
||||
stopStreamingUnavailable: "你的角色无权停止正在运行的回合。",
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,148 @@
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import {
|
||||
SidecarProvider,
|
||||
useSidecar,
|
||||
} from "@/components/workspace/sidecar/context";
|
||||
import { SidecarPanel } from "@/components/workspace/sidecar/sidecar-panel";
|
||||
import { AuthProvider, useAuth } from "@/core/auth/AuthProvider";
|
||||
import type { User } from "@/core/auth/types";
|
||||
import { DEFAULT_LOCALE } from "@/core/i18n";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
|
||||
// AuthProvider and the panel both reach into next/navigation. Keep it inert
|
||||
// under happy-dom (banner-test pattern).
|
||||
rs.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
|
||||
usePathname: () => "/workspace",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// The panel's model list is irrelevant to the delete-button gating; keep the
|
||||
// react-query 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 makeUser(permissions: string[] | null | undefined): User {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "user@example.test",
|
||||
system_role: "user",
|
||||
needs_setup: false,
|
||||
oauth_provider: null,
|
||||
...(permissions === undefined ? {} : { permissions }),
|
||||
} as User;
|
||||
}
|
||||
|
||||
/** Sets the provider's sidecarThreadId so the panel switches out of its
|
||||
* empty state — the delete button only renders for a live sidecar thread. */
|
||||
function SidecarThreadProbe() {
|
||||
const sidecar = useSidecar();
|
||||
useEffect(() => {
|
||||
sidecar.setSidecarThreadId("sidecar-thread-1");
|
||||
}, [sidecar]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Replaces the authenticated user in place via the auth context. */
|
||||
function FlipUserProbe({ to }: { to: User }) {
|
||||
const { applyUser } = useAuth();
|
||||
useEffect(() => {
|
||||
applyUser(to);
|
||||
}, [applyUser, to]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTree(
|
||||
initialUser: User,
|
||||
{
|
||||
withFlip = false,
|
||||
flipTo = makeUser(["threads:read"]),
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
}),
|
||||
} = {},
|
||||
): ReactNode {
|
||||
return (
|
||||
<I18nProvider initialLocale={DEFAULT_LOCALE}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider initialUser={initialUser}>
|
||||
{withFlip && <FlipUserProbe to={flipTo} />}
|
||||
<ThreadContext.Provider
|
||||
value={{ thread: { messages: [] } as never, isMock: true }}
|
||||
>
|
||||
<SidecarProvider
|
||||
parentThreadId="parent-1"
|
||||
isMock
|
||||
context={{ thread_id: "parent-1" } as never}
|
||||
>
|
||||
<SidecarThreadProbe />
|
||||
<SidecarPanel />
|
||||
</SidecarProvider>
|
||||
</ThreadContext.Provider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
rs.restoreAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SidecarPanel delete-button permission gating", () => {
|
||||
it("renders the delete button for a role holding threads:delete", async () => {
|
||||
render(buildTree(makeUser(["threads:read", "threads:delete"])));
|
||||
expect(await screen.findByTestId("sidecar-delete-button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the delete button for an unresolved permission list (pre-Phase-4 backend)", async () => {
|
||||
render(buildTree(makeUser(undefined)));
|
||||
expect(await screen.findByTestId("sidecar-delete-button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the delete button once the resolved list denies threads:delete", async () => {
|
||||
// Anchor on presence first, then flip only the permission list in place —
|
||||
// the disappearance is then provably caused by the gating, not by the
|
||||
// sidecar thread never mounting. The shared queryClient keeps the rerender
|
||||
// a true in-place update rather than a provider remount.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const { rerender } = render(
|
||||
buildTree(makeUser(["threads:delete"]), { queryClient }),
|
||||
);
|
||||
expect(await screen.findByTestId("sidecar-delete-button")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
buildTree(makeUser(["threads:delete"]), {
|
||||
withFlip: true,
|
||||
flipTo: makeUser(["threads:read"]),
|
||||
queryClient,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("sidecar-delete-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,98 @@
|
||||
import { afterEach, 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 { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ThreadSidebarItem } from "@/components/workspace/recent-chat-list";
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import type { User } from "@/core/auth/types";
|
||||
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(),
|
||||
useParams: () => ({}),
|
||||
}));
|
||||
|
||||
function makeUser(permissions: string[] | undefined): User {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "user@example.test",
|
||||
system_role: "user",
|
||||
...(permissions === undefined ? {} : { permissions }),
|
||||
} as User;
|
||||
}
|
||||
|
||||
function makeThread() {
|
||||
return {
|
||||
thread_id: "thread-1",
|
||||
title: "A thread",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
} as never;
|
||||
}
|
||||
|
||||
function renderItem(user: User): ReturnType<typeof render> {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const tree: ReactNode = (
|
||||
<I18nProvider initialLocale={DEFAULT_LOCALE}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider initialUser={user}>
|
||||
<SidebarProvider>
|
||||
<ThreadSidebarItem thread={makeThread()} isActive={false} />
|
||||
</SidebarProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
return render(tree);
|
||||
}
|
||||
|
||||
/** Opens the row's "more" dropdown — the Delete item lives inside it.
|
||||
* Radix triggers open on pointerdown, not click (verified in happy-dom). */
|
||||
async function openRowMenu(): Promise<void> {
|
||||
const trigger = await screen.findByRole("button", { name: /more/i });
|
||||
fireEvent.pointerDown(trigger, { button: 0, pointerType: "mouse" });
|
||||
fireEvent.click(trigger);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
rs.restoreAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ThreadSidebarItem delete-menu gating (threads:delete)", () => {
|
||||
it("offers Delete for a role holding threads:delete", async () => {
|
||||
renderItem(makeUser(["threads:read", "threads:delete"]));
|
||||
await openRowMenu();
|
||||
expect(await screen.findByText("Delete")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("omits Delete for a role denied threads:delete, keeping the other menu actions", async () => {
|
||||
renderItem(makeUser(["threads:read"]));
|
||||
await openRowMenu();
|
||||
// Other actions survive the gating…
|
||||
expect(await screen.findByText("Rename")).not.toBeNull();
|
||||
// …and Delete is gone. Anchor via waitFor on Rename first so the menu is
|
||||
// provably open before asserting absence.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("offers Delete for an unresolved permission list (pre-Phase-4 backend)", async () => {
|
||||
renderItem(makeUser(undefined));
|
||||
await openRowMenu();
|
||||
expect(await screen.findByText("Delete")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
70
frontend/tests/unit/core/auth/permissions.test.ts
Normal file
70
frontend/tests/unit/core/auth/permissions.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
|
||||
import { userSchema } from "@/core/auth/types";
|
||||
|
||||
describe("hasPermission", () => {
|
||||
it("grants a permission present in the resolved list", () => {
|
||||
expect(
|
||||
hasPermission(
|
||||
{ permissions: ["threads:read", "threads:delete"] },
|
||||
PERMISSIONS.THREADS_DELETE,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a permission absent from the resolved list", () => {
|
||||
expect(
|
||||
hasPermission(
|
||||
{ permissions: ["threads:read"] },
|
||||
PERMISSIONS.THREADS_DELETE,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an absent permissions field as permissive (pre-Phase-4 backend)", () => {
|
||||
// A mixed old-backend/new-frontend deploy must not hide actions the
|
||||
// caller can still perform — the Gateway route guards stay the
|
||||
// enforcement point, the UI field is advisory only.
|
||||
expect(hasPermission({}, PERMISSIONS.RUNS_CANCEL)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a null permissions field as permissive", () => {
|
||||
// Credential-creation responses (register/initialize) serialize null;
|
||||
// they never advertise an empty grant set.
|
||||
expect(hasPermission({ permissions: null }, PERMISSIONS.RUNS_CANCEL)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a not-yet-loaded user as permissive", () => {
|
||||
expect(hasPermission(null, PERMISSIONS.THREADS_DELETE)).toBe(true);
|
||||
expect(hasPermission(undefined, PERMISSIONS.THREADS_DELETE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userSchema permissions field", () => {
|
||||
const baseUser = {
|
||||
id: "user-1",
|
||||
email: "user@example.test",
|
||||
system_role: "user" as const,
|
||||
};
|
||||
|
||||
it("parses a /me payload that carries effective permissions", () => {
|
||||
const parsed = userSchema.parse({
|
||||
...baseUser,
|
||||
permissions: ["threads:read", "runs:cancel"],
|
||||
});
|
||||
expect(parsed.permissions).toEqual(["threads:read", "runs:cancel"]);
|
||||
});
|
||||
|
||||
it("parses a /me payload from a pre-Phase-4 backend (field absent)", () => {
|
||||
const parsed = userSchema.parse(baseUser);
|
||||
expect(parsed.permissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses a credential-creation payload that carries null", () => {
|
||||
const parsed = userSchema.parse({ ...baseUser, permissions: null });
|
||||
expect(parsed.permissions).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user