mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix(connections): keep task context through Cloud enrollment and OAuth (#13098)
## Thinking Path
> - Paperclip lets people manage agent work from tasks.
> - Agents can request app access in a task card.
> - Some apps first require Paperclip Cloud enrollment.
> - Enrollment could leave the task, and OAuth could lose the task
interaction ID.
> - This PR keeps enrollment in a separate window and retains the
interaction ID through OAuth.
> - The task can then recognize the connection and continue
automatically.
## Linked Issues or Issue Description
**What happened?**
A first Gmail connection could leave the task dialog during Cloud
enrollment. Setup resumed on the Apps page. Gmail connected, but the
task card could remain pending because OAuth did not retain its
interaction ID.
**Expected behavior**
Keep the task open and preserve its access choices. Resolve the card
after the server verifies connection access. Continue the agent
automatically.
**Steps to reproduce**
1. Start a fresh source test-drive instance without Cloud enrollment.
2. Ask an agent to read Gmail.
3. Open Connect on the task card.
4. Complete Cloud enrollment and Gmail authorization.
5. Check whether the task card updates without selecting the connection
again.
**Paperclip version or commit**
Reproduced on 35fdc0c66b. This branch
applies the fix to current master.
**Deployment mode**
Source test-drive in local-trusted mode. The shared setup code also
serves authenticated instances; live authenticated acceptance was not
performed.
Related PRs: #13058 introduced task connections. #12943 repaired expired
enrollment links. #12906 concerns Composio service matching and does not
fix this OAuth handoff.
## What Changed
- Open task enrollment in a reserved window, with a new-tab fallback.
- Refresh server enrollment status and the provider catalog while
keeping the task dialog and access choices.
- Return the enrollment callback to the verified task when available.
- Retain the interaction ID when OAuth resumes from the page host.
- Clear the server capability cache after enrollment and reject stale
cache writes.
- Close reserved popups on enrollment errors and invalid authorization
URLs.
- Add callback and setup regression tests, including blocked popups.
Update connection-intent documentation.
## Verification
- All 176 focused connector, enrollment, callback, OAuth, and setup
tests pass.
- Greptile gives commit `e58da6662` a 5/5 score. Both review threads are
resolved.
- `pnpm check:token-gates`, `pnpm build`, and `pnpm -r typecheck` pass.
- All CI checks pass for `e58da6662`, including the full test matrix,
browser tests, Runner verification, release registry, and canary dry
run.
- The serial local `pnpm test:run` was stopped after the complete CI
test matrix passed. It is not counted as a full local pass.
- Live browser test: fresh instance, native Codex runner, Gmail
read-only access, and a real Google account.
- Enrollment preserved the task dialog. The card changed to connected
and the agent called Gmail search and message-read tools without another
message or Run click.
- The connected card persisted after refresh. Google reused existing
consent during this attempt.
- The runner displayed only its completion summary. Full answer delivery
is a separate issue and is outside this PR.
## Risks
- Browsers can block or isolate authorization windows. The new-tab
fallback remains available, and the parent checks server state.
- Enrollment completion is only a prerequisite. It does not grant access
or resolve the task card by itself.
- No database migration, runner lifecycle change, or recovery UI is
included.
## Model Used
OpenAI Codex (GPT-6), with code editing, shell tools, and browser
automation. The session does not expose an exact runtime model ID or
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>
This commit is contained in:
1 parent
82f662656a
commit
bf753b997a
7 files changed
+219
-6
No files matched your search
@@ -11,6 +11,8 @@ Connection intents let an agent ask the responsible user for a known service con
|
||||
|
||||
Provider-specific setup must stay in the shared feature and `AppDefinition` metadata. Do not add provider forms or connection mutations to either host.
|
||||
|
||||
When a task connection needs Paperclip Cloud enrollment, the shared dialog opens enrollment in a separate window. The task keeps its access selection and interaction ID. A new-tab link is available if the window does not open. The dialog reads server enrollment status and refreshes the provider catalog after approval; enrollment alone does not mark the app connected. OAuth retains the interaction ID even if setup resumes in the page host, so the verified callback can resolve the task card and queue its continuation.
|
||||
|
||||
## Agent tools
|
||||
|
||||
Every active heartbeat with a responsible user receives two run-bound tools:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cloudConnectorEnrollmentReturnPath,
|
||||
cloudConnectorEnrollmentOutcomeHtml,
|
||||
connectionIntentOAuthOutcomeHtml,
|
||||
} from "./tool-access.js";
|
||||
|
||||
@@ -94,3 +95,25 @@ describe("connection intent OAuth callback document", () => {
|
||||
expect(html).toContain('window.location.replace("/issues")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline enrollment completion", () => {
|
||||
it("closes enrollment without redirecting the task and retains a safe setup fallback", () => {
|
||||
const html = cloudConnectorEnrollmentOutcomeHtml("GMA", "/apps/connect?source=gmail&intent=request-1&enrollment_host=dialog");
|
||||
expect(html).toContain("window.close()");
|
||||
expect(html).not.toContain("window.location");
|
||||
expect(html).toContain("/GMA/apps/connect?source=gmail&intent=request-1");
|
||||
expect(html).toContain("cloud_connector=enrolled");
|
||||
});
|
||||
|
||||
it("does not embed an external fallback or script-significant return path", () => {
|
||||
expect(cloudConnectorEnrollmentOutcomeHtml("GMA", "https://evil.example/")).not.toContain("evil.example");
|
||||
expect(cloudConnectorEnrollmentOutcomeHtml("GMA", "/apps/connect?source=</script>")).not.toContain("source=</script>");
|
||||
});
|
||||
|
||||
it("returns a task enrollment fallback to its verified task", () => {
|
||||
const html = cloudConnectorEnrollmentOutcomeHtml("GMA", "/apps/connect?source=gmail", "task-1");
|
||||
expect(html).toContain("Return to task");
|
||||
expect(html).toContain("/GMA/issues/task-1");
|
||||
expect(html).not.toContain("/apps/connect");
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ import type { ComposioClient } from "../services/composio.js";
|
||||
import type { VercelConnectClient } from "../services/vercel-connect.js";
|
||||
import {
|
||||
isPaperclipCloudConnectorStrategy,
|
||||
invalidatePaperclipCloudConnectorCapabilities,
|
||||
type PaperclipCloudConnector,
|
||||
paperclipCloudConnectorCapabilitiesFromEnv,
|
||||
} from "../services/paperclip-cloud-connector.js";
|
||||
@@ -202,6 +203,16 @@ function normalizeCloudConnectorEnrollmentReturnTo(returnTo?: string | null): st
|
||||
}
|
||||
}
|
||||
|
||||
export function cloudConnectorEnrollmentOutcomeHtml(issuePrefix: string, returnTo: string, issueId?: string): string {
|
||||
const fallbackPath = issueId
|
||||
? `/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issueId)}`
|
||||
: cloudConnectorEnrollmentReturnPath(issuePrefix, returnTo);
|
||||
const fallback = JSON.stringify(fallbackPath).replaceAll("<", "\\u003c");
|
||||
// This document is served only after server-verified enrollment. The parent
|
||||
// independently re-reads enrollment status; browser messages grant no access.
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Paperclip connected</title></head><body><p>Paperclip is connected. Return to your task to finish connecting the app.</p><script>if(window.opener&&window.opener!==window){window.close();}else{const link=document.createElement("a");link.href=${fallback};link.textContent=${JSON.stringify(issueId ? "Return to task" : "Continue setup")};document.body.append(link);}</script></body></html>`;
|
||||
}
|
||||
|
||||
export function cloudConnectorEnrollmentReturnPath(issuePrefix: string, returnTo?: string | null): string {
|
||||
const companyRoot = `/${encodeURIComponent(issuePrefix)}`;
|
||||
const normalizedReturnTo = normalizeCloudConnectorEnrollmentReturnTo(returnTo);
|
||||
@@ -1041,7 +1052,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
}
|
||||
const [company] = pending?.companyId
|
||||
? await db
|
||||
.select({ issuePrefix: companies.issuePrefix })
|
||||
.select({ id: companies.id, issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, pending.companyId))
|
||||
.limit(1)
|
||||
@@ -1050,6 +1061,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
let status;
|
||||
try {
|
||||
status = await completePaperclipCloudConnectorEnrollment({ enrollmentId, approvalCode, state });
|
||||
invalidatePaperclipCloudConnectorCapabilities();
|
||||
} catch {
|
||||
throw badRequest("Invalid or expired Paperclip Cloud enrollment callback");
|
||||
}
|
||||
@@ -1064,7 +1076,24 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
details: { environment: status.environment, status: status.status },
|
||||
});
|
||||
}
|
||||
res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix, pending?.returnTo));
|
||||
const returnTo = normalizeCloudConnectorEnrollmentReturnTo(pending?.returnTo);
|
||||
if (returnTo && new URL(returnTo, "http://paperclip.local").searchParams.get("enrollment_host") === "dialog") {
|
||||
res.set("Cache-Control", "no-store");
|
||||
const intent = new URL(returnTo, "http://paperclip.local").searchParams.get("intent");
|
||||
const parsedIntent = startToolOAuthSchema.safeParse({ interactionId: intent });
|
||||
const interactionId = parsedIntent.success ? parsedIntent.data.interactionId : undefined;
|
||||
const [interaction] = interactionId ? await db.select({ issueId: issueThreadInteractions.issueId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, interactionId),
|
||||
eq(issueThreadInteractions.companyId, company.id),
|
||||
eq(issueThreadInteractions.addresseeUserId, req.actor.userId ?? ""),
|
||||
eq(issueThreadInteractions.kind, "connection_intent"),
|
||||
)).limit(1) : [];
|
||||
res.type("html").send(cloudConnectorEnrollmentOutcomeHtml(company.issuePrefix, returnTo, interaction?.issueId));
|
||||
return;
|
||||
}
|
||||
res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix, returnTo));
|
||||
});
|
||||
|
||||
const handlePaperclipCloudConnectorCallback = async (req: Request, res: Response) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createPaperclipCloudConnector,
|
||||
invalidatePaperclipCloudConnectorCapabilities,
|
||||
GMAIL_CONNECTOR_SCOPES,
|
||||
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
||||
paperclipCloudConnectorCapabilitiesFromEnv,
|
||||
@@ -45,6 +46,39 @@ function config() {
|
||||
}
|
||||
|
||||
describe("Paperclip Cloud connector", () => {
|
||||
it("refreshes capabilities after enrollment and rejects stale cache writes", async () => {
|
||||
const keys = config().config;
|
||||
const env = {
|
||||
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: keys.baseUrl,
|
||||
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: keys.instanceId,
|
||||
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: keys.environment,
|
||||
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: keys.signPrivateKey,
|
||||
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: keys.sealPrivateKey,
|
||||
};
|
||||
let completeOldRequest!: (response: Response) => void;
|
||||
const request = vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(Response.json({ status: "pending", active: false }))
|
||||
.mockImplementationOnce(() => new Promise<Response>((resolve) => { completeOldRequest = resolve; }))
|
||||
.mockResolvedValue(Response.json({ status: "active", active: true, profiles: ["gmail.read"] }));
|
||||
invalidatePaperclipCloudConnectorCapabilities();
|
||||
try {
|
||||
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual([]);
|
||||
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual([]);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
invalidatePaperclipCloudConnectorCapabilities();
|
||||
const oldRequest = paperclipCloudConnectorCapabilitiesFromEnv(env);
|
||||
invalidatePaperclipCloudConnectorCapabilities();
|
||||
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual(["gmail.read"]);
|
||||
completeOldRequest(Response.json({ status: "pending", active: false }));
|
||||
await expect(oldRequest).resolves.toEqual([]);
|
||||
await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual(["gmail.read"]);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
request.mockRestore();
|
||||
invalidatePaperclipCloudConnectorCapabilities();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a signed session with exact endpoint audience and scope contract", async () => {
|
||||
const keys = config();
|
||||
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
|
||||
@@ -488,6 +488,12 @@ export function isPaperclipCloudConnectorStrategy(value: unknown): boolean {
|
||||
}
|
||||
|
||||
let capabilityCache: { key: string; expiresAt: number; profiles: PaperclipCloudConnectorProfileId[] } | null = null;
|
||||
let capabilityCacheGeneration = 0;
|
||||
|
||||
export function invalidatePaperclipCloudConnectorCapabilities(): void {
|
||||
capabilityCacheGeneration += 1;
|
||||
capabilityCache = null;
|
||||
}
|
||||
|
||||
export async function paperclipCloudConnectorCapabilitiesFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@@ -506,9 +512,12 @@ export async function paperclipCloudConnectorCapabilitiesFromEnv(
|
||||
if (!config) return [];
|
||||
const key = `${config.baseUrl}|${config.instanceId}|${config.environment}`;
|
||||
if (capabilityCache?.key === key && capabilityCache.expiresAt > Date.now()) return capabilityCache.profiles;
|
||||
const generation = capabilityCacheGeneration;
|
||||
const connector = createPaperclipCloudConnector({ config });
|
||||
const profiles = await connector.getCapabilities();
|
||||
capabilityCache = { key, expiresAt: Date.now() + 60_000, profiles };
|
||||
if (generation === capabilityCacheGeneration) {
|
||||
capabilityCache = { key, expiresAt: Date.now() + 60_000, profiles };
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
|
||||
@@ -908,9 +908,11 @@ export function ConnectionSetupFlow({
|
||||
&& !entryAdvertisesManagedConnector
|
||||
? recommendedManagedConnectorMethod(fullRequestedDefinition)
|
||||
: null;
|
||||
const [enrollmentAuthorizationUrl, setEnrollmentAuthorizationUrl] = useState<string | null>(null);
|
||||
const connectorEnrollmentQuery = useQuery({
|
||||
queryKey: ["cloud-connector", "enrollment"],
|
||||
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
|
||||
refetchInterval: enrollmentAuthorizationUrl ? 2_000 : false,
|
||||
enabled: Boolean(
|
||||
selectedCompanyId
|
||||
&& requestedDefinitionUsesManagedConnector
|
||||
@@ -918,6 +920,11 @@ export function ConnectionSetupFlow({
|
||||
),
|
||||
});
|
||||
const [connectorEnrollmentError, setConnectorEnrollmentError] = useState<string | null>(null);
|
||||
const closeEnrollmentPopup = useCallback(() => {
|
||||
oauthPopupRef.current?.close();
|
||||
oauthPopupRef.current = null;
|
||||
setEnrollmentAuthorizationUrl(null);
|
||||
}, []);
|
||||
const preserveEnrollmentAccess = useCallback(() => {
|
||||
if (!selectedCompanyId || !requestedAppKey) return;
|
||||
saveEnrollmentAccessState(selectedCompanyId, requestedAppKey, {
|
||||
@@ -929,11 +936,31 @@ export function ConnectionSetupFlow({
|
||||
const openConnectorEnrollment = useCallback((verificationUrl: string) => {
|
||||
const target = resolveAuthorizationTarget(verificationUrl);
|
||||
if (!target.ok) {
|
||||
closeEnrollmentPopup();
|
||||
setConnectorEnrollmentError(target.message);
|
||||
return;
|
||||
}
|
||||
if (host === "dialog") {
|
||||
setEnrollmentAuthorizationUrl(target.url);
|
||||
const popup = oauthPopupRef.current;
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.assign(target.url);
|
||||
popup.focus();
|
||||
} else {
|
||||
setConnectorEnrollmentError("Open authorization in a new tab to continue.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigateTopLevel(target.url);
|
||||
}, []);
|
||||
}, [host, closeEnrollmentPopup]);
|
||||
useEffect(() => {
|
||||
if (!enrollmentAuthorizationUrl || connectorEnrollmentQuery.data?.status !== "active") return;
|
||||
// Enrollment is only a prerequisite. Re-read the server catalog and keep
|
||||
// the task's access selection and interaction binding in this dialog.
|
||||
closeEnrollmentPopup();
|
||||
setConnectorEnrollmentError(null);
|
||||
void galleryQuery.refetch();
|
||||
}, [enrollmentAuthorizationUrl, connectorEnrollmentQuery.data?.status, galleryQuery.refetch, closeEnrollmentPopup]);
|
||||
const startConnectorEnrollment = useMutation({
|
||||
mutationFn: () => toolsApi.startCloudConnectorEnrollment(
|
||||
selectedCompanyId!,
|
||||
@@ -943,17 +970,19 @@ export function ConnectionSetupFlow({
|
||||
resumeConnectionId,
|
||||
reconnectConnectionId,
|
||||
interactionId: connectionIntentId,
|
||||
})
|
||||
}) + (host === "dialog" ? "&enrollment_host=dialog" : "")
|
||||
: undefined,
|
||||
),
|
||||
onSuccess: (status) => {
|
||||
if (!status.verificationUrl) {
|
||||
closeEnrollmentPopup();
|
||||
setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again.");
|
||||
return;
|
||||
}
|
||||
openConnectorEnrollment(status.verificationUrl);
|
||||
},
|
||||
onError: (error) => {
|
||||
closeEnrollmentPopup();
|
||||
setConnectorEnrollmentError(
|
||||
error instanceof Error ? error.message : "Paperclip couldn’t reach Paperclip Cloud. Try again.",
|
||||
);
|
||||
@@ -1229,7 +1258,7 @@ export function ConnectionSetupFlow({
|
||||
setGenericOAuthPending(false);
|
||||
return;
|
||||
}
|
||||
if (host === "dialog") {
|
||||
if (host === "dialog" || connectionIntentId) {
|
||||
setOAuthPhase("starting");
|
||||
setGenericOAuthPending(!entry);
|
||||
startOAuth(result.connection);
|
||||
@@ -2087,6 +2116,14 @@ export function ConnectionSetupFlow({
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
|
||||
{enrollmentAuthorizationUrl ? (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Finish authorization in the opened window.{' '}
|
||||
<a className="underline" href={enrollmentAuthorizationUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open authorization in a new tab
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
|
||||
Back
|
||||
@@ -2096,6 +2133,7 @@ export function ConnectionSetupFlow({
|
||||
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
|
||||
onClick={() => {
|
||||
setConnectorEnrollmentError(null);
|
||||
reserveOAuthPopup();
|
||||
preserveEnrollmentAccess();
|
||||
// Let the server reuse a live enrollment or replace an expired
|
||||
// one. A cached verification URL may expire while this page is open.
|
||||
|
||||
@@ -774,6 +774,84 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])("retains task access and interaction through enrollment (popup blocked: %s)", async (popupBlocked) => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(popupBlocked ? null : popup as unknown as Window);
|
||||
listGalleryMock.mockResolvedValue({ apps: [{
|
||||
...GMAIL, methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
|
||||
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
|
||||
}] });
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" });
|
||||
await render(client, false, <ConnectionSetupFlow host="dialog" serviceSlug="gmail" interactionId="intent-1" requestedAgentId="agent-1" />);
|
||||
await passAccessStep();
|
||||
await act(async () => buttonByText("Connect with Paperclip")?.click());
|
||||
await flushReact();
|
||||
expect(open).toHaveBeenCalled();
|
||||
if (popupBlocked) {
|
||||
expect(popup.location.assign).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(popup.location.assign).toHaveBeenCalledWith("https://my-staging.paperclip.app/connections/enroll?id=enroll-test");
|
||||
}
|
||||
const fallback = container.querySelector<HTMLAnchorElement>('a[target="_blank"]');
|
||||
expect(fallback?.textContent).toBe("Open authorization in a new tab");
|
||||
expect(fallback?.href).toBe("https://my-staging.paperclip.app/connections/enroll?id=enroll-test");
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith("company-1", "Paperclip", "/apps/connect?source=gmail&stage=setup&intent=intent-1&enrollment_host=dialog");
|
||||
listGalleryMock.mockResolvedValue({ apps: [{ ...GMAIL, ownershipAvailability: { ...GMAIL.ownershipAvailability, platform_shared: true } }] });
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "active" });
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: ["cloud-connector", "enrollment"] }); });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
if (!popupBlocked) expect(popup.close).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("What should Paperclip be able to do?");
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "per_user" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } });
|
||||
await act(async () => buttonByText("Continue to sign in")?.click());
|
||||
await flushReact();
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ grantKind: "user" }));
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("gmail-1", { asCurrentUser: true, interactionId: "intent-1" });
|
||||
expect(putConnectionInstallsMock).not.toHaveBeenCalled();
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it.each(["request", "missing-url", "invalid-url"])("closes the reserved enrollment popup after %s failure", async (failure) => {
|
||||
const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
|
||||
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
|
||||
listGalleryMock.mockResolvedValue({ apps: [{
|
||||
...GMAIL, methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
|
||||
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
|
||||
}] });
|
||||
getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" });
|
||||
if (failure === "request") {
|
||||
startCloudConnectorEnrollmentMock.mockRejectedValue(new Error("Enrollment unavailable"));
|
||||
} else {
|
||||
startCloudConnectorEnrollmentMock.mockResolvedValue({
|
||||
status: "pending",
|
||||
verificationUrl: failure === "missing-url" ? undefined : "javascript:alert(1)",
|
||||
});
|
||||
}
|
||||
await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="gmail" interactionId="intent-1" requestedAgentId="agent-1" />);
|
||||
await passAccessStep();
|
||||
await act(async () => buttonByText("Connect with Paperclip")?.click());
|
||||
await flushReact();
|
||||
expect(popup.close).toHaveBeenCalledOnce();
|
||||
expect(popup.location.assign).not.toHaveBeenCalled();
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain("Finish authorization in the opened window");
|
||||
});
|
||||
|
||||
it("binds OAuth to the task even when setup resumes in the page host", async () => {
|
||||
mockSearch.value = "source=gmail&stage=setup&intent=intent-1";
|
||||
listGalleryMock.mockResolvedValue({ apps: [{ ...GMAIL, ownershipAvailability: { ...GMAIL.ownershipAvailability, platform_shared: true } }] });
|
||||
connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "shared" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } });
|
||||
await render();
|
||||
await act(async () => buttonByText("Continue to sign in")?.click());
|
||||
await flushReact();
|
||||
expect(startOAuthMock).toHaveBeenCalledWith("gmail-1", { asCurrentUser: false, interactionId: "intent-1" });
|
||||
expect(navigateTopLevelMock).not.toHaveBeenCalledWith("https://example.test/unbound");
|
||||
});
|
||||
|
||||
it.each(["2020-01-01T00:00:00.000Z", "2099-01-01T00:00:00.000Z"])(
|
||||
"revalidates a cached pending enrollment before continuing (expiry %s)",
|
||||
async (expiresAt) => {
|
||||
|
||||
Reference in new issue
Block a user