Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1009.0",
"@chat-adapter/github": "4.27.0",
"@chat-adapter/slack": "^4.27.0",
"@chat-adapter/state-memory": "^4.27.0",
"@chat-adapter/state-redis": "^4.27.0",
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/app/api/chat/link-account/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, test } from '@jest/globals';
import { NextRequest } from 'next/server';
import { bot } from '@/lib/bot';
import { verifyLinkToken, linkKiloUser } from '@/lib/bot-identity';
import { getUserFromAuth } from '@/lib/user.server';
import { getPlatformIntegration } from '@/lib/bot/platform-helpers';
import { PLATFORM } from '@/lib/integrations/core/constants';
import type { SerializedMessage } from 'chat';

const mockedAfter = jest.fn();

jest.mock('next/server', () => {
const actual = jest.requireActual('next/server');
return {
...actual,
after: (fn: () => Promise<void> | void) => mockedAfter(fn),
};
});
jest.mock('@/lib/bot', () => ({
bot: {
initialize: jest.fn(async () => undefined),
getState: jest.fn(() => ({ kind: 'state' })),
},
}));
jest.mock('@/lib/bot-identity', () => ({
verifyLinkToken: jest.fn(),
linkKiloUser: jest.fn(async () => undefined),
consumeLinkAccountContext: jest.fn(async () => true),
}));
jest.mock('@/lib/user.server');
jest.mock('@/lib/bot/platform-helpers');
jest.mock('@/lib/organizations/organizations', () => ({
isOrganizationMember: jest.fn(async () => true),
}));
jest.mock('@/lib/bot/run', () => ({
processLinkedMessage: jest.fn(async () => undefined),
}));
jest.mock('@/lib/bot/platform-auth-context', () => ({
withBotPlatformAuthContext: jest.fn(async (_integration, callback) => callback()),
}));
jest.mock(
'chat',
() => ({
Message: {
fromJSON: jest.fn(value => value),
},
ThreadImpl: {
fromJSON: jest.fn(value => value),
},
}),
{ virtual: true }
);
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
}));

const mockedBot = jest.mocked(bot);
const mockedVerifyLinkToken = jest.mocked(verifyLinkToken);
const mockedLinkKiloUser = jest.mocked(linkKiloUser);
const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedGetPlatformIntegration = jest.mocked(getPlatformIntegration);

function makeRequest(pathWithQuery: string) {
return new NextRequest(`http://localhost:3000${pathWithQuery}`);
}

describe('GET /api/chat/link-account', () => {
beforeEach(() => {
jest.clearAllMocks();

mockedGetUserFromAuth.mockResolvedValue({
user: { id: 'kilo-user-id' },
authFailedResponse: null,
} as never);
mockedGetPlatformIntegration.mockResolvedValue({
owned_by_user_id: 'kilo-user-id',
owned_by_organization_id: null,
} as never);
});

test('rejects GitHub link token payloads before linking', async () => {
mockedVerifyLinkToken.mockResolvedValue({
contextKey: 'context-key',
identity: { platform: PLATFORM.GITHUB, teamId: '98765', userId: '12345' },
thread: {
_type: 'chat:Thread',
adapterName: 'github',
channelId: 'github:acme/widgets',
id: 'github:acme/widgets:issue:1',
isDM: false,
},
message: {
_type: 'chat:Message',
attachments: [],
author: {
fullName: 'octocat',
isBot: false,
isMe: false,
userId: '12345',
userName: 'octocat',
},
formatted: { type: 'root', children: [] },
id: 'm_1',
metadata: {
dateSent: '2026-05-05T07:32:52.000Z',
edited: false,
},
raw: {},
text: '@kilocode-dev fix this',
threadId: 'github:acme/widgets:issue:1',
} satisfies SerializedMessage,
});

const { GET } = await import('./route');
const response = await GET(makeRequest('/api/chat/link-account?token=signed') as never);

expect(response.status).toBe(400);
await expect(response.text()).resolves.toContain('GitHub account links must be created');
expect(mockedBot.initialize).toHaveBeenCalled();
expect(mockedGetUserFromAuth).not.toHaveBeenCalled();
expect(mockedGetPlatformIntegration).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
expect(mockedAfter).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions apps/web/src/app/api/chat/link-account/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { processLinkedMessage } from '@/lib/bot/run';
import { withBotPlatformAuthContext } from '@/lib/bot/platform-auth-context';
import { Message, ThreadImpl, type Thread } from 'chat';
import type { User } from '@kilocode/db';
import { PLATFORM } from '@/lib/integrations/core/constants';

function errorPage(title: string, message: string, status: number): Response {
return new Response(
Expand Down Expand Up @@ -100,6 +101,14 @@ export async function GET(request: Request) {

const { contextKey, identity, thread, message } = linkPayload;

if (identity.platform === PLATFORM.GITHUB) {
return errorPage(
'Link Not Supported',
'GitHub account links must be created from the GitHub link page.',
400
);
}

// Authenticate — redirect to sign-in if no session, then back here
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) {
Expand Down
213 changes: 213 additions & 0 deletions apps/web/src/app/api/integrations/github/callback/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, test } from '@jest/globals';
import { NextRequest, NextResponse } from 'next/server';
import { getUserFromAuth } from '@/lib/user.server';
import { verifyGitHubBotLinkState } from '@/lib/bot/github-link-state';
import { exchangeGitHubOAuthCode } from '@/lib/integrations/platforms/github/adapter';
import { githubUserIdentity, linkKiloUser } from '@/lib/bot-identity';
import { bot } from '@/lib/bot';
import { failureResult } from '@/lib/maybe-result';
import { findIntegrationByInstallationId } from '@/lib/integrations/db/platform-integrations';
import { isOrganizationMember } from '@/lib/organizations/organizations';
import type { StateAdapter } from 'chat';

const mockState = { kind: 'state' } as unknown as StateAdapter;

jest.mock('@/lib/user.server');
jest.mock('@/lib/bot/github-link-state');
jest.mock('@/lib/bot-identity');
jest.mock('@/lib/integrations/platforms/github/adapter');
jest.mock('@/lib/bot', () => ({
bot: {
initialize: jest.fn(async () => undefined),
getState: jest.fn(() => mockState),
},
}));
jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => ({
apps: {
getInstallation: jest.fn(),
listReposAccessibleToInstallation: jest.fn(),
},
})),
}));
jest.mock('@octokit/auth-app', () => ({
createAppAuth: jest.fn(),
}));
jest.mock('@/lib/integrations/platforms/github/app-selector', () => ({
getGitHubAppTypeForOrganization: jest.fn(async () => 'standard'),
getGitHubAppCredentials: jest.fn(() => ({
appId: 'app-id',
privateKey: 'private-key',
clientId: 'client-id',
clientSecret: 'client-secret',
appName: 'KiloConnect',
webhookSecret: 'webhook-secret',
})),
}));
jest.mock('@/routers/organizations/utils', () => ({
ensureOrganizationAccess: jest.fn(),
}));
jest.mock('@/lib/integrations/db/platform-integrations', () => ({
createPendingIntegration: jest.fn(),
findIntegrationByInstallationId: jest.fn(),
findPendingInstallationByRequesterId: jest.fn(),
upsertPlatformIntegrationForOwner: jest.fn(),
}));
jest.mock('@/lib/organizations/organizations', () => ({
isOrganizationMember: jest.fn(),
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
}));

const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedVerifyGitHubBotLinkState = jest.mocked(verifyGitHubBotLinkState);
const mockedExchangeGitHubOAuthCode = jest.mocked(exchangeGitHubOAuthCode);
const mockedGithubUserIdentity = jest.mocked(githubUserIdentity);
const mockedLinkKiloUser = jest.mocked(linkKiloUser);
const mockedBot = jest.mocked(bot);
const mockedFindIntegrationByInstallationId = jest.mocked(findIntegrationByInstallationId);
const mockedIsOrganizationMember = jest.mocked(isOrganizationMember);

const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847';
const OTHER_USER_ID = 'c00b91a1-6959-4b04-9ef8-e8d37b340f4a';
const GITHUB_USER_ID = '12345';
const INSTALLATION_ID = '98765';

function makeRequest(pathWithQuery: string) {
return new NextRequest(`http://localhost:3000${pathWithQuery}`);
}

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);
}

