Skip to content
Merged
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,10 @@ export function OnboardingStepRepo() {
if (!repo) return;

const platform = repo.platform ?? 'github';
if (platform === 'bitbucket') {
// TODO: Add Bitbucket support to Gastown.
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
210 changes: 210 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,210 @@
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,
BitbucketIntegrationConnectionConflictError,
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 {},
BitbucketIntegrationConnectionConflictError: class BitbucketIntegrationConnectionConflictError 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', 'pullrequest', 'repository', 'repository:write', 'webhook'],
} 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 callBitbucketCallbackImplementation(request: NextRequest) {
const { handleBitbucketOAuthCallback } =
await import('@/lib/integrations/oauth/platforms/bitbucket-callback');
return handleBitbucketOAuthCallback(request);
}

async function callPublicBitbucketCallback(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('dispatches the public callback through the Bitbucket OAuth implementation', async () => {
mockedFetchBitbucketWorkspaces.mockResolvedValue([WORKSPACE]);
mockedStoreBitbucketIntegration.mockResolvedValue({
status: 'connected',
integrationId: 'integration-id',
});
const state = createOAuthState(`user_${USER_ID}`, USER_ID);

const response = await callPublicBitbucketCallback(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('keeps the implementation directly testable for personal support', async () => {
mockedFetchBitbucketWorkspaces.mockResolvedValue([WORKSPACE]);
mockedStoreBitbucketIntegration.mockResolvedValue({
status: 'connected',
integrationId: 'integration-id',
});
const state = createOAuthState(`user_${USER_ID}`, USER_ID);

const response = await callBitbucketCallbackImplementation(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 callBitbucketCallbackImplementation(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 callBitbucketCallbackImplementation(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 callBitbucketCallbackImplementation(makeRequest(state));

expectRedirectLocation(
response,
`/organizations/${ORGANIZATION_ID}/integrations/bitbucket?error=unauthorized`
);
expect(mockedCaptureException).not.toHaveBeenCalled();
});

test('reports an existing Bitbucket connection without replacing it', async () => {
mockedFetchBitbucketWorkspaces.mockResolvedValue([WORKSPACE]);
mockedStoreBitbucketIntegration.mockRejectedValue(
new BitbucketIntegrationConnectionConflictError()
);
const state = createOAuthState(`org_${ORGANIZATION_ID}`, USER_ID);

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

expectRedirectLocation(
response,
`/organizations/${ORGANIZATION_ID}/integrations/bitbucket?error=connection_exists`
);
expect(mockedCaptureException).not.toHaveBeenCalled();
});
});
72 changes: 72 additions & 0 deletions apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, test } from '@jest/globals';
import { NextRequest } from 'next/server';
import { getUserFromAuth } from '@/lib/user/server';
import { verifyOAuthState } from '@/lib/integrations/oauth-state';
import { ensureOrganizationAccess } from '@/routers/organizations/utils';

jest.mock('@/lib/config.server', () => ({
BITBUCKET_CLIENT_ID: 'bitbucket-client-id',
NEXTAUTH_SECRET: 'test-nextauth-secret',
}));
jest.mock('@/lib/user/server');
jest.mock('@/routers/organizations/utils', () => ({
ensureOrganizationAccess: jest.fn(),
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
}));

const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedEnsureOrganizationAccess = jest.mocked(ensureOrganizationAccess);
const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847';
const ORGANIZATION_ID = '7e3011af-e99d-444f-8171-54c2225b87dc';

async function callPublicBitbucketConnect() {
const { GET } = await import('../../[platform]/connect/route');
return GET(
new NextRequest(
`http://localhost:3000/api/integrations/bitbucket/connect?organizationId=${ORGANIZATION_ID}&returnTo=%2Forganizations%2F${ORGANIZATION_ID}%2Fintegrations%2Fbitbucket`
),
{
params: Promise.resolve({ platform: 'bitbucket' }),
}
);
}

describe('GET /api/integrations/bitbucket/connect', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetUserFromAuth.mockResolvedValue({
user: { id: USER_ID },
authFailedResponse: null,
} as never);
mockedEnsureOrganizationAccess.mockResolvedValue('owner');
});

test('dispatches the public OAuth route with organization ownership and required scopes', async () => {
const response = await callPublicBitbucketConnect();

expect(response.status).toBe(307);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const url = new URL(location ?? '');
expect(`${url.origin}${url.pathname}`).toBe('https://bitbucket.org/site/oauth2/authorize');
expect(Object.fromEntries(url.searchParams)).toEqual(
expect.objectContaining({
client_id: 'bitbucket-client-id',
response_type: 'code',
scope: 'account repository:write pullrequest webhook',
})
);
expect(verifyOAuthState(url.searchParams.get('state'))).toEqual({
owner: `org_${ORGANIZATION_ID}`,
userId: USER_ID,
returnTo: `/organizations/${ORGANIZATION_ID}/integrations/bitbucket`,
});
expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith(
{ user: expect.objectContaining({ id: USER_ID }) },
ORGANIZATION_ID,
['owner', 'billing_manager']
);
});
});
14 changes: 2 additions & 12 deletions apps/web/src/app/collab/_components/setup-status.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import type { PlatformIntegrationSetupStatus } from '@/lib/integrations/platform-integration-setup-status';
import type { PlatformId } from './platforms';

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

export type PlatformInstallation = {
platform: SetupStatusPlatformId;
installed: boolean;
installation: {
accountLogin?: string | null;
guildName?: string | null;
teamName?: string | null;
workspaceName?: string | null;
} | null;
};
export type PlatformInstallation = PlatformIntegrationSetupStatus;

export type PlatformInstallationQueryState = {
data: readonly PlatformInstallation[] | undefined;
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>
);
}
Loading