f8d3cbdd59
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
205 lines
6.9 KiB
Python
205 lines
6.9 KiB
Python
"""First vertical read/login handler tests."""
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.config import Settings
|
|
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
|
from app.main import create_app
|
|
from app.security.auth import hash_secret
|
|
from app.tasks.repository import Task
|
|
|
|
pytestmark = pytest.mark.pve_stub
|
|
|
|
|
|
class FakePool:
|
|
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
|
if "principals" in sql and args[0] == "root@pam":
|
|
return {
|
|
"name": "root@pam",
|
|
"password_hash": hash_secret("secret", salt=b"pve-simulator-v1"),
|
|
}
|
|
if "FROM nodes" in sql and args[0] == "pve1":
|
|
return {"name": "pve1", "status": "online"}
|
|
if "FROM resources r" in sql and args == ("pve1", "100"):
|
|
if "SELECT r.id" in sql:
|
|
return {
|
|
"id": uuid.UUID("00000000-0000-0000-0000-000000000100"),
|
|
"state": '{"name":"demo","status":"stopped"}',
|
|
}
|
|
return {
|
|
"config": '{"name":"demo"}',
|
|
"state": '{"name":"demo","status":"stopped"}',
|
|
}
|
|
return None
|
|
|
|
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
|
del args
|
|
if "FROM nodes" in sql:
|
|
return [{"node": "pve1", "status": "online"}]
|
|
if "r.kind='qemu'" in sql:
|
|
return [{"vmid": 100, "state": '{"name":"demo","status":"stopped"}'}]
|
|
return [
|
|
{
|
|
"type": "qemu",
|
|
"external_id": "100",
|
|
"state": '{"status":"stopped"}',
|
|
"node": "pve1",
|
|
}
|
|
]
|
|
|
|
async def fetchval(self, sql: str) -> int:
|
|
return 100 if "pg_backend_pid" in sql else 1_700_000_000
|
|
|
|
|
|
class FakeDatabase:
|
|
pool = FakePool()
|
|
|
|
async def connect(self) -> None:
|
|
pass
|
|
|
|
async def close(self) -> None:
|
|
pass
|
|
|
|
async def is_ready(self) -> bool:
|
|
return True
|
|
|
|
|
|
def method(verb: str, name: str, parameters: tuple[Parameter, ...] = ()) -> Method:
|
|
return Method(
|
|
verb=verb,
|
|
name=name,
|
|
parameters=parameters,
|
|
returns=Schema(type="object"),
|
|
checksum=(name[0] * 64),
|
|
)
|
|
|
|
|
|
def write_snapshot(path: Path) -> None:
|
|
string = Schema(type="string")
|
|
paths = (
|
|
PathContract(path="/version", methods=(method("GET", "version"),)),
|
|
PathContract(
|
|
path="/access/ticket",
|
|
methods=(
|
|
method(
|
|
"POST",
|
|
"ticket",
|
|
(
|
|
Parameter(name="username", definition=string),
|
|
Parameter(name="password", definition=string),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
PathContract(path="/nodes", methods=(method("GET", "nodes"),)),
|
|
PathContract(
|
|
path="/nodes/{node}/status",
|
|
methods=(method("GET", "status", (Parameter(name="node", definition=string),)),),
|
|
),
|
|
PathContract(path="/cluster/resources", methods=(method("GET", "resources"),)),
|
|
PathContract(
|
|
path="/nodes/{node}/qemu",
|
|
methods=(method("GET", "qemu", (Parameter(name="node", definition=string),)),),
|
|
),
|
|
PathContract(
|
|
path="/nodes/{node}/qemu/{vmid}/config",
|
|
methods=(
|
|
method(
|
|
"GET",
|
|
"config",
|
|
(
|
|
Parameter(name="node", definition=string),
|
|
Parameter(name="vmid", definition=Schema(type="integer")),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
PathContract(
|
|
path="/nodes/{node}/qemu/{vmid}/status/start",
|
|
methods=(
|
|
method(
|
|
"POST",
|
|
"start",
|
|
(
|
|
Parameter(name="node", definition=string),
|
|
Parameter(name="vmid", definition=Schema(type="integer")),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
snapshot = Snapshot(
|
|
source_version="test",
|
|
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
raw_sha256="0" * 64,
|
|
paths=paths,
|
|
path_count=len(paths),
|
|
method_count=sum(len(item.methods) for item in paths),
|
|
)
|
|
path.write_bytes(snapshot.canonical_bytes())
|
|
|
|
|
|
async def test_core_login_and_read_endpoints(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
class FakeTaskRepository:
|
|
def __init__(self, pool: object) -> None:
|
|
del pool
|
|
|
|
async def create(self, **kwargs: object) -> Task:
|
|
return Task(
|
|
uuid.uuid4(),
|
|
str(kwargs["upid"]),
|
|
str(kwargs["task_type"]),
|
|
"queued",
|
|
{},
|
|
0,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
|
snapshot_path = tmp_path / "snapshot.json"
|
|
write_snapshot(snapshot_path)
|
|
database = FakeDatabase()
|
|
app = create_app(
|
|
Settings(contract_snapshot=snapshot_path, compatibility_evidence=None),
|
|
lambda _settings: database,
|
|
worker_factories=(),
|
|
)
|
|
async with app.router.lifespan_context(app):
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
login = await client.post(
|
|
"/api2/json/access/ticket",
|
|
content="username=root%40pam&password=secret",
|
|
headers={"content-type": "application/x-www-form-urlencoded"},
|
|
)
|
|
csrf = login.json()["data"]["CSRFPreventionToken"]
|
|
version = await client.get("/api2/json/version")
|
|
nodes = await client.get("/api2/json/nodes")
|
|
status = await client.get("/api2/json/nodes/pve1/status")
|
|
resources = await client.get("/api2/json/cluster/resources")
|
|
qemu = await client.get("/api2/json/nodes/pve1/qemu")
|
|
config = await client.get("/api2/json/nodes/pve1/qemu/100/config")
|
|
start = await client.post(
|
|
"/api2/json/nodes/pve1/qemu/100/status/start",
|
|
headers={"CSRFPreventionToken": csrf},
|
|
)
|
|
|
|
assert login.status_code == 200
|
|
assert login.json()["data"]["username"] == "root@pam"
|
|
assert "ticket" in login.json()["data"]
|
|
assert version.json()["data"]["version"] == "test"
|
|
assert version.json()["data"]["release"] == "test"
|
|
assert nodes.json()["data"][0]["node"] == "pve1"
|
|
assert status.json()["data"]["status"] == "online"
|
|
assert resources.json()["data"][0]["type"] == "qemu"
|
|
assert qemu.json()["data"][0]["vmid"] == 100
|
|
assert config.json()["data"]["name"] == "demo"
|
|
assert start.json()["data"].startswith("UPID:pve1:")
|