describe('GET /api/integrations/github/callback bot link flow', () => {
beforeEach(() => {
jest.clearAllMocks();

mockedGetUserFromAuth.mockResolvedValue({
user: { id: USER_ID },
authFailedResponse: null,
} as never);
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
mockedExchangeGitHubOAuthCode.mockResolvedValue({ id: GITHUB_USER_ID, login: 'octocat' });
mockedGithubUserIdentity.mockReturnValue({
platform: 'github',
teamId: 'user',
userId: GITHUB_USER_ID,
});
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
github_app_type: 'standard',
} as never);
mockedIsOrganizationMember.mockResolvedValue(true);
});

test('redirects unauthenticated bot-link callbacks to existing callback auth fallback', async () => {
mockedGetUserFromAuth.mockResolvedValue({
user: null,
authFailedResponse: NextResponse.json(failureResult('Unauthorized'), { status: 401 }),
} as never);

const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);

expect(response.status).toBe(307);
expectRedirectLocation(response, '/');
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('rejects invalid bot-link state without running installation callback logic', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue(null);

const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=bad') as never
);

expect(response.status).toBe(307);
expectRedirectLocation(response, '/');
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('rejects bot-link state user mismatches', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: OTHER_USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});

const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);

expect(response.status).toBe(403);
await expect(response.text()).resolves.toContain('started by another Kilo user');
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('rejects bot-link callbacks when the Kilo user cannot access the integration owner', async () => {
mockedIsOrganizationMember.mockResolvedValue(false);

const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);

expect(response.status).toBe(403);
await expect(response.text()).resolves.toContain(
'not a member of the organization that owns this GitHub integration'
);
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith('github', INSTALLATION_ID);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('links the OAuth-verified GitHub user through the existing app callback URL', async () => {
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);

expect(response.status).toBe(200);
await expect(response.text()).resolves.toContain('GitHub account octocat has been linked');
expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'standard');
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith('github', INSTALLATION_ID);
expect(mockedIsOrganizationMember).toHaveBeenCalledWith('org_1', USER_ID);
expect(mockedGithubUserIdentity).toHaveBeenCalledWith(GITHUB_USER_ID);
expect(mockedBot.initialize).toHaveBeenCalled();
expect(mockedLinkKiloUser).toHaveBeenCalledWith(
mockState,
{ platform: 'github', teamId: 'user', userId: GITHUB_USER_ID },
USER_ID
);
});

test("exchanges the OAuth code against the integration's github_app_type", async () => {
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
github_app_type: 'lite',
} as never);

const { GET } = await import('./route');
await GET(makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never);

expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite');
});
});
Loading