mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
fix(slack): recover rejected grants and consume creation confirmations
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
61d199b086
commit
34ffc905ea
5 files changed
+77
-6
No files matched your search
@@ -4236,6 +4236,16 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("reconnects a rejected manager token without losing pending manifest configuration", async () => {
|
||||
const f = await managedFixture(); f.state.failManifestUpdate = true; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manifest_update_pending");
|
||||
expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]);
|
||||
expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("reauthorize");
|
||||
await f.grants.complete(await f.authorize(), "new-claim", null, actor);
|
||||
f.state.failManifestUpdate = false; await f.provision();
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured");
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1);
|
||||
});
|
||||
it("does not restore a revoked grant when an in-flight refresh returns", async () => {
|
||||
const f = await managedFixture();
|
||||
await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId));
|
||||
|
||||
@@ -236,15 +236,20 @@ export function createSlackManagerGrants(store: SlackRegistrationStore) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`);
|
||||
const peers = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.managerAppId, grant.managerAppId), eq(chatSlackManagerGrants.workspaceId, grant.workspaceId))).orderBy(chatSlackManagerGrants.id);
|
||||
if (peers.some(peer => Date.parse(peer.rateLimits[method] ?? "") > Date.now())) return { error: providerFailure("slack_setup_rate_limited") };
|
||||
if ((await readGrant(tx, id, companyId, actor)).status !== "active") return { error: providerFailure("slack_manager_reauthorize") };
|
||||
try { return { value: await api(method, fields, authorized.token) }; }
|
||||
const current = await readGrant(tx, id, companyId, actor);
|
||||
if (current.status !== "active") return { error: providerFailure("slack_manager_reauthorize") };
|
||||
try { return { value: await api(method, fields, await secret(current, current.accessSecretId, tx)) }; }
|
||||
catch (error) {
|
||||
const details = object(object(error).details);
|
||||
if (details.code === "slack_setup_rate_limited") {
|
||||
const until = new Date(Date.now() + Math.min(Number(details.retryAfterSeconds) || 60, 86_400) * 1000).toISOString();
|
||||
for (const peer of peers) await tx.update(chatSlackManagerGrants).set({ rateLimits: sql`${chatSlackManagerGrants.rateLimits} || ${JSON.stringify({ [method]: until })}::jsonb` }).where(eq(chatSlackManagerGrants.id, peer.id));
|
||||
}
|
||||
return { error: details.code === "slack_configuration_token_invalid" ? providerFailure("slack_manager_reauthorize") : error };
|
||||
if (details.code === "slack_configuration_token_invalid") {
|
||||
await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id));
|
||||
return { error: providerFailure("slack_manager_reauthorize") };
|
||||
}
|
||||
return { error };
|
||||
}
|
||||
});
|
||||
if ("error" in result) throw result.error;
|
||||
|
||||
@@ -201,6 +201,59 @@ test.describe.serial("native chat adapter UI", () => {
|
||||
await expect.poll(() => authorizationRequests).toBe(1);
|
||||
await expect(page).toHaveURL("https://slack.test/manager-consent");
|
||||
});
|
||||
test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => {
|
||||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||||
const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||||
mock.setSlackUncertain();
|
||||
const attempts: { requestId: string; confirmedNoAppCreated?: boolean }[] = [];
|
||||
await page.route("**/slack/managed/provision", async route => {
|
||||
attempts.push(route.request().postDataJSON());
|
||||
await route.fulfill({ json: {} });
|
||||
});
|
||||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||||
const confirm = page.getByRole("checkbox", { name: "I checked Slack and no app was created. Create a new app." });
|
||||
const create = page.getByRole("button", { name: "Add to Slack", exact: true });
|
||||
await expect(create).toBeDisabled();
|
||||
await confirm.check(); await create.click();
|
||||
await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled();
|
||||
expect(attempts).toHaveLength(1); expect(attempts[0]!.confirmedNoAppCreated).toBe(true);
|
||||
await confirm.check(); await create.click();
|
||||
await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled();
|
||||
expect(attempts).toHaveLength(2); expect(attempts[1]!.requestId).not.toBe(attempts[0]!.requestId);
|
||||
});
|
||||
test("Slack: own-app creation consumes confirmation even when the response is lost", async ({ page }) => {
|
||||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||||
const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true });
|
||||
mock.setSlackUncertain();
|
||||
let attempts = 0;
|
||||
await page.route("**/slack/registration", async route => {
|
||||
attempts++; expect(route.request().postDataJSON().confirmedNoAppCreated).toBe(true);
|
||||
await route.fulfill({ status: 502, json: { error: "Creation response unavailable" } });
|
||||
});
|
||||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||||
const confirm = page.getByRole("checkbox");
|
||||
await confirm.check();
|
||||
await page.getByLabel("App configuration access token", { exact: true }).fill("fixture-config-token");
|
||||
await page.getByRole("button", { name: "Create Slack app", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await expect(confirm).not.toBeChecked();
|
||||
await expect(page.getByRole("button", { name: "Create Slack app", exact: true })).toBeDisabled();
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
test("Slack: temporarily unavailable managed setup can retry in place", async ({ page }) => {
|
||||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||||
await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||||
let available = false;
|
||||
await page.route("**/chat-slack/setup-options", async route => {
|
||||
if (available) return route.fallback();
|
||||
await route.fulfill({ json: { managedAvailable: false, defaultMethod: "automatic", workspaces: [] } });
|
||||
});
|
||||
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
|
||||
await expect(page.getByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible();
|
||||
available = true; await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Add to Slack", exact: true })).toBeEnabled();
|
||||
await expect(page.getByRole("heading", { name: "Add to Slack", exact: true })).toBeVisible();
|
||||
});
|
||||
test("Slack: own-app selection persists before provisioning", async ({ page }) => {
|
||||
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
|
||||
await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
|
||||
|
||||
@@ -45,7 +45,10 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on
|
||||
}
|
||||
if (!started) await saveDetails();
|
||||
if (confirmed || registration?.status === "failed") requestId.current = crypto.randomUUID();
|
||||
const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, { requestId: requestId.current, grantId, ...(confirmed ? { confirmedNoAppCreated: true } : {}) });
|
||||
const input = { requestId: requestId.current, grantId, ...(confirmed ? { confirmedNoAppCreated: true } : {}) };
|
||||
setConfirmed(false);
|
||||
const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, input);
|
||||
await choices.refetch();
|
||||
onSaved(await chatEndpointsApi.get(endpoint.id));
|
||||
if (result.authorization) window.location.assign(result.authorization.authorizationUrl);
|
||||
}
|
||||
@@ -72,7 +75,7 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on
|
||||
</div>}
|
||||
{!started && <div><Button variant="link" className="h-auto p-0 text-muted-foreground" disabled={busy} onClick={() => void run(onOwnApp)}>Use your own app</Button></div>}
|
||||
<SetupWizardFooter onSaveExit={onSaveExit} disabled={busy}>
|
||||
{choices.isError ? <Button onClick={() => void choices.refetch()}>Retry</Button> : <Button disabled={busy || disabled || creating || choices.isPending || !choices.data?.managedAvailable || (workspaces.length > 1 && !grantId) || (uncertain && !confirmed)} onClick={() => void run(connect)}>
|
||||
{choices.isError || !choices.isPending && !choices.data?.managedAvailable ? <Button disabled={busy || choices.isFetching} onClick={() => void choices.refetch()}>Retry</Button> : <Button disabled={busy || disabled || creating || choices.isPending || !choices.data?.managedAvailable || (workspaces.length > 1 && !grantId) || (uncertain && !confirmed)} onClick={() => void run(connect)}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}{needsAuthorization && started ? "Reconnect Slack" : registration?.appId ? "Continue in Slack" : "Add to Slack"}
|
||||
</Button>}
|
||||
</SetupWizardFooter>
|
||||
|
||||
@@ -77,6 +77,7 @@ export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSav
|
||||
try {
|
||||
if (!created) await saveDetails();
|
||||
if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID();
|
||||
setCheckedNoApp(false);
|
||||
next = await chatEndpointsApi.createSlackApp(endpoint.id, {
|
||||
requestId: requestId.current,
|
||||
credentials: { configurationToken: token },
|
||||
@@ -93,7 +94,6 @@ export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSav
|
||||
}
|
||||
token = "";
|
||||
if (!mounted.current) return;
|
||||
setCheckedNoApp(false);
|
||||
onSaved(next);
|
||||
const saved = next.setup?.slackRegistration;
|
||||
if (!saved?.appId) return;
|
||||
|
||||
Reference in new issue
Block a user