"""Step definitions for resource_handler_content_hash.feature.""" from __future__ import annotations import os import subprocess import tempfile from pathlib import Path from typing import Any from behave import given, then, when from cleveragents.domain.models.core.resource import PhysVirt, Resource from cleveragents.resource.handlers._base import EMPTY_CONTENT_HASH, BaseResourceHandler from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler def _make_resource(location: str | None = None) -> Resource: """Create a minimal Resource for testing.""" from ulid import ULID return Resource( resource_id=str(ULID()), resource_type_name="fs-directory", name="test-resource", classification=PhysVirt.PHYSICAL, location=location, ) # ── Given steps ────────────────────────────────────────────── @given("a resource with no location for content_hash") def step_resource_no_location(context: object) -> None: """Create a resource with no location.""" context.ch_resource = _make_resource(location=None) # type: ignore[attr-defined] @given('a resource with location "{path}" for content_hash') def step_resource_with_location(context: object, path: str) -> None: """Create a resource with a specific location.""" context.ch_resource = _make_resource(location=path) # type: ignore[attr-defined] @given('a temporary file with content "{content}" for content_hash') def step_temp_file(context: object, content: str) -> None: """Create a temporary file with given content.""" fd, path = tempfile.mkstemp(suffix=".txt") os.write(fd, content.encode()) os.close(fd) context.ch_resource = _make_resource(location=path) # type: ignore[attr-defined] context.ch_temp_path = path # type: ignore[attr-defined] @given('a second temporary file with content "{content}" for content_hash') def step_second_temp_file(context: object, content: str) -> None: """Create a second temporary file.""" fd, path = tempfile.mkstemp(suffix=".txt") os.write(fd, content.encode()) os.close(fd) context.ch_resource2 = _make_resource(location=path) # type: ignore[attr-defined] @given("a temporary directory with files for content_hash") def step_temp_dir_with_files(context: object) -> None: """Create a temp directory with test files.""" tmpdir = tempfile.mkdtemp(prefix="ch-test-") Path(tmpdir, "file_a.txt").write_text("alpha") Path(tmpdir, "file_b.txt").write_text("beta") Path(tmpdir, "sub").mkdir() Path(tmpdir, "sub", "file_c.txt").write_text("gamma") context.ch_resource = _make_resource(location=tmpdir) # type: ignore[attr-defined] context.ch_temp_dir = tmpdir # type: ignore[attr-defined] @given("a temporary git repository for content_hash") def step_temp_git_repo(context: object) -> None: """Create a temp git repo with a commit.""" tmpdir = tempfile.mkdtemp(prefix="ch-git-") subprocess.run( ["git", "init"], cwd=tmpdir, capture_output=True, check=True, ) subprocess.run( ["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True, check=True, ) subprocess.run( ["git", "config", "user.name", "Test"], cwd=tmpdir, capture_output=True, check=True, ) Path(tmpdir, "README.md").write_text("# Test") subprocess.run( ["git", "add", "."], cwd=tmpdir, capture_output=True, check=True, ) subprocess.run( ["git", "commit", "-m", "init"], cwd=tmpdir, capture_output=True, check=True, ) context.ch_resource = _make_resource(location=tmpdir) # type: ignore[attr-defined] # ── When steps ─────────────────────────────────────────────── @when("I compute the content_hash via base handler") def step_compute_base(context: object) -> None: """Compute hash via BaseResourceHandler.""" handler = BaseResourceHandler() handler._default_strategy = "none" # type: ignore[assignment] handler._type_label = "test" # type: ignore[assignment] context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] @when("I compute the content_hash twice via base handler") def step_compute_twice(context: object) -> None: """Compute hash twice to verify determinism.""" handler = BaseResourceHandler() handler._default_strategy = "none" # type: ignore[assignment] handler._type_label = "test" # type: ignore[assignment] res = context.ch_resource # type: ignore[attr-defined] context.ch_result = handler.content_hash(res) # type: ignore[attr-defined] context.ch_result2 = handler.content_hash(res) # type: ignore[attr-defined] @when("I compute content_hash for both files") def step_compute_both(context: object) -> None: """Compute hashes for two different resources.""" handler = BaseResourceHandler() handler._default_strategy = "none" # type: ignore[assignment] handler._type_label = "test" # type: ignore[assignment] context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] context.ch_result2 = handler.content_hash(context.ch_resource2) # type: ignore[attr-defined] @when("I compute the content_hash via FsDirectoryHandler") def step_compute_fsdir(context: object) -> None: """Compute hash via FsDirectoryHandler.""" handler = FsDirectoryHandler() context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] @when("I modify a file in the directory") def step_modify_file(context: object) -> None: """Modify a file in the temp directory.""" context.ch_prev_result = context.ch_result # type: ignore[attr-defined] tmpdir = context.ch_temp_dir # type: ignore[attr-defined] Path(tmpdir, "file_a.txt").write_text("alpha-modified") @when("I compute the content_hash via FsDirectoryHandler again") def step_compute_fsdir_again(context: object) -> None: """Re-compute hash after modification.""" handler = FsDirectoryHandler() context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] @when("I compute the content_hash via GitCheckoutHandler") def step_compute_git(context: object) -> None: """Compute hash via GitCheckoutHandler.""" handler = GitCheckoutHandler() context.ch_result = handler.content_hash(context.ch_resource) # type: ignore[attr-defined] @when('I compute the content_hash with algorithm "{algo}"') def step_compute_with_algo(context: object, algo: str) -> None: """Compute hash with a specific algorithm.""" handler = BaseResourceHandler() handler._default_strategy = "none" # type: ignore[assignment] handler._type_label = "test" # type: ignore[assignment] context.ch_result = handler.content_hash( # type: ignore[attr-defined] context.ch_resource, algorithm=algo, # type: ignore[attr-defined] ) # ── Then steps ─────────────────────────────────────────────── @then("the content_hash result should be the EMPTY sentinel") def step_is_sentinel(context: object) -> None: """Assert the result equals EMPTY_CONTENT_HASH.""" assert context.ch_result == EMPTY_CONTENT_HASH, ( # type: ignore[attr-defined] f"Expected sentinel, got {context.ch_result}" # type: ignore[attr-defined] ) @then("the content_hash result should not be the EMPTY sentinel") def step_not_sentinel(context: object) -> None: """Assert the result is not the sentinel.""" assert context.ch_result != EMPTY_CONTENT_HASH, ( # type: ignore[attr-defined] "Expected non-sentinel hash" ) @then("the two content_hash results should be identical") def step_results_identical(context: object) -> None: """Assert two hash results are the same.""" assert context.ch_result == context.ch_result2, ( # type: ignore[attr-defined] f"Hashes differ: {context.ch_result} vs {context.ch_result2}" # type: ignore[attr-defined] ) @then("the two content_hash results should differ") def step_results_differ(context: object) -> None: """Assert two hash results are different.""" assert context.ch_result != context.ch_result2, ( # type: ignore[attr-defined] f"Hashes should differ but both are {context.ch_result}" # type: ignore[attr-defined] ) @then("the two directory hashes should differ") def step_dir_hashes_differ(context: object) -> None: """Assert directory hash changed after modification.""" assert context.ch_prev_result != context.ch_result, ( # type: ignore[attr-defined] "Directory hash should change after file modification" ) @then("the content_hash length should be {length:d}") def step_hash_length(context: object, length: int) -> None: """Assert the hash has the expected hex-string length.""" actual = len(context.ch_result) # type: ignore[attr-defined] assert actual == length, f"Expected length {length}, got {actual}" @then("{handler_name} should have a content_hash method") def step_has_method(context: object, handler_name: str) -> None: """Assert the handler class has a content_hash method.""" handlers = { "GitCheckoutHandler": GitCheckoutHandler, "FsDirectoryHandler": FsDirectoryHandler, } # Lazy imports for handlers not already imported if handler_name == "DevcontainerHandler": from cleveragents.resource.handlers.devcontainer import DevcontainerHandler handlers["DevcontainerHandler"] = DevcontainerHandler elif handler_name == "DatabaseResourceHandler": from cleveragents.resource.handlers.database import DatabaseResourceHandler handlers["DatabaseResourceHandler"] = DatabaseResourceHandler cls = handlers.get(handler_name) assert cls is not None, f"Unknown handler: {handler_name}" assert hasattr(cls, "content_hash"), f"{handler_name} missing content_hash method" # --------------------------------------------------------------------------- # DevcontainerHandler content_hash steps # --------------------------------------------------------------------------- @given("a temporary devcontainer project for content_hash") def step_devcontainer_project(context: Any) -> None: tmpdir = tempfile.mkdtemp() dc_dir = os.path.join(tmpdir, ".devcontainer") os.makedirs(dc_dir) config = os.path.join(dc_dir, "devcontainer.json") with open(config, "w") as f: f.write( '{"name": "test", "image": "mcr.microsoft.com/devcontainers/base:ubuntu"}\n' ) context.ch_resource = Resource( resource_id="01DCTEST00000000000000DC01", resource_type_name="devcontainer-file", name="test-devcontainer", classification=PhysVirt.PHYSICAL, location=tmpdir, ) context._ch_tmpdir = tmpdir @when("I compute the content_hash via DevcontainerHandler") def step_devcontainer_hash(context: Any) -> None: from cleveragents.resource.handlers.devcontainer import DevcontainerHandler handler = DevcontainerHandler() context.ch_result = handler.content_hash(context.ch_resource) @when("I compute the content_hash via DevcontainerHandler for missing") def step_devcontainer_hash_missing(context: Any) -> None: from cleveragents.resource.handlers.devcontainer import DevcontainerHandler handler = DevcontainerHandler() context.ch_result = handler.content_hash(context.ch_resource) @then("the devcontainer hash should be from config file fallback") def step_devcontainer_config_fallback(context: Any) -> None: # If no running container, the hash comes from config file # Verify it's not empty (config file was hashed) assert context.ch_result != EMPTY_CONTENT_HASH assert len(context.ch_result) == 64 # SHA-256 # --------------------------------------------------------------------------- # DatabaseResourceHandler content_hash steps # --------------------------------------------------------------------------- @given("a temporary SQLite database for content_hash") def step_sqlite_db(context: Any) -> None: import sqlite3 fd, db_path = tempfile.mkstemp(suffix=".db") os.close(fd) conn = sqlite3.connect(db_path) conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") conn.execute("INSERT INTO users VALUES (1, 'alice')") conn.commit() conn.close() context.ch_resource = Resource( resource_id="01DBTEST00000000000000DB01", resource_type_name="sqlite", name="test-db", classification=PhysVirt.PHYSICAL, location=db_path, ) context._ch_db_path = db_path @when("I compute the content_hash via DatabaseResourceHandler") def step_database_hash(context: Any) -> None: from cleveragents.resource.handlers.database import DatabaseResourceHandler handler = DatabaseResourceHandler() context.ch_result = handler.content_hash(context.ch_resource) @when("I add a table to the database") def step_add_table(context: Any) -> None: import sqlite3 conn = sqlite3.connect(context._ch_db_path) conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)") conn.commit() conn.close() @when("I compute the content_hash via DatabaseResourceHandler again") def step_database_hash_again(context: Any) -> None: from cleveragents.resource.handlers.database import DatabaseResourceHandler handler = DatabaseResourceHandler() context.ch_result2 = handler.content_hash(context.ch_resource) @then("the two database hashes should differ") def step_database_hashes_differ(context: Any) -> None: assert context.ch_result != context.ch_result2, ( f"Expected different hashes after schema change, both are {context.ch_result}" ) @given("a remote database resource for content_hash") def step_remote_db_resource(context: Any) -> None: context.ch_resource = Resource( resource_id="01DBTEST00000000000000DB02", resource_type_name="postgres", name="test-remote-db", classification=PhysVirt.PHYSICAL, location="postgresql://host/db", ) @when("I compute the content_hash via DatabaseResourceHandler for remote") def step_database_hash_remote(context: Any) -> None: from cleveragents.resource.handlers.database import DatabaseResourceHandler handler = DatabaseResourceHandler() context.ch_result = handler.content_hash(context.ch_resource) # --------------------------------------------------------------------------- # CloudResourceHandler content_hash steps # --------------------------------------------------------------------------- @given("a cloud resource with ARN location for content_hash") def step_cloud_resource_with_location(context: Any) -> None: context.ch_resource = Resource( resource_id="01AATEST0000000000000CCC01", resource_type_name="aws-s3", name="test-bucket", classification=PhysVirt.PHYSICAL, location="arn:aws:s3:::my-bucket", ) @given("a cloud resource without location for content_hash") def step_cloud_resource_no_location(context: Any) -> None: context.ch_resource = Resource( resource_id="01AATEST0000000000000CCC02", resource_type_name="aws-s3", name="test-bucket-noloc", classification=PhysVirt.PHYSICAL, ) @when("I compute the content_hash via CloudResourceHandler") def step_cloud_hash(context: Any) -> None: from cleveragents.resource.handlers.cloud import CloudResourceHandler handler = CloudResourceHandler() context.ch_result = handler.content_hash(context.ch_resource) @when("I compute the content_hash via CloudResourceHandler for empty") def step_cloud_hash_empty(context: Any) -> None: from cleveragents.resource.handlers.cloud import CloudResourceHandler handler = CloudResourceHandler() context.ch_result = handler.content_hash(context.ch_resource) # --------------------------------------------------------------------------- # Edge case and error path coverage steps # --------------------------------------------------------------------------- @given("a temporary directory with only subdirectories for content_hash") def step_dir_only_subdirs(context: Any) -> None: tmpdir = tempfile.mkdtemp() os.makedirs(os.path.join(tmpdir, "subdir1")) os.makedirs(os.path.join(tmpdir, "subdir2")) context.ch_resource = Resource( resource_id="01EDGE00000000000000000001", resource_type_name="fs-directory", name="empty-subdirs", classification=PhysVirt.PHYSICAL, location=tmpdir, ) @when("I compute the content_hash via base handler for dir") def step_base_hash_dir(context: Any) -> None: handler = BaseResourceHandler() context.ch_result = handler.content_hash(context.ch_resource) @given("a temporary file pretending to be a directory for content_hash") def step_file_as_dir(context: Any) -> None: fd, fpath = tempfile.mkstemp(suffix=".txt") os.close(fd) context.ch_resource = Resource( resource_id="01EDGE00000000000000000002", resource_type_name="fs-directory", name="not-a-dir", classification=PhysVirt.PHYSICAL, location=fpath, ) @when("I compute the content_hash via FsDirectoryHandler for non-dir") def step_fsdir_hash_nondir(context: Any) -> None: handler = FsDirectoryHandler() context.ch_result = handler.content_hash(context.ch_resource) @given("a temporary directory with an unreadable file for content_hash") def step_dir_unreadable(context: Any) -> None: tmpdir = tempfile.mkdtemp() good = os.path.join(tmpdir, "good.txt") bad = os.path.join(tmpdir, "bad.txt") with open(good, "w") as f: f.write("readable content") with open(bad, "w") as f: f.write("secret") os.chmod(bad, 0o000) context.ch_resource = Resource( resource_id="01EDGE00000000000000000003", resource_type_name="fs-directory", name="has-unreadable", classification=PhysVirt.PHYSICAL, location=tmpdir, ) context._ch_bad_file = bad @when("I compute the content_hash via FsDirectoryHandler for unreadable") def step_fsdir_hash_unreadable(context: Any) -> None: handler = FsDirectoryHandler() context.ch_result = handler.content_hash(context.ch_resource) # Restore permissions for cleanup os.chmod(context._ch_bad_file, 0o644) @given("a temporary non-git directory for content_hash") def step_nongit_dir(context: Any) -> None: tmpdir = tempfile.mkdtemp() # Create a corrupt .git file so git rev-parse HEAD fails even if # the temp dir is inside a git worktree (CI environments). with open(os.path.join(tmpdir, ".git"), "w") as f: f.write("not a valid git dir\n") with open(os.path.join(tmpdir, "file.txt"), "w") as f: f.write("not a git repo") context.ch_resource = Resource( resource_id="01EDGE00000000000000000004", resource_type_name="git-checkout", name="not-git", classification=PhysVirt.PHYSICAL, location=tmpdir, ) @when("I compute the content_hash via GitCheckoutHandler for non-git") def step_git_hash_nongit(context: Any) -> None: handler = GitCheckoutHandler() context.ch_result = handler.content_hash(context.ch_resource) @given("a temporary file as git location for content_hash") def step_file_as_git(context: Any) -> None: fd, fpath = tempfile.mkstemp() os.close(fd) context.ch_resource = Resource( resource_id="01EDGE00000000000000000005", resource_type_name="git-checkout", name="file-not-dir", classification=PhysVirt.PHYSICAL, location=fpath, ) @when("I compute the content_hash via GitCheckoutHandler for file") def step_git_hash_file(context: Any) -> None: handler = GitCheckoutHandler() context.ch_result = handler.content_hash(context.ch_resource) @given("a database resource with no location for content_hash") def step_db_no_location(context: Any) -> None: context.ch_resource = Resource( resource_id="01EDGE00000000000000000006", resource_type_name="sqlite", name="db-noloc", classification=PhysVirt.PHYSICAL, ) @when("I compute the content_hash via DatabaseResourceHandler for noloc") def step_db_hash_noloc(context: Any) -> None: from cleveragents.resource.handlers.database import DatabaseResourceHandler handler = DatabaseResourceHandler() context.ch_result = handler.content_hash(context.ch_resource) @given("a SQLite database with non-standard extension for content_hash") def step_sqlite_nonext(context: Any) -> None: import sqlite3 fd, db_path = tempfile.mkstemp(suffix=".data") os.close(fd) conn = sqlite3.connect(db_path) conn.execute("CREATE TABLE t (id INTEGER)") conn.commit() conn.close() context.ch_resource = Resource( resource_id="01EDGE00000000000000000007", resource_type_name="sqlite", name="nonext-db", classification=PhysVirt.PHYSICAL, location=db_path, ) @when("I compute the content_hash via DatabaseResourceHandler for nonext") def step_db_hash_nonext(context: Any) -> None: from cleveragents.resource.handlers.database import DatabaseResourceHandler handler = DatabaseResourceHandler() context.ch_result = handler.content_hash(context.ch_resource) @when("I check _find_running_container") def step_check_find_container(context: Any) -> None: from cleveragents.resource.handlers.devcontainer import DevcontainerHandler handler = DevcontainerHandler() context._ch_container_id = handler._find_running_container(context.ch_resource) @then("the container ID should be None") def step_container_id_none(context: Any) -> None: assert context._ch_container_id is None # --------------------------------------------------------------------------- # Mock-based branch coverage steps # --------------------------------------------------------------------------- @when("I compute content_hash with mocked docker exec returning file hashes") def step_devcontainer_mock_exec(context: Any) -> None: from unittest.mock import MagicMock, patch from cleveragents.resource.handlers.devcontainer import DevcontainerHandler handler = DevcontainerHandler() mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = ( "abc123 /workspaces/project/file1.py\ndef456 /workspaces/project/file2.py\n" ) with ( patch("subprocess.run", return_value=mock_result), patch.object(handler, "_find_running_container", return_value="abc123def456"), ): context.ch_result = handler.content_hash(context.ch_resource) @when("I compute content_hash with docker ps raising OSError") def step_devcontainer_oserror(context: Any) -> None: from unittest.mock import patch from cleveragents.resource.handlers.devcontainer import DevcontainerHandler handler = DevcontainerHandler() with patch( "cleveragents.resource.handlers.devcontainer.subprocess.run", side_effect=OSError("docker not found"), ): context.ch_result = handler.content_hash(context.ch_resource) @then("the devcontainer falls back to config hash") def step_devcontainer_fallback(context: Any) -> None: # Should have fallen back to config file hash (not empty) assert context.ch_result != EMPTY_CONTENT_HASH assert len(context.ch_result) == 64 @given("a temporary git-checkout resource for content_hash timeout") def step_git_timeout_resource(context: Any) -> None: tmpdir = tempfile.mkdtemp() context.ch_resource = Resource( resource_id="01EDGEMCK000000000000000MK", resource_type_name="git-checkout", name="mock-git", classification=PhysVirt.PHYSICAL, location=tmpdir, ) @when("I compute content_hash with git rev-parse timing out") def step_git_timeout(context: Any) -> None: from unittest.mock import patch handler = GitCheckoutHandler() with patch( "subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10), ): context.ch_result = handler.content_hash(context.ch_resource) @when("I compute content_hash with git rev-parse raising OSError") def step_git_oserror(context: Any) -> None: from unittest.mock import patch handler = GitCheckoutHandler() with patch("subprocess.run", side_effect=OSError("git not found")): context.ch_result = handler.content_hash(context.ch_resource) @given("a resource pointing to a non-file non-dir path for content_hash") def step_special_file(context: Any) -> None: # Use a real temp path but mock os.path to make it exist but be neither file nor dir tmpdir = tempfile.mkdtemp() context.ch_resource = Resource( resource_id="01EDGE00000000000000SPEC01", resource_type_name="test", name="special", classification=PhysVirt.PHYSICAL, location=tmpdir, ) context._ch_special_tmpdir = tmpdir @when("I compute the content_hash via base handler for special") def step_base_hash_special(context: Any) -> None: from unittest.mock import patch handler = BaseResourceHandler() # Mock: path exists, but is neither file nor dir (e.g., a socket) with ( patch("os.path.exists", return_value=True), patch("os.path.isfile", return_value=False), patch("os.path.isdir", return_value=False), ): context.ch_result = handler.content_hash(context.ch_resource)