test(ai-chat): safety-critical coverage + a11y + pure refactors

Unit tests for the safety-critical paths: crypto secret-box (round-trip,
tamper detection, wrong key), the SSRF guard (blocked ranges + DNS-rebinding),
the ai-chat tools service, the page-embedding repo, and the
assistant-parts/serialization helpers. Those server helpers (assistantParts,
rowToUiMessage, serializeSteps) are exported ONLY for the tests — no runtime
change.

Also: keyboard a11y on the chat history header and conversation rows
(role/tabIndex/Enter+Space), and DRY refactors that move shared logic into one
place (isToolPart -> tool-parts util; buildInitialValues in the MCP form).

The behaviour-changing edits that previously rode along in this commit are
split out into the following two commits, per review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 17:58:44 +03:00
committed by vvzvlad
parent c8af637654
commit f1980cf425
13 changed files with 571 additions and 236 deletions

View File

@@ -400,7 +400,16 @@ export default function AiChatWindow() {
>
<div
className={classes.historyHeader}
role="button"
tabIndex={0}
aria-expanded={historyOpen}
onClick={() => setHistoryOpen((o) => !o)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setHistoryOpen((o) => !o);
}
}}
>
<IconChevronDown
size={12}

View File

@@ -115,7 +115,17 @@ export default function ConversationList({
classes.conversationItem,
isActive && classes.conversationItemActive,
)}
role="button"
tabIndex={0}
onClick={() => onSelect(chat.id)}
onKeyDown={(e) => {
// Activate on Enter/Space like a native button; the inner menu
// button stops propagation so its own keys never reach this row.
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(chat.id);
}
}}
>
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
{chat.title || t("Untitled chat")}

View File

@@ -3,7 +3,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -12,11 +12,6 @@ interface MessageItemProps {
message: UIMessage;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
/**
* Render a single UIMessage by iterating its `parts`:
* - `text` parts -> sanitized markdown.

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -11,11 +12,6 @@ interface MessageListProps {
isStreaming: boolean;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
// Distance (px) from the bottom within which the viewport still counts as
// "pinned" — absorbs sub-pixel rounding and small content jitter.
const BOTTOM_THRESHOLD = 40;

View File

@@ -5,9 +5,11 @@
*
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
* its `state` is one of input-streaming / input-available / output-available /
* output-error (we only surface running / done / error). The server tools are:
* searchPages, getPage, createPage, updatePageContent, renamePage, movePage,
* deletePage, createComment, resolveComment — see ai-chat-tools.service.ts.
* output-error (we only surface running / done / error). The full toolset the
* server exposes lives in `ai-chat-tools.service.ts` (the agent now exposes the
* complete Docmost toolset); friendly action-log labels exist ONLY for the
* tools listed in `toolLabelKey` below — every other tool falls through to the
* generic "Ran tool {{name}}" label.
*/
/** A tool UI part as it arrives from `useChat` / persisted history. */
@@ -38,6 +40,11 @@ export interface ToolCitation {
href: string;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
export function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
export function getToolName(part: ToolUiPart): string {
if (part.type === "dynamic-tool") return part.toolName ?? "";

View File

@@ -47,6 +47,21 @@ interface AiMcpServerFormProps {
onClose: () => void;
}
// Build the form's field values from a (possibly undefined) server. Used both
// for the initial mount and for re-hydration when the modal is reused for a
// different server, so the two stay in sync. authHeader is always empty: it is
// a write-only secret buffer never echoed back from the server.
function buildInitialValues(server?: IAiMcpServer): FormValues {
return {
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
};
}
// Tavily preset (§8.10): the API key goes in the Authorization HEADER, not the URL.
const TAVILY_PRESET = {
name: "Tavily",
@@ -72,26 +87,12 @@ export default function AiMcpServerForm({
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
},
initialValues: buildInitialValues(server),
});
// Re-hydrate when the target server changes (e.g. reusing the modal).
useEffect(() => {
form.setValues({
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
});
form.setValues(buildInitialValues(server));
form.resetDirty();
setHasHeaders(server?.hasHeaders ?? false);
setHeadersCleared(false);