master/test/test_docker_runtime.py
Azamat e629e34c4d ref #10: [fix] enforce UUID chat ids
Normalize chat ids to a single UUID form so locks, repository keys, and mount paths cannot diverge through path-like aliases.
2026-04-02 22:35:50 +03:00

218 lines
6.8 KiB
Python

from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, TypedDict
import pytest
from docker import DockerClient
from docker.errors import DockerException, NotFound
from docker.types import Mount
from adapter.config.model import SandboxConfig
from adapter.docker.runtime import DockerSandboxRuntime
from domain.error import SandboxError, SandboxStartError
from domain.sandbox import SandboxStatus
CHAT_ID = '123e4567-e89b-12d3-a456-426614174000'
NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000'
class FakeContainer:
def __init__(self, container_id: str) -> None:
self.id = container_id
self.stop_calls = 0
def stop(self) -> None:
self.stop_calls += 1
class RunKwargs(TypedDict):
detach: bool
labels: dict[str, str]
mounts: list[Mount]
class RunCall(TypedDict):
args: tuple[str]
kwargs: RunKwargs
class FakeContainers:
def __init__(self, run_result: FakeContainer | None = None) -> None:
self.run_calls: list[RunCall] = []
self.get_calls: list[str] = []
self.run_result = run_result or FakeContainer('container-123')
self.get_result: FakeContainer | Exception | None = None
def run(
self,
image: str,
*,
detach: bool,
labels: dict[str, str],
mounts: list[Mount],
) -> FakeContainer:
self.run_calls.append(
{
'args': (image,),
'kwargs': {
'detach': detach,
'labels': labels,
'mounts': mounts,
},
}
)
return self.run_result
def get(self, container_id: str) -> FakeContainer:
self.get_calls.append(container_id)
if isinstance(self.get_result, Exception):
raise self.get_result
if self.get_result is None:
raise AssertionError('missing get result')
return self.get_result
class FakeDockerClient(DockerClient):
def __init__(self, containers: FakeContainers) -> None:
self._containers = containers
@property
def containers(self) -> Any:
return self._containers
def build_config(tmp_path: Path) -> SandboxConfig:
return SandboxConfig(
image='sandbox:latest',
ttl_seconds=300,
cleanup_interval_seconds=60,
chats_root=str(tmp_path / 'chats'),
dependencies_host_path=str(tmp_path / 'dependencies'),
lambda_tools_host_path=str(tmp_path / 'lambda-tools'),
chat_mount_path='/workspace/chat',
dependencies_mount_path='/workspace/dependencies',
lambda_tools_mount_path='/workspace/lambda-tools',
)
def test_runtime_create_applies_mount_policy_and_labels_with_canonical_chat_id(
tmp_path: Path,
) -> None:
config = build_config(tmp_path)
(tmp_path / 'dependencies').mkdir()
(tmp_path / 'lambda-tools').mkdir()
containers = FakeContainers()
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expires_at = created_at + timedelta(minutes=5)
session = runtime.create(
session_id='session-123',
chat_id=NON_CANONICAL_CHAT_ID,
created_at=created_at,
expires_at=expires_at,
)
assert session.session_id == 'session-123'
assert session.chat_id == CHAT_ID
assert session.container_id == 'container-123'
assert session.status is SandboxStatus.RUNNING
assert session.created_at == created_at
assert session.expires_at == expires_at
assert (tmp_path / 'chats' / CHAT_ID).is_dir()
call = containers.run_calls[0]
assert call['args'] == ('sandbox:latest',)
assert call['kwargs']['detach'] is True
assert call['kwargs']['labels'] == {
'session_id': 'session-123',
'chat_id': CHAT_ID,
'expires_at': expires_at.isoformat(),
}
mounts = call['kwargs']['mounts']
assert [dict(mount) for mount in mounts] == [
{
'Target': '/workspace/chat',
'Source': str((tmp_path / 'chats' / CHAT_ID).resolve(strict=False)),
'Type': 'bind',
'ReadOnly': False,
},
{
'Target': '/workspace/dependencies',
'Source': str((tmp_path / 'dependencies').resolve(strict=False)),
'Type': 'bind',
'ReadOnly': True,
},
{
'Target': '/workspace/lambda-tools',
'Source': str((tmp_path / 'lambda-tools').resolve(strict=False)),
'Type': 'bind',
'ReadOnly': True,
},
]
def test_runtime_create_raises_start_error_when_container_id_is_missing(
tmp_path: Path,
) -> None:
config = build_config(tmp_path)
(tmp_path / 'dependencies').mkdir()
(tmp_path / 'lambda-tools').mkdir()
containers = FakeContainers(run_result=FakeContainer(''))
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
with pytest.raises(SandboxStartError) as excinfo:
runtime.create(
session_id='session-123',
chat_id=CHAT_ID,
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
)
assert str(excinfo.value) == 'sandbox_start_failed'
assert excinfo.value.chat_id == CHAT_ID
def test_runtime_stop_ignores_missing_container(tmp_path: Path) -> None:
config = build_config(tmp_path)
containers = FakeContainers()
containers.get_result = NotFound('missing')
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
runtime.stop('container-123')
assert containers.get_calls == ['container-123']
def test_runtime_stop_wraps_docker_errors(tmp_path: Path) -> None:
config = build_config(tmp_path)
containers = FakeContainers()
containers.get_result = DockerException('boom')
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
with pytest.raises(SandboxError) as excinfo:
runtime.stop('container-123')
assert str(excinfo.value) == 'sandbox_stop_failed'
@pytest.mark.parametrize('chat_id', ['.', 'a/..', 'x/../y'])
def test_runtime_create_rejects_non_uuid_chat_id(tmp_path: Path, chat_id: str) -> None:
config = build_config(tmp_path)
(tmp_path / 'dependencies').mkdir()
(tmp_path / 'lambda-tools').mkdir()
containers = FakeContainers()
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
with pytest.raises(SandboxStartError) as excinfo:
runtime.create(
session_id='session-123',
chat_id=chat_id,
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
)
assert str(excinfo.value) == 'sandbox_start_failed'
assert excinfo.value.chat_id == chat_id
assert containers.run_calls == []