Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret
# GitLab OAuth - create at https://gitlab.com/-/profile/applications
GITLAB_CLIENT_ID=your-gitlab-client-id
GITLAB_CLIENT_SECRET=your-gitlab-client-secret
# Bitbucket Cloud OAuth consumer - create in Bitbucket workspace settings
BITBUCKET_CLIENT_ID=
BITBUCKET_CLIENT_SECRET=
# LinkedIn OAuth - create at https://www.linkedin.com/developers/apps
LINKEDIN_CLIENT_ID=your-linkedin-client-id
LINKEDIN_CLIENT_SECRET=your-linkedin-client-secret
Expand Down Expand Up @@ -141,6 +144,10 @@ CREDIT_CATEGORIES_ENCRYPTION_KEY=
USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID=
# Base64-encoded PEM public key; keep the matching private key only in git-token-service
USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY=
# Bitbucket OAuth credential envelope encryption (dedicated RSA public key only in Web)
BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_KEY_ID=
# Base64-encoded PEM public key; keep the matching private key only in git-token-service
BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_PUBLIC_KEY=
# Agent environment vars encryption (RSA public key, base64 encoded)
AGENT_ENV_VARS_PUBLIC_KEY=
# User deployments
Expand Down
15 changes: 15 additions & 0 deletions apps/web/.env.development.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ APP_BUILDER_URL=http://localhost:8790
# @url kiloclaw
KILOCLAW_API_URL=http://localhost:8795

# @url cloudflare-git-token-service
GIT_TOKEN_SERVICE_API_URL=http://localhost:8802

# @from BITBUCKET_CLIENT_ID
BITBUCKET_CLIENT_ID=

# @from BITBUCKET_CLIENT_SECRET
BITBUCKET_CLIENT_SECRET=

# @from BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_KEY_ID
BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_KEY_ID=

# @from BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_PUBLIC_KEY
BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_PUBLIC_KEY=

# @url cloudflare-session-ingest
SESSION_INGEST_WORKER_URL=http://localhost:8800

