mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix(connections): preserve wizard recovery and repository restrictions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cf8578a9dd
commit
87945c8cf3
15 files changed
+750
-189
No files matched your search
@@ -37,6 +37,7 @@ vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: mocks }));
|
||||
vi.mock("@/api/githubChat", () => ({ githubChatApi: {
|
||||
configuration: async () => ({ revision: 0, configuration: {} }),
|
||||
progress: async () => mocks.get(),
|
||||
advance: async () => ({ endpointId: "endpoint-a", state: "create" }),
|
||||
} }));
|
||||
vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: "owner-user", name: "Owner" } }) } }));
|
||||
vi.mock("@/api/health", () => ({ healthApi: { get: async () => ({ deploymentMode: "authenticated" }) } }));
|
||||
@@ -587,13 +588,13 @@ describe("chat setup and identity-link clipboard actions", () => {
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Add Slack credentials");
|
||||
});
|
||||
|
||||
it.each(["slack", "github", "discord", "telegram", "microsoft-teams"] as const)(
|
||||
it.each(["slack", "discord", "telegram", "microsoft-teams"] as const)(
|
||||
"lets %s setup revisit the agent without duplicating the connection or losing provider fields",
|
||||
async (provider) => {
|
||||
await render(provider);
|
||||
const nav = container.querySelector(provider === "github" ? 'nav[aria-label="Setup progress"]' : 'aside nav[aria-label="Connection setup progress"]')!;
|
||||
const nav = container.querySelector('aside nav[aria-label="Connection setup progress"]')!;
|
||||
expect(nav).not.toBeNull();
|
||||
if (provider !== "github") expect(container.querySelector("main nav")).toBeNull();
|
||||
expect(container.querySelector("main nav")).toBeNull();
|
||||
const steps = [...nav.querySelectorAll("button")];
|
||||
expect(steps[1].getAttribute("aria-current")).toBe("step");
|
||||
expect(steps[2].disabled).toBe(true);
|
||||
@@ -607,55 +608,30 @@ describe("chat setup and identity-link clipboard actions", () => {
|
||||
expect([...container.querySelectorAll("main input")]).toEqual(fields);
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
await click("1Choose agent");
|
||||
await click(provider === "slack" ? "2Create Slack app" : provider === "github" ? "2Connect GitHub App" : "2Connect provider");
|
||||
await click(provider === "slack" ? "2Create Slack app" : "2Connect provider");
|
||||
expect(steps[1].getAttribute("aria-current")).toBe("step");
|
||||
},
|
||||
);
|
||||
|
||||
it("copies the one-time webhook secret through the same fallback", async () => {
|
||||
it("resumes an assigned GitHub draft in the two-screen wizard without creating a connection", async () => {
|
||||
await render("github");
|
||||
await click("Use an existing App");
|
||||
await click("Generate webhook secret");
|
||||
await click("Copy webhook secret");
|
||||
expect(copied).toEqual([secret]);
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
const steps = [...container.querySelectorAll('nav[aria-label="Setup progress"] button')];
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[1].getAttribute("aria-current")).toBe("step");
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Connect GitHub");
|
||||
expect(container.querySelector("input#github-app-name")?.getAttribute("value")).toBe("Maya");
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed secret copy without exposing the secret or an unhandled rejection", async () => {
|
||||
it("keeps manual existing-App recovery write-only without generating or copying credentials", async () => {
|
||||
await render("github");
|
||||
await click("Use an existing App");
|
||||
await click("Generate webhook secret");
|
||||
execCommand.mockReturnValue(false);
|
||||
await click("Copy webhook secret");
|
||||
expect(mocks.pushToast).toHaveBeenLastCalledWith({
|
||||
title: "Couldn't copy to clipboard",
|
||||
body: "Select and copy the value manually.",
|
||||
tone: "error",
|
||||
});
|
||||
expect(JSON.stringify(mocks.pushToast.mock.calls)).not.toContain(secret);
|
||||
});
|
||||
|
||||
// The toast provider drops an identical toast raised inside 3.5 seconds, so
|
||||
// a reader clicking twice against a blocked clipboard would see the failure
|
||||
// once and then nothing. The inline state has to answer every click.
|
||||
it("still shows a repeated copy failure the toast would have deduplicated", async () => {
|
||||
await render("github");
|
||||
await click("Use an existing App");
|
||||
await click("Generate webhook secret");
|
||||
execCommand.mockReturnValue(false);
|
||||
|
||||
await click("Copy webhook secret");
|
||||
expect(container.textContent).toContain("Couldn’t copy");
|
||||
|
||||
// Same failure again, well inside the dedupe window: the second toast is
|
||||
// suppressed, so the button itself is the only thing left to say so.
|
||||
const toastsAfterFirst = mocks.pushToast.mock.calls.length;
|
||||
await click("Couldn’t copy — select it manually");
|
||||
expect(container.textContent).toContain("Couldn’t copy");
|
||||
expect(container.textContent).not.toContain("Webhook secret copied");
|
||||
expect(mocks.pushToast.mock.calls.length).toBeGreaterThanOrEqual(
|
||||
toastsAfterFirst,
|
||||
);
|
||||
await click("I already have an App");
|
||||
expect(container.querySelector('input[type="password"]')).not.toBeNull();
|
||||
expect(container.querySelector("textarea")?.value).toBe("");
|
||||
expect(container.textContent).not.toContain("Copy webhook secret");
|
||||
expect(container.textContent).not.toContain("Generate webhook secret");
|
||||
expect(mocks.generateSetupSecret).not.toHaveBeenCalled();
|
||||
expect(copied).toEqual([]);
|
||||
});
|
||||
|
||||
it("copies the private identity link with fallback and preserves success feedback", async () => {
|
||||
|
||||
@@ -61,7 +61,12 @@ vi.mock("@/api/agents", () => ({
|
||||
agentsApi: { list: vi.fn(), get: vi.fn(), updatePermissions: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/api/chatEndpoints", () => ({
|
||||
chatEndpointsApi: { create: vi.fn(), get: vi.fn(), listResources: vi.fn() },
|
||||
chatEndpointsApi: {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
listResources: vi.fn(),
|
||||
setup: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api/githubChat", () => ({
|
||||
githubChatApi: {
|
||||
@@ -173,6 +178,24 @@ describe("GitHub App wizard", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
}
|
||||
it("reconnects the same App using vaulted credentials without requiring another paste", async () => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint);
|
||||
await render("resume=draft-1&reconnect=1");
|
||||
const button = [...container.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Reconnect App",
|
||||
)!;
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(container.querySelector("#github-webhook-secret")).toBeNull();
|
||||
await act(async () => {
|
||||
button.click();
|
||||
});
|
||||
expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", {
|
||||
action: "reconnect",
|
||||
});
|
||||
expect(githubChatApi.connectApp).not.toHaveBeenCalled();
|
||||
});
|
||||
async function render(query = "agentId=reviewer") {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
|
||||
@@ -54,6 +54,7 @@ export function GitHubChatSetup() {
|
||||
const queryClient = useQueryClient();
|
||||
const resume = params.get("resume");
|
||||
const identityOnly = params.get("stage") === "identity";
|
||||
const reconnect = params.get("reconnect") === "1";
|
||||
const [agentId, setAgentId] = useState(params.get("agentId") ?? "");
|
||||
const [ownerType, setOwnerType] = useState<"personal" | "organization">(
|
||||
"personal",
|
||||
@@ -106,7 +107,7 @@ export function GitHubChatSetup() {
|
||||
(agent) => agent.id === (bot?.assignedAgentId ?? agentId),
|
||||
);
|
||||
const state = progress.data;
|
||||
const connected = state?.state === "connected";
|
||||
const connected = state?.state === "connected" && !existing;
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Connectors", href: "/apps" },
|
||||
@@ -211,7 +212,7 @@ export function GitHubChatSetup() {
|
||||
step={bot ? 1 : 0}
|
||||
availableStep={bot ? 1 : 0}
|
||||
onSelect={() => {}}
|
||||
disabled={busy}
|
||||
disabled
|
||||
takeover
|
||||
/>
|
||||
)}
|
||||
@@ -220,7 +221,7 @@ export function GitHubChatSetup() {
|
||||
? "Choose agent"
|
||||
: connected
|
||||
? "GitHub connected"
|
||||
: identityOnly
|
||||
: identityOnly || state?.state === "identity"
|
||||
? "Connect your account"
|
||||
: "Connect GitHub"}
|
||||
</h1>
|
||||
@@ -333,7 +334,9 @@ export function GitHubChatSetup() {
|
||||
) : existing ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use the credentials from this App’s GitHub settings.
|
||||
{reconnect
|
||||
? "Leave these blank to reuse this App’s stored credentials, or enter both fields to repair them."
|
||||
: "Use the credentials from this App’s GitHub settings."}
|
||||
</p>
|
||||
<Label htmlFor="github-app-id">App ID</Label>
|
||||
<Input
|
||||
@@ -351,42 +354,53 @@ export function GitHubChatSetup() {
|
||||
setCredentials({ ...credentials, privateKey: event.target.value })
|
||||
}
|
||||
/>
|
||||
{!["active", "paused", "revoked"].includes(bot.status) && (
|
||||
<>
|
||||
<Label htmlFor="github-webhook-secret">Webhook secret</Label>
|
||||
<Input
|
||||
id="github-webhook-secret"
|
||||
type="password"
|
||||
value={credentials.webhookSecret}
|
||||
onChange={(event) =>
|
||||
setCredentials({
|
||||
...credentials,
|
||||
webhookSecret: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!reconnect &&
|
||||
!["active", "paused", "revoked"].includes(bot.status) && (
|
||||
<>
|
||||
<Label htmlFor="github-webhook-secret">Webhook secret</Label>
|
||||
<Input
|
||||
id="github-webhook-secret"
|
||||
type="password"
|
||||
value={credentials.webhookSecret}
|
||||
onChange={(event) =>
|
||||
setCredentials({
|
||||
...credentials,
|
||||
webhookSecret: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{footer(
|
||||
"Connect existing App",
|
||||
reconnect ? "Reconnect App" : "Connect existing App",
|
||||
async () => {
|
||||
if (["active", "paused", "revoked"].includes(bot.status))
|
||||
if (
|
||||
reconnect ||
|
||||
["active", "paused", "revoked"].includes(bot.status)
|
||||
)
|
||||
await chatEndpointsApi.setup(bot.id, {
|
||||
action: "reconnect",
|
||||
credentials: {
|
||||
appId: credentials.appId,
|
||||
privateKey: credentials.privateKey,
|
||||
},
|
||||
...(credentials.appId.trim() || credentials.privateKey.trim()
|
||||
? {
|
||||
credentials: {
|
||||
appId: credentials.appId,
|
||||
privateKey: credentials.privateKey,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
else await githubChatApi.connectApp(bot.id, credentials);
|
||||
setCredentials({ appId: "", privateKey: "", webhookSecret: "" });
|
||||
setExisting(false);
|
||||
await refresh();
|
||||
},
|
||||
!credentials.appId.trim() ||
|
||||
!credentials.privateKey.trim() ||
|
||||
(!["active", "paused", "revoked"].includes(bot.status) &&
|
||||
!credentials.webhookSecret.trim()),
|
||||
reconnect
|
||||
? !!(credentials.appId.trim() || credentials.privateKey.trim()) &&
|
||||
(!credentials.appId.trim() || !credentials.privateKey.trim())
|
||||
: !credentials.appId.trim() ||
|
||||
!credentials.privateKey.trim() ||
|
||||
(!["active", "paused", "revoked"].includes(bot.status) &&
|
||||
!credentials.webhookSecret.trim()),
|
||||
)}
|
||||
</>
|
||||
) : state?.state === "identity" || identityOnly ? (
|
||||
|
||||
Reference in new issue
Block a user