# #region Test.Api.ScenarioAutomation [C:3] [TYPE Module] [SEMANTICS test,api,scenario,automation,crud,rbac,trigger] # @BRIEF Verify scenario-automation API surface: schedule/trigger-rule/policy CRUD, metrics, # notifications, RBAC scopes and the direct PROD-gated scenario trigger. # @RELATION BINDS_TO -> [Api.ScenarioAutomation.Routes] # @RELATION VERIFIES -> [Api.ScenarioAutomation.DirectTrigger] # @RELATION BINDS_TO -> [ScenarioExecution.Runner.QueuedDispatch] # @RELATION VERIFIES -> [ScenarioExecution.EnvironmentPolicy.Resolve] # @RELATION VERIFIES -> [ScenarioExecution.Runner.TriggerSource.RejectAutomatedHuman] # @TEST_EDGE missing_manage_scope -> 403 on schedule mutation # @TEST_EDGE missing_trigger_scope -> 403 on direct trigger # @TEST_EDGE missing_prod_scope -> 403 on PROD direct trigger # @TEST_EDGE missing_read_scope -> 403 on all six operational reads (DG-2, T019) # @TEST_EDGE foreign_owner_rows -> filtered out of every read collection, no totals leak # @TEST_EDGE disabled_human_schedule -> identical 409 AUTOMATION_INELIGIBLE_HUMAN_STEP as enabled (DEF-02) # @TEST_EDGE idempotency_reuse -> 409 on changed request hash # @TEST_INVARIANT ScenarioExecution.Runner.Start: The 046 API trigger supplies a server-owned # automation source; a persisted human graph returns the typed manual-only # rejection before it creates a run, gate, or notification. # @TEST_INVARIANT Api.ScenarioAutomation.DirectTrigger: A non-PROD HTTP trigger/replay persists # only its queued row; its external boundary does not advance until the separate # 044 dispatcher claims it through status CAS. -> VERIFIED_BY: # test_direct_trigger_starts_run_with_idempotency # @TEST_INVARIANT ScenarioExecution.EnvironmentPolicy: A direct target's server-owned PROD class # requires automation PROD and scenario RUN_PROD before start; client payload has # no override. -> VERIFIED_BY: test_prod_trigger_requires_prod_scope from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import MagicMock from fastapi.testclient import TestClient from src.app import app from src.core.database import SessionLocal from src.dependencies import get_config_manager, get_current_user from src.models.auth import Permission, Role, User from src.models.scenario_automation import ( AutomationPolicy, ScenarioNotificationEvent, ScenarioRetentionDeletion, ScenarioSchedule, ScenarioTriggerRule, ) from src.models.scenario_registry import ScenarioRegistryEntry, ScenarioRevision from src.models.scenario_run import ScenarioRun from src.services.dashboard_testing.automation.poisoned_store import PoisonedRunStore from src.services.dashboard_testing.scenario.templates import ( ACTION_REGISTRY_VERSION, action_registry_fingerprint, resolve_action_descriptor, ) def _make_user_with_permissions(permissions: list[tuple[str, str]]) -> User: role = Role(id="automation-role-test", name="AutomationRole") role.permissions = [ Permission(resource=resource, action=action) for resource, action in permissions ] user = User(id="automation-user-test", username="automation.tester", email="auto@test.com") user.roles = [role] return user def _action_step(step_id: str, tool: str, action: str, **extra) -> dict: """Hardcoded exact 038 action fixture; no tool-only dispatch is valid.""" return { "id": step_id, "logical_step_id": step_id, "tool": tool, "action": action, "action_descriptor": resolve_action_descriptor( tool=tool, action=action, registry_version=ACTION_REGISTRY_VERSION, registry_hash=action_registry_fingerprint(), ).snapshot(), **extra, } # #region Test.Api.ScenarioAutomation.ConfigManager [C:2] [TYPE Block] [SEMANTICS test,api,scenario,automation,env,fixture] # @BRIEF Server-owned environment classification stub: PROD classification must come from # ConfigManager (stage/is_production), never from the environment_id string. # @TEST_FIXTURE env-preprod-01/env-preprod-02 -> PREPROD (non-prod); prod-01 -> PROD stage; # prod-mirror -> PREPROD stage but is_production=True (proves the boolean wins). def _make_config_manager(): envs = { "env-preprod-01": SimpleNamespace(stage="PREPROD", is_production=False), "env-preprod-02": SimpleNamespace(stage="PREPROD", is_production=False), "prod-01": SimpleNamespace(stage="PROD", is_production=True), "prod-mirror": SimpleNamespace(stage="PREPROD", is_production=True), } cm = MagicMock() cm.get_environment.side_effect = lambda environment_id: envs.get(str(environment_id)) return cm # #endregion Test.Api.ScenarioAutomation.ConfigManager @contextmanager def _client_for(user: User) -> Iterator[TestClient]: """TestClient with get_current_user + get_config_manager overridden for the whole context.""" app.dependency_overrides[get_current_user] = lambda: user app.dependency_overrides[get_config_manager] = lambda: _make_config_manager() try: yield TestClient(app) finally: app.dependency_overrides.pop(get_current_user, None) app.dependency_overrides.pop(get_config_manager, None) # #region Test.Api.ScenarioAutomation.Crud [C:2] [TYPE Class] [SEMANTICS test,api,scenario,automation,crud] # @BRIEF Persisted CRUD for schedules, trigger rules and policies; metrics and notifications list. class TestScenarioAutomationCrud: def test_schedule_crud_roundtrip(self, dashboard_testing_client): client = dashboard_testing_client created = client.post( "/api/scenario-automation/schedules", json={ "scenario_id": "11111111-1111-4111-8111-111111111111", "environment_id": "env-preprod-01", "cron_expr": "0 7 * * 1-5", "timezone": "Europe/Moscow", "missed_execution_policy": "run_latest", }, ) assert created.status_code == 201, created.text schedule_id = created.json()["id"] updated = client.patch( f"/api/scenario-automation/schedules/{schedule_id}", json={ "scenario_id": "11111111-1111-4111-8111-111111111111", "environment_id": "env-preprod-01", "cron_expr": "30 6 * * *", "enabled": False, }, ) assert updated.status_code == 200 assert updated.json()["enabled"] is False listed = client.get("/api/scenario-automation/schedules") assert listed.status_code == 200 assert any(item["id"] == schedule_id for item in listed.json()) deleted = client.delete(f"/api/scenario-automation/schedules/{schedule_id}") assert deleted.status_code == 204 def test_trigger_rule_crud_roundtrip(self, dashboard_testing_client): client = dashboard_testing_client created = client.post( "/api/scenario-automation/trigger-rules", json={ "scenario_id": "11111111-1111-4111-8111-111111111111", "environment_id": "env-preprod-01", "trigger": "etl_completed", }, ) assert created.status_code == 201, created.text rule_id = created.json()["id"] updated = client.patch( f"/api/scenario-automation/trigger-rules/{rule_id}", json={ "scenario_id": "11111111-1111-4111-8111-111111111111", "environment_id": "env-preprod-01", "trigger": "release_created", "enabled": False, }, ) assert updated.status_code == 200 assert updated.json()["trigger"] == "release_created" deleted = client.delete(f"/api/scenario-automation/trigger-rules/{rule_id}") assert deleted.status_code == 204 def test_trigger_rule_rejects_unknown_trigger(self, dashboard_testing_client): resp = dashboard_testing_client.post( "/api/scenario-automation/trigger-rules", json={"scenario_id": "s", "environment_id": "e", "trigger": "moon_phase"}, ) assert resp.status_code == 422 def test_policy_crud_roundtrip(self, dashboard_testing_client): client = dashboard_testing_client created = client.post( "/api/scenario-automation/policies", json={ "name": "test-policy", "workload_class": "scenario_smoke", "max_concurrent_per_env": 2, "dedup_window_seconds": 600, "overlap_rule": "warn", "retention_days": 30, "prod_gate_required": True, "on_repeated_failure": "disable", }, ) assert created.status_code == 201, created.text policy_id = created.json()["id"] listed = client.get("/api/scenario-automation/policies") assert listed.status_code == 200 assert any(item["id"] == policy_id for item in listed.json()) deleted = client.delete(f"/api/scenario-automation/policies/{policy_id}") assert deleted.status_code == 204 def test_metrics_and_notifications_readable(self, dashboard_testing_client): metrics = dashboard_testing_client.get("/api/scenario-automation/metrics") assert metrics.status_code == 200 body = metrics.json() assert "schedules_total" in body assert "success_rate" in body assert "trigger_distribution" in body notifications = dashboard_testing_client.get("/api/scenario-automation/notifications") assert notifications.status_code == 200 retention = dashboard_testing_client.get("/api/scenario-automation/retention") assert retention.status_code == 200 assert retention.json()["tiers"]["run_metadata"] == 180 assert retention.json()["tiers"]["raw_vlm"] == 7 # #endregion Test.Api.ScenarioAutomation.Crud # #region Test.Api.ScenarioAutomation.Rbac [C:2] [TYPE Class] [SEMANTICS test,api,scenario,automation,rbac,prod] # @BRIEF RBAC automation scopes: MANAGE/TRIGGER/PROD gates and the direct API trigger. class TestScenarioAutomationRbac: def test_schedule_mutation_requires_manage_scope(self): user = _make_user_with_permissions([("scenario:automation", "TRIGGER")]) with _client_for(user) as client: resp = client.post( "/api/scenario-automation/schedules", json={"scenario_id": "s", "environment_id": "e", "cron_expr": "0 7 * * *"}, ) assert resp.status_code == 403 def test_direct_trigger_requires_trigger_scope(self): user = _make_user_with_permissions([("scenario:automation", "MANAGE")]) with _client_for(user) as client: resp = client.post( "/api/scenario-automation/scenarios/sc-1/trigger", headers={"Idempotency-Key": "key-1"}, json={"environment_id": "env-preprod-01", "revision_id": "rev-1"}, ) assert resp.status_code == 403 def test_prod_trigger_requires_prod_scope(self): user = _make_user_with_permissions([("scenario:automation", "TRIGGER")]) with _client_for(user) as client: resp = client.post( "/api/scenario-automation/scenarios/sc-1/trigger", headers={"Idempotency-Key": "key-2"}, json={"environment_id": "prod-01", "revision_id": "rev-1"}, ) assert resp.status_code == 403 def test_direct_trigger_starts_run_with_idempotency(self): from src.models.scenario_registry import ScenarioRevision from src.models.scenario_run import ScenarioRun setup = SessionLocal() try: setup.query(ScenarioRun).filter(ScenarioRun.idempotency_key == "auto-key-001").delete() setup.query(ScenarioRegistryEntry).filter( ScenarioRegistryEntry.scenario_id == "44444444-4444-4444-8444-444444444444" ).delete() setup.commit() entry = ScenarioRegistryEntry( scenario_id="44444444-4444-4444-8444-444444444444", scenario_key="auto-trigger-fixture", name="Auto trigger fixture", dashboard_id=42, environment_ids=["env-preprod-01"], owner_id="user-qa-1", owner_username="qa.analyst", lifecycle_status="READY", validation_status="valid", current_revision_id="55555555-5555-4555-8555-555555555555", ) setup.add(entry) setup.add( ScenarioRevision( revision_id="55555555-5555-4555-8555-555555555555", scenario_id="44444444-4444-4444-8444-444444444444", content_hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", graph_snapshot={ "action_registry_version": ACTION_REGISTRY_VERSION, "action_registry_hash": action_registry_fingerprint(), "steps": [_action_step("step-1", "assertion", "structural_assert")], "dependencies": [], "environment_ids": ["env-preprod-01"], }, created_by="qa.analyst", activation_status="current", ) ) setup.commit() finally: setup.close() user = _make_user_with_permissions([("scenario:automation", "TRIGGER")]) with _client_for(user) as client: resp = client.post( "/api/scenario-automation/scenarios/44444444-4444-4444-8444-444444444444/trigger", headers={"Idempotency-Key": "auto-key-001"}, json={"environment_id": "env-preprod-01", "revision_id": "55555555-5555-4555-8555-555555555555"}, ) assert resp.status_code == 202, resp.text body = resp.json() # HTTP trigger persists only; no executor runs before the server dispatcher claim. assert body["status"] == "queued" assert body["run_id"] # Same idempotency key returns the same run; a changed request is rejected. same = client.post( "/api/scenario-automation/scenarios/44444444-4444-4444-8444-444444444444/trigger", headers={"Idempotency-Key": "auto-key-001"}, json={"environment_id": "env-preprod-01", "revision_id": "55555555-5555-4555-8555-555555555555"}, ) assert same.status_code == 202 assert same.json()["run_id"] == body["run_id"] changed = client.post( "/api/scenario-automation/scenarios/44444444-4444-4444-8444-444444444444/trigger", headers={"Idempotency-Key": "auto-key-001"}, json={"environment_id": "env-preprod-02", "revision_id": "55555555-5555-4555-8555-555555555555"}, ) assert changed.status_code == 409 assert changed.json()["detail"]["code"] == "IDEMPOTENCY_KEY_REUSED" from src.services.dashboard_testing.execution.runner import dispatch_queued_runs dispatcher = SessionLocal() try: outcomes = dispatch_queued_runs(dispatcher, worker_id="automation-api-dispatch") dispatcher.commit() finally: dispatcher.close() assert [outcome["status"] for outcome in outcomes] == ["inconclusive"] # #region Test.Api.ScenarioAutomation.Rbac.ManualOnly [C:3] [TYPE Function] # @BRIEF The external automation endpoint refuses a persisted human graph before any run-side effect. # @TEST_INVARIANT ScenarioExecution.Runner.Start: An API automation request for a human graph # cannot materialize a manual ScenarioRun or skip its HumanCheckpoint. def test_direct_trigger_rejects_human_revision_before_run_creation(self): from src.models.scenario_approval import ActionApprovalGate from src.models.scenario_automation import ScenarioNotificationEvent from src.models.scenario_registry import ScenarioRevision from src.models.scenario_run import ScenarioRun scenario_id = "60460000-0000-4000-8000-000000000004" revision_id = "60460000-0000-4000-8000-000000000014" setup = SessionLocal() try: setup.query(ScenarioRun).filter(ScenarioRun.scenario_id == scenario_id).delete() setup.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id == scenario_id).delete() setup.commit() setup.add(ScenarioRegistryEntry( scenario_id=scenario_id, scenario_key="api-manual-only-044", name="API manual-only fixture", dashboard_id=46, environment_ids=["env-preprod-01"], owner_id="analyst-046", owner_username="analyst.046", lifecycle_status="READY", validation_status="valid", current_revision_id=revision_id, )) setup.add(ScenarioRevision( revision_id=revision_id, scenario_id=scenario_id, content_hash="6" * 64, graph_snapshot={ "action_registry_version": ACTION_REGISTRY_VERSION, "action_registry_hash": action_registry_fingerprint(), "steps": [_action_step("human-api-044", "human", "human_checkpoint")], "dependencies": [], }, created_by="analyst-046", activation_status="current", )) setup.commit() before = ( setup.query(ActionApprovalGate).count(), setup.query(ScenarioNotificationEvent).filter(ScenarioNotificationEvent.scenario_id == scenario_id).count(), ) finally: setup.close() user = _make_user_with_permissions([("scenario:automation", "TRIGGER")]) with _client_for(user) as client: response = client.post( f"/api/scenario-automation/scenarios/{scenario_id}/trigger", headers={"Idempotency-Key": "api-human-manual-only-044"}, json={"environment_id": "env-preprod-01", "revision_id": revision_id}, ) assert response.status_code == 409, response.text assert response.json()["detail"]["code"] == "AUTOMATION_INELIGIBLE_HUMAN_STEP" verify = SessionLocal() try: assert verify.query(ScenarioRun).filter(ScenarioRun.scenario_id == scenario_id).count() == 0 assert ( verify.query(ActionApprovalGate).count(), verify.query(ScenarioNotificationEvent).filter(ScenarioNotificationEvent.scenario_id == scenario_id).count(), ) == before finally: verify.close() # #endregion Test.Api.ScenarioAutomation.Rbac.ManualOnly # #endregion Test.Api.ScenarioAutomation.Rbac # #region Test.Api.ScenarioAutomation.Sec01 [C:2] [TYPE Class] [SEMANTICS test,api,scenario,automation,rbac,anonymous] # @BRIEF SEC-01: operational reads require an authenticated principal; anonymous bearers are 401. # @TEST_INVARIANT Api.ScenarioAutomation: every operational read carries _USER; API-key/service # principals cannot bypass the OAuth2 bearer dependency. class TestScenarioAutomationAnonymousReads: _READ_PATHS = ( "/api/scenario-automation/schedules", "/api/scenario-automation/trigger-rules", "/api/scenario-automation/policies", "/api/scenario-automation/notifications", "/api/scenario-automation/metrics", "/api/scenario-automation/retention", "/api/scenario-automation/quarantine", ) def test_anonymous_reads_require_authenticated_principal(self, dashboard_testing_client): # Remove the auth bypass so the real OAuth2PasswordBearer dependency rejects an # anonymous/API-key principal (no Authorization Bearer header) on every operational read. app.dependency_overrides.pop(get_current_user, None) anonymous = TestClient(app) for path in self._READ_PATHS: resp = anonymous.get(path) assert resp.status_code == 401, (path, resp.text) # #endregion Test.Api.ScenarioAutomation.Sec01 # #region Test.Api.ScenarioAutomation.ReadAcl [C:4] [TYPE Class] [SEMANTICS test,api,scenario,automation,rbac,acl] # @BRIEF DG-2 (046 T019): six reads require scenario:automation READ plus per-object # scenario-ownership ACL; foreign rows vanish from collections without leaking totals. # @TEST_INVARIANT Api.ScenarioAutomation.ReadAcl: missing READ -> 403 on every read; # owner sees own rows only; admin sees all rows. # @TEST_INVARIANT Api.ScenarioAutomation.Schedules: disabled schedule bound to a human-step # revision is rejected with the identical code as enabled, with zero side-effect # rows (DEF-02) -> VERIFIED_BY: test_disabled_human_schedule_rejected_like_enabled def _make_acl_user() -> User: role = Role(id="acl-role-test", name="AclRole") role.permissions = [Permission(resource="scenario:automation", action="READ")] user = User(id="acl-viewer-1", username="acl.viewer", email="acl@test.com") user.roles = [role] return user def _make_admin_user() -> User: role = Role(id="acl-admin-role-test", name="AclAdmin", is_admin=True) user = User(id="acl-admin-1", username="acl.admin", email="acl.admin@test.com") user.roles = [role] return user _OWN_SCENARIO = "70460000-0000-4000-8000-0000000000a1" _FOREIGN_SCENARIO = "70460000-0000-4000-8000-0000000000b2" _HUMAN_SCENARIO = "70460000-0000-4000-8000-0000000000c3" _HUMAN_REVISION = "70460000-0000-4000-8000-0000000000c4" _ELIGIBLE_SCENARIO = "70460000-0000-4000-8000-0000000000d5" _ELIGIBLE_REVISION = "70460000-0000-4000-8000-0000000000d6" def _seed_acl_rows(db) -> dict[str, str]: """Hardcoded ACL fixture: one own + one foreign row per read collection.""" db.add(ScenarioRegistryEntry( scenario_id=_OWN_SCENARIO, scenario_key="acl-own", name="ACL own", dashboard_id=71, environment_ids=["env-preprod-01"], owner_id="acl-viewer-1", owner_username="acl.viewer", lifecycle_status="READY", validation_status="valid", )) db.add(ScenarioRegistryEntry( scenario_id=_FOREIGN_SCENARIO, scenario_key="acl-foreign", name="ACL foreign", dashboard_id=72, environment_ids=["env-preprod-01"], owner_id="foreign-owner-1", owner_username="foreign.owner", lifecycle_status="READY", validation_status="valid", )) own_policy = AutomationPolicy(name="acl-policy-own") foreign_policy = AutomationPolicy(name="acl-policy-foreign") free_policy = AutomationPolicy(name="acl-policy-free") db.add_all([own_policy, foreign_policy, free_policy]) db.flush() own_schedule = ScenarioSchedule( scenario_id=_OWN_SCENARIO, environment_id="env-preprod-01", cron_expr="0 7 * * *", policy_id=own_policy.id, ) foreign_schedule = ScenarioSchedule( scenario_id=_FOREIGN_SCENARIO, environment_id="env-preprod-01", cron_expr="0 8 * * *", policy_id=foreign_policy.id, ) own_rule = ScenarioTriggerRule(scenario_id=_OWN_SCENARIO, environment_id="env-preprod-01", trigger="api") foreign_rule = ScenarioTriggerRule(scenario_id=_FOREIGN_SCENARIO, environment_id="env-preprod-01", trigger="api") db.add_all([own_schedule, foreign_schedule, own_rule, foreign_rule]) own_notification = ScenarioNotificationEvent( event_type="run_finished", scenario_id=_OWN_SCENARIO, severity="info", payload={"k": "own"}, ) foreign_notification = ScenarioNotificationEvent( event_type="run_finished", scenario_id=_FOREIGN_SCENARIO, severity="info", payload={"k": "foreign"}, ) db.add_all([own_notification, foreign_notification]) db.add(ScenarioRun( scenario_id=_OWN_SCENARIO, scenario_revision_id="70460000-0000-4000-8000-0000000000e1", scenario_content_hash="a" * 64, environment_id="env-preprod-01", idempotency_key="acl-own-run-001", trigger_source="scheduled", )) db.add(ScenarioRun( scenario_id=_FOREIGN_SCENARIO, scenario_revision_id="70460000-0000-4000-8000-0000000000e2", scenario_content_hash="f" * 64, environment_id="env-preprod-01", idempotency_key="acl-foreign-run-001", trigger_source="scheduled", )) db.commit() return { "own_policy": own_policy.id, "foreign_policy": foreign_policy.id, "free_policy": free_policy.id, "own_schedule": own_schedule.id, "foreign_schedule": foreign_schedule.id, "own_rule": own_rule.id, "foreign_rule": foreign_rule.id, "own_notification": own_notification.id, "foreign_notification": foreign_notification.id, } def _drop_acl_rows(db) -> None: db.query(ScenarioRun).filter(ScenarioRun.idempotency_key.in_(["acl-own-run-001", "acl-foreign-run-001"])).delete() db.query(ScenarioNotificationEvent).filter(ScenarioNotificationEvent.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO])).delete() db.query(ScenarioSchedule).filter(ScenarioSchedule.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO])).delete() db.query(ScenarioTriggerRule).filter(ScenarioTriggerRule.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO])).delete() db.query(AutomationPolicy).filter(AutomationPolicy.name.in_(["acl-policy-own", "acl-policy-foreign", "acl-policy-free"])).delete() db.query(ScenarioRegistryEntry).filter(ScenarioRegistryEntry.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO])).delete() db.commit() class TestScenarioAutomationReadAcl: _READ_PATHS = ( "/api/scenario-automation/schedules", "/api/scenario-automation/trigger-rules", "/api/scenario-automation/policies", "/api/scenario-automation/notifications", "/api/scenario-automation/metrics", "/api/scenario-automation/retention", ) def test_reads_require_read_permission(self): user = _make_user_with_permissions([("scenario:automation", "MANAGE"), ("scenario:automation", "TRIGGER")]) with _client_for(user) as client: for path in self._READ_PATHS: resp = client.get(path) assert resp.status_code == 403, (path, resp.text) def test_read_acl_filters_foreign_rows_without_totals_leak(self): session = SessionLocal() try: ids = _seed_acl_rows(session) finally: session.close() try: with _client_for(_make_acl_user()) as client: schedules = client.get("/api/scenario-automation/schedules") assert schedules.status_code == 200, schedules.text schedule_ids = {item["id"] for item in schedules.json()} assert ids["own_schedule"] in schedule_ids assert ids["foreign_schedule"] not in schedule_ids rules = client.get("/api/scenario-automation/trigger-rules") assert rules.status_code == 200, rules.text rule_ids = {item["id"] for item in rules.json()} assert ids["own_rule"] in rule_ids assert ids["foreign_rule"] not in rule_ids notifications = client.get("/api/scenario-automation/notifications", params={"limit": 100}) assert notifications.status_code == 200, notifications.text notification_ids = {item["id"] for item in notifications.json()} assert ids["own_notification"] in notification_ids assert ids["foreign_notification"] not in notification_ids policies = client.get("/api/scenario-automation/policies") assert policies.status_code == 200, policies.text policy_ids = {item["id"] for item in policies.json()} assert ids["own_policy"] in policy_ids assert ids["free_policy"] in policy_ids assert ids["foreign_policy"] not in policy_ids metrics = client.get("/api/scenario-automation/metrics") assert metrics.status_code == 200, metrics.text body = metrics.json() assert body["schedules_total"] == 1 assert body["trigger_rules_total"] == 1 assert body["total_runs"] == 1 retention = client.get("/api/scenario-automation/retention") assert retention.status_code == 200, retention.text finally: cleanup = SessionLocal() try: _drop_acl_rows(cleanup) finally: cleanup.close() def test_admin_read_bypasses_acl_filter(self): session = SessionLocal() try: ids = _seed_acl_rows(session) finally: session.close() try: with _client_for(_make_admin_user()) as client: schedules = client.get("/api/scenario-automation/schedules") assert schedules.status_code == 200, schedules.text schedule_ids = {item["id"] for item in schedules.json()} assert ids["own_schedule"] in schedule_ids assert ids["foreign_schedule"] in schedule_ids policies = client.get("/api/scenario-automation/policies") policy_ids = {item["id"] for item in policies.json()} assert ids["foreign_policy"] in policy_ids finally: cleanup = SessionLocal() try: _drop_acl_rows(cleanup) finally: cleanup.close() def test_disabled_human_schedule_rejected_like_enabled(self): setup = SessionLocal() try: setup.query(ScenarioRegistryEntry).filter( ScenarioRegistryEntry.scenario_id.in_([_HUMAN_SCENARIO, _ELIGIBLE_SCENARIO]) ).delete() setup.commit() setup.add(ScenarioRegistryEntry( scenario_id=_HUMAN_SCENARIO, scenario_key="acl-human", name="ACL human fixture", dashboard_id=73, environment_ids=["env-preprod-01"], owner_id="acl-viewer-1", owner_username="acl.viewer", lifecycle_status="READY", validation_status="valid", current_revision_id=_HUMAN_REVISION, )) setup.add(ScenarioRevision( revision_id=_HUMAN_REVISION, scenario_id=_HUMAN_SCENARIO, content_hash="c" * 64, graph_snapshot={ "action_registry_version": ACTION_REGISTRY_VERSION, "action_registry_hash": action_registry_fingerprint(), "steps": [_action_step("human-acl-046", "human", "human_checkpoint")], "dependencies": [], }, created_by="acl.viewer", activation_status="current", )) setup.add(ScenarioRegistryEntry( scenario_id=_ELIGIBLE_SCENARIO, scenario_key="acl-eligible", name="ACL eligible fixture", dashboard_id=74, environment_ids=["env-preprod-01"], owner_id="acl-viewer-1", owner_username="acl.viewer", lifecycle_status="READY", validation_status="valid", current_revision_id=_ELIGIBLE_REVISION, )) setup.add(ScenarioRevision( revision_id=_ELIGIBLE_REVISION, scenario_id=_ELIGIBLE_SCENARIO, content_hash="e" * 64, graph_snapshot={ "action_registry_version": ACTION_REGISTRY_VERSION, "action_registry_hash": action_registry_fingerprint(), "steps": [_action_step("step-acl-046", "assertion", "structural_assert")], "dependencies": [], "environment_ids": ["env-preprod-01"], }, created_by="acl.viewer", activation_status="current", )) setup.commit() finally: setup.close() user = _make_user_with_permissions([("scenario:automation", "MANAGE")]) try: with _client_for(user) as client: enabled = client.post( "/api/scenario-automation/schedules", json={ "scenario_id": _HUMAN_SCENARIO, "environment_id": "env-preprod-01", "cron_expr": "0 7 * * *", "revision_policy": "pinned", "revision_id": _HUMAN_REVISION, "enabled": True, }, ) assert enabled.status_code == 409, enabled.text assert enabled.json()["detail"]["code"] == "AUTOMATION_INELIGIBLE_HUMAN_STEP" disabled = client.post( "/api/scenario-automation/schedules", json={ "scenario_id": _HUMAN_SCENARIO, "environment_id": "env-preprod-01", "cron_expr": "0 7 * * *", "revision_policy": "pinned", "revision_id": _HUMAN_REVISION, "enabled": False, }, ) assert disabled.status_code == 409, disabled.text assert disabled.json()["detail"]["code"] == "AUTOMATION_INELIGIBLE_HUMAN_STEP" created = client.post( "/api/scenario-automation/schedules", json={ "scenario_id": _ELIGIBLE_SCENARIO, "environment_id": "env-preprod-01", "cron_expr": "0 6 * * *", "revision_policy": "pinned", "revision_id": _ELIGIBLE_REVISION, "enabled": True, }, ) assert created.status_code == 201, created.text schedule_id = created.json()["id"] patch = client.patch( f"/api/scenario-automation/schedules/{schedule_id}", json={ "scenario_id": _HUMAN_SCENARIO, "environment_id": "env-preprod-01", "cron_expr": "0 6 * * *", "revision_policy": "pinned", "revision_id": _HUMAN_REVISION, "enabled": False, }, ) assert patch.status_code == 409, patch.text assert patch.json()["detail"]["code"] == "AUTOMATION_INELIGIBLE_HUMAN_STEP" verify = SessionLocal() try: assert verify.query(ScenarioSchedule).filter( ScenarioSchedule.scenario_id == _HUMAN_SCENARIO ).count() == 0 row = verify.query(ScenarioSchedule).filter(ScenarioSchedule.id == schedule_id).first() assert row is not None assert row.scenario_id == _ELIGIBLE_SCENARIO assert row.revision_id == _ELIGIBLE_REVISION finally: verify.close() # Remove the scheduler job through the API so no in-memory APScheduler # registration survives the fixture teardown. deleted = client.delete(f"/api/scenario-automation/schedules/{schedule_id}") assert deleted.status_code == 204 finally: cleanup = SessionLocal() try: cleanup.query(ScenarioSchedule).filter( ScenarioSchedule.scenario_id.in_([_HUMAN_SCENARIO, _ELIGIBLE_SCENARIO]) ).delete() cleanup.query(ScenarioRevision).filter( ScenarioRevision.scenario_id.in_([_HUMAN_SCENARIO, _ELIGIBLE_SCENARIO]) ).delete() cleanup.query(ScenarioRegistryEntry).filter( ScenarioRegistryEntry.scenario_id.in_([_HUMAN_SCENARIO, _ELIGIBLE_SCENARIO]) ).delete() cleanup.commit() finally: cleanup.close() # #endregion Test.Api.ScenarioAutomation.ReadAcl # #region Test.Api.ScenarioAutomation.RetentionReceipts [C:3] [TYPE Class] [SEMANTICS test,api,scenario,automation,retention,receipts,acl] # @BRIEF 046 T021: the retention read projects deletion receipts under the same DG-2 ACL as the # other five operational reads (READ grant + per-object scenario ownership, no totals leak). # @TEST_INVARIANT Api.ScenarioAutomation.ReadAcl: foreign deletion receipts are filtered from the # projection without leaking their count. -> VERIFIED_BY: # test_retention_receipts_projected_with_acl class TestScenarioAutomationRetentionReceipts: def test_retention_receipts_projected_with_acl(self): setup = SessionLocal() try: setup.query(ScenarioRetentionDeletion).filter( ScenarioRetentionDeletion.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO]) ).delete() setup.query(ScenarioRegistryEntry).filter( ScenarioRegistryEntry.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO]) ).delete() setup.add(ScenarioRegistryEntry( scenario_id=_OWN_SCENARIO, scenario_key="acl-receipt-own", name="Receipt own", dashboard_id=81, environment_ids=["env-preprod-01"], owner_id="acl-viewer-1", owner_username="acl.viewer", lifecycle_status="READY", validation_status="valid", )) setup.add(ScenarioRegistryEntry( scenario_id=_FOREIGN_SCENARIO, scenario_key="acl-receipt-foreign", name="Receipt foreign", dashboard_id=82, environment_ids=["env-preprod-01"], owner_id="foreign-owner-1", owner_username="foreign.owner", lifecycle_status="READY", validation_status="valid", )) setup.add(ScenarioRetentionDeletion( target_type="artifact", target_id="receipt-own-001", scenario_id=_OWN_SCENARIO, state="deletion_pending", holds_snapshot={"reasons": ["active_operation"]}, )) setup.add(ScenarioRetentionDeletion( target_type="artifact", target_id="receipt-foreign-001", scenario_id=_FOREIGN_SCENARIO, state="tombstoned", holds_snapshot={"reasons": []}, )) setup.commit() finally: setup.close() try: with _client_for(_make_acl_user()) as client: body = client.get("/api/scenario-automation/retention").json() assert body["tiers"]["raw_vlm"] == 7 receipt_targets = {item["target_id"] for item in body["deletions"]} assert "receipt-own-001" in receipt_targets assert "receipt-foreign-001" not in receipt_targets assert body["deletions_total"] == 1 with _client_for(_make_admin_user()) as client: admin_body = client.get("/api/scenario-automation/retention").json() admin_targets = {item["target_id"] for item in admin_body["deletions"]} assert {"receipt-own-001", "receipt-foreign-001"} <= admin_targets finally: cleanup = SessionLocal() try: cleanup.query(ScenarioRetentionDeletion).filter( ScenarioRetentionDeletion.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO]) ).delete() cleanup.query(ScenarioRegistryEntry).filter( ScenarioRegistryEntry.scenario_id.in_([_OWN_SCENARIO, _FOREIGN_SCENARIO]) ).delete() cleanup.commit() finally: cleanup.close() # #endregion Test.Api.ScenarioAutomation.RetentionReceipts # #region Test.Api.ScenarioAutomation.QuarantineRelease [C:3] [TYPE Class] [SEMANTICS test,api,scenario,automation,quarantine,recovery,operator] # @BRIEF Operator-only quarantine listing and CAS release; RBAC forbids a non-MANAGE principal. # @RELATION VERIFIES -> [Api.ScenarioAutomation.Quarantine] # @TEST_INVARIANT Api.ScenarioAutomation.Quarantine: release requires scenario:automation MANAGE and a matching # quarantine version; a released pair re-enables schedules and a second release is 404. # @TEST_EDGE missing_manage_scope -> 403 on quarantine release class TestScenarioAutomationQuarantineRelease: _SCENARIO = "77777777-7777-4777-8777-777777777777" _ENV = "env-preprod-01" def test_release_requires_manage_scope(self, monkeypatch, tmp_path): monkeypatch.setenv("SCENARIO_POISONED_STORE_PATH", str(tmp_path / "poisoned.jsonl")) user = _make_user_with_permissions([("scenario:automation", "TRIGGER")]) with _client_for(user) as client: resp = client.post( f"/api/scenario-automation/quarantine/{self._SCENARIO}/release", json={"environment_id": self._ENV, "expected_version": 1}, ) assert resp.status_code == 403 def test_operator_release_roundtrip(self, dashboard_testing_client, monkeypatch, tmp_path): client = dashboard_testing_client store_path = tmp_path / "poisoned" / "failures.jsonl" monkeypatch.setenv("SCENARIO_POISONED_STORE_PATH", str(store_path)) created = client.post( "/api/scenario-automation/schedules", json={"scenario_id": self._SCENARIO, "environment_id": self._ENV, "cron_expr": "0 7 * * *"}, ) assert created.status_code == 201, created.text schedule_id = created.json()["id"] disabled = client.patch( f"/api/scenario-automation/schedules/{schedule_id}", json={"scenario_id": self._SCENARIO, "environment_id": self._ENV, "cron_expr": "0 7 * * *", "enabled": False}, ) assert disabled.status_code == 200 store = PoisonedRunStore(store_path) for _ in range(3): store.record_failure(scenario_id=self._SCENARIO, environment_id=self._ENV, error_code="INFRA_TIMEOUT") listed = client.get("/api/scenario-automation/quarantine") assert listed.status_code == 200 assert any( item["scenario_id"] == self._SCENARIO and item["version"] == 1 for item in listed.json() ) released = client.post( f"/api/scenario-automation/quarantine/{self._SCENARIO}/release", json={"environment_id": self._ENV, "expected_version": 1}, ) assert released.status_code == 200, released.text assert schedule_id in released.json()["enabled_schedule_ids"] schedules = client.get("/api/scenario-automation/schedules") assert next(item for item in schedules.json() if item["id"] == schedule_id)["enabled"] is True assert client.get("/api/scenario-automation/quarantine").json() == [] again = client.post( f"/api/scenario-automation/quarantine/{self._SCENARIO}/release", json={"environment_id": self._ENV, "expected_version": 1}, ) assert again.status_code == 404 assert again.json()["detail"]["code"] == "NOT_QUARANTINED" client.delete(f"/api/scenario-automation/schedules/{schedule_id}") # #endregion Test.Api.ScenarioAutomation.QuarantineRelease # #endregion Test.Api.ScenarioAutomation