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.
This commit is contained in:
Azamat 2026-04-02 22:35:50 +03:00
parent 44f1549d80
commit e629e34c4d
7 changed files with 192 additions and 80 deletions

View file

@ -6,6 +6,14 @@ from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
from repository.sandbox_session import InMemorySandboxSessionRepository
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
CHAT_ID = '11111111-1111-1111-1111-111111111111'
NON_CANONICAL_CHAT_ID = '11111111111111111111111111111111'
EXPIRED_CHAT_ID = '22222222-2222-2222-2222-222222222222'
BOUNDARY_CHAT_ID = '33333333-3333-3333-3333-333333333333'
ACTIVE_CHAT_ID = '44444444-4444-4444-4444-444444444444'
FAIL_CHAT_ID = '55555555-5555-5555-5555-555555555555'
CLEAN_CHAT_ID = '66666666-6666-6666-6666-666666666666'
class FakeClock:
def __init__(self, now: datetime) -> None:
@ -183,7 +191,7 @@ def test_create_sandbox_reuses_active_session_when_not_expired() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
session = SandboxSession(
session_id='session-1',
chat_id='chat-1',
chat_id=CHAT_ID,
container_id='container-1',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=1),
@ -203,19 +211,19 @@ def test_create_sandbox_reuses_active_session_when_not_expired() -> None:
ttl=timedelta(minutes=5),
)
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
result = usecase.execute(CreateSandboxCommand(chat_id=CHAT_ID))
assert result == session
assert runtime.create_calls == []
assert runtime.stop_calls == []
assert repository.get_active_by_chat_id('chat-1') == session
assert locker.chat_ids == ['chat-1']
assert repository.get_active_by_chat_id(CHAT_ID) == session
assert locker.chat_ids == [CHAT_ID]
assert logger.messages == [
(
'info',
'sandbox_reused',
{
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'session_id': 'session-1',
'container_id': 'container-1',
},
@ -229,7 +237,7 @@ def test_create_sandbox_replaces_expired_session_and_creates_new_one(
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_session = SandboxSession(
session_id='session-old',
chat_id='chat-1',
chat_id=CHAT_ID,
container_id='container-old',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=10),
@ -250,33 +258,33 @@ def test_create_sandbox_replaces_expired_session_and_creates_new_one(
)
monkeypatch.setattr('usecase.sandbox._new_session_id', lambda: 'session-new')
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
result = usecase.execute(CreateSandboxCommand(chat_id=CHAT_ID))
assert runtime.stop_calls == ['container-old']
assert runtime.create_calls == [
{
'session_id': 'session-new',
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'created_at': now,
'expires_at': now + timedelta(minutes=5),
}
]
assert result == SandboxSession(
session_id='session-new',
chat_id='chat-1',
chat_id=CHAT_ID,
container_id='container-session-new',
status=SandboxStatus.RUNNING,
created_at=now,
expires_at=now + timedelta(minutes=5),
)
assert repository.get_active_by_chat_id('chat-1') == result
assert locker.chat_ids == ['chat-1']
assert repository.get_active_by_chat_id(CHAT_ID) == result
assert locker.chat_ids == [CHAT_ID]
assert logger.messages == [
(
'info',
'sandbox_replaced',
{
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'session_id': 'session-old',
'container_id': 'container-old',
},
@ -285,7 +293,7 @@ def test_create_sandbox_replaces_expired_session_and_creates_new_one(
'info',
'sandbox_created',
{
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'session_id': 'session-new',
'container_id': 'container-session-new',
},
@ -308,9 +316,9 @@ def test_create_sandbox_creates_new_session_when_none_exists() -> None:
ttl=timedelta(minutes=5),
)
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
result = usecase.execute(CreateSandboxCommand(chat_id=NON_CANONICAL_CHAT_ID))
assert result.chat_id == 'chat-1'
assert result.chat_id == CHAT_ID
assert result.container_id == f'container-{result.session_id}'
assert result.status is SandboxStatus.RUNNING
assert result.created_at == now
@ -318,19 +326,20 @@ def test_create_sandbox_creates_new_session_when_none_exists() -> None:
assert len(runtime.create_calls) == 1
assert runtime.create_calls[0] == {
'session_id': result.session_id,
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'created_at': now,
'expires_at': now + timedelta(minutes=5),
}
assert runtime.stop_calls == []
assert repository.get_active_by_chat_id('chat-1') == result
assert locker.chat_ids == ['chat-1']
assert repository.get_active_by_chat_id(CHAT_ID) == result
assert repository.get_active_by_chat_id(NON_CANONICAL_CHAT_ID) is None
assert locker.chat_ids == [CHAT_ID]
assert logger.messages == [
(
'info',
'sandbox_created',
{
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'session_id': result.session_id,
'container_id': result.container_id,
},
@ -361,7 +370,7 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
def run_create(index: int) -> None:
try:
results[index] = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
results[index] = usecase.execute(CreateSandboxCommand(chat_id=CHAT_ID))
except Exception as exc:
errors.append(exc)
@ -384,7 +393,7 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
assert results[0] == results[1]
assert results[0] == SandboxSession(
session_id='session-new',
chat_id='chat-1',
chat_id=CHAT_ID,
container_id='container-session-new',
status=SandboxStatus.RUNNING,
created_at=now,
@ -392,14 +401,14 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
)
assert len(runtime.create_calls) == 1
assert runtime.stop_calls == []
assert repository.get_active_by_chat_id('chat-1') == results[0]
assert locker.chat_ids == ['chat-1', 'chat-1']
assert repository.get_active_by_chat_id(CHAT_ID) == results[0]
assert locker.chat_ids == [CHAT_ID, CHAT_ID]
assert logger.messages == [
(
'info',
'sandbox_created',
{
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'session_id': 'session-new',
'container_id': 'container-session-new',
},
@ -408,7 +417,7 @@ def test_create_sandbox_serializes_duplicate_concurrent_create_for_chat_id(
'info',
'sandbox_reused',
{
'chat_id': 'chat-1',
'chat_id': CHAT_ID,
'session_id': 'session-new',
'container_id': 'container-session-new',
},
@ -420,7 +429,7 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_session = SandboxSession(
session_id='session-expired',
chat_id='chat-expired',
chat_id=EXPIRED_CHAT_ID,
container_id='container-expired',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=10),
@ -428,7 +437,7 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
)
boundary_session = SandboxSession(
session_id='session-boundary',
chat_id='chat-boundary',
chat_id=BOUNDARY_CHAT_ID,
container_id='container-boundary',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=5),
@ -436,7 +445,7 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
)
active_session = SandboxSession(
session_id='session-active',
chat_id='chat-active',
chat_id=ACTIVE_CHAT_ID,
container_id='container-active',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=1),
@ -461,16 +470,16 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
assert result == [expired_session, boundary_session]
assert runtime.stop_calls == ['container-expired', 'container-boundary']
assert repository.get_active_by_chat_id('chat-expired') is None
assert repository.get_active_by_chat_id('chat-boundary') is None
assert repository.get_active_by_chat_id('chat-active') == active_session
assert locker.chat_ids == ['chat-expired', 'chat-boundary']
assert repository.get_active_by_chat_id(EXPIRED_CHAT_ID) is None
assert repository.get_active_by_chat_id(BOUNDARY_CHAT_ID) is None
assert repository.get_active_by_chat_id(ACTIVE_CHAT_ID) == active_session
assert locker.chat_ids == [EXPIRED_CHAT_ID, BOUNDARY_CHAT_ID]
assert logger.messages == [
(
'info',
'sandbox_cleaned',
{
'chat_id': 'chat-expired',
'chat_id': EXPIRED_CHAT_ID,
'session_id': 'session-expired',
'container_id': 'container-expired',
},
@ -479,7 +488,7 @@ def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() ->
'info',
'sandbox_cleaned',
{
'chat_id': 'chat-boundary',
'chat_id': BOUNDARY_CHAT_ID,
'session_id': 'session-boundary',
'container_id': 'container-boundary',
},
@ -491,7 +500,7 @@ def test_cleanup_expired_sandboxes_skips_replaced_session_from_stale_snapshot()
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
expired_snapshot = SandboxSession(
session_id='session-expired',
chat_id='chat-1',
chat_id=CHAT_ID,
container_id='container-expired',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=10),
@ -499,7 +508,7 @@ def test_cleanup_expired_sandboxes_skips_replaced_session_from_stale_snapshot()
)
replacement_session = SandboxSession(
session_id='session-new',
chat_id='chat-1',
chat_id=CHAT_ID,
container_id='container-new',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(seconds=30),
@ -522,8 +531,8 @@ def test_cleanup_expired_sandboxes_skips_replaced_session_from_stale_snapshot()
assert result == []
assert runtime.stop_calls == []
assert repository.get_active_by_chat_id('chat-1') == replacement_session
assert locker.chat_ids == ['chat-1']
assert repository.get_active_by_chat_id(CHAT_ID) == replacement_session
assert locker.chat_ids == [CHAT_ID]
assert logger.messages == []
@ -531,7 +540,7 @@ def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
failing_session = SandboxSession(
session_id='session-fail',
chat_id='chat-fail',
chat_id=FAIL_CHAT_ID,
container_id='container-fail',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=10),
@ -539,7 +548,7 @@ def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
)
cleaned_session = SandboxSession(
session_id='session-clean',
chat_id='chat-clean',
chat_id=CLEAN_CHAT_ID,
container_id='container-clean',
status=SandboxStatus.RUNNING,
created_at=now - timedelta(minutes=9),
@ -563,15 +572,15 @@ def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
assert result == [cleaned_session]
assert runtime.stop_calls == ['container-fail', 'container-clean']
assert repository.get_active_by_chat_id('chat-fail') == failing_session
assert repository.get_active_by_chat_id('chat-clean') is None
assert locker.chat_ids == ['chat-fail', 'chat-clean']
assert repository.get_active_by_chat_id(FAIL_CHAT_ID) == failing_session
assert repository.get_active_by_chat_id(CLEAN_CHAT_ID) is None
assert locker.chat_ids == [FAIL_CHAT_ID, CLEAN_CHAT_ID]
assert logger.messages == [
(
'error',
'sandbox_clean_failed',
{
'chat_id': 'chat-fail',
'chat_id': FAIL_CHAT_ID,
'session_id': 'session-fail',
'container_id': 'container-fail',
'error': 'RuntimeError',
@ -581,7 +590,7 @@ def test_cleanup_expired_sandboxes_continues_after_stop_failure() -> None:
'info',
'sandbox_cleaned',
{
'chat_id': 'chat-clean',
'chat_id': CLEAN_CHAT_ID,
'session_id': 'session-clean',
'container_id': 'container-clean',
},