feat(enterprise): Bitbucket Data Center Integration (#13228)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Joe Laverty
2026-03-06 11:49:20 -05:00
committed by GitHub
parent b0cdd0358f
commit ede203add3
15 changed files with 764 additions and 0 deletions

View File

@@ -1772,6 +1772,40 @@
"sendIdTokenOnLogout": "true",
"passMaxAge": "false"
}
},
{
"alias": "bitbucket_data_center",
"displayName": "Bitbucket Data Center",
"internalId": "b77b4ead-20e8-451c-ad27-99f92d561616",
"providerId": "oauth2",
"enabled": true,
"updateProfileFirstLoginMode": "on",
"trustEmail": true,
"storeToken": true,
"addReadTokenRoleOnCreate": false,
"authenticateByDefault": false,
"linkOnly": false,
"hideOnLogin": false,
"config": {
"givenNameClaim": "given_name",
"userInfoUrl": "https://${WEB_HOST}/bitbucket-dc-proxy/oauth2/userinfo",
"clientId": "$BITBUCKET_DATA_CENTER_CLIENT_ID",
"tokenUrl": "https://${BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/token",
"acceptsPromptNoneForwardFromClient": "false",
"fullNameClaim": "name",
"userIDClaim": "sub",
"emailClaim": "email",
"userNameClaim": "preferred_username",
"caseSensitiveOriginalUsername": "false",
"familyNameClaim": "family_name",
"pkceEnabled": "false",
"authorizationUrl": "https://${BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/authorize",
"clientAuthMethod": "client_secret_post",
"syncMode": "IMPORT",
"clientSecret": "$BITBUCKET_DATA_CENTER_CLIENT_SECRET",
"allowedClockSkew": "0",
"defaultScope": "REPO_WRITE"
}
}
],
"identityProviderMappers": [
@@ -1829,6 +1863,26 @@
"syncMode": "FORCE",
"attribute": "identity_provider"
}
},
{
"name": "id-mapper",
"identityProviderAlias": "bitbucket_data_center",
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
"config": {
"syncMode": "FORCE",
"claim": "sub",
"user.attribute": "bitbucket_data_center_id"
}
},
{
"name": "identity-provider",
"identityProviderAlias": "bitbucket_data_center",
"identityProviderMapper": "hardcoded-attribute-idp-mapper",
"config": {
"attribute.value": "bitbucket_data_center",
"syncMode": "FORCE",
"attribute": "identity_provider"
}
}
],
"components": {

View File

@@ -109,6 +109,9 @@ lines.append(
lines.append(
'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService'
)
lines.append(
'OPENHANDS_BITBUCKET_DATA_CENTER_SERVICE_CLS=integrations.bitbucket_data_center.bitbucket_dc_service.SaaSBitbucketDCService'
)
lines.append(
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
)

View File

@@ -0,0 +1,65 @@
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import (
BitbucketDCService,
)
from openhands.integrations.service_types import ProviderType
class SaaSBitbucketDCService(BitbucketDCService):
def __init__(
self,
user_id: str | None = None,
external_auth_token: SecretStr | None = None,
external_auth_id: str | None = None,
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
logger.debug(
f'SaaSBitbucketDCService created with user_id {user_id}, external_auth_id {external_auth_id}, external_auth_token {'set' if external_auth_token else 'None'}, token {'set' if token else 'None'}, external_token_manager {external_token_manager}'
)
super().__init__(
user_id=user_id,
external_auth_token=external_auth_token,
external_auth_id=external_auth_id,
token=token,
external_token_manager=external_token_manager,
base_domain=base_domain,
)
self.token_manager = TokenManager(external=external_token_manager)
self.refresh = True
async def get_latest_token(self) -> SecretStr | None:
bitbucket_dc_token = None
if self.external_auth_token:
bitbucket_dc_token = SecretStr(
await self.token_manager.get_idp_token(
self.external_auth_token.get_secret_value(),
idp=ProviderType.BITBUCKET_DATA_CENTER,
)
)
logger.debug('Got Bitbucket DC token via external_auth_token')
elif self.external_auth_id:
offline_token = await self.token_manager.load_offline_token(
self.external_auth_id
)
bitbucket_dc_token = SecretStr(
await self.token_manager.get_idp_token_from_offline_token(
offline_token, ProviderType.BITBUCKET_DATA_CENTER
)
)
logger.debug('Got Bitbucket DC token via external_auth_id')
elif self.user_id:
bitbucket_dc_token = SecretStr(
await self.token_manager.get_idp_token_from_idp_user_id(
self.user_id, ProviderType.BITBUCKET_DATA_CENTER
)
)
logger.debug('Got Bitbucket DC token via user_id')
else:
logger.warning('external_auth_token and user_id not set!')
return bitbucket_dc_token

View File

@@ -14,6 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
from server.auth.auth_error import ExpiredError, NoCredentialsError # noqa: E402
from server.auth.constants import ( # noqa: E402
BITBUCKET_DATA_CENTER_HOST,
ENABLE_JIRA,
ENABLE_JIRA_DC,
ENABLE_LINEAR,
@@ -130,6 +131,12 @@ if ENABLE_JIRA_DC:
base_app.include_router(jira_dc_integration_router)
if ENABLE_LINEAR:
base_app.include_router(linear_integration_router)
if BITBUCKET_DATA_CENTER_HOST:
from server.routes.bitbucket_dc_proxy import (
router as bitbucket_dc_proxy_router, # noqa: E402
)
base_app.include_router(bitbucket_dc_proxy_router)
base_app.include_router(email_router) # Add routes for email management
base_app.include_router(feedback_router) # Add routes for conversation feedback
base_app.include_router(

View File

@@ -40,6 +40,16 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
)
DUPLICATE_EMAIL_CHECK = os.getenv('DUPLICATE_EMAIL_CHECK', 'true') in ('1', 'true')
BITBUCKET_DATA_CENTER_CLIENT_ID = os.getenv(
'BITBUCKET_DATA_CENTER_CLIENT_ID', ''
).strip()
BITBUCKET_DATA_CENTER_CLIENT_SECRET = os.getenv(
'BITBUCKET_DATA_CENTER_CLIENT_SECRET', ''
).strip()
BITBUCKET_DATA_CENTER_HOST = os.getenv('BITBUCKET_DATA_CENTER_HOST', '').strip()
BITBUCKET_DATA_CENTER_TOKEN_URL = (
f'https://{BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/token'
)
# reCAPTCHA Enterprise
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()

View File

@@ -13,6 +13,7 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
from server.auth.token_manager import TokenManager
from server.config import get_config
from server.logger import logger
@@ -177,6 +178,9 @@ class SaasUserAuth(UserAuth):
if user_secrets and idp_type in user_secrets.provider_tokens:
host = user_secrets.provider_tokens[idp_type].host
if idp_type == ProviderType.BITBUCKET_DATA_CENTER and not host:
host = BITBUCKET_DATA_CENTER_HOST or None
provider_token = await token_manager.get_idp_token(
access_token.get_secret_value(),
idp=idp_type,

View File

@@ -21,6 +21,10 @@ from server.auth.auth_error import ExpiredError
from server.auth.constants import (
BITBUCKET_APP_CLIENT_ID,
BITBUCKET_APP_CLIENT_SECRET,
BITBUCKET_DATA_CENTER_CLIENT_ID,
BITBUCKET_DATA_CENTER_CLIENT_SECRET,
BITBUCKET_DATA_CENTER_HOST,
BITBUCKET_DATA_CENTER_TOKEN_URL,
DUPLICATE_EMAIL_CHECK,
GITHUB_APP_CLIENT_ID,
GITHUB_APP_CLIENT_SECRET,
@@ -379,6 +383,8 @@ class TokenManager:
return await self._refresh_gitlab_token(refresh_token)
elif idp == ProviderType.BITBUCKET:
return await self._refresh_bitbucket_token(refresh_token)
elif idp == ProviderType.BITBUCKET_DATA_CENTER:
return await self._refresh_bitbucket_data_center_token(refresh_token)
else:
raise ValueError(f'Unsupported IDP: {idp}')
@@ -460,6 +466,33 @@ class TokenManager:
data = response.json()
return await self._parse_refresh_response(data)
async def _refresh_bitbucket_data_center_token(
self, refresh_token: str
) -> dict[str, str | int]:
if not BITBUCKET_DATA_CENTER_HOST:
raise ValueError(
'BITBUCKET_DATA_CENTER_HOST is not configured. '
'Set the BITBUCKET_DATA_CENTER_HOST environment variable.'
)
url = BITBUCKET_DATA_CENTER_TOKEN_URL
logger.info(f'Refreshing Bitbucket Data Center token with URL: {url}')
payload = {
'client_id': BITBUCKET_DATA_CENTER_CLIENT_ID,
'client_secret': BITBUCKET_DATA_CENTER_CLIENT_SECRET,
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed Bitbucket Data Center token')
data = response.json()
return await self._parse_refresh_response(data)
async def _parse_refresh_response(self, data: dict) -> dict[str, str | int]:
access_token = data.get('access_token')
refresh_token = data.get('refresh_token')

View File

@@ -9,6 +9,7 @@ import requests # type: ignore
from fastapi import HTTPException
from server.auth.constants import (
BITBUCKET_APP_CLIENT_ID,
BITBUCKET_DATA_CENTER_CLIENT_ID,
ENABLE_ENTERPRISE_SSO,
ENABLE_JIRA,
ENABLE_JIRA_DC,
@@ -164,6 +165,9 @@ class SaaSServerConfig(ServerConfig):
if ENABLE_ENTERPRISE_SSO:
providers_configured.append(ProviderType.ENTERPRISE_SSO)
if BITBUCKET_DATA_CENTER_CLIENT_ID:
providers_configured.append(ProviderType.BITBUCKET_DATA_CENTER)
config: dict[str, typing.Any] = {
'APP_MODE': self.app_mode,
'APP_SLUG': self.app_slug,

View File

@@ -0,0 +1,63 @@
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
from openhands.utils.http_session import httpx_verify_option
router = APIRouter(prefix='/bitbucket-dc-proxy')
BITBUCKET_DC_TIMEOUT = 10 # seconds
# Bitbucket Data Center is not an OIDC provider, so keycloak
# can't retrieve user info from it directly.
# This endpoint proxies requests to bitbucket data center to get user info
# given a Bitbucket Data Center access token. Keycloak
# is configured to use this endpoint as the User Info Endpoint
# for the Bitbucket Data Center OIDC provider.
@router.get('/oauth2/userinfo')
async def userinfo(request: Request):
if not BITBUCKET_DATA_CENTER_HOST:
raise ValueError('BITBUCKET_DATA_CENTER_HOST must be configured')
bitbucket_base_url = f'https://{BITBUCKET_DATA_CENTER_HOST}'
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return JSONResponse({'error': 'missing_token'}, status_code=401)
headers = {'Authorization': auth_header}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
# Step 1: get username
whoami_resp = await client.get(
f'{bitbucket_base_url}/plugins/servlet/applinks/whoami',
headers=headers,
timeout=BITBUCKET_DC_TIMEOUT,
)
if whoami_resp.status_code != 200:
return JSONResponse({'error': 'not_authenticated'}, status_code=401)
username = whoami_resp.text.strip()
if not username:
return JSONResponse({'error': 'not_authenticated'}, status_code=401)
# Step 2: get user details
user_resp = await client.get(
f'{bitbucket_base_url}/rest/api/latest/users/{username}',
headers=headers,
timeout=BITBUCKET_DC_TIMEOUT,
)
if user_resp.status_code != 200:
return JSONResponse(
{'error': f'bitbucket_error: {user_resp.status_code}'},
status_code=user_resp.status_code,
)
user_data = user_resp.json()
return JSONResponse(
{
'sub': str(user_data.get('id', username)),
'preferred_username': user_data.get('name', username),
'name': user_data.get('displayName', username),
'email': user_data.get('emailAddress', ''),
}
)

View File

@@ -0,0 +1,132 @@
"""Unit tests for SaaSBitbucketDCService."""
from unittest.mock import AsyncMock, patch
import pytest
from integrations.bitbucket_data_center.bitbucket_dc_service import (
SaaSBitbucketDCService,
)
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
@pytest.fixture
def service():
return SaaSBitbucketDCService()
@pytest.fixture
def service_with_external_auth_token():
return SaaSBitbucketDCService(external_auth_token=SecretStr('test_keycloak_token'))
@pytest.fixture
def service_with_external_auth_id():
return SaaSBitbucketDCService(external_auth_id='test_user_id')
@pytest.fixture
def service_with_user_id():
return SaaSBitbucketDCService(user_id='test_user_id')
class TestSaaSBitbucketDCServiceInit:
def test_refresh_flag_is_true(self):
# self.refresh = True is required so the base class BitbucketDCService
# retries the request with a refreshed token on 401 responses.
# See openhands/integrations/bitbucket_data_center/service/base.py,
# which checks `if self.refresh` before attempting the retry.
service = SaaSBitbucketDCService()
assert service.refresh is True
def test_token_manager_is_created(self):
service = SaaSBitbucketDCService()
assert isinstance(service.token_manager, TokenManager)
def test_external_token_manager_flag_passed(self):
service = SaaSBitbucketDCService(external_token_manager=True)
assert service.token_manager.external is True
class TestGetLatestToken:
@pytest.mark.asyncio
async def test_get_latest_token_with_external_auth_token(
self, service_with_external_auth_token
):
expected_token = 'test_bitbucket_dc_token'
with patch.object(
service_with_external_auth_token.token_manager,
'get_idp_token',
new_callable=AsyncMock,
return_value=expected_token,
):
token = await service_with_external_auth_token.get_latest_token()
assert token is not None
assert token.get_secret_value() == expected_token
@pytest.mark.asyncio
async def test_get_latest_token_with_external_auth_id(
self, service_with_external_auth_id
):
offline_token = 'test_offline_token'
expected_token = 'test_bitbucket_dc_token'
with patch.object(
service_with_external_auth_id.token_manager,
'load_offline_token',
new_callable=AsyncMock,
return_value=offline_token,
), patch.object(
service_with_external_auth_id.token_manager,
'get_idp_token_from_offline_token',
new_callable=AsyncMock,
return_value=expected_token,
):
token = await service_with_external_auth_id.get_latest_token()
assert token is not None
assert token.get_secret_value() == expected_token
@pytest.mark.asyncio
async def test_get_latest_token_with_user_id(self, service_with_user_id):
expected_token = 'test_bitbucket_dc_token'
with patch.object(
service_with_user_id.token_manager,
'get_idp_token_from_idp_user_id',
new_callable=AsyncMock,
return_value=expected_token,
):
token = await service_with_user_id.get_latest_token()
assert token is not None
assert token.get_secret_value() == expected_token
@pytest.mark.asyncio
async def test_get_latest_token_no_auth_returns_none(self, service):
token = await service.get_latest_token()
assert token is None
@pytest.mark.asyncio
async def test_get_latest_token_external_auth_token_priority(self):
"""external_auth_token takes priority over external_auth_id."""
expected_token = 'test_bitbucket_dc_token'
service = SaaSBitbucketDCService(
external_auth_token=SecretStr('test_keycloak_token'),
external_auth_id='test_user_id',
)
with patch.object(
service.token_manager,
'get_idp_token',
new_callable=AsyncMock,
return_value=expected_token,
) as mock_get_idp_token, patch.object(
service.token_manager,
'load_offline_token',
new_callable=AsyncMock,
) as mock_load_offline:
token = await service.get_latest_token()
assert token is not None
assert token.get_secret_value() == expected_token
mock_get_idp_token.assert_called_once()
mock_load_offline.assert_not_called()

View File

@@ -0,0 +1,182 @@
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from server.routes.bitbucket_dc_proxy import router
@pytest.fixture
def client():
app = FastAPI()
app.include_router(router)
with patch(
'server.routes.bitbucket_dc_proxy.BITBUCKET_DATA_CENTER_HOST', 'bitbucket.test'
):
yield TestClient(app)
def test_missing_authorization_header(client):
response = client.get('/bitbucket-dc-proxy/oauth2/userinfo')
assert response.status_code == 401
assert response.json() == {'error': 'missing_token'}
def test_non_bearer_scheme(client):
response = client.get(
'/bitbucket-dc-proxy/oauth2/userinfo',
headers={'Authorization': 'Basic xyz'},
)
assert response.status_code == 401
assert response.json() == {'error': 'missing_token'}
def test_whoami_non_200(client):
whoami_resp = MagicMock()
whoami_resp.status_code = 403
with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[whoami_resp])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get(
'/bitbucket-dc-proxy/oauth2/userinfo',
headers={'Authorization': 'Bearer some_token'},
)
assert response.status_code == 401
assert response.json() == {'error': 'not_authenticated'}
def test_whoami_empty_body(client):
whoami_resp = MagicMock()
whoami_resp.status_code = 200
whoami_resp.text = ' '
with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[whoami_resp])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get(
'/bitbucket-dc-proxy/oauth2/userinfo',
headers={'Authorization': 'Bearer some_token'},
)
assert response.status_code == 401
assert response.json() == {'error': 'not_authenticated'}
def test_user_details_non_200(client):
whoami_resp = MagicMock()
whoami_resp.status_code = 200
whoami_resp.text = 'testuser'
user_resp = MagicMock()
user_resp.status_code = 404
with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[whoami_resp, user_resp])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get(
'/bitbucket-dc-proxy/oauth2/userinfo',
headers={'Authorization': 'Bearer some_token'},
)
assert response.status_code == 404
assert response.json() == {'error': 'bitbucket_error: 404'}
def test_happy_path_full_user_data(client):
whoami_resp = MagicMock()
whoami_resp.status_code = 200
whoami_resp.text = 'jsmith'
user_resp = MagicMock()
user_resp.status_code = 200
user_resp.json.return_value = {
'id': 42,
'name': 'jsmith',
'displayName': 'John Smith',
'emailAddress': 'john@example.com',
}
with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[whoami_resp, user_resp])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get(
'/bitbucket-dc-proxy/oauth2/userinfo',
headers={'Authorization': 'Bearer some_token'},
)
assert response.status_code == 200
data = response.json()
assert data['sub'] == '42'
assert data['preferred_username'] == 'jsmith'
assert data['name'] == 'John Smith'
assert data['email'] == 'john@example.com'
mock_client.get.assert_has_calls(
[
call(
'https://bitbucket.test/plugins/servlet/applinks/whoami',
headers={'Authorization': 'Bearer some_token'},
timeout=10,
),
call(
'https://bitbucket.test/rest/api/latest/users/jsmith',
headers={'Authorization': 'Bearer some_token'},
timeout=10,
),
]
)
def test_happy_path_missing_id_falls_back_to_username(client):
whoami_resp = MagicMock()
whoami_resp.status_code = 200
whoami_resp.text = 'jsmith'
user_resp = MagicMock()
user_resp.status_code = 200
user_resp.json.return_value = {
'name': 'jsmith',
'displayName': 'John Smith',
'emailAddress': 'john@example.com',
}
with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[whoami_resp, user_resp])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get(
'/bitbucket-dc-proxy/oauth2/userinfo',
headers={'Authorization': 'Bearer some_token'},
)
assert response.status_code == 200
assert response.json()['sub'] == 'jsmith'
mock_client.get.assert_has_calls(
[
call(
'https://bitbucket.test/plugins/servlet/applinks/whoami',
headers={'Authorization': 'Bearer some_token'},
timeout=10,
),
call(
'https://bitbucket.test/rest/api/latest/users/jsmith',
headers={'Authorization': 'Bearer some_token'},
timeout=10,
),
]
)

View File

@@ -21,6 +21,7 @@ from server.auth.saas_user_auth import (
from storage.user_authorization import UserAuthorizationType
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.storage.data_models.secrets import Secrets
@pytest.fixture
@@ -238,6 +239,107 @@ async def test_get_provider_tokens(mock_token_manager):
pass
class TestGetProviderTokensBitbucketDCHost:
"""Tests for Bitbucket DC host fallback from BITBUCKET_DATA_CENTER_HOST."""
def _make_auth_token(self):
mock_token = MagicMock()
mock_token.identity_provider = 'bitbucket_data_center'
mock_token.id = 'token-id-1'
return mock_token
def _make_user_auth(self, mock_session_maker):
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [self._make_auth_token()]
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session_maker.return_value = mock_session
access_payload = {'sub': 'test_user_id', 'exp': int(time.time()) + 3600}
access_token = jwt.encode(access_payload, 'secret', algorithm='HS256')
user_auth = SaasUserAuth(
user_id='test_user_id',
refresh_token=SecretStr('refresh_token'),
access_token=SecretStr(access_token),
)
return user_auth, mock_session
@pytest.mark.asyncio
async def test_host_derived_from_token_url(self):
"""host is populated from BITBUCKET_DATA_CENTER_HOST when user secrets lack it."""
with (
patch('server.auth.saas_user_auth.token_manager') as mock_tm,
patch('server.auth.saas_user_auth.a_session_maker') as mock_session_maker,
patch(
'server.auth.saas_user_auth.BITBUCKET_DATA_CENTER_HOST',
'bitbucket.company.com',
),
):
mock_tm.get_idp_token = AsyncMock(return_value='bdc_access_token')
user_auth, mock_session = self._make_user_auth(mock_session_maker)
user_auth.get_secrets = AsyncMock(return_value=None)
result = await user_auth.get_provider_tokens()
assert ProviderType.BITBUCKET_DATA_CENTER in result
assert (
result[ProviderType.BITBUCKET_DATA_CENTER].host == 'bitbucket.company.com'
)
mock_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_host_from_user_secrets_takes_priority(self):
"""User-configured host in secrets takes priority over the HOST fallback."""
with (
patch('server.auth.saas_user_auth.token_manager') as mock_tm,
patch('server.auth.saas_user_auth.a_session_maker') as mock_session_maker,
patch(
'server.auth.saas_user_auth.BITBUCKET_DATA_CENTER_HOST',
'bitbucket.company.com',
),
):
mock_tm.get_idp_token = AsyncMock(return_value='bdc_access_token')
user_auth, mock_session = self._make_user_auth(mock_session_maker)
user_secrets = Secrets(
provider_tokens={
ProviderType.BITBUCKET_DATA_CENTER: ProviderToken(
token=SecretStr('existing_token'),
host='custom.bitbucket.host',
)
}
)
user_auth.get_secrets = AsyncMock(return_value=user_secrets)
result = await user_auth.get_provider_tokens()
assert ProviderType.BITBUCKET_DATA_CENTER in result
assert (
result[ProviderType.BITBUCKET_DATA_CENTER].host == 'custom.bitbucket.host'
)
mock_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_host_remains_none_when_host_empty(self):
"""host stays None when BITBUCKET_DATA_CENTER_HOST is empty."""
with (
patch('server.auth.saas_user_auth.token_manager') as mock_tm,
patch('server.auth.saas_user_auth.a_session_maker') as mock_session_maker,
patch('server.auth.saas_user_auth.BITBUCKET_DATA_CENTER_HOST', ''),
):
mock_tm.get_idp_token = AsyncMock(return_value='bdc_access_token')
user_auth, mock_session = self._make_user_auth(mock_session_maker)
user_auth.get_secrets = AsyncMock(return_value=None)
result = await user_auth.get_provider_tokens()
assert ProviderType.BITBUCKET_DATA_CENTER in result
assert result[ProviderType.BITBUCKET_DATA_CENTER].host is None
mock_session.execute.assert_called_once()
@pytest.mark.asyncio
async def test_get_provider_tokens_cached(mock_token_manager):
"""Test that get_provider_tokens returns cached tokens if available."""

View File

@@ -362,6 +362,111 @@ async def test_disable_keycloak_user_exception_handling(token_manager):
mock_admin.a_get_user.assert_called_once_with(user_id)
class TestRefreshBitbucketDataCenterToken:
"""Tests for the _refresh_bitbucket_data_center_token code path."""
@pytest.mark.asyncio
async def test_happy_path(self, token_manager):
"""Credentials are sent in the POST body (not Basic auth); response is parsed."""
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
'access_token': 'new_bbs_access',
'refresh_token': 'new_bbs_refresh',
'expires_in': 3600,
'refresh_token_expires_in': 86400,
}
with (
patch(
'server.auth.token_manager.BITBUCKET_DATA_CENTER_HOST',
'bitbucket.example.com',
),
patch(
'server.auth.token_manager.BITBUCKET_DATA_CENTER_TOKEN_URL',
'https://bitbucket.example.com/oauth2/token',
),
patch(
'server.auth.token_manager.BITBUCKET_DATA_CENTER_CLIENT_ID',
'test_client_id',
),
patch(
'server.auth.token_manager.BITBUCKET_DATA_CENTER_CLIENT_SECRET',
'test_client_secret',
),
patch('httpx.AsyncClient') as mock_client_cls,
):
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(
return_value=mock_client
)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
result = await token_manager._refresh_bitbucket_data_center_token(
'old_refresh_token'
)
# Credentials are sent in the POST body, not in a Basic-auth header
mock_client.post.assert_called_once_with(
'https://bitbucket.example.com/oauth2/token',
data={
'client_id': 'test_client_id',
'client_secret': 'test_client_secret',
'refresh_token': 'old_refresh_token',
'grant_type': 'refresh_token',
},
)
# Response is parsed correctly
assert result['access_token'] == 'new_bbs_access'
assert result['refresh_token'] == 'new_bbs_refresh'
@pytest.mark.asyncio
async def test_empty_url_raises_value_error(self, token_manager):
"""When BITBUCKET_DATA_CENTER_HOST is not set, ValueError is raised immediately."""
with patch('server.auth.token_manager.BITBUCKET_DATA_CENTER_HOST', ''):
with pytest.raises(ValueError, match='BITBUCKET_DATA_CENTER_HOST'):
await token_manager._refresh_bitbucket_data_center_token(
'some_refresh_token'
)
@pytest.mark.asyncio
async def test_http_error_propagates(self, token_manager):
"""When raise_for_status() raises, the exception propagates to the caller."""
import httpx
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
'401 Unauthorized',
request=MagicMock(),
response=MagicMock(status_code=401),
)
with (
patch(
'server.auth.token_manager.BITBUCKET_DATA_CENTER_HOST',
'bitbucket.example.com',
),
patch(
'server.auth.token_manager.BITBUCKET_DATA_CENTER_TOKEN_URL',
'https://bitbucket.example.com/oauth2/token',
),
patch('httpx.AsyncClient') as mock_client_cls,
):
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(
return_value=mock_client
)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(httpx.HTTPStatusError):
await token_manager._refresh_bitbucket_data_center_token(
'old_refresh_token'
)
class TestOrgTokenMethods:
"""Test cases for store_org_token and load_org_token methods."""