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
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:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user