feat(resource): implement ResourceHandler CRUD and discovery methods
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / lint (pull_request) Successful in 3m17s
CI / unit_tests (pull_request) Successful in 3m38s
CI / quality (pull_request) Successful in 3m42s
CI / typecheck (pull_request) Successful in 3m54s
CI / security (pull_request) Successful in 4m2s
CI / docker (pull_request) Successful in 50s
CI / e2e_tests (pull_request) Successful in 8m32s
CI / integration_tests (pull_request) Successful in 6m54s
CI / coverage (pull_request) Successful in 14m10s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 59m15s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / lint (pull_request) Successful in 3m17s
CI / unit_tests (pull_request) Successful in 3m38s
CI / quality (pull_request) Successful in 3m42s
CI / typecheck (pull_request) Successful in 3m54s
CI / security (pull_request) Successful in 4m2s
CI / docker (pull_request) Successful in 50s
CI / e2e_tests (pull_request) Successful in 8m32s
CI / integration_tests (pull_request) Successful in 6m54s
CI / coverage (pull_request) Successful in 14m10s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 59m15s
Extend the ResourceHandler protocol with six content operations (read, write, delete, list_children, diff, discover_children) and four frozen dataclass result types (Content, WriteResult, DeleteResult, DiffResult). Handler implementations: - GitCheckoutHandler: read via git show (binary-safe), write/delete via filesystem ops, list via git ls-tree, diff via git diff --no-index with locale-safe shortstat parsing, discover via git ls-tree -d - FsDirectoryHandler: full CRUD via pathlib/os/difflib/shutil - DevcontainerHandler: read/write/discover via devcontainer exec - CloudResourceHandler: NotImplementedError stubs for protocol compliance - DatabaseResourceHandler: inherits base NotImplementedError stubs Security: - Path traversal guard (_safe_resolve) on all read/write/delete ops using os.sep-suffixed startswith check to prevent prefix collisions - Empty-path deletion rejected with PermissionError Tests: - 22 Behave scenarios (115 steps): CRUD for FsDirectory and GitCheckout, path traversal rejection (3 scenarios), NotImplementedError defaults - 2 Robot integration tests: read -> write -> diff cycle on real temp directories and git repos ISSUES CLOSED: #827
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
"""Step definitions for resource_handler_crud.feature.
|
||||
|
||||
Tests CRUD and discovery operations (read, write, delete, list_children,
|
||||
diff, discover_children) for GitCheckoutHandler and FsDirectoryHandler.
|
||||
|
||||
Issue #827: ResourceHandler CRUD and discovery methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
)
|
||||
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] = []
|
||||
|
||||
_ULID_COUNTER = 0
|
||||
|
||||
|
||||
def _next_ulid() -> str:
|
||||
"""Generate a valid 26-char Crockford Base32 ID for tests."""
|
||||
global _ULID_COUNTER
|
||||
_ULID_COUNTER += 1
|
||||
cb32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
n = _ULID_COUNTER
|
||||
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, rid: str | None = None) -> Resource:
|
||||
return Resource(
|
||||
resource_id=rid or _next_ulid(),
|
||||
resource_type_name=rtype,
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
location=location,
|
||||
capabilities=ResourceCapabilities(
|
||||
readable=True, writable=True, sandboxable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: temp directory setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a temp directory with file "{fname}" containing "{content}"')
|
||||
def step_given_temp_dir_with_file(context: Context, fname: str, content: str) -> None:
|
||||
context.crud_tmpdir = tempfile.mkdtemp(prefix="crud_fs_")
|
||||
Path(context.crud_tmpdir, fname).write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@given('a temp sub-file "{fname}" containing "{content}"')
|
||||
def step_given_temp_sub_file(context: Context, fname: str, content: str) -> None:
|
||||
Path(context.crud_tmpdir, fname).write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@given('a temp sub-directory "{dirname}"')
|
||||
def step_given_temp_sub_dir(context: Context, dirname: str) -> None:
|
||||
Path(context.crud_tmpdir, dirname).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@given("an empty temp directory")
|
||||
def step_given_empty_temp_dir(context: Context) -> None:
|
||||
context.crud_tmpdir = tempfile.mkdtemp(prefix="crud_fs_")
|
||||
|
||||
|
||||
@given("an fs-directory resource pointing to that directory")
|
||||
def step_given_fs_resource(context: Context) -> None:
|
||||
context.crud_handler = FsDirectoryHandler()
|
||||
context.crud_resource = _make_resource("fs-directory", context.crud_tmpdir)
|
||||
|
||||
|
||||
# -- Second temp directory for diff scenarios
|
||||
|
||||
|
||||
@given('a second temp directory with file "{fname}" containing "{content}"')
|
||||
def step_given_second_temp_dir(context: Context, fname: str, content: str) -> None:
|
||||
context.crud_tmpdir2 = tempfile.mkdtemp(prefix="crud_fs2_")
|
||||
Path(context.crud_tmpdir2, fname).write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@given('a second temp sub-file "{fname}" containing "{content}"')
|
||||
def step_given_second_temp_sub_file(context: Context, fname: str, content: str) -> None:
|
||||
Path(context.crud_tmpdir2, fname).write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: temp git repo setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a temp git repo with file "{fname}" containing "{content}"')
|
||||
def step_given_temp_git_repo(context: Context, fname: str, content: str) -> None:
|
||||
context.crud_tmpdir = tempfile.mkdtemp(prefix="crud_git_")
|
||||
subprocess.run(
|
||||
["git", "init"],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@test.com"],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
Path(context.crud_tmpdir, fname).write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", "."],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "init"],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@given('a temp git sub-file "{fname}" containing "{content}"')
|
||||
def step_given_temp_git_sub_file(context: Context, fname: str, content: str) -> None:
|
||||
Path(context.crud_tmpdir, fname).write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", "."],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"add {fname}"],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
'a temp git sub-directory "{dirname}" with file "{fname}" containing "{content}"'
|
||||
)
|
||||
def step_given_temp_git_sub_dir(
|
||||
context: Context, dirname: str, fname: str, content: str
|
||||
) -> None:
|
||||
dirpath = Path(context.crud_tmpdir, dirname)
|
||||
dirpath.mkdir(parents=True, exist_ok=True)
|
||||
(dirpath / fname).write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", "."],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"add {dirname}/{fname}"],
|
||||
cwd=context.crud_tmpdir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@given("a git-checkout resource pointing to that repo")
|
||||
def step_given_git_resource(context: Context) -> None:
|
||||
context.crud_handler = GitCheckoutHandler()
|
||||
context.crud_resource = _make_resource("git-checkout", context.crud_tmpdir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: database handler (NotImplementedError tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a database resource handler")
|
||||
def step_given_db_handler(context: Context) -> None:
|
||||
context.crud_handler = DatabaseResourceHandler()
|
||||
|
||||
|
||||
@given("a dummy database resource")
|
||||
def step_given_dummy_db_resource(context: Context) -> None:
|
||||
context.crud_resource = _make_resource(
|
||||
"postgres", "/tmp/fake-db", rid="01KJ5C5TPMP8GGX3QC83E2MAQS"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When: CRUD operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I call read on the fs-directory handler with path "{path}"')
|
||||
@when('I call read on the git-checkout handler with path "{path}"')
|
||||
def step_when_read(context: Context, path: str) -> None:
|
||||
try:
|
||||
context.crud_result = context.crud_handler.read(
|
||||
resource=context.crud_resource, path=path
|
||||
)
|
||||
context.crud_error = None
|
||||
except Exception as exc:
|
||||
context.crud_result = None
|
||||
context.crud_error = exc
|
||||
|
||||
|
||||
@when("I call read on the fs-directory handler for the root")
|
||||
def step_when_read_root(context: Context) -> None:
|
||||
try:
|
||||
context.crud_result = context.crud_handler.read(
|
||||
resource=context.crud_resource, path=""
|
||||
)
|
||||
context.crud_error = None
|
||||
except Exception as exc:
|
||||
context.crud_result = None
|
||||
context.crud_error = exc
|
||||
|
||||
|
||||
@when("I call read on the database handler")
|
||||
def step_when_read_db(context: Context) -> None:
|
||||
try:
|
||||
context.crud_result = context.crud_handler.read(resource=context.crud_resource)
|
||||
context.crud_error = None
|
||||
except Exception as exc:
|
||||
context.crud_result = None
|
||||
context.crud_error = exc
|
||||
|
||||
|
||||
@when('I call write on the fs-directory handler with path "{path}" and data "{data}"')
|
||||
@when('I call write on the git-checkout handler with path "{path}" and data "{data}"')
|
||||
def step_when_write(context: Context, path: str, data: str) -> None:
|
||||
try:
|
||||
context.crud_result = context.crud_handler.write(
|
||||
resource=context.crud_resource,
|
||||
path=path,
|
||||
data=data.encode("utf-8"),
|
||||
)
|
||||
context.crud_error = None
|
||||
except Exception as exc:
|
||||
context.crud_result = None
|
||||
context.crud_error = exc
|
||||
|
||||
|
||||
@when('I call write on the database handler with path "{path}" and data "{data}"')
|
||||
def step_when_write_db(context: Context, path: str, data: str) -> None:
|
||||
try:
|
||||
context.crud_result = context.crud_handler.write(
|
||||
resource=context.crud_resource,
|
||||
path=path,
|
||||
data=data.encode("utf-8"),
|
||||
)
|
||||
context.crud_error = None
|
||||
except Exception as exc:
|
||||
context.crud_result = None
|
||||
context.crud_error = exc
|
||||
|
||||
|
||||
@when('I call delete on the fs-directory handler with path "{path}"')
|
||||
@when('I call delete on the git-checkout handler with path "{path}"')
|
||||
def step_when_delete(context: Context, path: str) -> None:
|
||||
try:
|
||||
context.crud_result = context.crud_handler.delete(
|
||||
resource=context.crud_resource, path=path
|
||||
)
|
||||
context.crud_error = None
|
||||
except Exception as exc:
|
||||
context.crud_result = None
|
||||
context.crud_error = exc
|
||||
|
||||
|
||||
@when("I call list_children on the fs-directory handler")
|
||||
@when("I call list_children on the git-checkout handler")
|
||||
def step_when_list_children(context: Context) -> None:
|
||||
context.crud_result = context.crud_handler.list_children(
|
||||
resource=context.crud_resource
|
||||
)
|
||||
|
||||
|
||||
@when("I call diff on the fs-directory handler against the second directory")
|
||||
@when("I call diff on the git-checkout handler against the second directory")
|
||||
def step_when_diff(context: Context) -> None:
|
||||
context.crud_result = context.crud_handler.diff(
|
||||
resource=context.crud_resource,
|
||||
other_location=context.crud_tmpdir2,
|
||||
)
|
||||
|
||||
|
||||
@when("I call discover_children on the fs-directory handler")
|
||||
@when("I call discover_children on the git-checkout handler")
|
||||
def step_when_discover(context: Context) -> None:
|
||||
context.crud_result = context.crud_handler.discover_children(
|
||||
resource=context.crud_resource
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the content data should be "{expected}"')
|
||||
def step_then_content_data(context: Context, expected: str) -> None:
|
||||
assert context.crud_result is not None, "Expected Content, got None"
|
||||
actual = context.crud_result.data.decode("utf-8").strip()
|
||||
assert actual == expected, f"Expected '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the content encoding should be "{expected}"')
|
||||
def step_then_content_encoding(context: Context, expected: str) -> None:
|
||||
assert context.crud_result.encoding == expected
|
||||
|
||||
|
||||
@then("the content hash should not be empty")
|
||||
def step_then_content_hash_not_empty(context: Context) -> None:
|
||||
assert context.crud_result.content_hash, "content_hash is empty"
|
||||
|
||||
|
||||
@then('the content text should contain "{substring}"')
|
||||
def step_then_content_text_contains(context: Context, substring: str) -> None:
|
||||
text = context.crud_result.text
|
||||
assert substring in text, f"'{substring}' not in '{text}'"
|
||||
|
||||
|
||||
@then("a crud FileNotFoundError should be raised")
|
||||
def step_then_crud_fnf_error(context: Context) -> None:
|
||||
assert isinstance(context.crud_error, FileNotFoundError), (
|
||||
f"Expected FileNotFoundError, got {type(context.crud_error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the write result should be successful")
|
||||
def step_then_write_success(context: Context) -> None:
|
||||
assert context.crud_result is not None
|
||||
assert context.crud_result.success is True, (
|
||||
f"Write failed: {context.crud_result.message}"
|
||||
)
|
||||
|
||||
|
||||
@then("the write result bytes_written should be {count:d}")
|
||||
def step_then_write_bytes(context: Context, count: int) -> None:
|
||||
assert context.crud_result.bytes_written == count
|
||||
|
||||
|
||||
@then('the file "{path}" should exist in the temp directory with content "{expected}"')
|
||||
def step_then_file_exists_with_content(
|
||||
context: Context, path: str, expected: str
|
||||
) -> None:
|
||||
target = Path(context.crud_tmpdir) / path
|
||||
assert target.exists(), f"{path} does not exist"
|
||||
actual = target.read_text(encoding="utf-8")
|
||||
assert actual == expected, f"Expected '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then('the file "{path}" should exist in the temp git repo with content "{expected}"')
|
||||
def step_then_git_file_exists(context: Context, path: str, expected: str) -> None:
|
||||
target = Path(context.crud_tmpdir) / path
|
||||
assert target.exists(), f"{path} does not exist"
|
||||
actual = target.read_text(encoding="utf-8")
|
||||
assert actual == expected, f"Expected '{expected}', got '{actual}'"
|
||||
|
||||
|
||||
@then("the delete result should be successful")
|
||||
def step_then_delete_success(context: Context) -> None:
|
||||
assert context.crud_result is not None
|
||||
assert context.crud_result.success is True
|
||||
|
||||
|
||||
@then('the file "{path}" should not exist in the temp directory')
|
||||
@then('the file "{path}" should not exist in the temp git repo')
|
||||
def step_then_file_not_exists(context: Context, path: str) -> None:
|
||||
target = Path(context.crud_tmpdir) / path
|
||||
assert not target.exists(), f"{path} still exists"
|
||||
|
||||
|
||||
@then("the children list should equal {expected}")
|
||||
def step_then_children_equal(context: Context, expected: str) -> None:
|
||||
import ast
|
||||
|
||||
expected_list = ast.literal_eval(expected)
|
||||
assert context.crud_result == expected_list, (
|
||||
f"Expected {expected_list}, got {context.crud_result}"
|
||||
)
|
||||
|
||||
|
||||
@then('the children list should contain "{item}"')
|
||||
def step_then_children_contains(context: Context, item: str) -> None:
|
||||
assert item in context.crud_result, f"'{item}' not in {context.crud_result}"
|
||||
|
||||
|
||||
@then("the diff result should have changes")
|
||||
def step_then_diff_has_changes(context: Context) -> None:
|
||||
assert context.crud_result.has_changes is True
|
||||
|
||||
|
||||
@then("the diff result should have no changes")
|
||||
def step_then_diff_no_changes(context: Context) -> None:
|
||||
assert context.crud_result.has_changes is False
|
||||
|
||||
|
||||
@then("the diff files_changed should be {count:d}")
|
||||
def step_then_diff_files_changed(context: Context, count: int) -> None:
|
||||
assert context.crud_result.files_changed == count
|
||||
|
||||
|
||||
@then("the diff insertions should be greater than {count:d}")
|
||||
def step_then_diff_insertions_gt(context: Context, count: int) -> None:
|
||||
assert context.crud_result.insertions > count, (
|
||||
f"insertions={context.crud_result.insertions} not > {count}"
|
||||
)
|
||||
|
||||
|
||||
@then("the discovered children should have {count:d} items")
|
||||
def step_then_discover_count(context: Context, count: int) -> None:
|
||||
assert len(context.crud_result) == count, (
|
||||
f"Expected {count} children, got {len(context.crud_result)}"
|
||||
)
|
||||
|
||||
|
||||
@then('the discovered children names should include "{name}"')
|
||||
def step_then_discover_name(context: Context, name: str) -> None:
|
||||
names = [r.name for r in context.crud_result]
|
||||
assert name in names, f"'{name}' not in {names}"
|
||||
|
||||
|
||||
@then(
|
||||
'a crud NotImplementedError should be raised with message containing "{fragment}"'
|
||||
)
|
||||
def step_then_crud_not_implemented(context: Context, fragment: str) -> None:
|
||||
assert isinstance(context.crud_error, NotImplementedError), (
|
||||
f"Expected NotImplementedError, got {type(context.crud_error)}"
|
||||
)
|
||||
assert fragment in str(context.crud_error), (
|
||||
f"'{fragment}' not in '{context.crud_error}'"
|
||||
)
|
||||
|
||||
|
||||
@then("a crud PermissionError should be raised")
|
||||
def step_then_crud_permission_error(context: Context) -> None:
|
||||
assert isinstance(context.crud_error, PermissionError), (
|
||||
f"Expected PermissionError, got {type(context.crud_error)}: {context.crud_error}"
|
||||
)
|
||||
Reference in New Issue
Block a user