Expand Down
4 changes: 4 additions & 0 deletions apps/web/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ IS_IN_AUTOMATED_TEST=1
GITHUB_CLIENT_SECRET=dummy-test-github-client-secret
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
BITBUCKET_CLIENT_ID=
BITBUCKET_CLIENT_SECRET=
BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_KEY_ID=
BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_PUBLIC_KEY=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GITHUB_CLIENT_ID=dummy-test-github-client-id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export function OnboardingStepRepo() {
if (!repo) return;

const platform = repo.platform ?? 'github';
if (platform === 'bitbucket') return;
const gitlabInstanceUrl = (gitlabReposQuery.data as { instanceUrl?: string } | undefined)
?.instanceUrl;
const gitUrl = resolveGitUrlFromRepo(platform, fullName, gitlabInstanceUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function PlatformCard({ platform, githubIdentityStatus, onNavigate }: Pla

return (
<Card
className={`transition-all ${
className={`flex flex-col justify-between transition-all ${
platform.enabled ? 'cursor-pointer hover:shadow-md' : 'cursor-not-allowed opacity-60'
}`}
onClick={platform.enabled ? handleClick : undefined}
Expand Down
165 changes: 165 additions & 0 deletions apps/web/src/app/api/integrations/bitbucket/callback/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, test } from '@jest/globals';
import { captureException } from '@sentry/nextjs';
import { NextRequest } from 'next/server';
import { createOAuthState } from '@/lib/integrations/oauth-state';
import {
exchangeBitbucketOAuthCode,
fetchBitbucketUser,
fetchBitbucketWorkspaces,
type BitbucketOAuthTokens,
} from '@/lib/integrations/platforms/bitbucket/adapter';
import {
BitbucketIntegrationAuthorizationError,
storeBitbucketIntegration,
} from '@/lib/integrations/platforms/bitbucket/credentials';
import { scheduleBitbucketRepositoryCachePrime } from '@/lib/integrations/platforms/bitbucket/repository-cache';
import { getUserFromAuth } from '@/lib/user/server';

jest.mock('@/lib/user/server');
jest.mock('@/routers/organizations/utils', () => ({
ensureOrganizationAccess: jest.fn(),
}));
jest.mock('@/lib/integrations/platforms/bitbucket/adapter', () => ({
exchangeBitbucketOAuthCode: jest.fn(),
fetchBitbucketUser: jest.fn(),
fetchBitbucketWorkspaces: jest.fn(),
}));
jest.mock('@/lib/integrations/platforms/bitbucket/credentials', () => ({
BitbucketIntegrationAuthorizationError: class BitbucketIntegrationAuthorizationError extends Error {},
storeBitbucketIntegration: jest.fn(),
}));
jest.mock('@/lib/integrations/platforms/bitbucket/repository-cache', () => ({
scheduleBitbucketRepositoryCachePrime: jest.fn(),
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
}));

const mockedCaptureException = jest.mocked(captureException);
const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedExchangeBitbucketOAuthCode = jest.mocked(exchangeBitbucketOAuthCode);
const mockedFetchBitbucketUser = jest.mocked(fetchBitbucketUser);
const mockedFetchBitbucketWorkspaces = jest.mocked(fetchBitbucketWorkspaces);
const mockedStoreBitbucketIntegration = jest.mocked(storeBitbucketIntegration);
const mockedScheduleBitbucketRepositoryCachePrime = jest.mocked(
scheduleBitbucketRepositoryCachePrime
);

const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847';
const ORGANIZATION_ID = '7e3011af-e99d-444f-8171-54c2225b87dc';
const BITBUCKET_TOKENS = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
tokenType: 'bearer',
expiresIn: 3600,
scopes: ['account', 'email', 'repository', 'repository:write'],
} satisfies BitbucketOAuthTokens;
const BITBUCKET_USER = {
uuid: '{bitbucket-user}',
nickname: 'bucket-user',
displayName: 'Bucket User',
};
const WORKSPACE = {
uuid: '{workspace-one}',
slug: 'workspace-one',
name: 'Workspace One',
};

function makeRequest(state: string) {
return new NextRequest(
`http://localhost:3000/api/integrations/bitbucket/callback?code=authorization-code&state=${encodeURIComponent(state)}`
);
}

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

async function callBitbucketCallback(request: NextRequest) {
const { GET } = await import('../../[platform]/callback/route');
return GET(request, { params: Promise.resolve({ platform: 'bitbucket' }) });
}

describe('GET /api/integrations/bitbucket/callback', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetUserFromAuth.mockResolvedValue({
user: { id: USER_ID },
authFailedResponse: null,
} as never);
mockedExchangeBitbucketOAuthCode.mockResolvedValue(BITBUCKET_TOKENS);
mockedFetchBitbucketUser.mockResolvedValue(BITBUCKET_USER);
});

test('redirects a single-workspace integration as connected', async () => {
mockedFetchBitbucketWorkspaces.mockResolvedValue([WORKSPACE]);
mockedStoreBitbucketIntegration.mockResolvedValue({
status: 'connected',
integrationId: 'integration-id',
});
const state = createOAuthState(`user_${USER_ID}`, USER_ID);

const response = await callBitbucketCallback(makeRequest(state));

expectRedirectLocation(response, '/integrations/bitbucket?success=connected');
expect(mockedStoreBitbucketIntegration).toHaveBeenCalledWith(
expect.objectContaining({ availableWorkspaces: [WORKSPACE] })
);
expect(mockedScheduleBitbucketRepositoryCachePrime).toHaveBeenCalledWith({
owner: { type: 'user', id: USER_ID },
kiloUserId: USER_ID,
integrationId: 'integration-id',
});
});

test('redirects multiple workspaces to explicit selection', async () => {
const secondWorkspace = {
uuid: '{workspace-two}',
slug: 'workspace-two',
name: 'Workspace Two',
};
mockedFetchBitbucketWorkspaces.mockResolvedValue([WORKSPACE, secondWorkspace]);
mockedStoreBitbucketIntegration.mockResolvedValue({
status: 'workspace_selection_required',
integrationId: 'integration-id',
});
const state = createOAuthState(`user_${USER_ID}`, USER_ID);

const response = await callBitbucketCallback(makeRequest(state));

expectRedirectLocation(
response,
'/integrations/bitbucket?success=workspace_selection_required'
);
});

test('does not replace an integration when no workspaces are available', async () => {
mockedFetchBitbucketWorkspaces.mockResolvedValue([]);
const state = createOAuthState(`user_${USER_ID}`, USER_ID);

const response = await callBitbucketCallback(makeRequest(state));

expectRedirectLocation(response, '/integrations/bitbucket?error=no_workspaces');
expect(mockedStoreBitbucketIntegration).not.toHaveBeenCalled();
});

test('reports authorization revoked during storage as unauthorized', async () => {
mockedFetchBitbucketWorkspaces.mockResolvedValue([WORKSPACE]);
mockedStoreBitbucketIntegration.mockRejectedValue(
new BitbucketIntegrationAuthorizationError('authorization revoked')
);
const state = createOAuthState(`org_${ORGANIZATION_ID}`, USER_ID);

const response = await callBitbucketCallback(makeRequest(state));

expectRedirectLocation(
response,
`/organizations/${ORGANIZATION_ID}/integrations/bitbucket?error=unauthorized`
);
expect(mockedCaptureException).not.toHaveBeenCalled();
});
});
5 changes: 4 additions & 1 deletion apps/web/src/app/collab/_components/setup-status.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { PlatformId } from './platforms';

type SetupStatusPlatformId = Exclude<PlatformId, 'microsoft-teams' | 'google-chat'> | 'dolthub';
type SetupStatusPlatformId =
| Exclude<PlatformId, 'microsoft-teams' | 'google-chat'>
| 'bitbucket'
| 'dolthub';

export type PlatformInstallation = {
platform: SetupStatusPlatformId;
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/auth/BitbucketLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { SVGProps } from 'react';

export function BitbucketLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="M2.04 3.08A1 1 0 0 1 3.03 2h17.94a1 1 0 0 1 .99 1.08l-1.88 17a1 1 0 0 1-.99.89H4.91a1 1 0 0 1-.99-.89l-1.88-17ZM14.86 15.7l.71-7.4H8.4l.71 7.4h5.75Zm-4.02-5.44h2.32l-.25 3.49h-1.82l-.25-3.49Z"
/>
</svg>
);
}
27 changes: 15 additions & 12 deletions apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,21 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi
},

respondToPermission: async payload => {
const trpc = organizationId
? trpcClient.organizations.cloudAgentNext
: trpcClient.cloudAgentNext;
await trpc.answerPermission.mutate(
{
...(organizationId ? { organizationId } : {}),
sessionId: payload.sessionId,
permissionId: payload.requestId,
response: payload.response,
},
{ context: { skipBatch: true } }
);
const input = {
sessionId: payload.sessionId,
permissionId: payload.requestId,
response: payload.response,
};
if (organizationId) {
await trpcClient.organizations.cloudAgentNext.answerPermission.mutate(
{ ...input, organizationId },
{ context: { skipBatch: true } }
);
return;
}
await trpcClient.cloudAgentNext.answerPermission.mutate(input, {
context: { skipBatch: true },
});
},
},

Expand Down
Loading