mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their governed app access. > - The Apps sidebar displays a Review badge for one connection. > - It polls the full company attention service every 30 seconds to get two counts. > - That service also loads every profile, rule and active catalog entry. > - This pull request adds an authorized connection read for only those counts. > - The same badge now avoids unrelated profile evaluation and database result decoding. ## Linked Issues or Issue Description **Current behavior** The contextual sidebar polls company-wide attention. It needs only one connection's signed pending action count and whether any catalog entries are quarantined. **Proposed behavior** Both sidebar variants poll a connection-scoped count endpoint with existing connection read authority. The full attention API stays unchanged. **Reason and benefit** The retained 200-profile fixture performs seven service queries and returns 80,115 rows for this badge. The new reader performs one query and returns one row. This reduces local service CPU and avoids unnecessary decoded profile/rule data. Related merged evaluator fixes are already in the baseline: Refs #18673, Refs #18681. A search found no existing PR for this scoped count reader. ROADMAP.md contains no duplicate planned work. ## What Changed - Add the narrow shared DTO, typed client, guarded REST/OpenAPI route and catalog-owned reader. - Count independent aggregates with both company boundaries and exact signed-argument semantics. Keep archived counts at zero. - Update both contextual sidebar variants. Keep the exact badge, 30 second interval and existing attention-prefix invalidation. - Add PostgreSQL differential tests, authenticated HTTP tests, sidebar tests, a repeatable benchmark and access documentation. ## Verification - Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, including both merged evaluator fixes. Service treatment: `0827f7d0b3`; route/fixture refinement: `230c42ee15`; final local existence gate: `390a218254`. - Passed: focused PostgreSQL differential test; assembled HTTP test with board session and agent JWT; 14 sidebar component tests; token gates; map checks; diff check. - Fixture: 200 profiles, 76,000 rules, 1,900 active tools, 695 removed references, 12 agents, a sibling connection and 2,000 unrelated historical invocations. Complete normalized two-count results are equal on both paths. - Quiet local measurement: seven alternating warm pairs after two warmup pairs, Node 24.22.2, macOS, real disposable PostgreSQL. Legacy/new service CPU median: 213.891/1.680 ms; ranges: 209.329–228.552/0.515–6.137 ms. Wall median: 156.867/1.248 ms; ranges: 151.739–162.289/1.115–1.386 ms. Queries: 7/1. Returned rows: 80,115/1. - Run `cd server && node --expose-gc --import ./node_modules/tsx/dist/loader.mjs scripts/benchmark-connection-review-counts.ts`. See `doc/connection-review-counts.md` for scope and failure history. Byte-volume accounting runs outside timing; no HTTP payload reduction is claimed. - Initial raw scalar SQL failed with ambiguous columns. Typed subqueries fixed it and passed real PostgreSQL equality. Initial fixture agents lacked permissions and correctly received 403. The final fixture grants explicit authority and tests revocation. An initial benchmark included byte serialization in timing; the reported run excludes it. - Passed required local `pnpm -r typecheck` and `pnpm build`. The initial sandbox typecheck could not open the tsx IPC socket; the escalated rerun passed. Full monolithic `pnpm test:run` was not run locally: repeating a 15–25 minute suite serially across both performance PRs adds no distinct evidence. Broad suite acceptance uses final-head CI, which runs the same general/serialized test partitions and the native Runner lane. Final-head CI is in progress. - Passed built-static Chromium journey on an owned local-trusted server and disposable PostgreSQL: Review 3 appears, only scoped counts are requested by this sidebar, resolving requests/clearing quarantines then reloading removes the badge, and archived counts are zero. The first ad hoc launcher omitted required decision-service options and failed at startup; using the normal no-op wake callback fixed that harness. - Greptile identified an invalid resolved-request fixture label. Corrected `denied` to the declared `rejected`, then reran the real PostgreSQL differential test. The count SQL and non-pending exclusion are unchanged. - The first final-head CI failed the static cross-tenant existence-oracle guard because the new explicit resource company assertion had no nearby `hasCompanyAccess` gate. Existing management admission already rejects foreign resources with 404. The route now also follows the standard local two-step gate. The unchanged guard plus authenticated HTTP negative cases passed (3 tests). No allowlist or test was weakened. - These local service measurements do not prove hosted performance. No deployment is included. ## Risks Low risk. New read admission reuses existing current-authority checks. The old attention API is unchanged. Counts still require database work over relevant pending/catalog rows. No cache, schema migration or telemetry is added. Roll back this PR if the badge differs or scoped reads deny previously permitted users. ## Model Used OpenAI `gpt-6.1-sol` (Sol 6.1), with reasoning, tool use and code execution. The host does not expose the configured context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
368 lines
15 KiB
TypeScript
368 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AppDetailSidebar } from "./AppConnectionSidebar";
|
|
import { AppDetailSidebar as ProductionAppDetailSidebar } from "./AppConnectionSidebar.production";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { aiConnectionRouterAppDefinition } from "@paperclipai/shared";
|
|
|
|
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
|
const currentPath = vi.hoisted(() => ({ value: "/apps/conn-1/permissions" }));
|
|
const mockToolsApi = vi.hoisted(() => ({
|
|
getConnection: vi.fn(),
|
|
listApplications: vi.fn(),
|
|
listConnections: vi.fn(),
|
|
listGallery: vi.fn(),
|
|
listAppsAttention: vi.fn(),
|
|
getConnectionReviewCounts: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({
|
|
children,
|
|
to,
|
|
onClick,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
to: string;
|
|
onClick?: () => void;
|
|
className?: string;
|
|
}) => (
|
|
<a href={to} onClick={onClick} className={className}>
|
|
{children}
|
|
</a>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/context/CompanyContext", () => ({
|
|
useCompany: () => ({
|
|
selectedCompanyId: "company-1",
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/context/SidebarContext", () => ({
|
|
useSidebar: () => ({
|
|
isMobile: false,
|
|
setSidebarOpen: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/api/tools", () => ({
|
|
toolsApi: mockToolsApi,
|
|
}));
|
|
|
|
vi.mock("@/pages/apps/AppLogo", () => ({
|
|
AppLogo: ({
|
|
name,
|
|
brandKey,
|
|
logoUrl,
|
|
allowRemoteFallback,
|
|
}: {
|
|
name: string;
|
|
brandKey?: string | null;
|
|
logoUrl?: string | null;
|
|
allowRemoteFallback?: boolean;
|
|
}) => (
|
|
<span
|
|
data-app-logo={name}
|
|
data-brand-key={brandKey ?? ""}
|
|
data-logo-url={logoUrl ?? ""}
|
|
data-allow-remote-fallback={allowRemoteFallback ? "true" : "false"}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./SidebarNavItem", () => ({
|
|
SidebarNavItem: (props: {
|
|
to: string;
|
|
label: string;
|
|
end?: boolean;
|
|
badge?: number;
|
|
badgeTone?: string;
|
|
badgeLabel?: string;
|
|
}) => {
|
|
sidebarNavItemMock(props);
|
|
return (
|
|
<div data-to={props.to} data-active={props.to === currentPath.value ? "true" : "false"}>
|
|
{props.label}
|
|
{props.badge ? ` ${props.badge}` : ""}
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
vi.mock("./SidebarNavItem.production", () => ({
|
|
SidebarNavItem: (props: {
|
|
to: string;
|
|
label: string;
|
|
end?: boolean;
|
|
badge?: number;
|
|
badgeTone?: string;
|
|
badgeLabel?: string;
|
|
}) => {
|
|
sidebarNavItemMock(props);
|
|
return (
|
|
<div data-to={props.to} data-active={props.to === currentPath.value ? "true" : "false"}>
|
|
{props.label}
|
|
{props.badge ? ` ${props.badge}` : ""}
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flushReact() {
|
|
for (let i = 0; i < 3; i += 1) {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
}
|
|
|
|
function connection(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "conn-1",
|
|
applicationId: "app-1",
|
|
name: "GitHub",
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
healthStatus: "healthy",
|
|
enabled: true,
|
|
config: {},
|
|
transportConfig: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("AppConnectionSidebar", () => {
|
|
let container: HTMLDivElement;
|
|
let root: ReturnType<typeof createRoot>;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
currentPath.value = "/apps/conn-1/permissions";
|
|
mockToolsApi.getConnection.mockResolvedValue(connection());
|
|
mockToolsApi.listApplications.mockResolvedValue({
|
|
applications: [{
|
|
id: "app-1",
|
|
applicationKey: "github",
|
|
name: "GitHub",
|
|
description: "GitHub app",
|
|
status: "active",
|
|
}],
|
|
});
|
|
mockToolsApi.listConnections.mockResolvedValue({
|
|
connections: [connection({ id: "archived-1", applicationId: "app-1", status: "archived" })],
|
|
});
|
|
mockToolsApi.listGallery.mockResolvedValue({
|
|
apps: [{ key: "github", name: "GitHub", logoUrl: "https://example.test/github.png" }],
|
|
});
|
|
mockToolsApi.getConnectionReviewCounts.mockResolvedValue({ pendingActionRequestCount: 2, quarantinedCatalogEntryCount: 3 });
|
|
mockToolsApi.listAppsAttention.mockResolvedValue({
|
|
apps: [
|
|
{
|
|
connection: connection(),
|
|
pendingActionRequestCount: 2,
|
|
quarantinedCatalogEntryCount: 3,
|
|
healthNeedsAttention: false,
|
|
reasons: ["pending_action_requests", "quarantined_catalog_entries"],
|
|
},
|
|
],
|
|
totals: {},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function renderSidebar(element = <AppDetailSidebar kind="connection" connectionId="conn-1" />) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
{element}
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
return queryClient;
|
|
}
|
|
|
|
it.each([AppDetailSidebar, ProductionAppDetailSidebar])("uses scoped counts, preserves the badge and refreshes through the existing attention prefix", async (Sidebar) => {
|
|
const client = await renderSidebar(<Sidebar kind="connection" connectionId="conn-1" />);
|
|
expect(mockToolsApi.getConnectionReviewCounts).toHaveBeenCalledWith("conn-1");
|
|
expect(mockToolsApi.listAppsAttention).not.toHaveBeenCalled();
|
|
expect(container.textContent).toContain("Review 3");
|
|
mockToolsApi.getConnectionReviewCounts.mockResolvedValue({ pendingActionRequestCount: 0, quarantinedCatalogEntryCount: 0 });
|
|
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.apps.attention("company-1") }); });
|
|
await flushReact();
|
|
expect(container.textContent).not.toContain("Review 3");
|
|
expect(client.getQueryData(queryKeys.apps.connectionReviewCounts("company-1", "conn-1"))).toEqual({ pendingActionRequestCount: 0, quarantinedCatalogEntryCount: 0 });
|
|
expect(client.getQueryData(queryKeys.apps.connectionReviewCounts("company-1", "other"))).toBeUndefined();
|
|
});
|
|
|
|
it.each([AppDetailSidebar, ProductionAppDetailSidebar])("waits for connection identity before polling", async (Sidebar) => {
|
|
mockToolsApi.getConnection.mockReturnValue(new Promise(() => {}));
|
|
await renderSidebar(<Sidebar kind="connection" connectionId="unresolved" />);
|
|
expect(mockToolsApi.getConnectionReviewCounts).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders the consolidated connected app tabs", async () => {
|
|
await renderSidebar();
|
|
|
|
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
|
expect(container.textContent).toContain("GitHub");
|
|
expect(container.querySelectorAll("[data-to]").length).toBe(2);
|
|
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 3, badgeTone: "danger" }));
|
|
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Permissions", end: true }));
|
|
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Test" }));
|
|
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Activity" }));
|
|
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" }));
|
|
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Setup" }));
|
|
});
|
|
|
|
it("marks the current tab active through the nav item target", async () => {
|
|
await renderSidebar();
|
|
|
|
expect(container.querySelector('[data-to="/apps/conn-1/permissions"]')?.getAttribute("data-active")).toBe("true");
|
|
expect(container.querySelector('[data-to="/apps/conn-1/setup"]')).toBeNull();
|
|
});
|
|
|
|
it("shows only pool settings and keeps connector branding after a pool is renamed", async () => {
|
|
const entry = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" });
|
|
mockToolsApi.getConnection.mockResolvedValue(connection({ name: "Research accounts", config: { aiRouter: { pluginKey: "example.pool" } } }));
|
|
mockToolsApi.listApplications.mockResolvedValue({ applications: [] });
|
|
mockToolsApi.listGallery.mockResolvedValue({ apps: [entry] });
|
|
await renderSidebar();
|
|
expect(container.querySelectorAll("[data-to]")).toHaveLength(1);
|
|
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Settings", end: true }));
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-logo-url")).toBe(entry.branding.logoUrl);
|
|
expect(container.textContent).not.toContain("Review");
|
|
});
|
|
|
|
it("uses the application key for a customized connection display name", async () => {
|
|
mockToolsApi.getConnection.mockResolvedValue(connection({ name: "Dotta's GitHub" }));
|
|
|
|
await renderSidebar();
|
|
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-brand-key")).toBe("github");
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-allow-remote-fallback")).toBe("true");
|
|
});
|
|
|
|
it("keeps remote fallback disabled until the stable application key resolves", async () => {
|
|
let resolveApplications!: (value: {
|
|
applications: Array<{ id: string; applicationKey: string; name: string; status: string }>;
|
|
}) => void;
|
|
mockToolsApi.getConnection.mockResolvedValue(connection({ name: "Dotta's source control" }));
|
|
mockToolsApi.listApplications.mockReturnValueOnce(new Promise((resolve) => {
|
|
resolveApplications = resolve;
|
|
}));
|
|
|
|
await renderSidebar();
|
|
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-brand-key")).toBe("");
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-allow-remote-fallback")).toBe("false");
|
|
|
|
await act(async () => {
|
|
resolveApplications({
|
|
applications: [{ id: "app-1", applicationKey: "github", name: "GitHub", status: "active" }],
|
|
});
|
|
});
|
|
await flushReact();
|
|
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-brand-key")).toBe("github");
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-allow-remote-fallback")).toBe("true");
|
|
});
|
|
|
|
it("allows remote fallback after application identity lookup fails", async () => {
|
|
mockToolsApi.listApplications.mockRejectedValueOnce(new Error("Application lookup unavailable"));
|
|
|
|
await renderSidebar();
|
|
|
|
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-allow-remote-fallback")).toBe("true");
|
|
});
|
|
|
|
it("keeps a customized connection logo when application identity lookup fails", async () => {
|
|
mockToolsApi.getConnection.mockResolvedValue(connection({
|
|
name: "Dotta's source control",
|
|
config: { sourceTemplateKey: "github" },
|
|
}));
|
|
mockToolsApi.listApplications.mockRejectedValueOnce(new Error("Application lookup unavailable"));
|
|
|
|
await renderSidebar();
|
|
|
|
const logo = container.querySelector("[data-app-logo]");
|
|
expect(logo?.getAttribute("data-brand-key")).toBe("github");
|
|
expect(logo?.getAttribute("data-logo-url")).toBe("https://example.test/github.png");
|
|
expect(logo?.getAttribute("data-allow-remote-fallback")).toBe("true");
|
|
});
|
|
|
|
it("renders application-mode tabs under the not-connected app route", async () => {
|
|
currentPath.value = "/apps/app/app-1/review";
|
|
|
|
await renderSidebar(<AppDetailSidebar kind="application" applicationId="app-1" />);
|
|
|
|
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
|
expect(container.textContent).toContain("GitHub");
|
|
expect(mockToolsApi.getConnection).not.toHaveBeenCalled();
|
|
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/review", label: "Review", end: true }));
|
|
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/permissions", label: "Permissions", end: true }));
|
|
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Activity" }));
|
|
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" }));
|
|
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Setup" }));
|
|
expect(container.querySelector('[data-to="/apps/app/app-1/review"]')?.getAttribute("data-active")).toBe("true");
|
|
// Testing is part of Permissions and Activity lives in the company Audit feed.
|
|
expect(container.querySelector('[data-to="/apps/app/app-1/test"]')).toBeNull();
|
|
expect(container.querySelectorAll("[data-to]").length).toBe(2);
|
|
});
|
|
|
|
it("keeps rendering a connection sidebar when its connection is unavailable", async () => {
|
|
mockToolsApi.getConnection.mockResolvedValue(undefined);
|
|
mockToolsApi.listGallery.mockResolvedValue({ apps: [] });
|
|
mockToolsApi.getConnectionReviewCounts.mockResolvedValue({ pendingActionRequestCount: 2, quarantinedCatalogEntryCount: 3 });
|
|
mockToolsApi.listAppsAttention.mockResolvedValue({ apps: [], totals: {} });
|
|
|
|
await renderSidebar();
|
|
|
|
expect(container.textContent).toContain("App");
|
|
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
|
expect(container.querySelectorAll("[data-to]").length).toBe(2);
|
|
});
|
|
|
|
it("keeps rendering an application sidebar when its application is unavailable", async () => {
|
|
mockToolsApi.listApplications.mockResolvedValue({ applications: [] });
|
|
mockToolsApi.listConnections.mockResolvedValue({ connections: [] });
|
|
mockToolsApi.listGallery.mockResolvedValue({ apps: [] });
|
|
mockToolsApi.getConnectionReviewCounts.mockResolvedValue({ pendingActionRequestCount: 2, quarantinedCatalogEntryCount: 3 });
|
|
mockToolsApi.listAppsAttention.mockResolvedValue({ apps: [], totals: {} });
|
|
|
|
await renderSidebar(<AppDetailSidebar kind="application" applicationId="missing-app" />);
|
|
|
|
expect(container.textContent).toContain("App");
|
|
expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors");
|
|
expect(container.querySelectorAll("[data-to]").length).toBe(2);
|
|
});
|
|
});
|