diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index c72ccbbcb6..23e4d28225 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -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)); diff --git a/server/src/services/connectors/slack/setup/manager-grants.ts b/server/src/services/connectors/slack/setup/manager-grants.ts index e5df238f85..c39927c99e 100644 --- a/server/src/services/connectors/slack/setup/manager-grants.ts +++ b/server/src/services/connectors/slack/setup/manager-grants.ts @@ -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; diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index d1fb2cb8a0..141e4670ae 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -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 }); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx index 96eeaba842..271c3cdc35 100644 --- a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx @@ -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 } {!started &&
} - {choices.isError ? : : } diff --git a/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx index 5396692e89..a08c09376b 100644 --- a/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx +++ b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx @@ -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;