155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlmodel import SQLModel, create_engine
|
|
|
|
|
|
@pytest.fixture
|
|
def api_client(tmp_path, monkeypatch):
|
|
db_path = tmp_path / "unraid_vm_actions.db"
|
|
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
|
|
|
|
from backend.app.db import session
|
|
from backend.app.db.init_db import init_db
|
|
from backend.app.main import app
|
|
|
|
engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False})
|
|
session.engine = engine
|
|
SQLModel.metadata.drop_all(engine)
|
|
SQLModel.metadata.create_all(engine)
|
|
init_db()
|
|
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
|
|
def test_vm_capabilities_expose_safe_execution_boundary(api_client):
|
|
response = api_client.get("/api/unraid/vms/capabilities")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
actions = {item["id"]: item for item in payload["actions"]}
|
|
assert actions["start"]["policyDecision"] == "allowed"
|
|
assert actions["start"]["executionSupported"] is True
|
|
assert actions["pause"]["policyDecision"] == "approval_required"
|
|
assert actions["pause"]["executionSupported"] is True
|
|
assert actions["stop"]["policyDecision"] == "blocked"
|
|
assert actions["stop"]["executionSupported"] is False
|
|
assert actions["force_stop"]["policyDecision"] == "blocked"
|
|
|
|
|
|
def test_manual_vm_start_runs_job_postcheck_and_audit(api_client, monkeypatch):
|
|
from backend.app.services import unraid_vm_service
|
|
|
|
inventory_calls = {"count": 0}
|
|
|
|
def fake_inventory(_self):
|
|
inventory_calls["count"] += 1
|
|
state = "SHUTOFF" if inventory_calls["count"] == 1 else "RUNNING"
|
|
return {"status": "ok", "vms": [{"id": "server:vm-1", "name": "Test VM", "state": state}]}
|
|
|
|
monkeypatch.setattr(unraid_vm_service.UnraidConnector, "get_vm_summary", fake_inventory)
|
|
monkeypatch.setattr(
|
|
unraid_vm_service.UnraidConnector,
|
|
"mutate_vm",
|
|
lambda _self, vm_id, action: {
|
|
"success": vm_id == "server:vm-1" and action == "start",
|
|
"status": "ok",
|
|
"message": "vm_start_accepted",
|
|
},
|
|
)
|
|
|
|
response = api_client.post(
|
|
"/api/unraid/vms/server%3Avm-1/actions/start",
|
|
json={"source": "manual", "confirm": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["success"] is True
|
|
assert payload["decision"] == "allowed"
|
|
assert payload["before_state"]["state"] == "SHUTOFF"
|
|
assert payload["after_state"] == {"state": "RUNNING", "verified": True}
|
|
assert payload["jobId"].startswith("job-")
|
|
assert payload["auditId"].startswith("audit-")
|
|
|
|
jobs = api_client.get("/api/jobs").json()
|
|
assert any(job["id"] == payload["jobId"] and job["status"] == "succeeded" for job in jobs)
|
|
|
|
audits = api_client.get("/api/audit").json()
|
|
audit = next(item for item in audits if item["id"] == payload["auditId"])
|
|
assert audit["action_id"] == "unraid.vm.start"
|
|
assert audit["target_type"] == "virtual_machine"
|
|
|
|
|
|
def test_vm_start_requires_explicit_confirmation(api_client):
|
|
response = api_client.post(
|
|
"/api/unraid/vms/vm-1/actions/start",
|
|
json={"source": "manual", "confirm": False},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "confirm_required"
|
|
|
|
|
|
def test_vm_pause_creates_executable_approval(api_client, monkeypatch):
|
|
from backend.app.services import unraid_vm_service
|
|
|
|
inventory_calls = {"count": 0}
|
|
|
|
def fake_inventory(_self):
|
|
inventory_calls["count"] += 1
|
|
state = "RUNNING" if inventory_calls["count"] == 1 else "PAUSED"
|
|
return {"status": "ok", "vms": [{"id": "vm-2", "name": "Workload", "state": state}]}
|
|
|
|
monkeypatch.setattr(unraid_vm_service.UnraidConnector, "get_vm_summary", fake_inventory)
|
|
monkeypatch.setattr(
|
|
unraid_vm_service.UnraidConnector,
|
|
"mutate_vm",
|
|
lambda _self, vm_id, action: {
|
|
"success": vm_id == "vm-2" and action == "pause",
|
|
"status": "ok",
|
|
"message": "vm_pause_accepted",
|
|
},
|
|
)
|
|
|
|
requested = api_client.post(
|
|
"/api/unraid/vms/vm-2/actions/pause",
|
|
json={"source": "manual", "confirm": True},
|
|
).json()
|
|
assert requested["decision"] == "approval_required"
|
|
assert requested["approvalId"].startswith("appr-")
|
|
|
|
approval_id = requested["approvalId"]
|
|
approved = api_client.patch(f"/api/approvals/{approval_id}", json={"status": "approved"})
|
|
assert approved.status_code == 200
|
|
|
|
executed = api_client.post(f"/api/approvals/{approval_id}/execute")
|
|
assert executed.status_code == 200
|
|
payload = executed.json()
|
|
assert payload["success"] is True
|
|
assert payload["approvalStatus"] == "executed"
|
|
assert payload["after_state"] == {"state": "PAUSED", "verified": True}
|
|
|
|
|
|
def test_vm_stop_is_blocked_without_calling_connector(api_client, monkeypatch):
|
|
from backend.app.services import unraid_vm_service
|
|
|
|
monkeypatch.setattr(
|
|
unraid_vm_service.UnraidConnector,
|
|
"mutate_vm",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("blocked action reached connector")),
|
|
)
|
|
|
|
response = api_client.post(
|
|
"/api/unraid/vms/vm-3/actions/stop",
|
|
json={"source": "manual", "confirm": True},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["decision"] == "blocked"
|
|
assert payload["result"] == "blocked"
|
|
assert "force-stop" in payload["message"]
|