mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -1,482 +1,466 @@
|
||||
"""
|
||||
Unit tests for ApiKeyService.
|
||||
|
||||
Tests API key CRUD operations with mocked persistence layer.
|
||||
|
||||
Source: src/langbot/pkg/api/http/service/apikey.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.authz import Permission, PermissionDeniedError
|
||||
from langbot.pkg.api.http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from langbot.pkg.api.http.service.apikey import ApiKeyService
|
||||
from langbot.pkg.entity.persistence.apikey import ApiKey
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import (
|
||||
Workspace,
|
||||
WorkspaceExecutionSource,
|
||||
WorkspaceExecutionState,
|
||||
WorkspaceSource,
|
||||
)
|
||||
from langbot.pkg.workspace.policy import SingleWorkspacePolicy
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class _PersistenceManager:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def get_db_engine(self):
|
||||
return self.engine
|
||||
|
||||
async def execute_async(self, *args, **kwargs):
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.execute(*args, **kwargs)
|
||||
await connection.commit()
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def serialize_model(model, row, masked_columns=()):
|
||||
return {
|
||||
column.name: (
|
||||
getattr(row, column.name).isoformat()
|
||||
if isinstance(getattr(row, column.name), datetime.datetime)
|
||||
else getattr(row, column.name)
|
||||
)
|
||||
for column in model.__table__.columns
|
||||
if column.name not in masked_columns
|
||||
}
|
||||
|
||||
|
||||
def _context(workspace_uuid: str, account_uuid: str, permissions: set[Permission]) -> RequestContext:
|
||||
return RequestContext(
|
||||
instance_uuid='api-key-instance',
|
||||
placement_generation=1,
|
||||
request_id=str(uuid.uuid4()),
|
||||
auth_type='user-token',
|
||||
principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid=account_uuid),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=workspace_uuid,
|
||||
membership_uuid=str(uuid.uuid4()),
|
||||
role='owner',
|
||||
permissions=frozenset(permission.value for permission in permissions),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def api_key_context(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "api-keys.db"}')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=_PersistenceManager(engine),
|
||||
instance_config=SimpleNamespace(data={'api': {'global_api_key': ''}}),
|
||||
logger=logging.getLogger('api-key-test'),
|
||||
)
|
||||
application.workspace_service = WorkspaceService(application, instance_uuid='api-key-instance')
|
||||
workspace = await application.workspace_service.ensure_singleton_workspace()
|
||||
account_uuid = str(uuid.uuid4())
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory.begin() as session:
|
||||
session.add(
|
||||
User(
|
||||
uuid=account_uuid,
|
||||
user='owner@example.com',
|
||||
normalized_email='owner@example.com',
|
||||
password='hash',
|
||||
account_type='local',
|
||||
)
|
||||
)
|
||||
service = ApiKeyService(application)
|
||||
context = _context(workspace.uuid, account_uuid, set(Permission))
|
||||
yield application, service, context, engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_secret_is_returned_once_and_only_hash_is_persisted(api_key_context):
|
||||
_application, service, context, engine = api_key_context
|
||||
|
||||
created = await service.create_api_key(context, 'Automation', 'CI key')
|
||||
secret = created['key']
|
||||
assert secret.startswith('lbk_')
|
||||
assert created['secret_available'] is True
|
||||
assert 'key_hash' not in created
|
||||
|
||||
listed = await service.get_api_keys(context)
|
||||
assert len(listed) == 1
|
||||
assert 'key' not in listed[0]
|
||||
assert 'key_hash' not in listed[0]
|
||||
assert listed[0]['secret_available'] is False
|
||||
|
||||
async with engine.connect() as connection:
|
||||
stored = await connection.scalar(sqlalchemy.select(ApiKey.key_hash))
|
||||
assert stored == hashlib.sha256(secret.encode()).hexdigest()
|
||||
assert secret not in stored
|
||||
|
||||
|
||||
async def test_authentication_derives_workspace_scopes_and_updates_usage(api_key_context):
|
||||
_application, service, context, engine = api_key_context
|
||||
created = await service.create_api_key(
|
||||
context,
|
||||
'Read only',
|
||||
scopes=[Permission.RESOURCE_VIEW.value],
|
||||
)
|
||||
|
||||
identity = await service.authenticate_api_key(created['key'])
|
||||
assert identity is not None
|
||||
assert identity.workspace_uuid == context.workspace_uuid
|
||||
assert identity.permissions == frozenset({Permission.RESOURCE_VIEW.value})
|
||||
|
||||
async with engine.connect() as connection:
|
||||
last_used_at = await connection.scalar(sqlalchemy.select(ApiKey.last_used_at))
|
||||
assert last_used_at is not None
|
||||
|
||||
|
||||
async def test_revoked_expired_and_unknown_keys_fail_closed(api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Revocable')
|
||||
await service.delete_api_key(context, created['id'])
|
||||
assert await service.authenticate_api_key(created['key']) is None
|
||||
assert await service.verify_api_key('') is False
|
||||
assert await service.verify_api_key('plain-secret') is False
|
||||
assert await service.verify_api_key('lbk_unknown') is False
|
||||
|
||||
expired_secret = 'lbk_expired'
|
||||
await service.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(ApiKey).values(
|
||||
workspace_uuid=context.workspace_uuid,
|
||||
name='Expired',
|
||||
key_hash=hashlib.sha256(expired_secret.encode()).hexdigest(),
|
||||
scopes=[Permission.RESOURCE_VIEW.value],
|
||||
status='active',
|
||||
expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
assert await service.authenticate_api_key(expired_secret) is None
|
||||
|
||||
|
||||
async def test_revoke_winning_last_used_update_race_fails_authentication(api_key_context):
|
||||
application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Racing revoke')
|
||||
original_execute = application.persistence_mgr.execute_async
|
||||
injected_revoke = False
|
||||
|
||||
async def execute_with_revoke(statement, *args, **kwargs):
|
||||
nonlocal injected_revoke
|
||||
if (
|
||||
not injected_revoke
|
||||
and isinstance(statement, sqlalchemy.sql.dml.Update)
|
||||
and statement.table.name == ApiKey.__tablename__
|
||||
):
|
||||
injected_revoke = True
|
||||
await original_execute(sqlalchemy.update(ApiKey).where(ApiKey.id == created['id']).values(status='revoked'))
|
||||
return await original_execute(statement, *args, **kwargs)
|
||||
|
||||
application.persistence_mgr.execute_async = execute_with_revoke
|
||||
|
||||
assert await service.authenticate_api_key(created['key']) is None
|
||||
assert injected_revoke is True
|
||||
|
||||
|
||||
async def test_cross_workspace_crud_and_secret_guessing_are_isolated(api_key_context):
|
||||
application, service, first_context, engine = api_key_context
|
||||
second_workspace_uuid = str(uuid.uuid4())
|
||||
async with async_sessionmaker(engine, expire_on_commit=False).begin() as session:
|
||||
session.add(
|
||||
Workspace(
|
||||
uuid=second_workspace_uuid,
|
||||
instance_uuid='api-key-instance',
|
||||
name='Second',
|
||||
slug='second',
|
||||
source=WorkspaceSource.CLOUD_PROJECTION.value,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
WorkspaceExecutionState(
|
||||
workspace_uuid=second_workspace_uuid,
|
||||
instance_uuid='api-key-instance',
|
||||
active_generation=3,
|
||||
state='active',
|
||||
write_fenced=False,
|
||||
source=WorkspaceExecutionSource.CLOUD.value,
|
||||
)
|
||||
)
|
||||
second_context = _context(second_workspace_uuid, first_context.account_uuid or '', set(Permission))
|
||||
created = await service.create_api_key(first_context, 'First only')
|
||||
|
||||
assert await service.get_api_key(second_context, created['id']) is None
|
||||
assert await service.get_api_keys(second_context) == []
|
||||
identity = await service.authenticate_api_key(created['key'])
|
||||
assert identity is not None
|
||||
assert identity.workspace_uuid == first_context.workspace_uuid
|
||||
assert identity.workspace_uuid != second_workspace_uuid
|
||||
|
||||
# Prove the explicit multi-Workspace policy does not change key-derived routing.
|
||||
application.workspace_service.policy = SingleWorkspacePolicy(workspace_limit=10, multi_workspace_enabled=True)
|
||||
identity = await service.authenticate_api_key(created['key'])
|
||||
assert identity is not None
|
||||
assert identity.workspace_uuid == first_context.workspace_uuid
|
||||
|
||||
|
||||
async def test_global_config_key_is_oss_singleton_only(api_key_context):
|
||||
application, service, _context_value, _engine = api_key_context
|
||||
application.instance_config.data['api']['global_api_key'] = 'configured-secret'
|
||||
|
||||
identity = await service.authenticate_api_key('configured-secret')
|
||||
assert identity is not None
|
||||
assert identity.api_key_uuid == 'global-oss-api-key'
|
||||
|
||||
application.workspace_service.policy = SingleWorkspacePolicy(workspace_limit=10, multi_workspace_enabled=True)
|
||||
assert await service.authenticate_api_key('configured-secret') is None
|
||||
|
||||
|
||||
async def test_explicit_scopes_cannot_exceed_callers_workspace_permissions(api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
limited_context = _context(
|
||||
context.workspace_uuid,
|
||||
context.account_uuid or '',
|
||||
{Permission.API_KEY_MANAGE, Permission.RESOURCE_VIEW},
|
||||
)
|
||||
|
||||
created = await service.create_api_key(
|
||||
limited_context,
|
||||
'Read only',
|
||||
scopes=[Permission.RESOURCE_VIEW.value],
|
||||
)
|
||||
identity = await service.authenticate_api_key(created['key'])
|
||||
assert identity is not None
|
||||
assert identity.permissions == frozenset({Permission.RESOURCE_VIEW.value})
|
||||
|
||||
with pytest.raises(PermissionDeniedError) as exc_info:
|
||||
await service.create_api_key(
|
||||
limited_context,
|
||||
'Escalated',
|
||||
scopes=[Permission.WORKSPACE_DELETE.value],
|
||||
)
|
||||
assert exc_info.value.permission == Permission.WORKSPACE_DELETE.value
|
||||
|
||||
|
||||
# Preserve the pre-tenancy CRUD and verification regression matrix while
|
||||
# exercising it through the new Workspace-bound API. The assertions reflect
|
||||
# intentional security changes: secrets are returned once, deletion revokes,
|
||||
# and missing Workspace resources are reported as not found.
|
||||
class TestApiKeyServiceGetApiKeys:
|
||||
"""Tests for get_api_keys method."""
|
||||
async def test_get_api_keys_empty_list(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
|
||||
async def test_get_api_keys_empty_list(self):
|
||||
"""Returns empty list when no API keys exist."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
mock_result = Mock()
|
||||
mock_result.all = Mock(return_value=[])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
side_effect=lambda model_cls, entity: {
|
||||
'id': entity.id,
|
||||
'name': entity.name,
|
||||
'key': entity.key,
|
||||
'description': entity.description,
|
||||
}
|
||||
if entity
|
||||
else {}
|
||||
)
|
||||
assert await service.get_api_keys(context) == []
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
async def test_get_api_keys_returns_serialized_list(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
await service.create_api_key(context, 'Test Key 1', 'First test key')
|
||||
await service.create_api_key(context, 'Test Key 2', 'Second test key')
|
||||
|
||||
# Execute
|
||||
result = await service.get_api_keys()
|
||||
result = await service.get_api_keys(context)
|
||||
|
||||
# Verify
|
||||
assert result == []
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
|
||||
async def test_get_api_keys_returns_serialized_list(self):
|
||||
"""Returns serialized list of API keys."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Create mock API key entities
|
||||
key1 = Mock(spec=ApiKey)
|
||||
key1.id = 1
|
||||
key1.name = 'Test Key 1'
|
||||
key1.key = 'lbk_test_key_1'
|
||||
key1.description = 'First test key'
|
||||
|
||||
key2 = Mock(spec=ApiKey)
|
||||
key2.id = 2
|
||||
key2.name = 'Test Key 2'
|
||||
key2.key = 'lbk_test_key_2'
|
||||
key2.description = 'Second test key'
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.all = Mock(return_value=[key1, key2])
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
side_effect=lambda model_cls, entity: {
|
||||
'id': entity.id,
|
||||
'name': entity.name,
|
||||
'key': entity.key,
|
||||
'description': entity.description,
|
||||
}
|
||||
)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_api_keys()
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
assert result[0]['name'] == 'Test Key 1'
|
||||
assert result[1]['name'] == 'Test Key 2'
|
||||
assert [item['name'] for item in result] == ['Test Key 1', 'Test Key 2']
|
||||
assert [item['description'] for item in result] == ['First test key', 'Second test key']
|
||||
assert all('key' not in item and 'key_hash' not in item for item in result)
|
||||
|
||||
|
||||
class TestApiKeyServiceCreateApiKey:
|
||||
"""Tests for create_api_key method."""
|
||||
async def test_create_api_key_generates_key_with_prefix(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
|
||||
async def test_create_api_key_generates_key_with_prefix(self):
|
||||
"""Creates API key with 'lbk_' prefix."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
monkeypatch.setattr(
|
||||
'langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', lambda _size: 'fixed-token'
|
||||
)
|
||||
result = await service.create_api_key(context, 'New Key', 'Test description')
|
||||
|
||||
created_key = Mock(spec=ApiKey)
|
||||
created_key.id = 1
|
||||
created_key.name = 'New Key'
|
||||
created_key.key = 'lbk_fixed-token'
|
||||
created_key.description = 'Test description'
|
||||
select_result = Mock()
|
||||
select_result.first = Mock(return_value=created_key)
|
||||
insert_params = []
|
||||
|
||||
async def mock_execute(query):
|
||||
params = query.compile().params
|
||||
if {'name', 'key', 'description'}.issubset(params):
|
||||
insert_params.append(params)
|
||||
return Mock()
|
||||
return select_result
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
side_effect=lambda model_cls, entity: {
|
||||
'id': 1,
|
||||
'name': entity.name,
|
||||
'key': entity.key,
|
||||
'description': entity.description,
|
||||
}
|
||||
)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
with patch('langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', return_value='fixed-token'):
|
||||
result = await service.create_api_key('New Key', 'Test description')
|
||||
|
||||
assert insert_params == [{'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'}]
|
||||
assert result['key'].startswith('lbk_')
|
||||
assert result['key'] == 'lbk_fixed-token'
|
||||
assert result['name'] == 'New Key'
|
||||
assert result['description'] == 'Test description'
|
||||
assert result['secret_available'] is True
|
||||
|
||||
async def test_create_api_key_without_description(self):
|
||||
"""Creates API key with empty description when not provided."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
async def test_create_api_key_without_description(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
|
||||
created_key = Mock(spec=ApiKey)
|
||||
created_key.id = 1
|
||||
created_key.name = 'No Desc Key'
|
||||
created_key.key = 'lbk_no_desc_key'
|
||||
created_key.description = ''
|
||||
result = await service.create_api_key(context, 'No Desc Key')
|
||||
|
||||
select_result = Mock()
|
||||
select_result.first = Mock(return_value=created_key)
|
||||
insert_result = Mock()
|
||||
|
||||
async def mock_execute(query):
|
||||
if hasattr(query, 'values'):
|
||||
return insert_result
|
||||
return select_result
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
'id': 1,
|
||||
'name': 'No Desc Key',
|
||||
'key': 'lbk_no_desc_key',
|
||||
'description': '',
|
||||
}
|
||||
)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.create_api_key('No Desc Key')
|
||||
|
||||
# Verify
|
||||
assert result['description'] == ''
|
||||
|
||||
|
||||
class TestApiKeyServiceGetApiKey:
|
||||
"""Tests for get_api_key method."""
|
||||
async def test_get_api_key_by_id_found(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Found Key', 'Found')
|
||||
|
||||
async def test_get_api_key_by_id_found(self):
|
||||
"""Returns API key when found by ID."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
result = await service.get_api_key(context, created['id'])
|
||||
|
||||
key = Mock(spec=ApiKey)
|
||||
key.id = 1
|
||||
key.name = 'Found Key'
|
||||
key.key = 'lbk_found_key'
|
||||
key.description = 'Found'
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=key)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
'id': 1,
|
||||
'name': 'Found Key',
|
||||
'key': 'lbk_found_key',
|
||||
'description': 'Found',
|
||||
}
|
||||
)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_api_key(1)
|
||||
|
||||
# Verify
|
||||
assert result is not None
|
||||
assert result['id'] == 1
|
||||
assert result['id'] == created['id']
|
||||
assert result['name'] == 'Found Key'
|
||||
assert 'key' not in result and 'key_hash' not in result
|
||||
|
||||
async def test_get_api_key_by_id_not_found(self):
|
||||
"""Returns None when API key not found."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
async def test_get_api_key_by_id_not_found(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
assert await service.get_api_key(context, 999) is None
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
async def test_get_api_key_by_id_zero(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
|
||||
# Execute
|
||||
result = await service.get_api_key(999)
|
||||
|
||||
# Verify
|
||||
assert result is None
|
||||
|
||||
async def test_get_api_key_by_id_zero(self):
|
||||
"""Handles ID=0 (edge case) correctly."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_api_key(0)
|
||||
|
||||
# Verify - should return None (no key with ID 0)
|
||||
assert result is None
|
||||
assert await service.get_api_key(context, 0) is None
|
||||
|
||||
|
||||
class TestApiKeyServiceVerifyApiKey:
|
||||
"""Tests for verify_api_key method."""
|
||||
async def test_verify_api_key_valid(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Valid')
|
||||
|
||||
@staticmethod
|
||||
def _make_ap(db_key=None, global_api_key=''):
|
||||
"""Build a mock Application with persistence + instance_config."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=db_key)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.instance_config = SimpleNamespace(data={'api': {'global_api_key': global_api_key}})
|
||||
return ap
|
||||
assert await service.verify_api_key(created['key']) is True
|
||||
|
||||
async def test_verify_api_key_valid(self):
|
||||
"""Returns True for valid API key."""
|
||||
# Setup
|
||||
key = Mock(spec=ApiKey)
|
||||
ap = self._make_ap(db_key=key)
|
||||
async def test_verify_api_key_invalid(self, api_key_context):
|
||||
_application, service, _context, _engine = api_key_context
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
assert await service.verify_api_key('lbk_invalid_key') is False
|
||||
|
||||
# Execute
|
||||
result = await service.verify_api_key('lbk_valid_key')
|
||||
async def test_verify_api_key_empty_string(self, api_key_context):
|
||||
_application, service, _context, _engine = api_key_context
|
||||
|
||||
# Verify
|
||||
assert result is True
|
||||
assert await service.verify_api_key('') is False
|
||||
|
||||
async def test_verify_api_key_invalid(self):
|
||||
"""Returns False for invalid API key."""
|
||||
# Setup
|
||||
ap = self._make_ap(db_key=None)
|
||||
async def test_verify_api_key_unknown_key(self, api_key_context):
|
||||
_application, service, _context, _engine = api_key_context
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
assert await service.verify_api_key('unknown_key') is False
|
||||
|
||||
# Execute
|
||||
result = await service.verify_api_key('lbk_invalid_key')
|
||||
async def test_verify_global_api_key_match(self, api_key_context):
|
||||
application, service, context, _engine = api_key_context
|
||||
application.instance_config.data['api']['global_api_key'] = 'my-global-secret'
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
identity = await service.authenticate_api_key('my-global-secret')
|
||||
|
||||
async def test_verify_api_key_empty_string(self):
|
||||
"""Returns False for empty key string."""
|
||||
# Setup
|
||||
ap = self._make_ap(db_key=None)
|
||||
assert identity is not None
|
||||
assert identity.workspace_uuid == context.workspace_uuid
|
||||
assert identity.api_key_uuid == 'global-oss-api-key'
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
async def test_verify_global_api_key_no_prefix_required(self, api_key_context):
|
||||
application, service, _context, _engine = api_key_context
|
||||
application.instance_config.data['api']['global_api_key'] = 'plainsecret123'
|
||||
|
||||
# Execute
|
||||
result = await service.verify_api_key('')
|
||||
assert await service.verify_api_key('plainsecret123') is True
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
async def test_verify_global_api_key_mismatch_falls_back_to_db(self, api_key_context):
|
||||
application, service, context, _engine = api_key_context
|
||||
application.instance_config.data['api']['global_api_key'] = 'my-global-secret'
|
||||
created = await service.create_api_key(context, 'DB key')
|
||||
|
||||
async def test_verify_api_key_unknown_key(self):
|
||||
"""Returns False when the key is not present in persistence."""
|
||||
# Setup
|
||||
ap = self._make_ap(db_key=None)
|
||||
identity = await service.authenticate_api_key(created['key'])
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
assert identity is not None
|
||||
assert identity.api_key_uuid == created['uuid']
|
||||
|
||||
# Execute
|
||||
result = await service.verify_api_key('unknown_key')
|
||||
async def test_verify_empty_global_api_key_disabled(self, api_key_context):
|
||||
application, service, _context, _engine = api_key_context
|
||||
application.instance_config.data['api']['global_api_key'] = ''
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
async def test_verify_global_api_key_match(self):
|
||||
"""Returns True when key matches the config.yaml global API key (no DB lookup)."""
|
||||
# Setup: no DB record, but a global key is configured
|
||||
ap = self._make_ap(db_key=None, global_api_key='my-global-secret')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.verify_api_key('my-global-secret')
|
||||
|
||||
# Verify: accepted purely on config match
|
||||
assert result is True
|
||||
# DB should not have been consulted for the global-key path
|
||||
ap.persistence_mgr.execute_async.assert_not_called()
|
||||
|
||||
async def test_verify_global_api_key_no_prefix_required(self):
|
||||
"""Global API key is accepted even without the lbk_ prefix."""
|
||||
ap = self._make_ap(db_key=None, global_api_key='plainsecret123')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
result = await service.verify_api_key('plainsecret123')
|
||||
|
||||
assert result is True
|
||||
|
||||
async def test_verify_global_api_key_mismatch_falls_back_to_db(self):
|
||||
"""A non-matching key still falls through to the DB lookup."""
|
||||
# Global key set, but request uses a different lbk_ key that IS in DB
|
||||
key = Mock(spec=ApiKey)
|
||||
ap = self._make_ap(db_key=key, global_api_key='my-global-secret')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
result = await service.verify_api_key('lbk_db_key')
|
||||
|
||||
assert result is True
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
|
||||
async def test_verify_empty_global_api_key_disabled(self):
|
||||
"""An empty global_api_key must never authenticate an empty/blank request."""
|
||||
ap = self._make_ap(db_key=None, global_api_key='')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Empty request key is rejected, and a blank global key never matches
|
||||
assert await service.verify_api_key('') is False
|
||||
assert await service.verify_api_key(' ') is False
|
||||
|
||||
async def test_verify_api_key_missing_global_config_key(self):
|
||||
"""Works even when api.global_api_key is absent (existing installs)."""
|
||||
# instance_config without the global_api_key field at all
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
mock_result = Mock()
|
||||
mock_result.first = Mock(return_value=None)
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
||||
ap.instance_config = SimpleNamespace(data={'api': {}})
|
||||
async def test_verify_api_key_missing_global_config_key(self, api_key_context):
|
||||
application, service, _context, _engine = api_key_context
|
||||
application.instance_config.data = {'api': {}}
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
result = await service.verify_api_key('lbk_some_key')
|
||||
|
||||
assert result is False
|
||||
assert await service.verify_api_key('lbk_some_key') is False
|
||||
|
||||
|
||||
class TestApiKeyServiceDeleteApiKey:
|
||||
"""Tests for delete_api_key method."""
|
||||
async def test_delete_api_key_by_id(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Delete me')
|
||||
|
||||
async def test_delete_api_key_by_id(self):
|
||||
"""Deletes API key by ID."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
await service.delete_api_key(context, created['id'])
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
stored = await service.get_api_key(context, created['id'])
|
||||
assert stored is not None
|
||||
assert stored['status'] == 'revoked'
|
||||
assert await service.verify_api_key(created['key']) is False
|
||||
|
||||
# Execute
|
||||
await service.delete_api_key(1)
|
||||
async def test_delete_api_key_nonexistent_id(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
|
||||
# Verify - execute_async was called (delete operation)
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
|
||||
async def test_delete_api_key_nonexistent_id(self):
|
||||
"""Delete operation completes even for nonexistent ID (no error raised)."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute - should not raise error
|
||||
await service.delete_api_key(999)
|
||||
|
||||
# Verify - execute_async was called regardless
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
with pytest.raises(WorkspaceNotFoundError, match='API key not found'):
|
||||
await service.delete_api_key(context, 999)
|
||||
|
||||
|
||||
class TestApiKeyServiceUpdateApiKey:
|
||||
"""Tests for update_api_key method."""
|
||||
async def test_update_api_key_name_only(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Original', 'Description')
|
||||
|
||||
async def test_update_api_key_name_only(self):
|
||||
"""Updates only the name field."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
await service.update_api_key(context, created['id'], name='Updated Name')
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
stored = await service.get_api_key(context, created['id'])
|
||||
assert stored is not None
|
||||
assert stored['name'] == 'Updated Name'
|
||||
assert stored['description'] == 'Description'
|
||||
|
||||
# Execute
|
||||
await service.update_api_key(1, name='Updated Name')
|
||||
async def test_update_api_key_description_only(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Original', 'Description')
|
||||
|
||||
# Verify - execute_async was called with update
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
await service.update_api_key(context, created['id'], description='Updated description')
|
||||
|
||||
async def test_update_api_key_description_only(self):
|
||||
"""Updates only the description field."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
stored = await service.get_api_key(context, created['id'])
|
||||
assert stored is not None
|
||||
assert stored['name'] == 'Original'
|
||||
assert stored['description'] == 'Updated description'
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
async def test_update_api_key_both_fields(self, api_key_context):
|
||||
_application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Original', 'Description')
|
||||
|
||||
# Execute
|
||||
await service.update_api_key(1, description='Updated description')
|
||||
await service.update_api_key(
|
||||
context,
|
||||
created['id'],
|
||||
name='New Name',
|
||||
description='New description',
|
||||
)
|
||||
|
||||
# Verify
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
stored = await service.get_api_key(context, created['id'])
|
||||
assert stored is not None
|
||||
assert stored['name'] == 'New Name'
|
||||
assert stored['description'] == 'New description'
|
||||
|
||||
async def test_update_api_key_both_fields(self):
|
||||
"""Updates both name and description."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
async def test_update_api_key_no_fields(self, api_key_context):
|
||||
application, service, context, _engine = api_key_context
|
||||
created = await service.create_api_key(context, 'Original')
|
||||
original_execute = application.persistence_mgr.execute_async
|
||||
application.persistence_mgr.execute_async = AsyncMock(wraps=original_execute)
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
await service.update_api_key(context, created['id'])
|
||||
|
||||
# Execute
|
||||
await service.update_api_key(1, name='New Name', description='New description')
|
||||
|
||||
# Verify
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
|
||||
async def test_update_api_key_no_fields(self):
|
||||
"""Does nothing when no fields provided."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
|
||||
service = ApiKeyService(ap)
|
||||
|
||||
# Execute
|
||||
await service.update_api_key(1)
|
||||
|
||||
# Verify - no execute call since no update_data
|
||||
ap.persistence_mgr.execute_async.assert_not_called()
|
||||
application.persistence_mgr.execute_async.assert_not_awaited()
|
||||
|
||||
Reference in New Issue
Block a user