From 483c4660087f696b648390d2eeb9d97d904f1b0c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 09:44:32 -0500 Subject: [PATCH] fix(slack): make manager refresh and installation recovery resumable Co-Authored-By: Paperclip --- doc/connections/slack-setup.md | 4 +- packages/shared/src/slack-app-manifest.ts | 1 + .../chat-channels.integration.test.ts | 55 ++++++++++++ .../connectors/slack/setup/completion.ts | 4 +- .../connectors/slack/setup/managed.ts | 3 +- .../connectors/slack/setup/manager-grants.ts | 83 ++++++++++++------- tests/e2e/chat-adapters-ui-providers.spec.ts | 25 ++++++ .../apps/chat/slack/ManagedSlackSetup.tsx | 2 +- 8 files changed, 144 insertions(+), 33 deletions(-) diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md index 1a041796da7..703366eb198 100644 --- a/doc/connections/slack-setup.md +++ b/doc/connections/slack-setup.md @@ -65,7 +65,9 @@ Provider-side app removal remains explicit through the saved Slack management UR complete the child app's state-bound OAuth approval; the installer must match the grant's Slack identity before personal linking. - Returned bot credentials are vaulted before inventory or runtime configuration. - Retry connecting reuses those credentials. + Retry connecting reuses those credentials after network or inventory failures. + Rejected or under-scoped bot credentials instead reinstall the saved app; they + never trigger another app creation. - Manifest updates finish before managed installation. Changes requiring updated permissions therefore pass through installation again before activation. - Expired or revoked manager grants require reconnection. Losing managed diff --git a/packages/shared/src/slack-app-manifest.ts b/packages/shared/src/slack-app-manifest.ts index 314e584a469..e9c24683382 100644 --- a/packages/shared/src/slack-app-manifest.ts +++ b/packages/shared/src/slack-app-manifest.ts @@ -104,6 +104,7 @@ export function slackRegistrationErrorMessage(code: string): string { slack_install_identity_mismatch: "Slack returned a different app, workspace, or bot. Install the app created for this connection.", slack_install_scopes_missing: "Slack did not grant all required bot permissions. Install the app again and approve its requested permissions.", slack_bot_already_connected: "This Slack bot is already connected to Paperclip. Resume its existing connection or use a different app.", + slack_install_token_invalid: "Slack rejected the saved bot credentials. Continue in Slack to authorize this app again.", slack_configuration_incomplete: "The app is installed, but Paperclip could not finish connecting it. Retry connecting the saved installation.", slack_install_account_missing: "Slack did not identify your account. Authorize the same app again to connect your Slack account.", slack_install_account_conflict: "This Slack account already has an existing link. Manage account links in connection settings, or authorize with your own Slack account.", diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 3706250ca65..42c82be9669 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -4209,6 +4209,33 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(saved.providerAccountId).toBeNull(); expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_scopes_missing" }); }); + it("reinstalls the saved app after missing bot scopes without creating another app", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.grantedScopes = scopes; 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.managedInstall"))).toHaveLength(2); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("clears a manager reconnect error after renewing the bound grant", async () => { + const f = await managedFixture(); f.state.managedError = "invalid_auth"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manager_reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBeNull(); + f.state.managedError = null; 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)); + f.broker.refresh.mockImplementation(async () => { + await f.grants.revoke(f.grantId, f.companyId, actor); + return f.credentials; + }); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("revoked"); + }); it("claims concurrent manager callbacks once", async () => { const f = await managedFixture(); const state = await f.authorize(); const results = await Promise.allSettled([1, 2].map(() => f.grants.complete(state, "claim", null, actor))); @@ -4263,6 +4290,34 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(saved.providerAccountId).toBeNull(); expect(saved.setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); }); + it("uses the held transaction for grant queries without acquiring another pool connection", async () => { + const f = await managedFixture(); + const transactionContext = new AsyncLocalStorage(); + const originalTransaction = db.transaction.bind(db); + const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((work, ...args) => + originalTransaction(tx => transactionContext.run(true, () => work(tx)), ...args)); + const querySpies = (["select", "insert", "update", "delete", "execute"] as const).map(method => { + const original = db[method].bind(db) as (...args: unknown[]) => unknown; + return vi.spyOn(db, method).mockImplementation(((...args: unknown[]) => { + if (transactionContext.getStore()) throw new Error("Acquired outer pool connection from held transaction"); + return original(...args); + }) as never); + }); + try { + await f.grants.token(f.grantId, f.companyId, actor); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + await f.grants.token(f.grantId, f.companyId, actor); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await f.grants.request(f.grantId, f.companyId, actor, "apps.manifest.validate", { manifest: "{}" }); + await f.grants.complete(await f.authorize(), "renewed-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBeUndefined(); + await f.grants.revoke(f.grantId, f.companyId, actor); + expect(f.broker.revoke).toHaveBeenCalledTimes(2); + } finally { + transactionSpy.mockRestore(); + for (const spy of querySpies) spy.mockRestore(); + } + }); it("serializes refreshes across endpoints and never replays an ambiguous refresh", 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/completion.ts b/server/src/services/connectors/slack/setup/completion.ts index 27ce4773b9e..931f7bdef0c 100644 --- a/server/src/services/connectors/slack/setup/completion.ts +++ b/server/src/services/connectors/slack/setup/completion.ts @@ -121,8 +121,8 @@ export function createSlackCompletion(store: SlackRegistrationStore) { await linkAccount(row, actor, lease); } catch (error) { const code = object(object(error).details).code; - const safeCode = code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" - ? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing") ? "slack_install_scopes_missing" + const safeCode = code === "slack_configuration_token_invalid" ? "slack_install_token_invalid" : code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" + ? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing" || code === "slack_setup_permission_denied") ? "slack_install_scopes_missing" : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; await setFailure(row, "credentials_saved", safeCode, lease); await audit(row, actor, "configuration_failed", safeCode); diff --git a/server/src/services/connectors/slack/setup/managed.ts b/server/src/services/connectors/slack/setup/managed.ts index ac4eb255cf6..44e4b67aeb4 100644 --- a/server/src/services/connectors/slack/setup/managed.ts +++ b/server/src/services/connectors/slack/setup/managed.ts @@ -22,7 +22,8 @@ export function createManagedSlackSetup(store: SlackRegistrationStore, grants: R if (!row?.appId || row.status === "removed" || row.errorCode === "slack_manifest_update_pending") return; if (row.managerGrantId !== authorized.grant.id || latest.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Slack setup changed"); store.assertOrigins(row, latest.endpoint.publicId); - if (row.status === "configured" || row.status === "credentials_saved") { + const needsReinstall = row.status === "credentials_saved" && ["slack_install_token_invalid", "slack_install_scopes_missing"].includes(row.errorCode ?? ""); + if (row.status === "configured" || row.status === "credentials_saved" && !needsReinstall) { await completion.resumeLocked(endpointId, actor, lease); return; } diff --git a/server/src/services/connectors/slack/setup/manager-grants.ts b/server/src/services/connectors/slack/setup/manager-grants.ts index a95e73c3a0d..e5df238f858 100644 --- a/server/src/services/connectors/slack/setup/manager-grants.ts +++ b/server/src/services/connectors/slack/setup/manager-grants.ts @@ -1,6 +1,6 @@ import { randomBytes, randomUUID } from "node:crypto"; import { and, eq, gt, like, sql } from "drizzle-orm"; -import { chatEndpoints, chatSlackManagerGrants, companySecrets, toolOauthStates } from "@paperclipai/db"; +import { chatEndpoints, chatSlackManagerGrants, chatSlackRegistrations, companySecrets, toolOauthStates } from "@paperclipai/db"; import { SLACK_MANAGER_SCOPES, type SlackSetupOptions as SetupChoices } from "@paperclipai/shared"; import { conflict, forbidden } from "../../../../errors.js"; import { getCloudRuntimeIdentity } from "../../../cloud-runtime-identity.js"; @@ -15,7 +15,7 @@ import { object, providerFailure, type SlackRegistrationStore, type SlackSetupAc export const SLACK_MANAGER_STATE_PREFIX = "slack-manager."; export function createSlackManagerGrants(store: SlackRegistrationStore) { const { db, options, endpoint, origin, api } = store; - const vault = secretService(db); + type Database = Parameters[0]; function broker(): PaperclipCloudConnector | null { if (options.managed) return options.managed.enabled ? options.managed.broker : null; // Open-source instances never contact a hosted service to discover this path. @@ -23,9 +23,9 @@ export function createSlackManagerGrants(store: SlackRegistrationStore) { const config = paperclipCloudConnectorConfigFromEnv(); return config ? createPaperclipCloudConnector({ config }) : null; } - async function access(companyId: string, actor: SlackSetupActor) { - if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); - if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections"); + async function access(companyId: string, actor: SlackSetupActor, database: Database = db) { + if (!(await instanceSettingsService(database as typeof db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + if (!actor.bypassPermissionCheck && !await accessService(database as typeof db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections"); } async function available() { const client = broker(); @@ -114,6 +114,7 @@ export function createSlackManagerGrants(store: SlackRegistrationStore) { if (existing.managerAppId !== identity.appId || existing.workspaceId !== identity.workspaceId || existing.slackUserId !== identity.userId) throw providerFailure("slack_install_identity_mismatch"); } await endpoint(current.endpoint.id, actor); + await access(current.endpoint.companyId, actor); await db.transaction(async tx => { await lease.assertOwned(tx); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager:${current.endpoint.companyId}:${actor.userId}:${identity.appId}:${identity.workspaceId}`}, 0))`); @@ -128,8 +129,12 @@ export function createSlackManagerGrants(store: SlackRegistrationStore) { .onConflictDoUpdate({ target: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.userId, chatSlackManagerGrants.managerAppId, chatSlackManagerGrants.workspaceId], set: { updatedAt: new Date() } }).returning(); // A reauthorization must never change which Slack person this grant represents. if (saved.slackUserId !== identity.userId) throw conflict("Reconnect using the original Slack account"); - await access(current.endpoint.companyId, actor); + await access(current.endpoint.companyId, actor, tx); await save(tx, saved, grant, actor); + if (row?.managerGrantId === saved.id) { + await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, current.endpoint.id), eq(chatSlackRegistrations.managerGrantId, saved.id), eq(chatSlackRegistrations.errorCode, "slack_manager_reauthorize"))); + } }); await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorized", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { workspaceId: identity.workspaceId, managerAppId: identity.appId } }); return outcome(null); @@ -139,7 +144,11 @@ export function createSlackManagerGrants(store: SlackRegistrationStore) { type Grant = typeof chatSlackManagerGrants.$inferSelect; async function get(id: string, companyId: string, actor: SlackSetupActor) { await access(companyId, actor); - const [grant] = await db.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + return readGrant(db, id, companyId, actor); + } + async function readGrant(database: Database, id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor, database); + const [grant] = await database.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId))); if (!grant) throw forbidden("This Slack workspace authorization belongs to another account"); return grant; } @@ -150,48 +159,66 @@ export function createSlackManagerGrants(store: SlackRegistrationStore) { const refreshToken = credentials.refreshToken ? await write("refresh_token", credentials.refreshToken, row.refreshSecretId) : null; await tx.update(chatSlackManagerGrants).set({ accessSecretId: accessToken.secret.id, refreshSecretId: refreshToken?.secret.id ?? row.refreshSecretId, expiresAt: credentials.accessTokenExpiresAt ? new Date(credentials.accessTokenExpiresAt) : null, status: "active", revision: row.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, row.id)); } - async function secret(row: Grant, id: string | null) { + async function secret(row: Grant, id: string | null, database: Database = db) { if (!id) throw providerFailure("slack_manager_reauthorize"); - const [ref] = await db.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId))); + const [ref] = await database.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId))); if (!ref?.userSecretDefinitionId) throw providerFailure("slack_manager_reauthorize"); // Owner-scoped system consumer; the durable grant is its authority, not a tool binding. - const value = await vault.resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId }); + const value = await secretService(database).resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId }); if (!value) throw providerFailure("slack_manager_reauthorize"); return value.value; } + const tokenRequests = new Map>(); async function token(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); const client = await requiredBroker(); - // Cross-endpoint serialization is required because rotated refresh tokens are single use. - return db.transaction(async tx => { - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); - const grant = await get(id, companyId, actor); - if (grant.managerAppId !== await client.getSlackManagerAppId()) throw providerFailure("slack_manager_reauthorize"); - if (grant.status !== "active") throw providerFailure("slack_manager_reauthorize"); - if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId) }; - // Mark before dispatch. A lost refresh response requires fresh consent, never replay. - await db.update(chatSlackManagerGrants).set({ status: "reauthorize" }).where(eq(chatSlackManagerGrants.id, id)); + const managerAppId = await client.getSlackManagerAppId(); + const key = `${companyId}:${actor.userId}:${id}`; + const running = tokenRequests.get(key); + if (running) return running; + const operation = (async () => { + // Commit the single-use refresh claim before dispatch, without holding a + // pool connection while another query or the broker needs it. + const prepared = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const grant = await readGrant(tx, id, companyId, actor); + if (grant.managerAppId !== managerAppId || grant.status !== "active") throw providerFailure("slack_manager_reauthorize"); + if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId, tx) }; + const refreshToken = await secret(grant, grant.refreshSecretId, tx); + const [claimed] = await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: grant.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)).returning(); + return { grant: claimed!, refreshToken }; + }); + if ("token" in prepared) return { grant: prepared.grant, token: prepared.token! }; try { - const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: await secret(grant, grant.refreshSecretId) }); + const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: prepared.refreshToken }); await access(companyId, actor); - await save(tx, grant, refreshed, actor); - return { grant, token: refreshed.accessToken }; - } catch { return null; } - }).then(result => { if (!result) throw providerFailure("slack_manager_reauthorize"); return result; }); + return await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "reauthorize" || current.revision !== prepared.grant.revision) throw providerFailure("slack_manager_reauthorize"); + await save(tx, current, refreshed, actor); + return { grant: await readGrant(tx, id, companyId, actor), token: refreshed.accessToken }; + }); + } catch { throw providerFailure("slack_manager_reauthorize"); } + })(); + tokenRequests.set(key, operation); + try { return await operation; } finally { if (tokenRequests.get(key) === operation) tokenRequests.delete(key); } } async function revoke(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); let providerRevoked = true; const grant = await db.transaction(async tx => { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); - const current = await get(id, companyId, actor); + const current = await readGrant(tx, id, companyId, actor); await tx.update(chatSlackManagerGrants).set({ status: "revoked", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, companyId), eq(toolOauthStates.createdByActorId, actor.userId), like(toolOauthStates.state, `${SLACK_MANAGER_STATE_PREFIX}%`))); const client = broker(); for (const secretId of [current.accessSecretId, current.refreshSecretId]) if (secretId) { try { if (!client) throw providerFailure("slack_managed_unavailable"); - await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId) }); + await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId, tx) }); } catch { providerRevoked = false; } - await vault.remove(secretId); + await secretService(tx).remove(secretId); } await tx.update(chatSlackManagerGrants).set({ accessSecretId: null, refreshSecretId: null }).where(eq(chatSlackManagerGrants.id, id)); return current; @@ -209,7 +236,7 @@ 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 get(id, companyId, actor)).status !== "active") return { error: providerFailure("slack_manager_reauthorize") }; + if ((await readGrant(tx, id, companyId, actor)).status !== "active") return { error: providerFailure("slack_manager_reauthorize") }; try { return { value: await api(method, fields, authorized.token) }; } catch (error) { const details = object(object(error).details); diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index dec1d2f3cd9..d1fb2cb8a03 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -176,6 +176,31 @@ test.describe.serial("native chat adapter UI", () => { await page.getByRole("button", { name: "Done", exact: true }).click(); await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); }); + test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let refreshFailed = false; + let authorizationRequests = 0; + await page.route("**/chat-slack/setup-options", async route => { + if (!refreshFailed) return route.fallback(); + await route.fulfill({ json: { managedAvailable: true, defaultMethod: "managed", workspaces: [] } }); + }); + await page.route("**/slack/managed/provision", async route => { + refreshFailed = true; + await route.fulfill({ status: 422, json: { error: "Reconnect your Slack workspace.", details: { code: "slack_manager_reauthorize" } } }); + }); + await page.route("**/slack/managed/authorize", async route => { + authorizationRequests++; + await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } }); + }); + await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await page.getByRole("button", { name: "Add to Slack", exact: true }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await page.getByRole("button", { name: "Add to Slack", exact: true }).click(); + await expect.poll(() => authorizationRequests).toBe(1); + await expect(page).toHaveURL("https://slack.test/manager-consent"); + }); 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 99fec6b3332..96eeaba8425 100644 --- a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx @@ -30,7 +30,7 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on if (inFlight.current) return; inFlight.current = true; setBusy(true); onBusy(true); setError(null); try { await action(); } - catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await chatEndpointsApi.get(endpoint.id).then(onSaved).catch(() => {}); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); } finally { inFlight.current = false; setBusy(false); onBusy(false); } } async function authorize() {