Spaces:
Sleeping
Sleeping
| """Comprehensive tests for LogSentinel v2 — Multi-Agent SOC War-Room.""" | |
| import sys | |
| import os | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from server import app | |
| from environment import LogSentinelEnv | |
| from log_generator import ( | |
| generate_task1_logs, | |
| generate_task2_logs, | |
| generate_task3_logs, | |
| generate_scenario, | |
| filter_logs_for_role, | |
| ROLE_SOURCE_VISIBILITY, | |
| ) | |
| from graders import ( | |
| grade_action, | |
| compute_episode_reward, | |
| check_anti_hacking, | |
| ) | |
| from models import ( | |
| Action, | |
| AgentRole, | |
| CurriculumState, | |
| DifficultyLevel, | |
| EpisodePhase, | |
| GroundTruth, | |
| ScenarioConfig, | |
| ) | |
| # =========================================================================== | |
| # 1. Log generator tests (legacy + new) | |
| # =========================================================================== | |
| class TestLogGenerator: | |
| def test_task1_generates_10_logs(self): | |
| logs, gt = generate_task1_logs() | |
| assert len(logs) == 10 | |
| assert len(gt.log_classifications) == 10 | |
| def test_task1_deterministic(self): | |
| logs1, gt1 = generate_task1_logs(seed=42) | |
| logs2, gt2 = generate_task1_logs(seed=42) | |
| assert [lg.message for lg in logs1] == [lg.message for lg in logs2] | |
| assert gt1.log_classifications == gt2.log_classifications | |
| def test_task2_generates_20_logs(self): | |
| logs, gt = generate_task2_logs() | |
| assert len(logs) == 20 | |
| assert len(gt.incidents) == 2 | |
| def test_task3_generates_30plus_logs(self): | |
| logs, gt = generate_task3_logs() | |
| assert len(logs) >= 30 | |
| assert len(gt.incidents) == 3 | |
| def test_task3_has_security_incident(self): | |
| _, gt = generate_task3_logs() | |
| types = [inc["type"] for inc in gt.incidents] | |
| assert "security_breach" in types | |
| def test_scenario_deterministic_with_seed(self): | |
| cfg = ScenarioConfig(num_incidents=2, difficulty=DifficultyLevel.MEDIUM, seed=999) | |
| logs1, gt1 = generate_scenario(cfg) | |
| logs2, gt2 = generate_scenario(cfg) | |
| assert len(logs1) == len(logs2) | |
| assert [lg.message for lg in logs1] == [lg.message for lg in logs2] | |
| assert gt1.incidents == gt2.incidents | |
| def test_scenario_easy_has_one_incident(self): | |
| cfg = ScenarioConfig(num_incidents=1, difficulty=DifficultyLevel.EASY, seed=1) | |
| _, gt = generate_scenario(cfg) | |
| assert len(gt.incidents) == 1 | |
| def test_scenario_hard_has_three_incidents(self): | |
| cfg = ScenarioConfig(num_incidents=3, difficulty=DifficultyLevel.HARD, seed=2) | |
| _, gt = generate_scenario(cfg) | |
| assert len(gt.incidents) == 3 | |
| def test_scenario_noise_adds_normal_logs(self): | |
| cfg = ScenarioConfig( | |
| num_incidents=1, difficulty=DifficultyLevel.EASY, | |
| confounding_noise_ratio=0.5, seed=3 | |
| ) | |
| logs, gt = generate_scenario(cfg) | |
| normal_count = sum(1 for c in gt.log_classifications.values() if c == "normal") | |
| assert normal_count > 0 | |
| def test_observability_drops_logs(self): | |
| cfg_full = ScenarioConfig( | |
| num_incidents=1, difficulty=DifficultyLevel.EASY, | |
| observability_quality=1.0, confounding_noise_ratio=0.3, seed=77 | |
| ) | |
| cfg_low = ScenarioConfig( | |
| num_incidents=1, difficulty=DifficultyLevel.EASY, | |
| observability_quality=0.5, confounding_noise_ratio=0.3, seed=77 | |
| ) | |
| logs_full, _ = generate_scenario(cfg_full) | |
| logs_low, _ = generate_scenario(cfg_low) | |
| # Low observability should have <= full observability log count | |
| assert len(logs_low) <= len(logs_full) | |
| # =========================================================================== | |
| # 2. Role-based partial observability tests | |
| # =========================================================================== | |
| class TestPartialObservability: | |
| def test_filter_logs_for_app_sre(self): | |
| logs, _ = generate_task3_logs(seed=42) | |
| visible = filter_logs_for_role(logs, "app_sre") | |
| allowed = set(ROLE_SOURCE_VISIBILITY["app_sre"]) | |
| for log in visible: | |
| assert log.source in allowed | |
| def test_filter_logs_for_db_sre(self): | |
| logs, _ = generate_task3_logs(seed=42) | |
| visible = filter_logs_for_role(logs, "db_sre") | |
| allowed = set(ROLE_SOURCE_VISIBILITY["db_sre"]) | |
| for log in visible: | |
| assert log.source in allowed | |
| def test_filter_logs_for_security_analyst(self): | |
| logs, _ = generate_task3_logs(seed=42) | |
| visible = filter_logs_for_role(logs, "security_analyst") | |
| allowed = set(ROLE_SOURCE_VISIBILITY["security_analyst"]) | |
| for log in visible: | |
| assert log.source in allowed | |
| def test_incident_commander_sees_more_logs_than_db_sre(self): | |
| logs, _ = generate_task3_logs(seed=42) | |
| cmd_visible = filter_logs_for_role(logs, "incident_commander") | |
| db_visible = filter_logs_for_role(logs, "db_sre") | |
| # IC sees all sources; DB SRE sees subset | |
| assert len(cmd_visible) >= len(db_visible) | |
| def test_env_step_returns_role_filtered_observation(self): | |
| env = LogSentinelEnv() | |
| result = env.reset(task_name="soc_warroom_easy", agent_role="db_sre", seed=10) | |
| obs = result["observation"] | |
| assert obs["agent_role"] == "db_sre" | |
| # Logs visible to db_sre should only be from db_sre sources | |
| allowed = set(ROLE_SOURCE_VISIBILITY["db_sre"]) | |
| for log in obs["log_entries"]: | |
| assert log["source"] in allowed | |
| def test_shared_board_updated_after_handoff(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_medium", agent_role="app_sre", seed=42) | |
| env.step({ | |
| "action_type": "propose_incident", | |
| "agent_role": "app_sre", | |
| "incident_type": "outage", | |
| "evidence_indices": [0, 1], | |
| }) | |
| result = env.step({ | |
| "action_type": "request_handoff", | |
| "agent_role": "app_sre", | |
| "handoff_to": "db_sre", | |
| "handoff_note": "Possible replication lag causing outage. Check pg replica.", | |
| }) | |
| obs = result["observation"] | |
| # Shared board should have the handoff recorded | |
| assert obs.get("shared_board") is not None | |
| board = obs["shared_board"] | |
| assert any("handoff" in k for k in board) | |
| # =========================================================================== | |
| # 3. Phase transition tests | |
| # =========================================================================== | |
| class TestPhaseTransitions: | |
| def test_initial_phase_is_detect(self): | |
| env = LogSentinelEnv() | |
| result = env.reset(task_name="soc_warroom_easy", seed=42) | |
| assert result["observation"]["current_phase"] == "detect" | |
| def test_propose_incident_triggers_triage_phase(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=42) | |
| env.step({ | |
| "action_type": "propose_incident", | |
| "agent_role": "app_sre", | |
| "incident_type": "outage", | |
| "evidence_indices": [0], | |
| }) | |
| # Now take a triage action → should advance to triage | |
| result = env.step({ | |
| "action_type": "assign_severity", | |
| "agent_role": "app_sre", | |
| "incident_type": "outage", | |
| "severity": "P1", | |
| }) | |
| obs = result["observation"] | |
| assert obs["current_phase"] in ("triage", "detect") # may advance | |
| def test_state_reflects_current_phase(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_medium", seed=10) | |
| state = env.state | |
| assert "current_phase" in state | |
| assert state["current_phase"] in ("detect", "triage", "mitigate", "verify", "final_report") | |
| def test_phase_tracks_in_world_state(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=1) | |
| state = env.state | |
| assert state["world_state"]["phase"] == "detect" | |
| def test_execute_mitigation_advances_phase(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=5) | |
| # Detect | |
| env.step({"action_type": "propose_incident", "agent_role": "app_sre", | |
| "incident_type": "outage", "evidence_indices": [0]}) | |
| # Triage | |
| env.step({"action_type": "assign_severity", "agent_role": "app_sre", | |
| "incident_type": "outage", "severity": "P2"}) | |
| # Mitigate → should advance phase | |
| result = env.step({ | |
| "action_type": "execute_mitigation", | |
| "agent_role": "app_sre", | |
| "mitigation_id": "restart_app_servers", | |
| "evidence_indices": [0, 1], | |
| }) | |
| state = env.state | |
| assert state["current_phase"] in ("triage", "mitigate", "verify") | |
| # =========================================================================== | |
| # 4. Delayed mitigation effects tests | |
| # =========================================================================== | |
| class TestDelayedMitigationEffects: | |
| def test_mitigation_improves_service_health(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=7) | |
| state_before = env.state | |
| health_before = state_before["world_state"]["service_health"] | |
| env.step({"action_type": "propose_incident", "agent_role": "app_sre", | |
| "incident_type": "resource_exhaustion", "evidence_indices": [0]}) | |
| env.step({ | |
| "action_type": "execute_mitigation", | |
| "agent_role": "db_sre", | |
| "mitigation_id": "scale_connection_pool", | |
| "evidence_indices": [0, 1, 2], | |
| }) | |
| state_after = env.state | |
| health_after = state_after["world_state"]["service_health"] | |
| # Health should not decrease after relevant mitigation | |
| assert health_after >= health_before - 0.01 # allow tiny float drift | |
| def test_verify_recovery_sets_containment(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=8) | |
| env.step({"action_type": "propose_incident", "agent_role": "security_analyst", | |
| "incident_type": "security_breach", "evidence_indices": [0]}) | |
| env.step({"action_type": "execute_mitigation", "agent_role": "security_analyst", | |
| "mitigation_id": "block_attacker_ip", "evidence_indices": [0]}) | |
| env.step({ | |
| "action_type": "verify_recovery", | |
| "agent_role": "incident_commander", | |
| "evidence_indices": [0, 1], | |
| }) | |
| state = env.state | |
| assert state["world_state"]["containment_status"] is True | |
| def test_unsafe_mitigation_tracked(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=9) | |
| env.step({ | |
| "action_type": "execute_mitigation", | |
| "agent_role": "app_sre", | |
| "mitigation_id": "restart_everything", | |
| # No evidence_indices → unsafe | |
| }) | |
| state = env.state | |
| assert state["anti_hacking"]["unsafe_mitigations"] >= 1 | |
| # =========================================================================== | |
| # 5. Reward component correctness tests | |
| # =========================================================================== | |
| class TestRewardComponents: | |
| def _make_gt(self) -> GroundTruth: | |
| return GroundTruth( | |
| log_classifications={0: "error", 1: "warning", 2: "security"}, | |
| incidents=[ | |
| {"type": "outage", "severity": "P1", "correlated_indices": [0, 1], "description": "test"}, | |
| {"type": "security_breach", "severity": "P1", "correlated_indices": [2], "description": "test"}, | |
| ], | |
| ) | |
| def test_correct_classification_rewards(self): | |
| _, gt = generate_task1_logs() | |
| idx = 0 | |
| expected_class = gt.log_classifications[idx] | |
| action = Action(action_type="classify_log", target_log_indices=[idx], classification=expected_class) | |
| reward = grade_action(action, gt) | |
| assert reward > 0 | |
| def test_wrong_classification_no_reward(self): | |
| _, gt = generate_task1_logs() | |
| idx = 0 | |
| wrong_class = "security" if gt.log_classifications[idx] != "security" else "normal" | |
| action = Action(action_type="classify_log", target_log_indices=[idx], classification=wrong_class) | |
| reward = grade_action(action, gt) | |
| assert reward == 0.0 | |
| def test_incident_detection_reward(self): | |
| _, gt = generate_task2_logs() | |
| itype = gt.incidents[0]["type"] | |
| action = Action(action_type="detect_incident", incident_type=itype) | |
| reward = grade_action(action, gt) | |
| assert reward > 0 | |
| def test_correlation_reward(self): | |
| _, gt = generate_task2_logs() | |
| indices = gt.incidents[0]["correlated_indices"] | |
| action = Action(action_type="correlate_logs", correlated_indices=indices) | |
| reward = grade_action(action, gt) | |
| assert reward > 0 | |
| def test_report_grading(self): | |
| _, gt = generate_task2_logs() | |
| action = Action( | |
| action_type="submit_report", | |
| report={ | |
| "incidents": [{"type": "resource_exhaustion"}] * 2, | |
| "severity": "P2", | |
| "summary": "Database connection exhaustion causing cascading failures and disk issues", | |
| }, | |
| ) | |
| reward = grade_action(action, gt) | |
| assert reward > 0 | |
| def test_unknown_action_zero_reward(self): | |
| _, gt = generate_task1_logs() | |
| # action_type validation will raise; catch it | |
| with pytest.raises(Exception): | |
| Action(action_type="totally_unknown_xyz") | |
| def test_observe_logs_zero_reward(self): | |
| _, gt = generate_task1_logs() | |
| action = Action(action_type="observe_logs", agent_role="app_sre") | |
| assert grade_action(action, gt) == 0.0 | |
| def test_episode_reward_breakdown_structure(self): | |
| gt = self._make_gt() | |
| rb = compute_episode_reward( | |
| detected_incident_types=["outage"], | |
| severity_votes=[("outage", "P1")], | |
| total_steps=10, max_steps=30, | |
| noop_count=2, handoff_count=1, useful_handoffs=1, redundant_actions=0, | |
| service_restored=True, breach_contained=True, | |
| ground_truth=gt, | |
| ) | |
| assert 0.0 <= rb.r_outcome <= 1.0 | |
| assert 0.0 <= rb.r_detection_f1 <= 1.0 | |
| assert 0.0 <= rb.r_severity_accuracy <= 1.0 | |
| assert 0.0 <= rb.r_efficiency <= 1.0 | |
| assert -1.0 <= rb.total <= 1.0 | |
| def test_episode_reward_r_outcome_full(self): | |
| gt = self._make_gt() | |
| rb = compute_episode_reward( | |
| detected_incident_types=["outage", "security_breach"], | |
| severity_votes=[("outage", "P1"), ("security_breach", "P1")], | |
| total_steps=5, max_steps=30, noop_count=0, | |
| handoff_count=2, useful_handoffs=2, redundant_actions=0, | |
| service_restored=True, breach_contained=True, | |
| ground_truth=gt, | |
| ) | |
| assert rb.r_outcome == pytest.approx(1.0) | |
| assert rb.total > 0.5 | |
| def test_mitigation_reward_positive(self): | |
| _, gt = generate_task2_logs() | |
| action = Action( | |
| action_type="execute_mitigation", | |
| agent_role="db_sre", | |
| mitigation_id="scale_connection_pool", | |
| evidence_indices=[0, 1], | |
| ) | |
| reward = grade_action(action, gt) | |
| assert reward >= 0.0 # 0 for no-match tasks, positive for relevant | |
| def test_handoff_reward_positive(self): | |
| _, gt = generate_task3_logs() | |
| action = Action( | |
| action_type="request_handoff", | |
| agent_role="app_sre", | |
| handoff_to="db_sre", | |
| handoff_note="Observed high replication lag, needs DB SRE investigation.", | |
| ) | |
| reward = grade_action(action, gt) | |
| assert reward >= 0.05 | |
| def test_severity_near_miss_partial_credit(self): | |
| _, gt = generate_task3_logs() | |
| # P1 incident exists (outage), vote P2 → partial credit | |
| action = Action(action_type="assign_severity", agent_role="app_sre", | |
| incident_type="outage", severity="P2") | |
| reward = grade_action(action, gt) | |
| assert reward > 0 # partial credit | |
| def test_severity_exact_match_full_credit(self): | |
| _, gt = generate_task3_logs() | |
| action = Action(action_type="assign_severity", agent_role="app_sre", | |
| incident_type="outage", severity="P1") | |
| reward = grade_action(action, gt) | |
| assert reward >= 0.15 | |
| # =========================================================================== | |
| # 6. Anti-hacking penalty tests | |
| # =========================================================================== | |
| class TestAntiHacking: | |
| def test_duplicate_incident_proposal_penalty(self): | |
| action = Action(action_type="propose_incident", agent_role="app_sre", incident_type="outage") | |
| history = ["propose:outage", "propose:outage"] | |
| penalty, reason = check_anti_hacking(action, history, ["outage"]) | |
| assert penalty > 0 | |
| assert "duplicate" in reason | |
| def test_no_penalty_first_proposal(self): | |
| action = Action(action_type="propose_incident", agent_role="app_sre", incident_type="outage") | |
| penalty, reason = check_anti_hacking(action, [], []) | |
| assert penalty == 0.0 | |
| assert reason == "ok" | |
| def test_repeated_noop_penalty(self): | |
| action = Action(action_type="observe_logs", agent_role="app_sre") | |
| history = ["observe_logs", "observe_logs", "observe_logs"] | |
| penalty, reason = check_anti_hacking(action, history, []) | |
| assert penalty > 0 | |
| assert "noop" in reason | |
| def test_unsafe_mitigation_penalty(self): | |
| action = Action(action_type="execute_mitigation", agent_role="app_sre", | |
| mitigation_id="restart_db") # no evidence_indices | |
| penalty, reason = check_anti_hacking(action, [], []) | |
| assert penalty >= 0.1 | |
| assert "unsafe_mitigation" in reason | |
| def test_safe_mitigation_no_penalty(self): | |
| action = Action(action_type="execute_mitigation", agent_role="app_sre", | |
| mitigation_id="restart_db", evidence_indices=[0, 1]) | |
| penalty, reason = check_anti_hacking(action, [], []) | |
| assert penalty == 0.0 | |
| def test_report_before_detection_penalty(self): | |
| action = Action(action_type="submit_report", | |
| report={"incidents": [], "severity": "P4", "summary": "Nothing found"}) | |
| penalty, reason = check_anti_hacking(action, [], []) # no detected types | |
| assert penalty >= 0.1 | |
| assert "report_before_detection" in reason | |
| def test_env_unsafe_mitigation_tracked_in_state(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=42) | |
| env.step({ | |
| "action_type": "execute_mitigation", | |
| "agent_role": "app_sre", | |
| "mitigation_id": "blind_restart", | |
| }) | |
| state = env.state | |
| assert state["anti_hacking"]["unsafe_mitigations"] == 1 | |
| def test_duplicate_proposal_tracked_in_state(self): | |
| env = LogSentinelEnv() | |
| env.reset(task_name="soc_warroom_easy", seed=42) | |
| for _ in range(3): | |
| env.step({"action_type": "propose_incident", "agent_role": "app_sre", | |
| "incident_type": "outage", "evidence_indices": [0]}) | |
| state = env.state | |
| assert state["anti_hacking"]["duplicate_proposals"] >= 1 | |
| # =========================================================================== | |
| # 7. Curriculum difficulty adjustment tests | |
| # =========================================================================== | |
| class TestCurriculumDifficulty: | |
| def test_initial_difficulty_is_easy(self): | |
| curriculum = CurriculumState() | |
| assert curriculum.current_difficulty == DifficultyLevel.EASY | |
| def test_high_success_promotes_difficulty(self): | |
| curriculum = CurriculumState() | |
| # Record many successes | |
| for _ in range(10): | |
| curriculum.record(0.9) | |
| new_diff = curriculum.adjust_difficulty() | |
| assert new_diff == DifficultyLevel.MEDIUM | |
| def test_low_success_keeps_easy(self): | |
| curriculum = CurriculumState() | |
| for _ in range(10): | |
| curriculum.record(0.1) | |
| new_diff = curriculum.adjust_difficulty() | |
| assert new_diff == DifficultyLevel.EASY # can't go below easy | |
| def test_medium_high_success_promotes_to_hard(self): | |
| curriculum = CurriculumState(current_difficulty=DifficultyLevel.MEDIUM) | |
| for _ in range(10): | |
| curriculum.record(0.9) | |
| new_diff = curriculum.adjust_difficulty() | |
| assert new_diff == DifficultyLevel.HARD | |
| def test_medium_low_success_demotes_to_easy(self): | |
| curriculum = CurriculumState(current_difficulty=DifficultyLevel.MEDIUM) | |
| for _ in range(10): | |
| curriculum.record(0.1) | |
| new_diff = curriculum.adjust_difficulty() | |
| assert new_diff == DifficultyLevel.EASY | |
| def test_curriculum_adjusts_scenario_difficulty(self): | |
| env = LogSentinelEnv() | |
| # Simulate 10 successful episodes | |
| for _ in range(10): | |
| env._curriculum.record(0.9) | |
| env._curriculum.adjust_difficulty() | |
| # Reset adaptive curriculum → should generate medium difficulty scenario | |
| result = env.reset(task_name="adaptive_curriculum", seed=42) | |
| state = env.state | |
| assert state["curriculum_difficulty"] in ("easy", "medium", "hard") | |
| def test_curriculum_window_size_capped(self): | |
| curriculum = CurriculumState(window_size=5) | |
| for _ in range(20): | |
| curriculum.record(0.9) | |
| assert len(curriculum.success_history) <= 5 | |
| # =========================================================================== | |
| # 8. Deterministic generation with seed tests | |
| # =========================================================================== | |
| class TestDeterministicGeneration: | |
| def test_reset_same_seed_same_logs(self): | |
| env = LogSentinelEnv() | |
| r1 = env.reset(task_name="soc_warroom_easy", seed=42) | |
| logs1 = [lg["message"] for lg in r1["observation"]["log_entries"]] | |
| r2 = env.reset(task_name="soc_warroom_easy", seed=42) | |
| logs2 = [lg["message"] for lg in r2["observation"]["log_entries"]] | |
| assert logs1 == logs2 | |
| def test_reset_different_seeds_different_logs(self): | |
| env = LogSentinelEnv() | |
| r1 = env.reset(task_name="soc_warroom_medium", seed=1) | |
| logs1 = [lg["message"] for lg in r1["observation"]["log_entries"]] | |
| r2 = env.reset(task_name="soc_warroom_medium", seed=2) | |
| logs2 = [lg["message"] for lg in r2["observation"]["log_entries"]] | |
| # Very likely to differ | |
| assert logs1 != logs2 | |
| def test_scenario_generator_seed_reproducible(self): | |
| cfg = ScenarioConfig(num_incidents=2, difficulty=DifficultyLevel.MEDIUM, seed=12345) | |
| logs_a, gt_a = generate_scenario(cfg) | |
| logs_b, gt_b = generate_scenario(cfg) | |
| assert [lg.timestamp for lg in logs_a] == [lg.timestamp for lg in logs_b] | |
| assert len(gt_a.incidents) == len(gt_b.incidents) | |
| # =========================================================================== | |
| # 9. Reset / step / state API compatibility tests | |
| # =========================================================================== | |
| class TestEnvironment: | |
| def setup_method(self): | |
| self.env = LogSentinelEnv() | |
| def test_reset_returns_observation(self): | |
| result = self.env.reset(task_name="log_classification") | |
| assert "observation" in result | |
| assert "reward" in result | |
| assert "done" in result | |
| assert result["done"] is False | |
| assert result["reward"] is None | |
| def test_reset_has_log_entries(self): | |
| result = self.env.reset(task_name="log_classification") | |
| obs = result["observation"] | |
| assert len(obs["log_entries"]) == 10 | |
| def test_step_returns_reward(self): | |
| self.env.reset(task_name="log_classification") | |
| result = self.env.step({ | |
| "action_type": "classify_log", | |
| "target_log_indices": [0], | |
| "classification": "normal", | |
| }) | |
| assert "reward" in result | |
| assert isinstance(result["reward"], float) | |
| def test_step_returns_info(self): | |
| self.env.reset(task_name="soc_warroom_easy", seed=42) | |
| result = self.env.step({ | |
| "action_type": "observe_logs", | |
| "agent_role": "app_sre", | |
| }) | |
| assert "info" in result | |
| assert "phase" in result["info"] | |
| def test_submit_report_ends_episode(self): | |
| self.env.reset(task_name="log_classification") | |
| result = self.env.step({ | |
| "action_type": "submit_report", | |
| "report": {"incidents": [], "severity": "P4", "summary": "No incidents found"}, | |
| }) | |
| assert result["done"] is True | |
| def test_max_steps_ends_episode(self): | |
| self.env.reset(task_name="log_classification") | |
| for _ in range(20): | |
| result = self.env.step({ | |
| "action_type": "classify_log", | |
| "target_log_indices": [0], | |
| "classification": "normal", | |
| }) | |
| if result["done"]: | |
| break | |
| assert result["done"] is True | |
| def test_state_tracks_progress(self): | |
| self.env.reset(task_name="log_classification") | |
| state = self.env.state | |
| assert state["step_count"] == 0 | |
| assert state["episode_id"] is not None | |
| self.env.step({"action_type": "classify_log", "target_log_indices": [0], "classification": "normal"}) | |
| state = self.env.state | |
| assert state["step_count"] == 1 | |
| def test_state_has_world_state(self): | |
| self.env.reset(task_name="soc_warroom_easy", seed=1) | |
| state = self.env.state | |
| assert "world_state" in state | |
| ws = state["world_state"] | |
| assert "service_health" in ws | |
| assert "error_rate" in ws | |
| def test_state_has_reward_breakdown_after_done(self): | |
| self.env.reset(task_name="soc_warroom_easy", seed=1) | |
| self.env.step({"action_type": "propose_incident", "agent_role": "app_sre", | |
| "incident_type": "outage", "evidence_indices": [0]}) | |
| self.env.step({ | |
| "action_type": "submit_joint_report", | |
| "agent_role": "incident_commander", | |
| "report": {"incidents": [{"type": "outage"}], "severity": "P1", | |
| "summary": "Outage detected, mitigation applied"}, | |
| }) | |
| state = self.env.state | |
| rb = state["reward_breakdown"] | |
| assert "r_outcome" in rb | |
| assert "r_detection_f1" in rb | |
| assert "total" in rb | |
| def test_all_legacy_tasks_work(self): | |
| for task in ["log_classification", "incident_detection", "full_triage"]: | |
| result = self.env.reset(task_name=task) | |
| assert result["done"] is False | |
| assert len(result["observation"]["log_entries"]) > 0 | |
| def test_all_soc_tasks_work(self): | |
| for task in ["soc_warroom_easy", "soc_warroom_medium", "soc_warroom_hard"]: | |
| result = self.env.reset(task_name=task, seed=42) | |
| assert result["done"] is False | |
| obs = result["observation"] | |
| assert "current_phase" in obs | |
| assert obs["current_phase"] == "detect" | |
| def test_invalid_action_type_returns_negative_reward(self): | |
| self.env.reset(task_name="soc_warroom_easy", seed=42) | |
| result = self.env.step({"action_type": "not_a_real_action"}) | |
| # Should return negative reward due to validation error | |
| assert result["reward"] is not None | |
| assert result["reward"] <= 0 | |
| def test_episode_after_done_returns_zero_reward(self): | |
| self.env.reset(task_name="log_classification") | |
| self.env.step({"action_type": "submit_report", | |
| "report": {"incidents": [], "severity": "P4", "summary": "done"}}) | |
| result = self.env.step({"action_type": "classify_log", | |
| "target_log_indices": [0], "classification": "normal"}) | |
| assert result["reward"] == 0.0 | |
| assert result["done"] is True | |
| # =========================================================================== | |
| # 10. FastAPI endpoint integration tests | |
| # =========================================================================== | |
| class TestServer: | |
| def setup_method(self): | |
| self.client = TestClient(app) | |
| def test_health(self): | |
| r = self.client.get("/health") | |
| assert r.status_code == 200 | |
| assert r.json()["status"] == "healthy" | |
| def test_tasks_include_soc_tasks(self): | |
| r = self.client.get("/tasks") | |
| assert r.status_code == 200 | |
| tasks = r.json()["tasks"] | |
| names = {t["name"] for t in tasks} | |
| assert "log_classification" in names | |
| assert "soc_warroom_easy" in names | |
| assert "soc_warroom_hard" in names | |
| assert "adaptive_curriculum" in names | |
| def test_reset(self): | |
| r = self.client.post("/reset", json={}) | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert "observation" in data | |
| assert data["done"] is False | |
| def test_reset_with_legacy_task(self): | |
| r = self.client.post("/reset", json={"task_name": "incident_detection"}) | |
| assert r.status_code == 200 | |
| obs = r.json()["observation"] | |
| assert len(obs["log_entries"]) == 20 | |
| def test_reset_with_soc_task(self): | |
| r = self.client.post("/reset", json={"task_name": "soc_warroom_easy", "seed": 42}) | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert data["done"] is False | |
| assert data["observation"]["current_phase"] == "detect" | |
| def test_step(self): | |
| self.client.post("/reset", json={}) | |
| r = self.client.post("/step", json={ | |
| "action": { | |
| "action_type": "classify_log", | |
| "target_log_indices": [0], | |
| "classification": "normal", | |
| } | |
| }) | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert "reward" in data | |
| assert "done" in data | |
| assert "info" in data | |
| def test_state(self): | |
| self.client.post("/reset", json={}) | |
| r = self.client.get("/state") | |
| assert r.status_code == 200 | |
| state = r.json() | |
| assert "episode_id" in state | |
| assert "step_count" in state | |
| assert "world_state" in state | |
| def test_full_legacy_episode(self): | |
| self.client.post("/reset", json={"task_name": "log_classification"}) | |
| r = self.client.post("/step", json={ | |
| "action": {"action_type": "classify_log", "target_log_indices": [0, 1, 2], | |
| "classification": "normal"} | |
| }) | |
| assert r.status_code == 200 | |
| assert r.json()["done"] is False | |
| r = self.client.post("/step", json={ | |
| "action": {"action_type": "submit_report", | |
| "report": {"incidents": [], "severity": "P4", | |
| "summary": "Classified logs, no major incidents"}} | |
| }) | |
| assert r.status_code == 200 | |
| assert r.json()["done"] is True | |
| def test_full_soc_episode(self): | |
| self.client.post("/reset", json={"task_name": "soc_warroom_easy", "seed": 42}) | |
| # Detect | |
| self.client.post("/step", json={"action": { | |
| "action_type": "propose_incident", | |
| "agent_role": "app_sre", | |
| "incident_type": "resource_exhaustion", | |
| "evidence_indices": [0, 1], | |
| }}) | |
| # Triage | |
| self.client.post("/step", json={"action": { | |
| "action_type": "assign_severity", | |
| "agent_role": "app_sre", | |
| "incident_type": "resource_exhaustion", | |
| "severity": "P2", | |
| }}) | |
| # Mitigate | |
| self.client.post("/step", json={"action": { | |
| "action_type": "execute_mitigation", | |
| "agent_role": "db_sre", | |
| "mitigation_id": "scale_connection_pool", | |
| "evidence_indices": [0, 1], | |
| }}) | |
| # Verify | |
| self.client.post("/step", json={"action": { | |
| "action_type": "verify_recovery", | |
| "agent_role": "incident_commander", | |
| "evidence_indices": [0], | |
| }}) | |
| # Final report | |
| r = self.client.post("/step", json={"action": { | |
| "action_type": "submit_joint_report", | |
| "agent_role": "incident_commander", | |
| "report": { | |
| "incidents": [{"type": "resource_exhaustion", "severity": "P2"}], | |
| "severity": "P2", | |
| "summary": "DB connection pool exhaustion detected and mitigated. Service restored.", | |
| }, | |
| }}) | |
| assert r.status_code == 200 | |
| assert r.json()["done"] is True | |
| def test_step_with_role_in_action(self): | |
| self.client.post("/reset", json={"task_name": "soc_warroom_medium", "seed": 10}) | |
| r = self.client.post("/step", json={"action": { | |
| "action_type": "observe_logs", | |
| "agent_role": "security_analyst", | |
| }}) | |
| assert r.status_code == 200 | |
| obs = r.json()["observation"] | |
| assert obs["agent_role"] == "security_analyst" | |
| def test_state_has_curriculum_difficulty(self): | |
| self.client.post("/reset", json={"task_name": "adaptive_curriculum", "seed": 42}) | |
| r = self.client.get("/state") | |
| assert r.status_code == 200 | |
| state = r.json() | |
| assert "curriculum_difficulty" in state | |
| assert state["curriculum_difficulty"] in ("easy", "medium", "hard") | |