OpenHands/tests/unit/runtime/test_runtime_git_tokens.py
Wan Arif 3504ca7752
feat: add Azure DevOps integration support (#11243)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-11-22 14:00:24 -05:00

575 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from types import MappingProxyType
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import SecretStr
from openhands.core.config import OpenHandsConfig
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
from openhands.events.action import Action
from openhands.events.action.commands import CmdRunAction
from openhands.events.observation import (
CmdOutputObservation,
NullObservation,
Observation,
)
from openhands.events.stream import EventStream
from openhands.integrations.provider import ProviderHandler, ProviderToken, ProviderType
from openhands.integrations.service_types import AuthenticationError, Repository
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.base import Runtime
from openhands.storage import get_file_store
class MockRuntime(Runtime):
"""A concrete implementation of Runtime for testing"""
def __init__(self, *args, **kwargs):
# Ensure llm_registry is provided if not already in kwargs
if 'llm_registry' not in kwargs and len(args) < 3:
# Create a mock LLMRegistry if not provided
config = (
kwargs.get('config')
if 'config' in kwargs
else args[0]
if args
else OpenHandsConfig()
)
kwargs['llm_registry'] = LLMRegistry(config=config)
super().__init__(*args, **kwargs)
self.run_action_calls = []
self._execute_shell_fn_git_handler = MagicMock(
return_value=MagicMock(exit_code=0, stdout='', stderr='')
)
async def connect(self):
pass
def close(self):
pass
def browse(self, action):
return NullObservation(content='')
def browse_interactive(self, action):
return NullObservation(content='')
def run(self, action):
return NullObservation(content='')
def run_ipython(self, action):
return NullObservation(content='')
def read(self, action):
return NullObservation(content='')
def write(self, action):
return NullObservation(content='')
def copy_from(self, path):
return ''
def copy_to(self, path, content):
pass
def list_files(self, path):
return []
def run_action(self, action: Action) -> Observation:
self.run_action_calls.append(action)
# Return a mock git remote URL for git remote get-url commands
# Use an OLD token to simulate token refresh scenario
if (
isinstance(action, CmdRunAction)
and 'git remote get-url origin' in action.command
):
# Extract provider from previous clone command
if len(self.run_action_calls) > 0:
clone_cmd = (
self.run_action_calls[0].command if self.run_action_calls else ''
)
if 'github.com' in clone_cmd:
mock_url = 'https://old_github_token@github.com/owner/repo.git'
elif 'gitlab.com' in clone_cmd:
mock_url = (
'https://oauth2:old_gitlab_token@gitlab.com/owner/repo.git'
)
else:
mock_url = 'https://github.com/owner/repo.git'
return CmdOutputObservation(
content=mock_url, command_id=-1, command='', exit_code=0
)
# Return success for git remote set-url commands
if (
isinstance(action, CmdRunAction)
and 'git remote set-url origin' in action.command
):
return CmdOutputObservation(
content='', command_id=-1, command='', exit_code=0
)
return NullObservation(content='')
def call_tool_mcp(self, action):
return NullObservation(content='')
def edit(self, action):
return NullObservation(content='')
def get_mcp_config(
self, extra_stdio_servers: list[MCPStdioServerConfig] | None = None
):
return MCPConfig()
@pytest.fixture
def temp_dir(tmp_path_factory: pytest.TempPathFactory) -> str:
return str(tmp_path_factory.mktemp('test_event_stream'))
@pytest.fixture
def runtime(temp_dir):
"""Fixture for runtime testing"""
config = OpenHandsConfig()
git_provider_tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr('test_token'))}
)
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
llm_registry = LLMRegistry(config=config)
runtime = MockRuntime(
config=config,
event_stream=event_stream,
llm_registry=llm_registry,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
return runtime
def mock_repo_and_patch(
monkeypatch, provider=ProviderType.GITHUB, is_public=True, full_name='owner/repo'
):
repo = Repository(
id='123', full_name=full_name, git_provider=provider, is_public=is_public
)
async def mock_verify_repo_provider(*_args, **_kwargs):
return repo
monkeypatch.setattr(
ProviderHandler, 'verify_repo_provider', mock_verify_repo_provider
)
return repo
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_no_user_id(temp_dir):
"""Test that no token export happens when user_id is not set"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(config=config, event_stream=event_stream, sid='test')
# Create a command that would normally trigger token export
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
# This should not raise any errors and should return None
await runtime._export_latest_git_provider_tokens(cmd)
# Verify no secrets were set
assert not event_stream.secrets
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_no_token_ref(temp_dir):
"""Test that no token export happens when command doesn't reference tokens"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
# Create a command that doesn't reference any tokens
cmd = CmdRunAction(command='echo "hello"')
# This should not raise any errors and should return None
await runtime._export_latest_git_provider_tokens(cmd)
# Verify no secrets were set
assert not event_stream.secrets
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_success(runtime):
"""Test successful token export when command references tokens"""
# Create a command that references the GitHub token
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
# Export the tokens
await runtime._export_latest_git_provider_tokens(cmd)
# Verify that the token was exported to the event stream
assert runtime.event_stream.secrets == {'github_token': 'test_token'}
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_multiple_refs(temp_dir):
"""Test token export with multiple token references"""
config = OpenHandsConfig()
# Initialize with GitHub, GitLab, and Azure DevOps tokens
git_provider_tokens = MappingProxyType(
{
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
ProviderType.AZURE_DEVOPS: ProviderToken(
token=SecretStr('azure_devops_token')
),
}
)
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config,
event_stream=event_stream,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
# Create a command that references multiple tokens
cmd = CmdRunAction(
command='echo $GITHUB_TOKEN && echo $GITLAB_TOKEN && echo $AZURE_DEVOPS_TOKEN'
)
# Export the tokens
await runtime._export_latest_git_provider_tokens(cmd)
# Verify that all tokens were exported
assert event_stream.secrets == {
'github_token': 'github_token',
'gitlab_token': 'gitlab_token',
'azure_devops_token': 'azure_devops_token',
}
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_token_update(runtime, monkeypatch):
"""Test that token updates are handled correctly"""
# First export with initial token
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
await runtime._export_latest_git_provider_tokens(cmd)
# Ensure refresh-token flow is enabled in ProviderHandler
monkeypatch.setenv('WEB_HOST', 'example.com')
# Simulate that provider handler will now fetch a new token from refresh endpoint
new_token = 'new_test_token'
# Patch ProviderHandler._get_latest_provider_token to return new SecretStr
with patch.object(
ProviderHandler,
'_get_latest_provider_token',
new=AsyncMock(return_value=SecretStr(new_token)),
):
# Export again with updated token runtime should fetch latest and update EventStream secrets
await runtime._export_latest_git_provider_tokens(cmd)
# Verify that the new token was exported to the event stream
assert runtime.event_stream.secrets == {'github_token': new_token}
@pytest.mark.asyncio
async def test_clone_or_init_repo_no_repo_init_git_in_empty_workspace(temp_dir):
"""Test that git init is run when no repository is selected and init_git_in_empty_workspace"""
config = OpenHandsConfig()
config.init_git_in_empty_workspace = True
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config, event_stream=event_stream, sid='test', user_id=None
)
# Call the function with no repository
result = await runtime.clone_or_init_repo(None, None, None)
# Verify that git init was called
assert len(runtime.run_action_calls) == 1
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert (
runtime.run_action_calls[0].command
== f'git init && git config --global --add safe.directory {runtime.workspace_root}'
)
assert result == ''
@pytest.mark.asyncio
async def test_clone_or_init_repo_no_repo_no_user_id_with_workspace_base(temp_dir):
"""Test that git init is not run when no repository is selected, no user_id, but workspace_base is set"""
config = OpenHandsConfig()
config.workspace_base = '/some/path' # Set workspace_base
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config, event_stream=event_stream, sid='test', user_id=None
)
# Call the function with no repository
result = await runtime.clone_or_init_repo(None, None, None)
# Verify that git init was not called
assert len(runtime.run_action_calls) == 0
assert result == ''
@pytest.mark.asyncio
async def test_clone_or_init_repo_auth_error(temp_dir):
"""Test that RuntimeError is raised when authentication fails"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
# Mock the verify_repo_provider method to raise AuthenticationError
with patch.object(
ProviderHandler,
'verify_repo_provider',
side_effect=AuthenticationError('Auth failed'),
):
# Call the function with a repository
with pytest.raises(Exception) as excinfo:
await runtime.clone_or_init_repo(None, 'owner/repo', None)
# Verify the error message
assert 'Git provider authentication issue when getting remote URL' in str(
excinfo.value
)
@pytest.mark.asyncio
async def test_clone_or_init_repo_github_with_token(temp_dir, monkeypatch):
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
github_token = 'github_test_token'
git_provider_tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr(github_token))}
)
runtime = MockRuntime(
config=config,
event_stream=event_stream,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB)
result = await runtime.clone_or_init_repo(git_provider_tokens, 'owner/repo', None)
# Verify that git clone, checkout, and git remote URL update were called
assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
assert isinstance(runtime.run_action_calls[2], CmdRunAction)
# Check that the first command is the git clone with the correct URL format with token
clone_cmd = runtime.run_action_calls[0].command
assert f'https://{github_token}@github.com/owner/repo.git' in clone_cmd
expected_repo_path = str(runtime.workspace_root / 'repo')
assert expected_repo_path in clone_cmd
# Check that the second command is the checkout
checkout_cmd = runtime.run_action_calls[1].command
assert f'cd {expected_repo_path}' in checkout_cmd
assert 'git checkout -b openhands-workspace-' in checkout_cmd
# Check that the third command sets the remote URL immediately after clone
set_url_cmd = runtime.run_action_calls[2].command
assert f'cd {expected_repo_path}' in set_url_cmd
assert 'git remote set-url origin' in set_url_cmd
assert github_token in set_url_cmd
assert result == 'repo'
@pytest.mark.asyncio
async def test_clone_or_init_repo_github_no_token(temp_dir, monkeypatch):
"""Test cloning a GitHub repository without a token"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB)
result = await runtime.clone_or_init_repo(None, 'owner/repo', None)
# Verify that git clone, checkout, and remote update were called
assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
assert isinstance(runtime.run_action_calls[2], CmdRunAction)
# Check that the first command is the git clone with the correct URL format without token
clone_cmd = runtime.run_action_calls[0].command
expected_repo_path = str(runtime.workspace_root / 'repo')
assert 'git clone https://github.com/owner/repo.git' in clone_cmd
assert expected_repo_path in clone_cmd
# Check that the second command is the checkout
checkout_cmd = runtime.run_action_calls[1].command
assert f'cd {expected_repo_path}' in checkout_cmd
assert 'git checkout -b openhands-workspace-' in checkout_cmd
# Check that the third command sets the remote URL after clone
set_url_cmd = runtime.run_action_calls[2].command
assert f'cd {expected_repo_path}' in set_url_cmd
assert 'git remote set-url origin' in set_url_cmd
assert result == 'repo'
@pytest.mark.asyncio
async def test_clone_or_init_repo_gitlab_with_token(temp_dir, monkeypatch):
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
gitlab_token = 'gitlab_test_token'
git_provider_tokens = MappingProxyType(
{ProviderType.GITLAB: ProviderToken(token=SecretStr(gitlab_token))}
)
runtime = MockRuntime(
config=config,
event_stream=event_stream,
sid='test',
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITLAB)
result = await runtime.clone_or_init_repo(git_provider_tokens, 'owner/repo', None)
# Verify that git clone, checkout, and git remote URL update were called
assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
assert isinstance(runtime.run_action_calls[2], CmdRunAction)
# Check that the first command is the git clone with the correct URL format with token
clone_cmd = runtime.run_action_calls[0].command
expected_repo_path = str(runtime.workspace_root / 'repo')
assert f'https://oauth2:{gitlab_token}@gitlab.com/owner/repo.git' in clone_cmd
assert expected_repo_path in clone_cmd
# Check that the second command is the checkout
checkout_cmd = runtime.run_action_calls[1].command
assert f'cd {expected_repo_path}' in checkout_cmd
assert 'git checkout -b openhands-workspace-' in checkout_cmd
# Check that the third command sets the remote URL immediately after clone
set_url_cmd = runtime.run_action_calls[2].command
assert f'cd {expected_repo_path}' in set_url_cmd
assert 'git remote set-url origin' in set_url_cmd
assert gitlab_token in set_url_cmd
assert result == 'repo'
@pytest.mark.asyncio
async def test_clone_or_init_repo_azure_devops_with_token(temp_dir, monkeypatch):
"""Test cloning Azure DevOps repository with token"""
config = OpenHandsConfig()
# Set up Azure DevOps token
azure_devops_token = 'azure_devops_test_token'
git_provider_tokens = MappingProxyType(
{ProviderType.AZURE_DEVOPS: ProviderToken(token=SecretStr(azure_devops_token))}
)
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config,
event_stream=event_stream,
user_id='test_user',
git_provider_tokens=git_provider_tokens,
)
# Mock the repository to be Azure DevOps with 3-part format: org/project/repo
azure_repo_name = 'testorg/testproject/testrepo'
mock_repo_and_patch(
monkeypatch, provider=ProviderType.AZURE_DEVOPS, full_name=azure_repo_name
)
# Call the method with Azure DevOps 3-part format: org/project/repo
result = await runtime.clone_or_init_repo(
git_provider_tokens=git_provider_tokens,
selected_repository=azure_repo_name,
selected_branch=None,
)
# Check that the first command is the git clone with the correct URL format with token
# Azure DevOps uses Basic auth format: https://org:token@dev.azure.com/org/project/_git/repo
clone_cmd = runtime.run_action_calls[0].command
expected_repo_path = str(runtime.workspace_root / 'testrepo')
assert (
f'https://testorg:{azure_devops_token}@dev.azure.com/testorg/testproject/_git/testrepo'
in clone_cmd
)
assert expected_repo_path in clone_cmd
# Check that the second command is the checkout
checkout_cmd = runtime.run_action_calls[1].command
assert f'cd {expected_repo_path}' in checkout_cmd
assert 'git checkout -b openhands-workspace-' in checkout_cmd
assert result == 'testrepo'
@pytest.mark.asyncio
async def test_clone_or_init_repo_with_branch(temp_dir, monkeypatch):
"""Test cloning a repository with a specified branch"""
config = OpenHandsConfig()
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('abc', file_store)
runtime = MockRuntime(
config=config, event_stream=event_stream, sid='test', user_id='test_user'
)
mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB)
result = await runtime.clone_or_init_repo(None, 'owner/repo', 'feature-branch')
# Verify that git clone, checkout, and remote update were called
assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url
assert isinstance(runtime.run_action_calls[0], CmdRunAction)
assert isinstance(runtime.run_action_calls[1], CmdRunAction)
assert isinstance(runtime.run_action_calls[2], CmdRunAction)
# Check that the first command is the git clone
clone_cmd = runtime.run_action_calls[0].command
expected_repo_path = str(runtime.workspace_root / 'repo')
assert 'git clone https://github.com/owner/repo.git' in clone_cmd
assert expected_repo_path in clone_cmd
# Check that the second command contains the correct branch checkout
checkout_cmd = runtime.run_action_calls[1].command
assert f'cd {expected_repo_path}' in checkout_cmd
assert 'git checkout feature-branch' in checkout_cmd
set_url_cmd = runtime.run_action_calls[2].command
assert f'cd {expected_repo_path}' in set_url_cmd
assert 'git remote set-url origin' in set_url_cmd
assert 'git checkout -b' not in checkout_cmd # Should not create a new branch
assert result == 'repo'