mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Merge main into openhands/issue-2228-gui-settings-schema
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
787
tests/unit/app_server/test_sandbox_secrets_router.py
Normal file
787
tests/unit/app_server/test_sandbox_secrets_router.py
Normal file
@@ -0,0 +1,787 @@
|
||||
"""Unit + integration tests for the sandbox settings endpoints and /users/me expose_secrets.
|
||||
|
||||
Tests:
|
||||
- GET /api/v1/users/me?expose_secrets=true
|
||||
- GET /api/v1/sandboxes/{sandbox_id}/settings/secrets
|
||||
- GET /api/v1/sandboxes/{sandbox_id}/settings/secrets/{secret_name}
|
||||
- Shared session_auth.validate_session_key()
|
||||
- Integration tests exercising the real auth validation stack via HTTP
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
SecretNamesResponse,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_router import (
|
||||
get_secret_value,
|
||||
list_secret_names,
|
||||
)
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.app_server.user.user_router import (
|
||||
_validate_session_key_ownership,
|
||||
get_current_user,
|
||||
)
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.secret import StaticSecret
|
||||
|
||||
SANDBOX_ID = 'sb-test-123'
|
||||
USER_ID = 'test-user-id'
|
||||
|
||||
|
||||
def _make_sandbox_info(
|
||||
sandbox_id: str = SANDBOX_ID,
|
||||
user_id: str | None = USER_ID,
|
||||
) -> SandboxInfo:
|
||||
return SandboxInfo(
|
||||
id=sandbox_id,
|
||||
created_by_user_id=user_id,
|
||||
sandbox_spec_id='test-spec',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key='session-key',
|
||||
)
|
||||
|
||||
|
||||
def _patch_sandbox_service(return_sandbox: SandboxInfo | None):
|
||||
"""Patch ``get_sandbox_service`` in ``session_auth`` to return a mock service."""
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=return_sandbox
|
||||
)
|
||||
ctx = patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
|
||||
)
|
||||
return ctx, mock_sandbox_service
|
||||
|
||||
|
||||
def _create_sandbox_service_context_manager(sandbox_service):
|
||||
"""Create an async context manager that yields the given sandbox service."""
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _context_manager(state, request=None):
|
||||
yield sandbox_service
|
||||
|
||||
return _context_manager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_session_key (shared utility)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestValidateSessionKey:
|
||||
"""Tests for the shared session_auth.validate_session_key utility."""
|
||||
|
||||
async def test_rejects_missing_key(self):
|
||||
"""Missing session key raises 401."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_session_key(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Session-API-Key' in exc_info.value.detail
|
||||
|
||||
async def test_rejects_empty_string_key(self):
|
||||
"""Empty string session key raises 401."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_session_key('')
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
async def test_rejects_invalid_key(self):
|
||||
"""Session key that maps to no sandbox raises 401."""
|
||||
ctx, mock_svc = _patch_sandbox_service(None)
|
||||
with ctx as mock_get:
|
||||
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
|
||||
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_session_key('bogus-key')
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Invalid session API key' in exc_info.value.detail
|
||||
|
||||
async def test_accepts_valid_key(self):
|
||||
"""Valid session key returns sandbox info."""
|
||||
sandbox = _make_sandbox_info()
|
||||
ctx, mock_svc = _patch_sandbox_service(sandbox)
|
||||
with ctx as mock_get:
|
||||
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
|
||||
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
result = await validate_session_key('valid-key')
|
||||
assert result.id == SANDBOX_ID
|
||||
|
||||
async def test_rejects_sandbox_without_user_in_saas_mode(self):
|
||||
"""In SAAS mode, sandbox without created_by_user_id raises 401."""
|
||||
sandbox = _make_sandbox_info(user_id=None)
|
||||
ctx, mock_svc = _patch_sandbox_service(sandbox)
|
||||
with (
|
||||
ctx as mock_get,
|
||||
patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_global_config'
|
||||
) as mock_cfg,
|
||||
):
|
||||
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
|
||||
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
mock_cfg.return_value.app_mode = AppMode.SAAS
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_session_key('valid-key')
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'no user' in exc_info.value.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/me?expose_secrets=true
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetCurrentUserExposeSecrets:
|
||||
"""Test suite for GET /users/me?expose_secrets=true."""
|
||||
|
||||
async def test_expose_secrets_returns_raw_api_key(self):
|
||||
"""With valid session key, expose_secrets=true returns unmasked llm_api_key."""
|
||||
user_info = UserInfo(
|
||||
id=USER_ID,
|
||||
llm_model='anthropic/claude-sonnet-4-20250514',
|
||||
llm_api_key=SecretStr('sk-test-key-123'),
|
||||
llm_base_url='https://litellm.example.com',
|
||||
)
|
||||
mock_context = AsyncMock()
|
||||
mock_context.get_user_info = AsyncMock(return_value=user_info)
|
||||
mock_context.get_user_id = AsyncMock(return_value=USER_ID)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.user.user_router._validate_session_key_ownership'
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = None
|
||||
result = await get_current_user(
|
||||
user_context=mock_context,
|
||||
expose_secrets=True,
|
||||
x_session_api_key='valid-key',
|
||||
)
|
||||
|
||||
# JSONResponse — parse the body
|
||||
import json
|
||||
|
||||
body = json.loads(result.body)
|
||||
assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514'
|
||||
assert body['llm_api_key'] == 'sk-test-key-123'
|
||||
assert body['llm_base_url'] == 'https://litellm.example.com'
|
||||
|
||||
async def test_expose_secrets_rejects_missing_session_key(self):
|
||||
"""expose_secrets=true without X-Session-API-Key is rejected."""
|
||||
mock_context = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _validate_session_key_ownership(mock_context, session_api_key=None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Session-API-Key' in exc_info.value.detail
|
||||
|
||||
async def test_expose_secrets_rejects_wrong_user(self):
|
||||
"""expose_secrets=true with session key from different user is rejected."""
|
||||
mock_context = AsyncMock()
|
||||
mock_context.get_user_id = AsyncMock(return_value='user-A')
|
||||
|
||||
other_user_sandbox = _make_sandbox_info(user_id='user-B')
|
||||
|
||||
ctx, mock_svc = _patch_sandbox_service(other_user_sandbox)
|
||||
with ctx as mock_get, pytest.raises(HTTPException) as exc_info:
|
||||
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
|
||||
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
await _validate_session_key_ownership(
|
||||
mock_context, session_api_key='stolen-key'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
async def test_expose_secrets_rejects_unknown_caller(self):
|
||||
"""If caller_id cannot be determined, reject with 401."""
|
||||
mock_context = AsyncMock()
|
||||
mock_context.get_user_id = AsyncMock(return_value=None)
|
||||
|
||||
sandbox = _make_sandbox_info(user_id='user-B')
|
||||
|
||||
ctx, mock_svc = _patch_sandbox_service(sandbox)
|
||||
with ctx as mock_get, pytest.raises(HTTPException) as exc_info:
|
||||
mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc)
|
||||
mock_get.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
await _validate_session_key_ownership(
|
||||
mock_context, session_api_key='some-key'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Cannot determine authenticated user' in exc_info.value.detail
|
||||
|
||||
async def test_default_masks_api_key(self):
|
||||
"""Without expose_secrets, llm_api_key is masked (no session key needed)."""
|
||||
user_info = UserInfo(
|
||||
id=USER_ID,
|
||||
llm_api_key=SecretStr('sk-test-key-123'),
|
||||
)
|
||||
mock_context = AsyncMock()
|
||||
mock_context.get_user_info = AsyncMock(return_value=user_info)
|
||||
|
||||
result = await get_current_user(
|
||||
user_context=mock_context, expose_secrets=False, x_session_api_key=None
|
||||
)
|
||||
|
||||
# Returns UserInfo directly (FastAPI will serialize with masking)
|
||||
assert isinstance(result, UserInfo)
|
||||
assert result.llm_api_key is not None
|
||||
# The raw value is still in the object, but serialization masks it
|
||||
dumped = result.model_dump(mode='json')
|
||||
assert dumped['llm_api_key'] == '**********'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /sandboxes/{sandbox_id}/settings/secrets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListSecretNames:
|
||||
"""Test suite for GET /sandboxes/{sandbox_id}/settings/secrets."""
|
||||
|
||||
async def test_returns_secret_names_without_values(self):
|
||||
"""Response contains names and descriptions, NOT raw values."""
|
||||
secrets = {
|
||||
'GITHUB_TOKEN': StaticSecret(
|
||||
value=SecretStr('ghp_test123'),
|
||||
description='GitHub personal access token',
|
||||
),
|
||||
'MY_API_KEY': StaticSecret(
|
||||
value=SecretStr('my-api-key-value'),
|
||||
description='Custom API key',
|
||||
),
|
||||
}
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(return_value=secrets)
|
||||
ctx.get_provider_tokens = AsyncMock(return_value={})
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
result = await list_secret_names(sandbox_info=sandbox_info)
|
||||
|
||||
assert isinstance(result, SecretNamesResponse)
|
||||
assert len(result.secrets) == 2
|
||||
names = {s.name for s in result.secrets}
|
||||
assert 'GITHUB_TOKEN' in names
|
||||
assert 'MY_API_KEY' in names
|
||||
|
||||
gh = next(s for s in result.secrets if s.name == 'GITHUB_TOKEN')
|
||||
assert gh.description == 'GitHub personal access token'
|
||||
# Verify no 'value' field is exposed
|
||||
assert not hasattr(gh, 'value')
|
||||
|
||||
async def test_returns_empty_when_no_secrets(self):
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(return_value={})
|
||||
ctx.get_provider_tokens = AsyncMock(return_value={})
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
result = await list_secret_names(sandbox_info=sandbox_info)
|
||||
|
||||
assert len(result.secrets) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /sandboxes/{sandbox_id}/settings/secrets/{name}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetSecretValue:
|
||||
"""Test suite for GET /sandboxes/{sandbox_id}/settings/secrets/{name}."""
|
||||
|
||||
async def test_returns_raw_secret_value(self):
|
||||
"""Raw secret value returned as plain text."""
|
||||
secrets = {
|
||||
'GITHUB_TOKEN': StaticSecret(
|
||||
value=SecretStr('ghp_actual_secret'),
|
||||
description='GitHub token',
|
||||
),
|
||||
}
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(return_value=secrets)
|
||||
ctx.get_provider_tokens = AsyncMock(return_value={})
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
response = await get_secret_value(
|
||||
secret_name='GITHUB_TOKEN',
|
||||
sandbox_info=sandbox_info,
|
||||
)
|
||||
|
||||
assert response.body == b'ghp_actual_secret'
|
||||
assert response.media_type == 'text/plain'
|
||||
|
||||
async def test_returns_404_for_unknown_secret(self):
|
||||
"""404 when requested secret doesn't exist in custom secrets or provider tokens."""
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(return_value={})
|
||||
ctx.get_provider_tokens = AsyncMock(return_value={})
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_secret_value(
|
||||
secret_name='NONEXISTENT',
|
||||
sandbox_info=sandbox_info,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
async def test_returns_404_for_none_value_secret(self):
|
||||
"""404 when secret exists but has None value."""
|
||||
secrets = {
|
||||
'EMPTY_SECRET': StaticSecret(value=None),
|
||||
}
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(return_value=secrets)
|
||||
ctx.get_provider_tokens = AsyncMock(return_value={})
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_secret_value(
|
||||
secret_name='EMPTY_SECRET',
|
||||
sandbox_info=sandbox_info,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Integration tests — real HTTP requests through real auth validation logic.
|
||||
#
|
||||
# Only the data layer (sandbox service, user context) is mocked.
|
||||
# The session key validation, ownership checks, and FastAPI routing are REAL.
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _build_integration_test_app(
|
||||
mock_user_context: AsyncMock | None = None,
|
||||
) -> FastAPI:
|
||||
"""Build a minimal FastAPI app with the real user and sandbox routers.
|
||||
|
||||
The ``depends_user_context`` dependency is overridden with a mock, but the
|
||||
session key validation logic in ``validate_session_key`` and
|
||||
``_validate_session_key_ownership`` runs unmodified.
|
||||
|
||||
Router-level dependencies (e.g. ``check_session_api_key`` from ``SESSION_API_KEY``
|
||||
env var) are overridden to no-ops so we can exercise the endpoint-level auth logic
|
||||
in isolation.
|
||||
"""
|
||||
from openhands.app_server.sandbox.sandbox_router import (
|
||||
router as sandbox_router,
|
||||
)
|
||||
from openhands.app_server.user.user_router import router as user_router
|
||||
from openhands.server.dependencies import check_session_api_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Disable router-level auth (SESSION_API_KEY check) — we're testing the
|
||||
# endpoint-level session key validation, not the router middleware.
|
||||
app.dependency_overrides[check_session_api_key] = lambda: None
|
||||
|
||||
if mock_user_context is not None:
|
||||
from openhands.app_server.user.user_router import user_dependency
|
||||
|
||||
app.dependency_overrides[user_dependency.dependency] = lambda: mock_user_context
|
||||
|
||||
app.include_router(user_router, prefix='/api/v1')
|
||||
app.include_router(sandbox_router, prefix='/api/v1')
|
||||
return app
|
||||
|
||||
|
||||
class TestExposeSecretsIntegration:
|
||||
"""Integration tests for /users/me?expose_secrets=true via real HTTP.
|
||||
|
||||
These tests exercise the full auth validation stack:
|
||||
- validate_session_key (real)
|
||||
- _validate_session_key_ownership (real)
|
||||
- ownership check (real)
|
||||
Only the data layer (sandbox service lookup, user context) is mocked.
|
||||
"""
|
||||
|
||||
def test_expose_secrets_without_session_key_returns_401(self):
|
||||
"""Bearer token alone cannot expose secrets (no X-Session-API-Key)."""
|
||||
mock_user_ctx = AsyncMock()
|
||||
mock_user_ctx.get_user_info = AsyncMock(
|
||||
return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123'))
|
||||
)
|
||||
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
|
||||
|
||||
app = _build_integration_test_app(mock_user_ctx)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
response = client.get('/api/v1/users/me', params={'expose_secrets': 'true'})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert 'X-Session-API-Key' in response.json()['detail']
|
||||
|
||||
def test_expose_secrets_with_invalid_session_key_returns_401(self):
|
||||
"""Invalid session key (no matching sandbox) is rejected."""
|
||||
mock_user_ctx = AsyncMock()
|
||||
mock_user_ctx.get_user_info = AsyncMock(
|
||||
return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123'))
|
||||
)
|
||||
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
|
||||
|
||||
mock_sandbox_svc = AsyncMock()
|
||||
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(return_value=None)
|
||||
|
||||
app = _build_integration_test_app(mock_user_ctx)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
|
||||
_create_sandbox_service_context_manager(mock_sandbox_svc),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/v1/users/me',
|
||||
params={'expose_secrets': 'true'},
|
||||
headers={'X-Session-API-Key': 'bogus-key'},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert 'Invalid session API key' in response.json()['detail']
|
||||
|
||||
def test_expose_secrets_with_wrong_user_returns_403(self):
|
||||
"""Session key from a different user's sandbox is rejected."""
|
||||
mock_user_ctx = AsyncMock()
|
||||
mock_user_ctx.get_user_info = AsyncMock(
|
||||
return_value=UserInfo(id='user-A', llm_api_key=SecretStr('sk-secret-123'))
|
||||
)
|
||||
mock_user_ctx.get_user_id = AsyncMock(return_value='user-A')
|
||||
|
||||
# Sandbox owned by user-B
|
||||
sandbox_b = _make_sandbox_info(user_id='user-B')
|
||||
mock_sandbox_svc = AsyncMock()
|
||||
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=sandbox_b
|
||||
)
|
||||
|
||||
app = _build_integration_test_app(mock_user_ctx)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
|
||||
_create_sandbox_service_context_manager(mock_sandbox_svc),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/v1/users/me',
|
||||
params={'expose_secrets': 'true'},
|
||||
headers={'X-Session-API-Key': 'stolen-key'},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert 'does not belong' in response.json()['detail']
|
||||
|
||||
def test_expose_secrets_valid_dual_auth_returns_200_unmasked(self):
|
||||
"""Valid Bearer + valid session key owned by same user → 200 with secrets."""
|
||||
mock_user_ctx = AsyncMock()
|
||||
mock_user_ctx.get_user_info = AsyncMock(
|
||||
return_value=UserInfo(
|
||||
id=USER_ID,
|
||||
llm_model='anthropic/claude-sonnet-4-20250514',
|
||||
llm_api_key=SecretStr('sk-real-secret'),
|
||||
llm_base_url='https://litellm.example.com',
|
||||
)
|
||||
)
|
||||
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
|
||||
|
||||
sandbox = _make_sandbox_info(user_id=USER_ID)
|
||||
mock_sandbox_svc = AsyncMock()
|
||||
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=sandbox
|
||||
)
|
||||
|
||||
app = _build_integration_test_app(mock_user_ctx)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
|
||||
_create_sandbox_service_context_manager(mock_sandbox_svc),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/v1/users/me',
|
||||
params={'expose_secrets': 'true'},
|
||||
headers={'X-Session-API-Key': 'valid-key'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body['llm_api_key'] == 'sk-real-secret'
|
||||
assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514'
|
||||
assert body['llm_base_url'] == 'https://litellm.example.com'
|
||||
|
||||
def test_default_masks_secrets_via_http(self):
|
||||
"""Without expose_secrets, secrets are masked even via real HTTP."""
|
||||
mock_user_ctx = AsyncMock()
|
||||
mock_user_ctx.get_user_info = AsyncMock(
|
||||
return_value=UserInfo(
|
||||
id=USER_ID, llm_api_key=SecretStr('sk-should-be-masked')
|
||||
)
|
||||
)
|
||||
|
||||
app = _build_integration_test_app(mock_user_ctx)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
response = client.get('/api/v1/users/me')
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body['llm_api_key'] == '**********'
|
||||
|
||||
|
||||
class TestSandboxSecretsIntegration:
|
||||
"""Integration tests for sandbox-scoped secrets endpoints via real HTTP.
|
||||
|
||||
The session key validation in ``_valid_sandbox_from_session_key`` runs
|
||||
unmodified — only the sandbox service (database) is mocked.
|
||||
"""
|
||||
|
||||
def test_secrets_list_without_session_key_returns_401(self):
|
||||
"""Missing X-Session-API-Key on secrets endpoint is rejected."""
|
||||
app = _build_integration_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
response = client.get(f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets')
|
||||
|
||||
assert response.status_code == 401
|
||||
assert 'X-Session-API-Key' in response.json()['detail']
|
||||
|
||||
def test_secrets_list_with_invalid_session_key_returns_401(self):
|
||||
"""Invalid session key on secrets endpoint is rejected."""
|
||||
app = _build_integration_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
mock_sandbox_svc = AsyncMock()
|
||||
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
|
||||
_create_sandbox_service_context_manager(mock_sandbox_svc),
|
||||
):
|
||||
response = client.get(
|
||||
f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets',
|
||||
headers={'X-Session-API-Key': 'bogus'},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert 'Invalid session API key' in response.json()['detail']
|
||||
|
||||
def test_secrets_list_with_mismatched_sandbox_id_returns_403(self):
|
||||
"""Session key maps to a different sandbox than the URL path → 403."""
|
||||
app = _build_integration_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Session key maps to sandbox "other-sandbox", but URL says SANDBOX_ID
|
||||
other_sandbox = _make_sandbox_info(sandbox_id='other-sandbox')
|
||||
mock_sandbox_svc = AsyncMock()
|
||||
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=other_sandbox
|
||||
)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
|
||||
_create_sandbox_service_context_manager(mock_sandbox_svc),
|
||||
):
|
||||
response = client.get(
|
||||
f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets',
|
||||
headers={'X-Session-API-Key': 'valid-key'},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert 'does not match' in response.json()['detail']
|
||||
|
||||
def test_sandbox_without_user_returns_401_for_secret_value(self):
|
||||
"""Sandbox with no owning user → 401 when fetching a secret value."""
|
||||
app = _build_integration_test_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Sandbox exists but has no owning user
|
||||
sandbox_no_user = _make_sandbox_info(user_id=None)
|
||||
mock_sandbox_svc = AsyncMock()
|
||||
mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=sandbox_no_user
|
||||
)
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.session_auth.get_sandbox_service',
|
||||
_create_sandbox_service_context_manager(mock_sandbox_svc),
|
||||
):
|
||||
response = client.get(
|
||||
f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets/MY_SECRET',
|
||||
headers={'X-Session-API-Key': 'valid-key'},
|
||||
)
|
||||
|
||||
# _get_user_context raises 401 because created_by_user_id is None
|
||||
assert response.status_code == 401
|
||||
assert 'no associated user' in response.json()['detail']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider tokens in sandbox secrets endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestProviderTokensInEndpoints:
|
||||
"""Verify that sandbox secrets endpoints include provider tokens resolved lazily."""
|
||||
|
||||
async def test_get_provider_tokens_as_env_vars(self):
|
||||
"""get_provider_tokens(as_env_vars=True) returns fresh values keyed by env name."""
|
||||
mock_user_auth = AsyncMock()
|
||||
mock_user_auth.get_provider_tokens = AsyncMock(
|
||||
return_value={
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('ghp_test123')),
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr('glpat-test456')),
|
||||
}
|
||||
)
|
||||
|
||||
ctx = AuthUserContext(user_auth=mock_user_auth)
|
||||
result = await ctx.get_provider_tokens(as_env_vars=True)
|
||||
|
||||
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
|
||||
gl_key = ProviderHandler.get_provider_env_key(ProviderType.GITLAB)
|
||||
assert result[gh_key] == 'ghp_test123'
|
||||
assert result[gl_key] == 'glpat-test456'
|
||||
|
||||
async def test_empty_provider_tokens_excluded(self):
|
||||
"""Provider tokens with empty token values are excluded."""
|
||||
mock_user_auth = AsyncMock()
|
||||
mock_user_auth.get_provider_tokens = AsyncMock(
|
||||
return_value={
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('')),
|
||||
}
|
||||
)
|
||||
|
||||
ctx = AuthUserContext(user_auth=mock_user_auth)
|
||||
result = await ctx.get_provider_tokens(as_env_vars=True)
|
||||
|
||||
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
|
||||
assert gh_key not in result
|
||||
|
||||
async def test_none_provider_tokens_returns_empty(self):
|
||||
"""get_provider_tokens(as_env_vars=True) with None tokens yields empty dict."""
|
||||
mock_user_auth = AsyncMock()
|
||||
mock_user_auth.get_provider_tokens = AsyncMock(return_value=None)
|
||||
|
||||
ctx = AuthUserContext(user_auth=mock_user_auth)
|
||||
result = await ctx.get_provider_tokens(as_env_vars=True)
|
||||
assert result == {}
|
||||
|
||||
async def test_list_secret_names_includes_provider_tokens(self):
|
||||
"""list_secret_names returns both custom secrets and provider token names."""
|
||||
custom_secrets = {
|
||||
'MY_KEY': StaticSecret(
|
||||
value=SecretStr('my-value'), description='custom key'
|
||||
),
|
||||
}
|
||||
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
|
||||
provider_env_vars = {gh_key: 'ghp_test123'}
|
||||
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(return_value=custom_secrets)
|
||||
ctx.get_provider_tokens = AsyncMock(return_value=provider_env_vars)
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
result = await list_secret_names(sandbox_info=sandbox_info)
|
||||
|
||||
names = {s.name for s in result.secrets}
|
||||
assert 'MY_KEY' in names
|
||||
assert gh_key in names
|
||||
assert len(result.secrets) == 2
|
||||
|
||||
async def test_get_secret_value_resolves_provider_token(self):
|
||||
"""get_secret_value falls back to provider tokens when not in custom secrets."""
|
||||
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(return_value={})
|
||||
ctx.get_provider_tokens = AsyncMock(
|
||||
return_value={gh_key: 'ghp_fresh_token'}
|
||||
)
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
response = await get_secret_value(
|
||||
secret_name=gh_key, sandbox_info=sandbox_info
|
||||
)
|
||||
|
||||
assert response.body == b'ghp_fresh_token'
|
||||
assert response.media_type == 'text/plain'
|
||||
|
||||
async def test_custom_secret_takes_priority_over_provider_token(self):
|
||||
"""If a custom secret has the same name, it takes priority."""
|
||||
gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB)
|
||||
sandbox_info = _make_sandbox_info()
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.sandbox.sandbox_router._get_user_context'
|
||||
) as mock_ctx:
|
||||
ctx = AsyncMock()
|
||||
ctx.get_secrets = AsyncMock(
|
||||
return_value={
|
||||
gh_key: StaticSecret(
|
||||
value=SecretStr('custom-override'),
|
||||
description='user override',
|
||||
)
|
||||
}
|
||||
)
|
||||
# Provider token should NOT be called since custom secret matches
|
||||
ctx.get_provider_tokens = AsyncMock(return_value={gh_key: 'provider-value'})
|
||||
mock_ctx.return_value = ctx
|
||||
|
||||
response = await get_secret_value(
|
||||
secret_name=gh_key, sandbox_info=sandbox_info
|
||||
)
|
||||
|
||||
assert response.body == b'custom-override'
|
||||
Reference in New Issue
Block a user