"""Step definitions for resource_handler_sandbox.feature. Tests sandbox lifecycle, checkpoint/rollback, and project_access for GitCheckoutHandler and FsDirectoryHandler. All step text uses the ``rh836-`` prefix to avoid collisions. Issue #836: ResourceHandler sandbox and checkpoint methods. """ from __future__ import annotations import subprocess import tempfile from pathlib import Path from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.domain.models.core.resource import ( PhysVirt, Resource, ResourceCapabilities, ) from cleveragents.infrastructure.sandbox.factory import SandboxFactory from cleveragents.infrastructure.sandbox.manager import SandboxManager from cleveragents.resource.handlers.database import DatabaseResourceHandler from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler __all__: list[str] = [] _COUNTER = 0 _CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" def _next_id() -> str: """Generate a valid 26-char Crockford Base32 ID for tests.""" global _COUNTER _COUNTER += 1 n = _COUNTER + 0x836000 chars: list[str] = [] for _ in range(26): chars.append(_CB32[n % 32]) n //= 32 return "".join(reversed(chars)) def _make_resource(rtype: str, location: str) -> Resource: return Resource( resource_id=_next_id(), resource_type_name=rtype, classification=PhysVirt.PHYSICAL, location=location, capabilities=ResourceCapabilities( readable=True, writable=True, sandboxable=True ), ) # --------------------------------------------------------------------------- # Given: temp directories and git repos # --------------------------------------------------------------------------- @given('rh836- a temp directory with file "{fname}" containing "{content}"') def step_rh836_temp_dir(context: Context, fname: str, content: str) -> None: context.rh836_tmpdir = tempfile.mkdtemp(prefix="rh836_fs_") Path(context.rh836_tmpdir, fname).write_text(content, encoding="utf-8") @given("rh836- an fs-directory resource and sandbox manager") def step_rh836_fs_resource_and_manager(context: Context) -> None: context.rh836_handler = FsDirectoryHandler() context.rh836_resource = _make_resource("fs-directory", context.rh836_tmpdir) context.rh836_plan_id = _next_id() factory = SandboxFactory() context.rh836_sandbox_mgr = SandboxManager(factory=factory, cleanup_on_exit=False) @given('rh836- a temp git repo with file "{fname}" containing "{content}"') def step_rh836_temp_git_repo(context: Context, fname: str, content: str) -> None: context.rh836_tmpdir = tempfile.mkdtemp(prefix="rh836_git_") subprocess.run( ["git", "init"], cwd=context.rh836_tmpdir, capture_output=True, check=True ) subprocess.run( ["git", "config", "user.email", "test@test.com"], cwd=context.rh836_tmpdir, capture_output=True, check=True, ) subprocess.run( ["git", "config", "user.name", "Test"], cwd=context.rh836_tmpdir, capture_output=True, check=True, ) Path(context.rh836_tmpdir, fname).write_text(content, encoding="utf-8") subprocess.run( ["git", "add", "."], cwd=context.rh836_tmpdir, capture_output=True, check=True ) subprocess.run( ["git", "commit", "-m", "init"], cwd=context.rh836_tmpdir, capture_output=True, check=True, ) @given("rh836- a git-checkout resource and sandbox manager") def step_rh836_git_resource_and_manager(context: Context) -> None: context.rh836_handler = GitCheckoutHandler() context.rh836_resource = _make_resource("git-checkout", context.rh836_tmpdir) context.rh836_plan_id = _next_id() factory = SandboxFactory() context.rh836_sandbox_mgr = SandboxManager(factory=factory, cleanup_on_exit=False) @given("rh836- a database handler and dummy resource") def step_rh836_db_handler(context: Context) -> None: context.rh836_handler = DatabaseResourceHandler() context.rh836_resource = _make_resource("postgres", "/tmp/fake-db") context.rh836_plan_id = _next_id() context.rh836_sandbox_mgr = MagicMock(spec=SandboxManager) # --------------------------------------------------------------------------- # When: sandbox operations # --------------------------------------------------------------------------- @when("rh836- I call create_sandbox on the fs-directory handler") @when("rh836- I call create_sandbox on the git-checkout handler") def step_rh836_create_sandbox(context: Context) -> None: context.rh836_sandbox_result = context.rh836_handler.create_sandbox( resource=context.rh836_resource, plan_id=context.rh836_plan_id, sandbox_manager=context.rh836_sandbox_mgr, ) @when("rh836- I call create_sandbox on the fs-directory handler again") @when("rh836- I call create_sandbox on the git-checkout handler again") def step_rh836_create_sandbox_again(context: Context) -> None: context.rh836_sandbox_result_2 = context.rh836_handler.create_sandbox( resource=context.rh836_resource, plan_id=context.rh836_plan_id, sandbox_manager=context.rh836_sandbox_mgr, ) @when("rh836- I create a checkpoint on the fs-directory handler") @when("rh836- I create a checkpoint on the git-checkout handler") def step_rh836_create_checkpoint(context: Context) -> None: context.rh836_checkpoint_result = context.rh836_handler.create_checkpoint( resource=context.rh836_resource, plan_id=context.rh836_plan_id, sandbox_manager=context.rh836_sandbox_mgr, ) @when('rh836- I modify the sandbox file "{fname}" to "{content}"') def step_rh836_modify_sandbox_file(context: Context, fname: str, content: str) -> None: sandbox_path = context.rh836_sandbox_result.sandbox_path Path(sandbox_path, fname).write_text(content, encoding="utf-8") @when("rh836- I rollback the fs-directory handler to the checkpoint") @when("rh836- I rollback the git-checkout handler to the checkpoint") def step_rh836_rollback(context: Context) -> None: context.rh836_rollback_result = context.rh836_handler.rollback_to( resource=context.rh836_resource, plan_id=context.rh836_plan_id, checkpoint_id=context.rh836_checkpoint_result.checkpoint_id, sandbox_manager=context.rh836_sandbox_mgr, ) @when('rh836- I check project_access for principal "{principal}" action "{action}"') def step_rh836_check_access(context: Context, principal: str, action: str) -> None: context.rh836_access_result = context.rh836_handler.project_access( resource=context.rh836_resource, principal=principal, action=action, ) @when("rh836- I call create_checkpoint on the database handler") def step_rh836_db_checkpoint(context: Context) -> None: try: context.rh836_result = context.rh836_handler.create_checkpoint( resource=context.rh836_resource, plan_id=context.rh836_plan_id, sandbox_manager=context.rh836_sandbox_mgr, ) context.rh836_error = None except Exception as exc: context.rh836_error = exc context.rh836_result = None @when("rh836- I call rollback_to on the database handler") def step_rh836_db_rollback(context: Context) -> None: try: context.rh836_result = context.rh836_handler.rollback_to( resource=context.rh836_resource, plan_id=context.rh836_plan_id, checkpoint_id="fake-checkpoint", sandbox_manager=context.rh836_sandbox_mgr, ) context.rh836_error = None except Exception as exc: context.rh836_error = exc context.rh836_result = None # --------------------------------------------------------------------------- # Then: assertions # --------------------------------------------------------------------------- @then("rh836- the sandbox result should be created") def step_rh836_sandbox_created(context: Context) -> None: assert context.rh836_sandbox_result.created is True @then("rh836- the sandbox result path should exist") def step_rh836_sandbox_path_exists(context: Context) -> None: assert Path(context.rh836_sandbox_result.sandbox_path).exists() @then("rh836- the second sandbox result should not be created") def step_rh836_second_not_created(context: Context) -> None: assert context.rh836_sandbox_result_2.created is False @then("rh836- both sandbox results should have the same sandbox_id") def step_rh836_same_sandbox_id(context: Context) -> None: assert ( context.rh836_sandbox_result.sandbox_id == context.rh836_sandbox_result_2.sandbox_id ) @then("rh836- the rollback result should be successful") def step_rh836_rollback_success(context: Context) -> None: assert context.rh836_rollback_result.success is True, ( f"Rollback failed: {context.rh836_rollback_result.message}" ) @then('rh836- the sandbox file "{fname}" should contain "{expected}"') def step_rh836_sandbox_file_contains( context: Context, fname: str, expected: str ) -> None: sandbox_path = context.rh836_sandbox_result.sandbox_path actual = Path(sandbox_path, fname).read_text(encoding="utf-8").strip() assert actual == expected, f"Expected '{expected}', got '{actual}'" @then("rh836- the access result should be permitted") def step_rh836_access_permitted(context: Context) -> None: assert context.rh836_access_result.permitted is True @then('rh836- the access result reason should contain "{fragment}"') def step_rh836_access_reason(context: Context, fragment: str) -> None: assert fragment in context.rh836_access_result.reason, ( f"'{fragment}' not in '{context.rh836_access_result.reason}'" ) @then('rh836- a NotImplementedError should be raised containing "{fragment}"') def step_rh836_not_implemented(context: Context, fragment: str) -> None: assert isinstance(context.rh836_error, NotImplementedError), ( f"Expected NotImplementedError, got {type(context.rh836_error)}" ) assert fragment in str(context.rh836_error), ( f"'{fragment}' not in '{context.rh836_error}'" ) @then("rh836- a checkpoint result should be returned without error") def step_rh836_checkpoint_result_no_error(context: Context) -> None: assert context.rh836_error is None, ( f"Expected no error, got {type(context.rh836_error)}: {context.rh836_error}" ) assert context.rh836_result is not None, "Expected a checkpoint result, got None" @then("rh836- a rollback result should be returned without error") def step_rh836_rollback_result_no_error(context: Context) -> None: assert context.rh836_error is None, ( f"Expected no error, got {type(context.rh836_error)}: {context.rh836_error}" ) assert context.rh836_result is not None, "Expected a rollback result, got None"