"""Respondline — proposal gating, human-gated execution, rejection.""" from __future__ import annotations import pytest from sqlalchemy import create_engine from psyc import db from psyc.lines import ledger as ledger_line from psyc.lines import respond from psyc.models import ActionStatus, ActionType, Outcome, Severity from psyc.result import Ok from conftest import make_case @pytest.fixture def fresh_db(tmp_path, monkeypatch): test_db = tmp_path / "test.db" eng = create_engine(f"sqlite:///{test_db}", future=True) db._metadata.create_all(eng, checkfirst=True) monkeypatch.setattr(db, "_engine", eng) monkeypatch.setattr(db, "DB_PATH", test_db) yield test_db def test_low_severity_proposes_nothing(fresh_db): case = make_case(feed="urlhaus", ips=["1.2.3.4"], severity=Severity.LOW) db.upsert_case(case) assert respond.propose_for_case(case) == [] def test_high_severity_proposes_alert_and_blocklist(fresh_db): case = make_case(feed="feodo", ips=["9.9.9.9"], domains=["evil.com"], severity=Severity.HIGH) db.upsert_case(case) ids = respond.propose_for_case(case) assert len(ids) == 2 actions = respond.list_actions(status=ActionStatus.PROPOSED) types = {a.action_type for a in actions} assert types == {ActionType.ALERT, ActionType.BLOCKLIST} def test_proposal_is_idempotent_per_case(fresh_db): case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.CRITICAL) db.upsert_case(case) respond.propose_for_case(case) assert respond.propose_for_case(case) == [] # second call adds nothing assert respond.action_count(ActionStatus.PROPOSED) == 2 def test_blocklist_skipped_when_no_network_iocs(fresh_db): case = make_case(feed="malware-bazaar", hashes=["a" * 64], severity=Severity.HIGH) db.upsert_case(case) respond.propose_for_case(case) actions = respond.list_actions() # hash-only case → alert yes, blocklist no assert {a.action_type for a in actions} == {ActionType.ALERT} def test_execute_fires_and_marks_executed(fresh_db, monkeypatch): case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH) db.upsert_case(case) aid = respond.propose_for_case(case)[0] captured = {} class _Resp: def raise_for_status(self): pass def json(self): return {"receipt_id": "MOCK-AB12"} class _Client: def __init__(self, *a, **k): pass def __enter__(self): return self def __exit__(self, *a): return False def post(self, url, json): captured["url"] = url captured["payload"] = json return _Resp() monkeypatch.setattr(respond.httpx, "Client", _Client) result = respond.execute_action(aid, approver="alice") assert isinstance(result, Ok) assert result.value.status is ActionStatus.EXECUTED assert result.value.approver == "alice" assert captured["payload"]["approved_by"] == "alice" # ledger has an ACTIONED row entries = ledger_line.list_by_case(case.case_id, limit=10) assert any(e.outcome is Outcome.ACTIONED for e in entries) def test_execute_failure_marks_failed(fresh_db, monkeypatch): case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH) db.upsert_case(case) aid = respond.propose_for_case(case)[0] class _Client: def __init__(self, *a, **k): pass def __enter__(self): return self def __exit__(self, *a): return False def post(self, url, json): raise RuntimeError("sink down") monkeypatch.setattr(respond.httpx, "Client", _Client) result = respond.execute_action(aid) assert not isinstance(result, Ok) assert respond.get_action(aid).value.status is ActionStatus.FAILED def test_reject_fires_nothing_and_records(fresh_db, monkeypatch): case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH) db.upsert_case(case) aid = respond.propose_for_case(case)[0] def boom(*a, **k): raise AssertionError("reject must not POST") monkeypatch.setattr(respond.httpx, "Client", boom) result = respond.reject_action(aid, approver="bob", reason="false positive") assert isinstance(result, Ok) a = respond.get_action(aid).value assert a.status is ActionStatus.REJECTED assert a.approver == "bob" entries = ledger_line.list_by_case(case.case_id, limit=10) assert any(e.outcome is Outcome.REJECTED and "false positive" in (e.detail or "") for e in entries) def test_double_execute_refused(fresh_db, monkeypatch): case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH) db.upsert_case(case) aid = respond.propose_for_case(case)[0] class _Resp: def raise_for_status(self): pass def json(self): return {"receipt_id": "X"} class _Client: def __init__(self, *a, **k): pass def __enter__(self): return self def __exit__(self, *a): return False def post(self, url, json): return _Resp() monkeypatch.setattr(respond.httpx, "Client", _Client) respond.execute_action(aid) again = respond.execute_action(aid) assert not isinstance(again, Ok) # already executed