""" Tests for runbook runner (PR2): create run, next (http_check + manual), complete, abort. Uses tmp docs + rebuilt index; http_check monkeypatched to avoid network. """ from __future__ import annotations import asyncio from pathlib import Path from unittest.mock import AsyncMock, patch import pytest @pytest.fixture def tmp_docs_rehearsal(tmp_path): """Create tmp_path/docs/runbook/rehearsal-v1-30min-checklist.md.""" docs_root = tmp_path / "docs" runbook_dir = docs_root / "runbook" runbook_dir.mkdir(parents=True) (runbook_dir / "rehearsal-v1-30min-checklist.md").write_text( "# Rehearsal v1 — 30-minute execution plan\n\n" "## 0–5 min — Preflight\n\n" "Run STRICT=1 bash ops/preflight_sofiia_console.sh\n\n" "## 5–10 min — Restart\n\n" "Restart sofiia-console.", encoding="utf-8", ) return docs_root def test_create_run_returns_run_id_and_steps_total(sofiia_module, tmp_path, tmp_docs_rehearsal, monkeypatch): """Create run with rehearsal runbook returns run_id, status, steps_total >= 1.""" import app.docs_index as docs_index_mod import app.runbook_runner as runner_mod monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() async def run(): await docs_index_mod.rebuild_index(tmp_docs_rehearsal) return await runner_mod.create_run( "runbook/rehearsal-v1-30min-checklist.md", operator_id="test-op", sofiia_url="http://127.0.0.1:8002", ) out = loop.run_until_complete(run()) assert "run_id" in out assert out["status"] == "running" assert out["current_step"] == 0 assert out["steps_total"] >= 1 def test_get_run_404_for_unknown_run_id(sofiia_client): """GET /api/runbooks/runs/{run_id} returns 404 for unknown run_id.""" r = sofiia_client.get("/api/runbooks/runs/00000000-0000-0000-0000-000000000000") assert r.status_code == 404 def test_create_run_400_for_invalid_path(sofiia_client): """POST /api/runbooks/runs with invalid runbook_path returns 400.""" r = sofiia_client.post( "/api/runbooks/runs", json={"runbook_path": "../../../etc/passwd"}, ) assert r.status_code == 400 def test_next_step_http_check_then_manual(sofiia_module, sofiia_client, tmp_path, tmp_docs_rehearsal, monkeypatch): """Create run, call next 3 times (http_check mocked 200), then next returns manual step.""" import app.docs_index as docs_index_mod import app.runbook_runner as runner_mod monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() async def create(): await docs_index_mod.rebuild_index(tmp_docs_rehearsal) return await runner_mod.create_run( "runbook/rehearsal-v1-30min-checklist.md", sofiia_url="http://127.0.0.1:8002", ) create_out = loop.run_until_complete(create()) run_id = create_out["run_id"] with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: def resp_for_url(url): return type("Res", (), {"status_code": 401 if "audit" in str(url) else 200})() async def fake_get(url, **kwargs): return resp_for_url(url) mock_get.side_effect = fake_get for _ in range(3): r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={}) assert r.status_code == 200, r.text data = r.json() assert data.get("type") in ("http_check", "manual") if data.get("type") == "http_check": assert data.get("result", {}).get("ok") is True r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={}) assert r.status_code == 200, r.text data = r.json() assert data.get("type") == "manual" assert "instructions_md" in data or "title" in data def test_complete_step_advances_run(sofiia_module, sofiia_client, tmp_path, tmp_docs_rehearsal, monkeypatch): """After manual step returned by next, complete_step advances and returns ok.""" import app.docs_index as docs_index_mod import app.runbook_runner as runner_mod monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() async def create(): await docs_index_mod.rebuild_index(tmp_docs_rehearsal) return await runner_mod.create_run( "runbook/rehearsal-v1-30min-checklist.md", sofiia_url="http://127.0.0.1:8002", ) create_out = loop.run_until_complete(create()) run_id = create_out["run_id"] with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: def resp(url): return type("Res", (), {"status_code": 401 if "audit" in str(url) else 200})() async def fake_get(url, **kwargs): return resp(url) mock_get.side_effect = fake_get for _ in range(3): sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={}) r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/next", json={}) assert r.status_code == 200 manual = r.json() assert manual.get("type") == "manual" step_index = manual["step_index"] r2 = sofiia_client.post( f"/api/runbooks/runs/{run_id}/steps/{step_index}/complete", json={"status": "ok", "notes": "done"}, ) assert r2.status_code == 200, r2.text assert r2.json().get("ok") is True def test_abort_run(sofiia_module, sofiia_client, tmp_path, tmp_docs_rehearsal, monkeypatch): """Create run, abort returns ok; get_run then shows status aborted.""" import app.docs_index as docs_index_mod import app.runbook_runner as runner_mod monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) loop = asyncio.get_event_loop() async def create(): await docs_index_mod.rebuild_index(tmp_docs_rehearsal) return await runner_mod.create_run( "runbook/rehearsal-v1-30min-checklist.md", sofiia_url="http://127.0.0.1:8002", ) create_out = loop.run_until_complete(create()) run_id = create_out["run_id"] r = sofiia_client.post(f"/api/runbooks/runs/{run_id}/abort", json={}) assert r.status_code == 200, r.text assert r.json().get("status") == "aborted" run = loop.run_until_complete(runner_mod.get_run(run_id)) assert run is not None assert run["status"] == "aborted"