Files
microdao-daarion/tests/test_sofiia_runbook_runner.py
Apple ad8bddf595 feat(sofiia-console): add guided runbook runner with http checks and audit integration
adds runbook_runs/runbook_steps state machine

parses markdown runbooks into guided steps

supports allowlisted http_check (health/metrics/audit)

integrates runbook execution with audit trail

exposes authenticated runbook runs API

Made-with: Cursor
2026-03-03 04:49:19 -08:00

177 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"
"## 05 min — Preflight\n\n"
"Run STRICT=1 bash ops/preflight_sofiia_console.sh\n\n"
"## 510 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"