Skip to content
Open
21 changes: 14 additions & 7 deletions apps/web/src/app/api/integrations/oauth-install-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ jest.mock('@kilocode/web-shared/routers/organizations/utils', () => ({
jest.mock('@/lib/slack-bot/model-allow-list', () => ({
getDefaultAllowedModel: jest.fn(async () => 'test/default-model'),
}));
jest.mock('@kilocode/web-shared/lib/config.server', () => ({
...jest.requireActual('@kilocode/web-shared/lib/config.server'),
LINEAR_CLIENT_ID: 'test-linear-client-id',
LINEAR_CLIENT_SECRET: 'test-linear-client-secret',
SLACK_CLIENT_ID: 'test-slack-client-id',
SLACK_CLIENT_SECRET: 'test-slack-client-secret',
}));
jest.mock('@kilocode/web-shared/lib/config.server', () => {
// Test-only envelope keyset; a jest factory cannot import the module above.
const slackTestKeyset = jest.requireActual(
'@/lib/integrations/platforms/slack/credential-test-keyset'
) as { TEST_SLACK_CREDENTIAL_KEYSET_JSON: string };
return {
...jest.requireActual('@kilocode/web-shared/lib/config.server'),
LINEAR_CLIENT_ID: 'test-linear-client-id',
LINEAR_CLIENT_SECRET: 'test-linear-client-secret',
SLACK_CLIENT_ID: 'test-slack-client-id',
SLACK_CLIENT_SECRET: 'test-slack-client-secret',
SLACK_CREDENTIAL_KEYSET_JSON: slackTestKeyset.TEST_SLACK_CREDENTIAL_KEYSET_JSON,
};
});
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/app/api/integrations/slack/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { beforeEach, describe, expect, test } from '@jest/globals';
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 {
kilocode_users,
platform_integrations,
slack_oauth_credentials,
} from '@kilocode/db/schema';
import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper';
import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server';
import { createOAuthState } from '@/lib/integrations/oauth-state';
Expand Down Expand Up @@ -45,6 +49,16 @@ jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
}));
jest.mock('@kilocode/web-shared/lib/config.server', () => {
// Test-only envelope keyset; a jest factory cannot import the module above.
const slackTestKeyset = jest.requireActual(
'@/lib/integrations/platforms/slack/credential-test-keyset'
) as { TEST_SLACK_CREDENTIAL_KEYSET_JSON: string };
return {
...jest.requireActual('@kilocode/web-shared/lib/config.server'),
SLACK_CREDENTIAL_KEYSET_JSON: slackTestKeyset.TEST_SLACK_CREDENTIAL_KEYSET_JSON,
};
});

const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedBotGetAdapter = jest.mocked(bot.getAdapter);
Expand Down Expand Up @@ -149,8 +163,14 @@ describe('GET /api/integrations/slack/callback', () => {
platform: 'slack',
platform_installation_id: TEAM_ID,
platform_account_login: 'Acme Workspace',
metadata: expect.objectContaining({ bot_user_id: 'U0BOT', access_token: 'xoxb-new-token' }),
metadata: expect.objectContaining({ bot_user_id: 'U0BOT' }),
});
expect((stored.metadata as Record<string, unknown>).access_token).toBeUndefined();
const credentialRows = await db
.select()
.from(slack_oauth_credentials)
.where(eq(slack_oauth_credentials.platform_integration_id, stored.id));
expect(credentialRows).toHaveLength(1);
} finally {
mockedUpsertSlackInstallation.mockReset();
await db.delete(kilocode_users).where(eq(kilocode_users.id, USER_ID));
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/bot/platforms/slack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import type { BotPlatform, RequesterInfo } from '@/lib/bot/platforms/types';
import { BOT_CONTEXT_MESSAGE_LIMIT } from '@/lib/bot/constants';
import { APP_URL } from '@kilocode/web-shared/lib/constants';
import { getAccessTokenFromInstallation } from '@/lib/integrations/slack-service';
import { getSlackBotToken } from '@/lib/integrations/slack-service';
import { PLATFORM } from '@/lib/integrations/core/constants';
import { getSlackMessagePermalink } from '@/lib/slack-bot/slack-utils';
import { captureException } from '@sentry/nextjs';
Expand Down Expand Up @@ -219,7 +219,7 @@ async function getSlackRequesterInfo(
platformIntegration: PlatformIntegration,
displayName: string
): Promise<RequesterInfo> {
const accessToken = getAccessTokenFromInstallation(platformIntegration);
const accessToken = await getSlackBotToken(platformIntegration);
if (!accessToken) {
return { displayName, platform: PLATFORM.SLACK };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,7 @@ let cached: SlackCredentialKeyset | undefined;

/**
* Resolves the keyset, memoized per process. Throws `SlackCredentialKeysetError`
* when unconfigured or invalid — callers on the install path treat that as
* non-fatal while the new store is still write-only.
* when the keyset is unconfigured or invalid.
*/
export function requireSlackCredentialKeyset(): SlackCredentialKeyset {
if (cached) return cached;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ import {
/**
* System of record for Slack bot credentials.
*
* Nothing in production reads this store yet — Step 2 only dual-writes it alongside
* the existing plaintext `platform_integrations.metadata.access_token`, so the change
* is reversible. Step 3 repoints the read paths here and deletes the plaintext copy.
* Install writes and every server read path of the bot credential go through here.
*/

export type WriteSlackCredentialInput = {
Expand Down Expand Up @@ -73,11 +71,9 @@ export async function writeSlackCredential(
/**
* Creates the credential row only when the integration does not have one yet.
*
* For callers whose token comes from a snapshot rather than from a live Slack
* response — the Step 2 backfill reads `platform_integrations.metadata` up front — a
* reinstall part-way through the run writes a fresh credential that a blind replace
* would overwrite with the by-then-revoked snapshot token. Existence is therefore
* re-checked inside the same locked transaction as the write.
* A caller whose token comes from a snapshot rather than from a live Slack response
* needs the current row to win: existence is re-checked inside the same locked
* transaction as the write, so a credential written after the snapshot is kept.
*/
export async function createSlackCredentialIfAbsent(
input: WriteSlackCredentialInput
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { generateKeyPairSync } from 'node:crypto';

const KEY_ID = 'slack-credential-test-key';

const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

/**
* Envelope keyset for tests that run the real Slack credential store.
*
* Test-only material, generated per test module registry. Use it where a test
* must exercise the install path end to end instead of mocking the store.
*/
export const TEST_SLACK_CREDENTIAL_KEYSET_JSON = JSON.stringify({
active: { keyId: KEY_ID, publicKeyPem: publicKey },
decrypt: [{ keyId: KEY_ID, privateKeyPem: privateKey }],
});
Loading
Loading