fix(provenance): address #143 re-review — shared resolver + decoupled badge
Architecture & design: - Arch A: introduce resolveProvenance() as the single source of truth for deriving a write's actor/aiChatId from the SIGNED identity, and wire it into BOTH transport seams — the REST jwt.strategy and the collab authentication.extension. Previously the collab seam derived actor from the token claim alone and ignored user.isAgent, so a flagged service account's page-content edits over the websocket persisted as lastUpdatedSource='user', drifting from REST. The seams now share one resolver and can't diverge. - Arch B: drop AiAgentBadge's page-history coupling. The generic ui/ badge no longer imports historyAtoms; it exposes an onActivate callback fired after the deep-link, and the history row passes onActivate to close its own modal. Suggestions/warnings: - S1: soften the jwt.strategy provenance comment (applies to every REST write). - S2/suggestion-3: drop the redundant comment-list-item null-aiChatId test (covered by ai-agent-badge.test.tsx). - S3: de-duplicate jwt.strategy.spec test #3 (the no-claim→'user' half duplicated test #2); keep only the signed actor='agent' claim assertion. - W2: add keyboard-activation tests for the badge (Enter/Space, unrelated key). - W3: flip the design doc status to "реализовано (#143)". Tests: - new auth-provenance.decorator.spec.ts unit-tests resolveProvenance + agentSourceFields. - new collab-seam test: is_agent user with no claim → actor='agent' (Arch A regression guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
@@ -20,6 +19,33 @@ function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Render a clickable badge inside an explicit jotai store, with a leftover draft
|
||||
// and an onActivate + parent-click spy, so the deep-link side effects are
|
||||
// assertable. Returns the store and spies.
|
||||
function setupClickable() {
|
||||
const store = createStore();
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const onActivate = vi.fn();
|
||||
const onParentClick = vi.fn();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
<div onClick={onParentClick}>
|
||||
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
|
||||
}
|
||||
|
||||
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
|
||||
}
|
||||
|
||||
describe("AiAgentBadge", () => {
|
||||
it("renders the AI-agent label", () => {
|
||||
renderBadge({ authorName: "Bot" });
|
||||
@@ -33,33 +59,31 @@ describe("AiAgentBadge", () => {
|
||||
expect(badge.textContent).toContain("AI-agent");
|
||||
});
|
||||
|
||||
it("deep-links on click: sets the active chat, clears the draft, opens the AI-chat window, closes the history modal — and stops propagation", () => {
|
||||
const store = createStore();
|
||||
// Pre-set the state the click must change, so the assertions are meaningful.
|
||||
store.set(historyAtoms, true); // history modal open
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const onParentClick = vi.fn();
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
{/* Parent click handler must NOT fire — the badge stops propagation. */}
|
||||
<div onClick={onParentClick}>
|
||||
<AiAgentBadge authorName="Bot" aiChatId="chat-1" />
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||
expect(store.get(historyAtoms)).toBe(false); // history modal closed
|
||||
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
|
||||
const { store, onActivate, onParentClick, badge } = setupClickable();
|
||||
fireEvent.click(badge);
|
||||
expectDeepLinked(store, onActivate);
|
||||
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
|
||||
});
|
||||
|
||||
it.each(["Enter", " "])(
|
||||
"keyboard %j activates the deep-link (same side effects as click)",
|
||||
(key) => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key });
|
||||
expectDeepLinked(store, onActivate);
|
||||
},
|
||||
);
|
||||
|
||||
it("an unrelated key does NOT activate the badge", () => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key: "Tab" });
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
|
||||
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ aiChatId: null }, {}])(
|
||||
"is a plain non-clickable label without a chat target (%o)",
|
||||
(props) => {
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
|
||||
interface AiAgentBadgeProps {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
// Fired after the badge deep-links into its chat. The caller handles its own
|
||||
// context (e.g. the page-history row closes the history modal) so this generic
|
||||
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,18 +24,22 @@ interface AiAgentBadgeProps {
|
||||
* page-history list and the comments sidebar.
|
||||
*
|
||||
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
|
||||
* badge deep-links into that chat: it sets the active-chat atom, opens the
|
||||
* floating AI-chat window, and closes the history modal. When `aiChatId` is
|
||||
* null/absent (an external MCP write with no internal ai_chats row), the badge
|
||||
* is a plain non-clickable label. The click is contained (stopPropagation) so it
|
||||
* does not also trigger an enclosing row's click handler.
|
||||
* badge deep-links into that chat: it sets the active-chat atom and opens the
|
||||
* floating AI-chat window, then invokes `onActivate` so the caller can react
|
||||
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
|
||||
* external MCP write with no internal ai_chats row), the badge is a plain
|
||||
* non-clickable label. The click is contained (stopPropagation) so it does not
|
||||
* also trigger an enclosing row's click handler.
|
||||
*/
|
||||
export function AiAgentBadge({ authorName, aiChatId }: AiAgentBadgeProps) {
|
||||
export function AiAgentBadge({
|
||||
authorName,
|
||||
aiChatId,
|
||||
onActivate,
|
||||
}: AiAgentBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
@@ -47,9 +54,9 @@ export function AiAgentBadge({ authorName, aiChatId }: AiAgentBadgeProps) {
|
||||
// unsent draft so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
setHistoryModalOpen(false);
|
||||
onActivate?.();
|
||||
},
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, setHistoryModalOpen],
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||
);
|
||||
|
||||
const badge = (
|
||||
|
||||
@@ -53,10 +53,7 @@ describe("CommentListItem — AI badge", () => {
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders a non-clickable badge when aiChatId is null (external MCP agent)", () => {
|
||||
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
// No deep-link target → no interactive button role.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
|
||||
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
|
||||
// the insertion gate (agent → badge, user → no badge) above (#143 review).
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
@@ -26,6 +28,8 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onHoverEnd,
|
||||
isActive,
|
||||
}: HistoryItemProps) {
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(historyItem.id, index);
|
||||
}, [onSelect, historyItem.id, index]);
|
||||
@@ -99,6 +103,9 @@ const HistoryItem = memo(function HistoryItem({
|
||||
<AiAgentBadge
|
||||
authorName={historyItem.lastUpdatedBy?.name}
|
||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||
// The history row owns the modal: close it when the badge deep-links
|
||||
// into the chat (the badge no longer reaches into page-history).
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user