| """Unit tests for Cascade_Engine.
|
|
|
| Tests specific BFS traversal, depth-6 boundary, severity decay values,
|
| CascadeError on unknown root, and blast radius after recovery.
|
| """
|
| from __future__ import annotations
|
|
|
| import pytest
|
|
|
| from sentinel.cascade_engine import Cascade_Engine
|
| from sentinel.exceptions import CascadeError
|
| from sentinel.models import FailureType
|
| from sentinel.world_state import NexaStackWorldState
|
|
|
|
|
|
|
|
|
|
|
| def _fresh() -> tuple[NexaStackWorldState, Cascade_Engine]:
|
| return NexaStackWorldState(), Cascade_Engine()
|
|
|
|
|
| _FAILURE_TYPE = FailureType.cpu_spike
|
|
|
|
|
|
|
|
|
|
|
|
|
| class TestBFSTraversal:
|
| def test_root_is_in_blast_radius(self):
|
| ws, engine = _fresh()
|
| result = engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
| assert "web-gateway" in result
|
|
|
| def test_depth_1_cart_service_affected(self):
|
| """web-gateway → cart-service is a direct CDG edge (depth 1)."""
|
| ws, engine = _fresh()
|
| result = engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
| assert "cart-service" in result, (
|
| "cart-service should be in blast radius at depth 1 from web-gateway"
|
| )
|
|
|
| def test_depth_2_redis_cache_affected(self):
|
| """web-gateway → cart-service → redis-cache (depth 2)."""
|
| ws, engine = _fresh()
|
| result = engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
| assert "redis-cache" in result, (
|
| "redis-cache should be in blast radius at depth 2 from web-gateway"
|
| )
|
|
|
| def test_depth_1_order_service_affected(self):
|
| """web-gateway → order-service is a direct CDG edge (depth 1)."""
|
| ws, engine = _fresh()
|
| result = engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
| assert "order-service" in result
|
|
|
| def test_depth_1_user_auth_affected(self):
|
| """web-gateway → user-auth is a direct CDG edge (depth 1)."""
|
| ws, engine = _fresh()
|
| result = engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
| assert "user-auth" in result
|
|
|
| def test_blast_radius_matches_get_blast_radius(self):
|
| ws, engine = _fresh()
|
| result = engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
| assert engine.get_blast_radius() == set(result.keys())
|
|
|
|
|
|
|
|
|
|
|
|
|
| class TestDepth6Boundary:
|
| def test_services_beyond_depth_6_not_affected(self):
|
| """No service at BFS depth > 6 from root should appear in blast radius."""
|
| import networkx as nx
|
|
|
| ws, engine = _fresh()
|
| root = "web-gateway"
|
| result = engine.propagate_failure(ws, root, _FAILURE_TYPE, 1.0)
|
|
|
| bfs_depths = nx.single_source_shortest_path_length(ws.cdg, root)
|
|
|
| for service in result:
|
| depth = bfs_depths.get(service)
|
| assert depth is not None, f"{service} not reachable from {root}"
|
| assert depth <= 6, (
|
| f"{service} at depth {depth} should not be in blast radius (max=6)"
|
| )
|
|
|
| def test_max_depth_constant_is_6(self):
|
| assert Cascade_Engine.MAX_DEPTH == 6
|
|
|
| def test_services_at_depth_7_excluded(self):
|
| """Verify that if any service would be at depth 7, it's excluded.
|
|
|
| We construct a minimal chain: A→B→C→D→E→F→G→H (8 nodes, 7 hops).
|
| H should NOT be in the blast radius when propagating from A.
|
| """
|
| import networkx as nx
|
|
|
|
|
| ws = NexaStackWorldState()
|
|
|
| chain = [
|
| "web-gateway",
|
| "cart-service",
|
| "redis-cache",
|
| "service-mesh",
|
| "api-gateway",
|
| "config-service",
|
| "secret-manager",
|
| "load-balancer",
|
| ]
|
| g = nx.DiGraph()
|
| g.add_nodes_from(ws.cdg.nodes())
|
| for i in range(len(chain) - 1):
|
| g.add_edge(chain[i], chain[i + 1], weight=1.0)
|
| ws.cdg = g
|
|
|
| engine = Cascade_Engine()
|
| result = engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
|
|
| assert "load-balancer" not in result, (
|
| "load-balancer is at depth 7 and must NOT be in blast radius"
|
| )
|
| assert "secret-manager" in result, (
|
| "secret-manager is at depth 6 and MUST be in blast radius"
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
| class TestSeverityDecay:
|
| """Verify severity = initial * (0.7^d) at each depth."""
|
|
|
| def _chain_state(self) -> tuple[NexaStackWorldState, list[str]]:
|
| """Build a world state with a linear chain CDG for predictable depths."""
|
| import networkx as nx
|
|
|
| ws = NexaStackWorldState()
|
| chain = [
|
| "web-gateway",
|
| "cart-service",
|
| "redis-cache",
|
| "service-mesh",
|
| "api-gateway",
|
| "config-service",
|
| "secret-manager",
|
| ]
|
| g = nx.DiGraph()
|
| g.add_nodes_from(ws.cdg.nodes())
|
| for i in range(len(chain) - 1):
|
| g.add_edge(chain[i], chain[i + 1], weight=1.0)
|
| ws.cdg = g
|
| return ws, chain
|
|
|
| def test_depth_0_severity_equals_initial(self):
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| initial = 0.8
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, initial)
|
| assert abs(result[chain[0]] - initial) < 1e-9
|
|
|
| def test_depth_1_severity_decay(self):
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| initial = 0.8
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, initial)
|
| expected = initial * 0.7
|
| assert abs(result[chain[1]] - expected) < 1e-9, (
|
| f"depth 1: expected {expected}, got {result[chain[1]]}"
|
| )
|
|
|
| def test_depth_2_severity_decay(self):
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| initial = 0.8
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, initial)
|
| expected = initial * (0.7 ** 2)
|
| assert abs(result[chain[2]] - expected) < 1e-9, (
|
| f"depth 2: expected {expected}, got {result[chain[2]]}"
|
| )
|
|
|
| def test_depth_3_severity_decay(self):
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| initial = 0.8
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, initial)
|
| expected = initial * (0.7 ** 3)
|
| assert abs(result[chain[3]] - expected) < 1e-9
|
|
|
| def test_depth_4_severity_decay(self):
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| initial = 0.8
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, initial)
|
| expected = initial * (0.7 ** 4)
|
| assert abs(result[chain[4]] - expected) < 1e-9
|
|
|
| def test_depth_5_severity_decay(self):
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| initial = 0.8
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, initial)
|
| expected = initial * (0.7 ** 5)
|
| assert abs(result[chain[5]] - expected) < 1e-9
|
|
|
| def test_depth_6_severity_decay(self):
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| initial = 0.8
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, initial)
|
| expected = initial * (0.7 ** 6)
|
| assert abs(result[chain[6]] - expected) < 1e-9
|
|
|
| def test_severity_clamped_to_1_at_depth_0(self):
|
| """Initial severity > 1.0 must be clamped to 1.0 at depth 0."""
|
| ws, chain = self._chain_state()
|
| engine = Cascade_Engine()
|
| result = engine.propagate_failure(ws, chain[0], _FAILURE_TYPE, 2.0)
|
| assert result[chain[0]] == 1.0, (
|
| f"Severity at depth 0 should be clamped to 1.0, got {result[chain[0]]}"
|
| )
|
|
|
| def test_decay_constant_is_0_7(self):
|
| assert Cascade_Engine.SEVERITY_DECAY == 0.7
|
|
|
|
|
|
|
|
|
|
|
|
|
| class TestCascadeError:
|
| def test_unknown_root_raises_cascade_error(self):
|
| ws, engine = _fresh()
|
| with pytest.raises(CascadeError, match="not in the Causal Dependency Graph"):
|
| engine.propagate_failure(ws, "nonexistent-service", _FAILURE_TYPE, 0.5)
|
|
|
| def test_empty_string_root_raises_cascade_error(self):
|
| ws, engine = _fresh()
|
| with pytest.raises(CascadeError):
|
| engine.propagate_failure(ws, "", _FAILURE_TYPE, 0.5)
|
|
|
| def test_cascade_error_message_contains_service_name(self):
|
| ws, engine = _fresh()
|
| bad_service = "totally-fake-service"
|
| with pytest.raises(CascadeError) as exc_info:
|
| engine.propagate_failure(ws, bad_service, _FAILURE_TYPE, 0.5)
|
| assert bad_service in str(exc_info.value)
|
|
|
|
|
|
|
|
|
|
|
|
|
| class TestRecovery:
|
| def test_root_service_restored_after_recovery(self):
|
| """After recovery, the root service must be out of blast radius."""
|
| ws, engine = _fresh()
|
| engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 0.9)
|
| assert len(engine.get_blast_radius()) > 0, "Blast radius should be non-empty after failure"
|
|
|
| engine.propagate_recovery(ws, "web-gateway")
|
| assert "web-gateway" not in engine.get_blast_radius(), (
|
| "Root service must be removed from blast radius after recovery"
|
| )
|
|
|
| def test_blast_radius_shrinks_after_recovery(self):
|
| """Granular recovery: blast radius should shrink but may not be empty."""
|
| ws, engine = _fresh()
|
| engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 0.9)
|
| pre_recovery_br = len(engine.get_blast_radius())
|
| assert pre_recovery_br > 0
|
|
|
| engine.propagate_recovery(ws, "web-gateway")
|
| post_recovery_br = len(engine.get_blast_radius())
|
| assert post_recovery_br < pre_recovery_br, (
|
| f"Blast radius should shrink after recovery: {pre_recovery_br} -> {post_recovery_br}"
|
| )
|
|
|
| def test_root_service_metrics_restored_to_baseline(self):
|
| """The root service should be fully restored to baseline after recovery."""
|
| from sentinel.world_state import ALL_SERVICES, _baseline_metrics
|
|
|
| ws, engine = _fresh()
|
| baseline_m = _baseline_metrics()
|
|
|
| engine.propagate_failure(ws, "web-gateway", _FAILURE_TYPE, 1.0)
|
| engine.propagate_recovery(ws, "web-gateway")
|
|
|
| m = ws.services["web-gateway"]
|
| assert (m.cpu, m.memory, m.latency_ms, m.error_rate, m.saturation, m.availability) == (
|
| baseline_m.cpu, baseline_m.memory, baseline_m.latency_ms,
|
| baseline_m.error_rate, baseline_m.saturation, baseline_m.availability,
|
| ), "web-gateway metrics should be baseline after recovery"
|
|
|
| def test_direct_dependents_partially_recovered(self):
|
| """Direct dependents should have reduced severity after root recovery."""
|
| import networkx as nx
|
|
|
| ws, engine = _fresh()
|
| root = "web-gateway"
|
| engine.propagate_failure(ws, root, _FAILURE_TYPE, 0.9)
|
|
|
|
|
| dependents = list(ws.cdg.successors(root))
|
| pre_recovery_affected = {
|
| svc: engine._affected_services.get(svc, 0)
|
| for svc in dependents
|
| if svc in engine._affected_services
|
| }
|
|
|
| engine.propagate_recovery(ws, root)
|
|
|
|
|
| for svc, pre_sev in pre_recovery_affected.items():
|
| post_sev = engine._affected_services.get(svc, 0)
|
| assert post_sev <= pre_sev, (
|
| f"{svc} severity should decrease: {pre_sev} -> {post_sev}"
|
| )
|
|
|
| def test_get_blast_radius_empty_before_any_propagation(self):
|
| _, engine = _fresh()
|
| assert engine.get_blast_radius() == set()
|
|
|
| def test_recovery_without_prior_failure_is_safe(self):
|
| """propagate_recovery on a fresh engine should not raise."""
|
| ws, engine = _fresh()
|
| engine.propagate_recovery(ws, "web-gateway")
|
| assert engine.get_blast_radius() == set()
|
|
|