From ae22586f2c29c9cd14b5db9c31f9308292dcc1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:49:57 +0000 Subject: [PATCH 1/3] fix(slack): keep a workspace install with its recorded owner A Slack OAuth install now leaves the workspace installation attached to the owner recorded for that workspace. The callback rebuilds that owner's stored installation and writes it back; when the workspace has no recorded owner the installation is removed, and a token with no installation is revoked. Workspace identity links are left as they are, and the redirect still reports workspace_already_connected. --- .../oauth/platforms/slack-callback.test.ts | 233 ++++++++++++++++++ .../oauth/platforms/slack-callback.ts | 81 ++++++ 2 files changed, 314 insertions(+) create mode 100644 apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts diff --git a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts new file mode 100644 index 00000000000..3b3791ed573 --- /dev/null +++ b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts @@ -0,0 +1,233 @@ +process.env.NEXTAUTH_SECRET ||= 'test-nextauth-secret'; + +import { beforeEach, describe, expect, test } from '@jest/globals'; +import { NextRequest } from 'next/server'; +import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server'; +import { createOAuthState } from '@/lib/integrations/oauth-state'; +import { GET as slackCallbackRoute } from '@/app/api/integrations/[platform]/callback/route'; +import { bot } from '@/lib/bot'; +import { + getInstallationByTeamId, + revokeSlackToken, + SlackWorkspaceAlreadyConnectedError, + upsertSlackInstallation, +} from '@/lib/integrations/slack-service'; + +const mockHandleOAuthCallback = jest.fn(); +const mockSetInstallation = jest.fn(async (_teamId: string, _installation: unknown) => undefined); +const mockDeleteInstallation = jest.fn(async (_teamId: string) => undefined); +const mockConsumeProviderOAuthAttempt = jest.fn(async () => true); +const mockCancelProviderOAuthAttempt = jest.fn(async () => true); +const mockCaptureException = jest.fn(); + +jest.mock('@kilocode/web-shared/lib/user/server'); +jest.mock('@/lib/bot', () => ({ + bot: { + initialize: jest.fn(async () => undefined), + getAdapter: jest.fn(), + getState: jest.fn(() => ({ kind: 'state' })), + }, +})); +jest.mock('@/lib/integrations/slack-service', () => { + class MockSlackWorkspaceAlreadyConnectedError extends Error { + constructor(teamName: string) { + super(`${teamName} is already connected to another Kilo account or organization.`); + this.name = 'SlackWorkspaceAlreadyConnectedError'; + } + } + + return { + SlackWorkspaceAlreadyConnectedError: MockSlackWorkspaceAlreadyConnectedError, + getInstallationByTeamId: jest.fn(), + revokeSlackToken: jest.fn(async () => true), + upsertSlackInstallation: jest.fn(), + }; +}); +jest.mock('@/lib/integrations/provider-oauth-attempts', () => ({ + consumeProviderOAuthAttempt: () => mockConsumeProviderOAuthAttempt(), + cancelProviderOAuthAttempt: () => mockCancelProviderOAuthAttempt(), +})); +jest.mock('@kilocode/web-shared/routers/organizations/utils', () => ({ + ensureOrganizationAccess: jest.fn(), +})); +jest.mock('@sentry/nextjs', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), + captureMessage: jest.fn(), +})); + +const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); +const mockedBotGetAdapter = jest.mocked(bot.getAdapter); +const mockedUpsertSlackInstallation = jest.mocked(upsertSlackInstallation); +const mockedGetInstallationByTeamId = jest.mocked(getInstallationByTeamId); +const mockedRevokeSlackToken = jest.mocked(revokeSlackToken); + +const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847'; +const TEAM_ID = 'T_SHARED'; +const TEAM_NAME = 'Shared Workspace'; +const STORED_TOKEN = 'bot-token-existing-owner'; +const REJECTED_TOKEN = 'bot-token-rejected-exchange'; + +const createProviderState = () => + createOAuthState(`user_${USER_ID}`, USER_ID, undefined, 'provider_install'); + +function makeRequest(pathWithQuery: string) { + return new NextRequest(`http://localhost:3000${pathWithQuery}`); +} + +function callSlackCallback(request: NextRequest) { + return slackCallbackRoute(request, { params: Promise.resolve({ platform: 'slack' }) }); +} + +function expectRedirectLocation(response: Response, expectedPathWithQuery: string) { + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + const url = new URL(location ?? ''); + expect(`${url.pathname}${url.search}`).toBe(expectedPathWithQuery); +} + +function buildStoredIntegration(overrides: Record = {}) { + return { + id: 'integration-existing-owner', + platform: 'slack', + platform_installation_id: TEAM_ID, + platform_account_id: TEAM_ID, + platform_account_login: TEAM_NAME, + integration_status: 'active', + owned_by_user_id: 'other-owner', + owned_by_organization_id: null, + metadata: { access_token: STORED_TOKEN, bot_user_id: 'U_EXISTING_OWNER' }, + ...overrides, + } as never; +} + +function mockAdapter(rejectedToken = REJECTED_TOKEN) { + mockHandleOAuthCallback.mockResolvedValue({ + teamId: TEAM_ID, + installation: { + botToken: rejectedToken, + botUserId: 'U_REJECTED_INSTALL', + teamName: TEAM_NAME, + }, + }); + mockedBotGetAdapter.mockReturnValue({ + handleOAuthCallback: mockHandleOAuthCallback, + setInstallation: mockSetInstallation, + deleteInstallation: mockDeleteInstallation, + } as never); +} + +describe('GET /api/integrations/slack/callback workspace ownership', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSetInstallation.mockResolvedValue(undefined); + mockDeleteInstallation.mockResolvedValue(undefined); + mockConsumeProviderOAuthAttempt.mockResolvedValue(true); + mockCancelProviderOAuthAttempt.mockResolvedValue(true); + mockedGetUserFromAuth.mockResolvedValue({ + user: { id: USER_ID }, + authFailedResponse: null, + } as never); + mockedRevokeSlackToken.mockResolvedValue(true); + mockedGetInstallationByTeamId.mockResolvedValue(null); + }); + + test("keeps the recorded owner's installation when another owner installs the workspace", async () => { + mockAdapter(); + mockedGetInstallationByTeamId.mockResolvedValue(buildStoredIntegration()); + mockedUpsertSlackInstallation.mockRejectedValue( + new SlackWorkspaceAlreadyConnectedError(TEAM_NAME) + ); + + const state = createProviderState(); + const response = await callSlackCallback( + makeRequest(`/api/integrations/slack/callback?code=abc&state=${state}`) + ); + + expectRedirectLocation(response, '/integrations/slack?error=workspace_already_connected'); + expect(mockedGetInstallationByTeamId).toHaveBeenCalledWith(TEAM_ID); + expect(mockSetInstallation).toHaveBeenCalledTimes(1); + expect(mockSetInstallation).toHaveBeenCalledWith(TEAM_ID, { + botToken: STORED_TOKEN, + botUserId: 'U_EXISTING_OWNER', + teamName: TEAM_NAME, + }); + expect(mockDeleteInstallation).not.toHaveBeenCalled(); + expect(mockedRevokeSlackToken).toHaveBeenCalledTimes(1); + expect(mockedRevokeSlackToken).toHaveBeenCalledWith(REJECTED_TOKEN); + }); + + test('keeps the workspace token when an install returns the recorded token', async () => { + mockAdapter(STORED_TOKEN); + mockedGetInstallationByTeamId.mockResolvedValue(buildStoredIntegration()); + mockedUpsertSlackInstallation.mockRejectedValue( + new SlackWorkspaceAlreadyConnectedError(TEAM_NAME) + ); + + const state = createProviderState(); + const response = await callSlackCallback( + makeRequest(`/api/integrations/slack/callback?code=abc&state=${state}`) + ); + + expectRedirectLocation(response, '/integrations/slack?error=workspace_already_connected'); + expect(mockSetInstallation).toHaveBeenCalledWith(TEAM_ID, { + botToken: STORED_TOKEN, + botUserId: 'U_EXISTING_OWNER', + teamName: TEAM_NAME, + }); + expect(mockedRevokeSlackToken).not.toHaveBeenCalled(); + }); + + test('removes the installation when the workspace has no recorded owner', async () => { + mockAdapter(); + mockedGetInstallationByTeamId.mockResolvedValue(null); + mockedUpsertSlackInstallation.mockRejectedValue( + new SlackWorkspaceAlreadyConnectedError(TEAM_NAME) + ); + + const state = createProviderState(); + const response = await callSlackCallback( + makeRequest(`/api/integrations/slack/callback?code=abc&state=${state}`) + ); + + expectRedirectLocation(response, '/integrations/slack?error=workspace_already_connected'); + expect(mockSetInstallation).not.toHaveBeenCalled(); + expect(mockDeleteInstallation).toHaveBeenCalledWith(TEAM_ID); + expect(mockedRevokeSlackToken).toHaveBeenCalledWith(REJECTED_TOKEN); + }); + + test('removes the installation when the recorded owner cannot be written', async () => { + mockAdapter(); + mockSetInstallation.mockRejectedValue(new Error('state unavailable')); + mockedGetInstallationByTeamId.mockResolvedValue(buildStoredIntegration()); + mockedUpsertSlackInstallation.mockRejectedValue( + new SlackWorkspaceAlreadyConnectedError(TEAM_NAME) + ); + + const state = createProviderState(); + const response = await callSlackCallback( + makeRequest(`/api/integrations/slack/callback?code=abc&state=${state}`) + ); + + expectRedirectLocation(response, '/integrations/slack?error=workspace_already_connected'); + expect(mockDeleteInstallation).toHaveBeenCalledWith(TEAM_ID); + expect(mockedRevokeSlackToken).toHaveBeenCalledWith(REJECTED_TOKEN); + expect(mockCaptureException).toHaveBeenCalledTimes(1); + }); + + test('leaves a same-owner reinstall untouched', async () => { + mockAdapter(); + mockedUpsertSlackInstallation.mockResolvedValue(buildStoredIntegration()); + + const state = createProviderState(); + const response = await callSlackCallback( + makeRequest(`/api/integrations/slack/callback?code=abc&state=${state}`) + ); + + expectRedirectLocation(response, '/integrations/slack?success=installed'); + expect(mockedUpsertSlackInstallation).toHaveBeenCalledTimes(1); + expect(mockSetInstallation).not.toHaveBeenCalled(); + expect(mockDeleteInstallation).not.toHaveBeenCalled(); + expect(mockedRevokeSlackToken).not.toHaveBeenCalled(); + expect(mockedGetInstallationByTeamId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts index e679e699a4c..0f9d1163308 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts @@ -4,9 +4,13 @@ import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server'; import { ensureOrganizationAccess } from '@kilocode/web-shared/routers/organizations/utils'; import { captureException, captureMessage } from '@sentry/nextjs'; import { + getInstallationByTeamId, + revokeSlackToken, SlackWorkspaceAlreadyConnectedError, upsertSlackInstallation, } from '@/lib/integrations/slack-service'; +import type { SlackAdapter, SlackInstallation } from '@chat-adapter/slack'; +import type { PlatformIntegration } from '@kilocode/db/schema'; import { isLegacyProviderOAuthState, verifyOAuthState } from '@/lib/integrations/oauth-state'; import { APP_URL } from '@kilocode/web-shared/lib/constants'; import { bot } from '@/lib/bot'; @@ -23,6 +27,66 @@ import { consumeProviderOAuthAttempt } from '@/lib/integrations/provider-oauth-a const SLACK_REDIRECT_URI = getPlatformOAuthCallbackUrl(PLATFORM.SLACK); +/** + * Build the Chat SDK installation for a workspace from its recorded + * integration. + */ +function buildStoredSlackInstallation(integration: PlatformIntegration): SlackInstallation | null { + const metadata = integration.metadata as + | { access_token?: unknown; bot_user_id?: unknown } + | null + | undefined; + const botToken = typeof metadata?.access_token === 'string' ? metadata.access_token : null; + + if (!botToken) return null; + + return { + botToken, + ...(typeof metadata?.bot_user_id === 'string' ? { botUserId: metadata.bot_user_id } : {}), + ...(integration.platform_account_login ? { teamName: integration.platform_account_login } : {}), + }; +} + +/** + * Keep a workspace's Chat SDK installation attached to the owner recorded for + * it. The workspace installation is rebuilt from the recorded integration and + * written back; without a recorded integration the installation is removed. + * A token that no installation is attached to is revoked. Workspace identity + * links are left as they are. + */ +async function restoreRecordedSlackInstallation({ + slackAdapter, + teamId, + incomingInstallation, +}: { + slackAdapter: SlackAdapter; + teamId: string; + incomingInstallation: SlackInstallation; +}): Promise { + const recordedIntegration = await getInstallationByTeamId(teamId); + const recordedInstallation = recordedIntegration + ? buildStoredSlackInstallation(recordedIntegration) + : null; + + if (recordedInstallation) { + try { + await slackAdapter.setInstallation(teamId, recordedInstallation); + } catch (error) { + captureException(error, { + tags: { endpoint: 'slack/callback', source: 'slack_oauth', op: 'restore-installation' }, + extra: { teamId }, + }); + await slackAdapter.deleteInstallation(teamId); + } + } else { + await slackAdapter.deleteInstallation(teamId); + } + + if (incomingInstallation.botToken !== recordedInstallation?.botToken) { + await revokeSlackToken(incomingInstallation.botToken); + } +} + /** * Slack OAuth Callback * @@ -153,6 +217,23 @@ export async function handleSlackOAuthCallback(request: NextRequest) { await upsertSlackInstallation({ owner, teamId, installation }); } catch (error) { if (error instanceof SlackWorkspaceAlreadyConnectedError) { + try { + await restoreRecordedSlackInstallation({ + slackAdapter, + teamId, + incomingInstallation: installation, + }); + } catch (rollbackError) { + captureException(rollbackError, { + tags: { + endpoint: 'slack/callback', + source: 'slack_oauth', + op: 'install-conflict-rollback', + }, + extra: { teamId }, + }); + } + return NextResponse.redirect( new URL( buildIntegrationOAuthRedirectPathFromState( From 03c747007c4e0d8374666cc734b08326bd8e54dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:00:51 +0000 Subject: [PATCH 2/3] fix(slack): keep token revocation independent of the installation write Revoking a token that ends up with no installation now runs whether or not the installation write succeeds, and a revocation that does not complete is reported to Sentry. Adds coverage for a recorded integration without a token and for a removal that fails. --- .../oauth/platforms/slack-callback.test.ts | 38 +++++++++++ .../oauth/platforms/slack-callback.ts | 67 +++++++++++++------ 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts index 3b3791ed573..6ccc9f2f2b2 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.test.ts @@ -195,6 +195,44 @@ describe('GET /api/integrations/slack/callback workspace ownership', () => { expect(mockedRevokeSlackToken).toHaveBeenCalledWith(REJECTED_TOKEN); }); + test('removes the installation when the recorded integration has no token', async () => { + mockAdapter(); + mockedGetInstallationByTeamId.mockResolvedValue( + buildStoredIntegration({ metadata: { bot_user_id: 'U_EXISTING_OWNER' } }) + ); + mockedUpsertSlackInstallation.mockRejectedValue( + new SlackWorkspaceAlreadyConnectedError(TEAM_NAME) + ); + + const state = createProviderState(); + const response = await callSlackCallback( + makeRequest(`/api/integrations/slack/callback?code=abc&state=${state}`) + ); + + expectRedirectLocation(response, '/integrations/slack?error=workspace_already_connected'); + expect(mockSetInstallation).not.toHaveBeenCalled(); + expect(mockDeleteInstallation).toHaveBeenCalledWith(TEAM_ID); + expect(mockedRevokeSlackToken).toHaveBeenCalledWith(REJECTED_TOKEN); + }); + + test('revokes the incoming token when removing the installation fails', async () => { + mockAdapter(); + mockDeleteInstallation.mockRejectedValue(new Error('state unavailable')); + mockedGetInstallationByTeamId.mockResolvedValue(null); + mockedUpsertSlackInstallation.mockRejectedValue( + new SlackWorkspaceAlreadyConnectedError(TEAM_NAME) + ); + + const state = createProviderState(); + const response = await callSlackCallback( + makeRequest(`/api/integrations/slack/callback?code=abc&state=${state}`) + ); + + expectRedirectLocation(response, '/integrations/slack?error=workspace_already_connected'); + expect(mockedRevokeSlackToken).toHaveBeenCalledWith(REJECTED_TOKEN); + expect(mockCaptureException).toHaveBeenCalledTimes(1); + }); + test('removes the installation when the recorded owner cannot be written', async () => { mockAdapter(); mockSetInstallation.mockRejectedValue(new Error('state unavailable')); diff --git a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts index 0f9d1163308..75c5acaa66b 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.ts @@ -47,12 +47,40 @@ function buildStoredSlackInstallation(integration: PlatformIntegration): SlackIn }; } +/** + * Write the workspace installation recorded for the workspace, or remove the + * workspace installation when nothing is recorded. + */ +async function writeRecordedSlackInstallation({ + slackAdapter, + teamId, + recordedInstallation, +}: { + slackAdapter: SlackAdapter; + teamId: string; + recordedInstallation: SlackInstallation | null; +}): Promise { + if (!recordedInstallation) { + await slackAdapter.deleteInstallation(teamId); + return; + } + + try { + await slackAdapter.setInstallation(teamId, recordedInstallation); + } catch (error) { + captureException(error, { + tags: { endpoint: 'slack/callback', source: 'slack_oauth', op: 'restore-installation' }, + extra: { teamId }, + }); + await slackAdapter.deleteInstallation(teamId); + } +} + /** * Keep a workspace's Chat SDK installation attached to the owner recorded for - * it. The workspace installation is rebuilt from the recorded integration and - * written back; without a recorded integration the installation is removed. - * A token that no installation is attached to is revoked. Workspace identity - * links are left as they are. + * it, and revoke a token that ends up attached to no installation. Revocation + * runs whether or not the installation write succeeds. Workspace identity links + * are left as they are. */ async function restoreRecordedSlackInstallation({ slackAdapter, @@ -67,23 +95,24 @@ async function restoreRecordedSlackInstallation({ const recordedInstallation = recordedIntegration ? buildStoredSlackInstallation(recordedIntegration) : null; + const reusesRecordedToken = incomingInstallation.botToken === recordedInstallation?.botToken; - if (recordedInstallation) { - try { - await slackAdapter.setInstallation(teamId, recordedInstallation); - } catch (error) { - captureException(error, { - tags: { endpoint: 'slack/callback', source: 'slack_oauth', op: 'restore-installation' }, - extra: { teamId }, - }); - await slackAdapter.deleteInstallation(teamId); + try { + await writeRecordedSlackInstallation({ slackAdapter, teamId, recordedInstallation }); + } finally { + if (!reusesRecordedToken) { + const revoked = await revokeSlackToken(incomingInstallation.botToken); + if (!revoked) { + captureMessage( + 'Slack token revocation did not complete while settling a workspace install', + { + level: 'warning', + tags: { endpoint: 'slack/callback', source: 'slack_oauth', op: 'revoke-token' }, + extra: { teamId }, + } + ); + } } - } else { - await slackAdapter.deleteInstallation(teamId); - } - - if (incomingInstallation.botToken !== recordedInstallation?.botToken) { - await revokeSlackToken(incomingInstallation.botToken); } } From 26b5f1fb6581d2121d6bd1913c46ac1712bd5445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:14:41 +0000 Subject: [PATCH 3/3] test(slack): cover the install settle path against the database --- .../oauth/platforms/slack-callback.db.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 apps/web/src/lib/integrations/oauth/platforms/slack-callback.db.test.ts diff --git a/apps/web/src/lib/integrations/oauth/platforms/slack-callback.db.test.ts b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.db.test.ts new file mode 100644 index 00000000000..33c05083174 --- /dev/null +++ b/apps/web/src/lib/integrations/oauth/platforms/slack-callback.db.test.ts @@ -0,0 +1,200 @@ +import { NextRequest } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@kilocode/web-shared/lib/drizzle'; +import { kilocode_users, platform_integrations } from '@kilocode/db/schema'; +import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server'; +import { createOAuthState } from '@/lib/integrations/oauth-state'; +import { beginProviderOAuthAttempt } from '@/lib/integrations/provider-oauth-attempts'; +import { SLACK_SCOPES } from '@/lib/integrations/slack-service'; +import { handleSlackOAuthCallback } from './slack-callback'; + +const mockHandleOAuthCallback = jest.fn(); +const mockSetInstallation = jest.fn(); +const mockDeleteInstallation = jest.fn(); +const mockRevokeToken = jest.fn(); +const mockRevokedTokens: string[] = []; +const mockWriteSlackCredential = jest.fn(); + +jest.mock('@kilocode/web-shared/lib/user/server'); +jest.mock('@/lib/bot', () => ({ + bot: { + initialize: jest.fn(async () => {}), + getAdapter: () => ({ + handleOAuthCallback: (...args: unknown[]) => mockHandleOAuthCallback(...args), + setInstallation: (...args: unknown[]) => mockSetInstallation(...args), + deleteInstallation: (...args: unknown[]) => mockDeleteInstallation(...args), + }), + }, +})); +jest.mock('@slack/web-api', () => ({ + WebClient: function WebClient(this: unknown, token: string) { + mockRevokedTokens.push(token); + return { auth: { revoke: () => mockRevokeToken() } }; + }, +})); +jest.mock('@/lib/integrations/platforms/slack/credential-store', () => ({ + writeSlackCredential: (...args: unknown[]) => mockWriteSlackCredential(...args), +})); +jest.mock('@/lib/slack-bot/model-allow-list', () => ({ + getDefaultAllowedModel: jest.fn(async () => 'test-model'), +})); + +const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); + +const OWNER_USER_ID = crypto.randomUUID(); +const ACTING_USER_ID = crypto.randomUUID(); +const TEAM_ID = 'T_DB_SHARED'; +const TEAM_NAME = 'Shared DB Workspace'; +const RECORDED_TOKEN = 'fixture-recorded-token'; +const INCOMING_TOKEN = 'fixture-incoming-token'; +const REINSTALL_TOKEN = 'fixture-reinstall-token'; + +function buildUser(id: string, name: string) { + return { + id, + google_user_email: `${id}@example.com`, + google_user_name: name, + google_user_image_url: 'https://example.com/avatar.png', + stripe_customer_id: `cus_slack_${id}`, + }; +} + +async function callCallback(ownerUserId: string) { + const owner = `user_${ownerUserId}`; + const state = createOAuthState(owner, ownerUserId, undefined, 'provider_install'); + await beginProviderOAuthAttempt({ + actorUserId: ownerUserId, + owner: { type: 'user', id: ownerUserId }, + provider: 'slack', + state, + purpose: 'provider_install', + }); + const request = new NextRequest( + `http://localhost:3000/api/integrations/slack/callback?code=code-1&state=${encodeURIComponent(state)}` + ); + return handleSlackOAuthCallback(request); +} + +describe('Slack OAuth callback workspace binding', () => { + beforeAll(async () => { + await db + .insert(kilocode_users) + .values([ + buildUser(OWNER_USER_ID, 'Recorded Owner'), + buildUser(ACTING_USER_ID, 'Second Installer'), + ]); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockRevokedTokens.length = 0; + mockSetInstallation.mockResolvedValue(undefined); + mockDeleteInstallation.mockResolvedValue(undefined); + mockRevokeToken.mockResolvedValue({ ok: true }); + mockWriteSlackCredential.mockResolvedValue(undefined); + await db + .delete(platform_integrations) + .where(eq(platform_integrations.owned_by_user_id, OWNER_USER_ID)); + await db.insert(platform_integrations).values({ + owned_by_user_id: OWNER_USER_ID, + platform: 'slack', + integration_type: 'oauth', + platform_installation_id: TEAM_ID, + platform_account_id: TEAM_ID, + platform_account_login: TEAM_NAME, + scopes: SLACK_SCOPES, + integration_status: 'active', + metadata: { access_token: RECORDED_TOKEN, bot_user_id: 'U_RECORDED_OWNER' }, + }); + }); + + afterAll(async () => { + await db + .delete(platform_integrations) + .where(eq(platform_integrations.owned_by_user_id, OWNER_USER_ID)); + await db + .delete(platform_integrations) + .where(eq(platform_integrations.owned_by_user_id, ACTING_USER_ID)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, OWNER_USER_ID)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, ACTING_USER_ID)); + }); + + function recordInvocation(accessToken = INCOMING_TOKEN) { + mockHandleOAuthCallback.mockResolvedValue({ + teamId: TEAM_ID, + installation: { + botToken: accessToken, + botUserId: 'U_INCOMING', + teamName: TEAM_NAME, + }, + }); + } + + it("leaves the recorded owner's row and installation in place when another user installs the workspace", async () => { + recordInvocation(); + mockedGetUserFromAuth.mockResolvedValue({ + user: { id: ACTING_USER_ID, is_admin: false }, + authFailedResponse: null, + } as never); + + const [before] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.owned_by_user_id, OWNER_USER_ID)); + + const response = await callCallback(ACTING_USER_ID); + + const location = new URL(response.headers.get('location') ?? ''); + expect(`${location.pathname}${location.search}`).toBe( + '/integrations/slack?error=workspace_already_connected' + ); + + const [after] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.owned_by_user_id, OWNER_USER_ID)); + expect(after).toEqual(before); + expect(mockSetInstallation).toHaveBeenCalledWith(TEAM_ID, { + botToken: RECORDED_TOKEN, + botUserId: 'U_RECORDED_OWNER', + teamName: TEAM_NAME, + }); + expect(mockDeleteInstallation).not.toHaveBeenCalled(); + expect(mockRevokedTokens).toEqual([INCOMING_TOKEN]); + expect(mockWriteSlackCredential).not.toHaveBeenCalled(); + + const actingIntegrations = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.owned_by_user_id, ACTING_USER_ID)); + expect(actingIntegrations).toEqual([]); + }); + + it('records a reinstall by the same user and leaves the installation untouched', async () => { + recordInvocation(REINSTALL_TOKEN); + mockedGetUserFromAuth.mockResolvedValue({ + user: { id: OWNER_USER_ID, is_admin: false }, + authFailedResponse: null, + } as never); + + const response = await callCallback(OWNER_USER_ID); + + const location = new URL(response.headers.get('location') ?? ''); + expect(`${location.pathname}${location.search}`).toBe('/integrations/slack?success=installed'); + + const [after] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.owned_by_user_id, OWNER_USER_ID)); + expect(after.platform_installation_id).toBe(TEAM_ID); + expect(after.metadata).toEqual( + expect.objectContaining({ + access_token: REINSTALL_TOKEN, + bot_user_id: 'U_INCOMING', + }) + ); + expect(mockSetInstallation).not.toHaveBeenCalled(); + expect(mockDeleteInstallation).not.toHaveBeenCalled(); + expect(mockRevokedTokens).toEqual([]); + }); +});