mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
fix(slack): make manager refresh and installation recovery resumable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
26622523c6
commit
483c466008
8 files changed
+144
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<boolean>();
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof secretService>[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<string, Promise<{ grant: Grant; token: string }>>();
|
||||
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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user