fix(resource): enforce linked child removal guard
CI / push-validation (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 27s
CI / helm (pull_request) Successful in 29s
CI / security (pull_request) Successful in 54s
CI / e2e_tests (pull_request) Successful in 3m5s
CI / build (pull_request) Successful in 3m19s
CI / quality (pull_request) Successful in 3m53s
CI / typecheck (pull_request) Successful in 3m58s
CI / unit_tests (pull_request) Successful in 5m23s
CI / docker (pull_request) Successful in 1m32s
CI / integration_tests (pull_request) Successful in 7m6s
CI / coverage (pull_request) Successful in 13m22s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 57m50s

- query ResourceLinkModel before allowing deletion
- add Behave and Robot coverage without mocking
- reuse DI container setup for integration helper

ISSUES CLOSED: #6886
This commit is contained in:
2026-04-12 17:59:45 +00:00
parent 8b7bdb5edf
commit b792b040a3
7 changed files with 292 additions and 17 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ Feature: Resource CLI coverage boost for remaining uncovered lines
Given a mock resource service whose session reports edges on the resource
When I invoke resource remove with --yes via CliRunner for the edged resource
Then the CliRunner exit code should be non-zero
And the CliRunner output should contain "edge(s) still reference it"
And the CliRunner output should contain "link(s) still reference it"
# ---- resource_remove generic Exception rollback (lines 668-672) ----
@@ -291,13 +291,13 @@ def step_mock_service_resource_edges(context: Context) -> None:
mock_session = MagicMock()
# ResourceEdgeModel query → count returns 3 (edges exist)
edge_query = MagicMock()
edge_query.filter.return_value.count.return_value = 3
# ResourceLinkModel query → count returns 3 (links exist)
link_query = MagicMock()
link_query.filter.return_value.count.return_value = 3
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceEdgeModel": edge_query,
"ResourceLinkModel": link_query,
}
)
svc._session.return_value = mock_session
@@ -335,9 +335,9 @@ def step_mock_service_resource_delete_exception(context: Context) -> None:
mock_session = MagicMock()
# ResourceEdgeModel query → count returns 0 (no edges)
edge_query = MagicMock()
edge_query.filter.return_value.count.return_value = 0
# ResourceLinkModel query → count returns 0 (no links)
link_query = MagicMock()
link_query.filter.return_value.count.return_value = 0
# ResourceModel query → first returns a mock row
mock_row = MagicMock()
@@ -346,7 +346,7 @@ def step_mock_service_resource_delete_exception(context: Context) -> None:
mock_session.query.side_effect = _smart_query_side_effect(
{
"ResourceEdgeModel": edge_query,
"ResourceLinkModel": link_query,
"ResourceModel": resource_query,
}
)
@@ -0,0 +1,28 @@
"""Step definitions for resource removal linked-children guard TDD tests."""
from __future__ import annotations
from behave import given # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from features.steps import resource_cli_steps as resource_cli
def _get_cli_service(context: Context) -> ResourceRegistryService:
"""Ensure the resource CLI service is available for linking operations."""
try:
return context.resource_cli_service # type: ignore[attr-defined]
except AttributeError:
return resource_cli._make_service(context)
@given('the resource registry CLI links child "{child}" under parent "{parent}"')
def step_link_child(context: Context, child: str, parent: str) -> None:
"""Link a child resource beneath a parent using the shared CLI service."""
service = _get_cli_service(context)
service.link_child(parent, child)
@@ -0,0 +1,13 @@
@tdd_issue
@tdd_issue_6886
Feature: Resource removal guard respects manual child links
Scenario: Prevent deleting a resource that still has linked children
Given a fresh in-memory resource registry
And built-in types are bootstrapped
And I run resource add "git-checkout" "local/test-repo" with path "demo_repo"
And I run resource add "fs-directory" "local/test-repo/src" with path "demo_repo/src"
And the resource registry CLI links child "local/test-repo/src" under parent "local/test-repo"
When I run resource remove "local/test-repo" with yes flag
Then the resource command should fail
And the resource output should contain "link(s) still reference"
@@ -0,0 +1,195 @@
"""Helper script for ``tdd_resource_remove_children_guard.robot`` integration tests.
This helper drives the public ``agents resource`` CLI through a fully wired
dependency-injection container pointing at an isolated SQLite database. The
Robot suite executes this script as a subprocess to exercise the guard logic
without resorting to mocking; instead, the container is reconfigured via the
documented ``reset_container``/``get_container`` workflow and environment
variables.
"""
from __future__ import annotations
import contextlib
import os
import shutil
import sys
import tempfile
from collections.abc import Iterator
from pathlib import Path
from typing import NoReturn
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.pool import StaticPool
from typer.testing import CliRunner, Result
from cleveragents.application.container import get_container, reset_container
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from cleveragents.cli.commands.resource import app as resource_app
from cleveragents.core.exceptions import NotFoundError
from cleveragents.infrastructure.database.models import Base
RUNNER: CliRunner = CliRunner()
@contextlib.contextmanager
def _test_environment() -> Iterator[tuple[ResourceRegistryService, dict[str, str]]]:
"""Provision an isolated CLI environment and yield the service plus env vars."""
temp_dir = Path(tempfile.mkdtemp(prefix="resource-guard-"))
db_path = temp_dir / "registry.sqlite"
database_url = f"sqlite:///{db_path}"
previous_db = os.environ.get("CLEVERAGENTS_DATABASE_URL")
previous_data_dir = os.environ.get("CLEVERAGENTS_DATA_DIR")
os.environ["CLEVERAGENTS_DATABASE_URL"] = database_url
os.environ["CLEVERAGENTS_DATA_DIR"] = str(temp_dir)
reset_container()
engine: Engine = create_engine(
database_url,
echo=False,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
container = get_container()
service = container.resource_registry_service()
env = {
"NO_COLOR": "1",
"CLEVERAGENTS_DATABASE_URL": database_url,
"CLEVERAGENTS_DATA_DIR": str(temp_dir),
}
try:
yield service, env
finally:
engine.dispose()
reset_container()
if previous_db is None:
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
else:
os.environ["CLEVERAGENTS_DATABASE_URL"] = previous_db
if previous_data_dir is None:
os.environ.pop("CLEVERAGENTS_DATA_DIR", None)
else:
os.environ["CLEVERAGENTS_DATA_DIR"] = previous_data_dir
shutil.rmtree(temp_dir, ignore_errors=True)
def _fail(message: str) -> NoReturn:
"""Exit the helper with an error message and status 1."""
print(message, file=sys.stderr)
raise SystemExit(1)
def _register_sample_resources(service: ResourceRegistryService) -> tuple[str, str]:
"""Register parent/child resources and return their names."""
parent_name = "local/test-repo"
child_name = "local/test-repo/src"
service.register_resource(
"git-checkout",
name=parent_name,
location="demo_repo",
)
service.register_resource(
"fs-directory",
name=child_name,
location="demo_repo/src",
)
return parent_name, child_name
def _invoke(env: dict[str, str], *args: str) -> Result:
"""Invoke the resource CLI with the provided *args* using *env* variables."""
runner_env = os.environ.copy()
runner_env.update(env)
return RUNNER.invoke(resource_app, list(args), env=runner_env)
def _scenario_block_parent_removal() -> None:
"""Verify the guard blocks removal when DAG links exist."""
with _test_environment() as (service, env):
parent_name, child_name = _register_sample_resources(service)
service.link_child(parent_name, child_name)
result = _invoke(env, "remove", "--yes", parent_name)
if result.exit_code == 0:
_fail("resource remove succeeded unexpectedly despite existing DAG links.")
if "link(s) still reference" not in result.stdout:
_fail(
"resource remove did not report remaining links when guard triggered.\n"
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
try:
service.show_resource(parent_name)
except NotFoundError as exc:
_fail(f"Parent resource was deleted despite guard: {exc}")
print("tdd-resource-remove-guard-parent-blocks-ok")
def _scenario_unlink_then_remove() -> None:
"""Verify removal succeeds once DAG links are removed."""
with _test_environment() as (service, env):
parent_name, child_name = _register_sample_resources(service)
service.link_child(parent_name, child_name)
service.unlink_child(parent_name, child_name)
result = _invoke(env, "remove", "--yes", parent_name)
if result.exit_code != 0:
_fail(
"resource remove should succeed after unlinking children.\n"
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
if "Removed resource:" not in result.stdout:
_fail(
"resource remove did not report successful deletion after unlinking.\n"
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
try:
service.show_resource(parent_name)
except NotFoundError:
print("tdd-resource-remove-guard-unlink-then-remove-ok")
return
_fail("Parent resource is still present after successful removal.")
def main(argv: list[str]) -> int:
"""Entry point dispatcher for helper subcommands."""
if len(argv) < 2:
_fail(
"Missing subcommand: expected 'block-parent-removal' or "
"'unlink-then-remove'."
)
command = argv[1]
if command == "block-parent-removal":
_scenario_block_parent_removal()
return 0
if command == "unlink-then-remove":
_scenario_unlink_then_remove()
return 0
_fail(f"Unknown subcommand: {command}")
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
@@ -0,0 +1,39 @@
*** Settings ***
Documentation TDD Bug #6886 — resource removal guard must block linked resources
... Integration regression tests ensuring `agents resource remove`
... refuses to delete resources that still participate in the
... Resource DAG and succeeds after links are removed.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_tdd_resource_remove_children_guard.py
*** Test Cases ***
TDD Resource Remove Blocks Linked Parent
[Documentation] Ensure the CLI refuses to delete a parent resource while
... it retains linked children, reproducing bug #6886.
[Tags] tdd_issue tdd_issue_6886
${result}= Run Process ${PYTHON} ${HELPER} block-parent-removal
... cwd=${WORKSPACE}
... timeout=60s
... on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tdd-resource-remove-guard-parent-blocks-ok
TDD Resource Remove Allows Delete After Unlink
[Documentation] Verify removal succeeds after unlinking children so the
... guard does not over-protect once DAG edges are cleared.
[Tags] tdd_issue tdd_issue_6886
${result}= Run Process ${PYTHON} ${HELPER} unlink-then-remove
... cwd=${WORKSPACE}
... timeout=60s
... on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tdd-resource-remove-guard-unlink-then-remove-ok
+8 -8
View File
@@ -1377,23 +1377,23 @@ def resource_remove(
session = service._session()
try:
from cleveragents.infrastructure.database.models import (
ResourceEdgeModel,
ResourceLinkModel,
ResourceModel,
)
# Check for edges
edge_count: int = (
session.query(ResourceEdgeModel)
# Check for existing DAG links
link_count: int = (
session.query(ResourceLinkModel)
.filter(
(ResourceEdgeModel.parent_id == res.resource_id)
| (ResourceEdgeModel.child_id == res.resource_id)
(ResourceLinkModel.parent_id == res.resource_id)
| (ResourceLinkModel.child_id == res.resource_id)
)
.count()
)
if edge_count > 0:
if link_count > 0:
console.print(
f"[red]Cannot remove resource '{res.name or res.resource_id}': "
f"{edge_count} edge(s) still reference it.[/red]"
f"{link_count} link(s) still reference it.[/red]"
)
raise typer.Abort()