Add a stateful Proxmox API console and broad handler coverage beyond the

initial QEMU slice, backed by imported contracts for majors 6–9.
- Implement durable handlers for access/auth, cluster, LXC, storage, HA,
  firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops
- Serve an interactive Web UI with catalog browsing, demo seed controls,
  and OpenAPI/help surfaces
- Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3
- Support in-memory runtime contract Apply (POST /ui/api/contract/apply)
  so /version and /api2 routes follow the selected major until restart
- Expand seed profiles (including demo-cluster), migrations 007–008, TLS
  gateway config, Compose/Makefile tooling, and compatibility evidence
- Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
This commit is contained in:
Sergey Antropoff
2026-07-16 01:08:01 +03:00
parent 003ee5d634
commit 777926487b
189 changed files with 241501 additions and 944 deletions
@@ -0,0 +1,38 @@
"""CI gate: every declared method on majors 6-9 is callable without critical failures.
Critical = HTTP 501, server 5xx, unhandled exceptions, or emulator-limitation
strings. Synthetic 4xx (missing object / incomplete payload) are allowed.
Requires PostgreSQL via ``TEST_DATABASE_URL``.
"""
from __future__ import annotations
import os
import pytest
from app.surface_probe import run_probe
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
@pytest.mark.asyncio
async def test_all_majors_surface_has_zero_critical_failures() -> None:
results = await run_probe()
assert len(results) == 4
for item in results:
version = item["version"]
declared = item["declared"]
assert item["implemented"] == declared, version
assert item["verified"] == declared, version
assert item["dimensions_min"] == declared, version
assert item["failure_count"] == 0, f"{version} critical failures: {item.get('failures')}"
by_verb = item["by_verb"]
for verb, buckets in by_verb.items():
assert buckets.get("unimplemented_501", 0) == 0, (version, verb, buckets)
assert buckets.get("unsupported_message", 0) == 0, (version, verb, buckets)
assert buckets.get("server_5xx", 0) == 0, (version, verb, buckets)
assert buckets.get("exception", 0) == 0, (version, verb, buckets)
+281
View File
@@ -0,0 +1,281 @@
"""Group-level API smoke with real PostgreSQL persistence.
Exercises representative create/update/read paths per major API group so the
surface verified ledger is backed by working handlers, not only route presence.
Requires ``TEST_DATABASE_URL`` (same as other integration tests).
"""
from __future__ import annotations
import asyncio
import os
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any, cast
import asyncpg # type: ignore[import-untyped]
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from app.config import Settings
from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase
from app.main import create_app
from app.simulation.seed import apply_seed, small_profile
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
_BUNDLED_9 = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
_NODE = "pve01"
async def _prepare_database(url: str) -> None:
connection = await asyncpg.connect(url)
try:
await migrate(connection)
await apply_seed(connection, small_profile())
finally:
await connection.close()
async def _login(client: AsyncClient) -> str:
response = await client.post(
"/api2/json/access/ticket",
content="username=root%40pam&password=secret",
headers={"content-type": "application/x-www-form-urlencoded"},
)
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["username"] == "root@pam"
ticket = data["ticket"]
client.cookies.set("PVEAuthCookie", ticket)
return str(data["CSRFPreventionToken"])
async def _wait_task(client: AsyncClient, upid: str, *, node: str = _NODE) -> dict[str, Any]:
for _ in range(100):
response = await client.get(f"/api2/json/nodes/{node}/tasks/{upid}/status")
assert response.status_code == 200, response.text
task = cast(dict[str, Any], response.json()["data"])
if task.get("status") == "stopped":
return task
await asyncio.sleep(0.05)
raise AssertionError(f"task did not finish: {upid}")
@pytest.fixture
async def api_client() -> AsyncIterator[tuple[AsyncClient, str]]:
url = os.environ["TEST_DATABASE_URL"]
await _prepare_database(url)
settings = Settings(
database_url=SecretStr(url),
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
ticket_signing_key=SecretStr("development-only-signing-key-change-me"),
)
def database_factory(resolved: Settings) -> AsyncpgDatabase:
return AsyncpgDatabase(resolved)
app = create_app(settings=settings, database_factory=database_factory)
async with app.router.lifespan_context(app):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
csrf = await _login(client)
yield client, csrf
async def test_access_group_realm_and_user_persist(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
realm = "smoke-ldap"
create_realm = await client.post(
"/api2/json/access/domains",
data={
"realm": realm,
"type": "ldap",
"server1": "ldap.smoke.local",
"base_dn": "dc=smoke,dc=local",
"comment": "group smoke realm",
},
headers={"CSRFPreventionToken": csrf},
)
assert create_realm.status_code == 200, create_realm.text
listed = await client.get("/api2/json/access/domains")
assert listed.status_code == 200
names = {item["realm"] for item in listed.json()["data"]}
assert realm in names
detail = await client.get(f"/api2/json/access/domains/{realm}")
assert detail.status_code == 200
assert detail.json()["data"]["type"] == "ldap"
user = "smoke-user@pam"
create_user = await client.post(
"/api2/json/access/users",
data={"userid": user, "comment": "group smoke user", "enable": 1},
headers={"CSRFPreventionToken": csrf},
)
assert create_user.status_code == 200, create_user.text
got_user = await client.get(f"/api2/json/access/users/{user}")
assert got_user.status_code == 200
assert got_user.json()["data"]["userid"] == user
delete_realm = await client.delete(
f"/api2/json/access/domains/{realm}",
headers={"CSRFPreventionToken": csrf},
)
assert delete_realm.status_code == 200, delete_realm.text
async def test_qemu_group_create_config_and_power(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
vmid = 9101
create = await client.post(
f"/api2/json/nodes/{_NODE}/qemu",
data={
"vmid": str(vmid),
"name": "smoke-qemu",
"cores": "1",
"memory": "512",
},
headers={"CSRFPreventionToken": csrf},
)
assert create.status_code == 200, create.text
upid = create.json()["data"]
assert isinstance(upid, str) and upid.startswith("UPID:")
task = await _wait_task(client, upid)
assert task.get("exitstatus") == "OK"
config = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config")
assert config.status_code == 200
assert config.json()["data"]["name"] == "smoke-qemu"
update = await client.put(
f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config",
data={"name": "smoke-qemu-renamed", "cores": "2"},
headers={"CSRFPreventionToken": csrf},
)
assert update.status_code == 200, update.text
config2 = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config")
assert config2.json()["data"]["name"] == "smoke-qemu-renamed"
assert int(config2.json()["data"]["cores"]) == 2
start = await client.post(
f"/api2/json/nodes/{_NODE}/qemu/{vmid}/status/start",
headers={"CSRFPreventionToken": csrf},
)
assert start.status_code == 200, start.text
start_task = await _wait_task(client, start.json()["data"])
assert start_task.get("exitstatus") == "OK"
status = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/status/current")
assert status.status_code == 200
assert status.json()["data"]["status"] in {"running", "started"}
async def test_lxc_group_create_and_status(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
vmid = 9201
create = await client.post(
f"/api2/json/nodes/{_NODE}/lxc",
data={
"vmid": str(vmid),
"hostname": "smoke-lxc",
"ostemplate": "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst",
"memory": "256",
"rootfs": "local-lvm:4",
},
headers={"CSRFPreventionToken": csrf},
)
assert create.status_code == 200, create.text
upid = create.json()["data"]
task = await _wait_task(client, upid)
assert task.get("exitstatus") == "OK"
config = await client.get(f"/api2/json/nodes/{_NODE}/lxc/{vmid}/config")
assert config.status_code == 200
cfg = config.json()["data"]
assert "hostname" in cfg or cfg.get("hostname") == "smoke-lxc"
status = await client.get(f"/api2/json/nodes/{_NODE}/lxc/{vmid}/status/current")
assert status.status_code == 200
assert "status" in status.json()["data"]
async def test_storage_and_cluster_groups_mutate(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
storages = await client.get("/api2/json/storage")
if storages.status_code == 404:
storages = await client.get(f"/api2/json/nodes/{_NODE}/storage")
assert storages.status_code == 200, storages.text
assert storages.json()["data"]
content = await client.get(f"/api2/json/nodes/{_NODE}/storage/local/content")
assert content.status_code == 200, content.text
assert isinstance(content.json()["data"], list)
resources = await client.get("/api2/json/cluster/resources")
assert resources.status_code == 200
assert resources.json()["data"]
notify = await client.post(
"/api2/json/cluster/notifications/endpoints/gotify",
data={
"name": "smoke-gotify",
"server": "https://gotify.smoke.local",
"token": "smoke-token",
},
headers={"CSRFPreventionToken": csrf},
)
assert notify.status_code == 200, notify.text
got = await client.get("/api2/json/cluster/notifications/endpoints/gotify/smoke-gotify")
assert got.status_code == 200
assert got.json()["data"]["name"] == "smoke-gotify"
# secret must not be echoed
assert "token" not in got.json()["data"] or got.json()["data"].get("token") in {None, ""}
async def test_sdn_and_node_ops_groups_persist(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
zone = await client.post(
"/api2/json/cluster/sdn/zones",
data={"zone": "smokecn", "type": "simple", "mtu": "1500"},
headers={"CSRFPreventionToken": csrf},
)
assert zone.status_code == 200, zone.text
zones = await client.get("/api2/json/cluster/sdn/zones")
assert zones.status_code == 200
names = {item.get("zone") or item.get("id") for item in zones.json()["data"]}
assert "smokecn" in names
network_put = await client.put(
f"/api2/json/nodes/{_NODE}/network",
data={},
headers={"CSRFPreventionToken": csrf},
)
# Apply/reload may return null/UPID; must not be 501.
assert network_put.status_code == 200, network_put.text
dns = await client.get(f"/api2/json/nodes/{_NODE}/dns")
assert dns.status_code == 200
assert isinstance(dns.json()["data"], dict)
dns_put = await client.put(
f"/api2/json/nodes/{_NODE}/dns",
data={"search": "smoke.local", "dns1": "1.1.1.1"},
headers={"CSRFPreventionToken": csrf},
)
assert dns_put.status_code == 200, dns_put.text
dns2 = await client.get(f"/api2/json/nodes/{_NODE}/dns")
assert dns2.json()["data"].get("search") == "smoke.local" or "dns1" in dns2.json()["data"]
+10
View File
@@ -98,6 +98,7 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
name="created-by-proxmoxer",
cores=2,
memory=1024,
agent=1,
scsi0="local-lvm:vm-150-disk-0,size=8G",
)
with pytest.raises(ResourceException) as duplicate_create:
@@ -120,6 +121,15 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
move_upid = disk_api.move_disk.post(disk="scsi0", storage="local")
assert wait_task(proxmox, move_upid)["exitstatus"] == "OK"
assert disk_api.config.get()["scsi0"].startswith("local:")
assert disk_api.pending.get() == []
assert wait_task(proxmox, disk_api.status.start.post())["exitstatus"] == "OK"
assert disk_api.agent.ping.post()["result"] == {}
assert disk_api.agent.info.get()["result"]["version"] == "9.2.0-simulator"
assert disk_api.agent("get-osinfo").get()["result"]["machine"] == "x86_64"
assert disk_api.agent("get-host-name").get()["result"]["host-name"] == "async-update"
assert disk_api.agent("network-get-interfaces").get()["result"][0]["name"] == "eth0"
assert disk_api.agent("get-time").get()["result"]["seconds"] > 0
assert wait_task(proxmox, disk_api.status.stop.post())["exitstatus"] == "OK"
snapshots = proxmox.nodes("pve1").qemu("150").snapshot
snapshot_upid = snapshots.post(snapname="baseline", description="before change")
@@ -0,0 +1,71 @@
"""Durable full-surface verified ledger for bundled Proxmox majors 6-9.
These tests stay offline (no TLS gateway). When a new contract snapshot is
imported, regenerate ledgers with ``make evidence`` and commit the updated
``evidence/pve-*.json`` files so this suite stays green.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.evidence_gen import generate_all
from app.main import create_app
from tests.unit.test_health import FakeDatabase
_BUNDLED_9 = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
_MAJORS = (6, 7, 8, 9)
def _app() -> FastAPI:
settings = Settings(
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
)
return create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
@pytest.mark.parametrize("major", _MAJORS)
async def test_hot_swap_reports_full_verified_surface(major: int) -> None:
app = _app()
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
applied = await client.post("/ui/api/contract/apply", params={"major": major})
assert applied.status_code == 200
assert applied.json()["ok"] is True
report = await client.get("/admin/compatibility")
assert report.status_code == 200
body = report.json()
declared = body["total_declared"]
levels = body["levels"]
assert declared > 0
assert levels["implemented"]["count"] == declared
assert levels["observed"]["count"] == declared
assert levels["verified"]["count"] == declared
assert levels["verified"]["score"] == pytest.approx(1.0)
for name, dimension in (body.get("dimensions") or {}).items():
assert dimension["count"] == declared, name
assert dimension["score"] == pytest.approx(1.0), name
assert len(body.get("classifications", {}).get("fully_compatible") or []) == declared
def test_committed_evidence_matches_generator(tmp_path: Path) -> None:
written = generate_all(out_dir=tmp_path)
assert set(written) == {"6.4-15", "7.4-16", "8.4.5", "9.2.3"}
for version, generated in written.items():
committed = Path("evidence") / f"pve-{version}.json"
assert committed.is_file(), f"missing committed ledger for {version}"
assert generated.read_text(encoding="utf-8") == committed.read_text(encoding="utf-8")
+28 -1
View File
@@ -47,7 +47,7 @@ async def test_small_seed_is_idempotent() -> None:
await migrate(connection)
await apply_seed(connection, small_profile())
await apply_seed(connection, small_profile())
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve1'") == 1
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve01'") == 1
assert (
await connection.fetchval(
"""SELECT count(*) FROM resources
@@ -72,6 +72,33 @@ async def test_small_seed_is_idempotent() -> None:
await connection.close()
async def test_demo_cluster_seed_populates_realistic_state() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
from app.simulation.seed import build_profile
await apply_seed(connection, build_profile("demo-cluster"))
assert await connection.fetchval("SELECT count(*) FROM nodes") == 20
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 850
assert await connection.fetchval("SELECT count(*) FROM containers") == 150
assert (
await connection.fetchval("SELECT count(*) FROM resources WHERE kind = 'ceph-osd'")
== 300
)
assert await connection.fetchval("SELECT count(*) FROM backups") >= 400
assert await connection.fetchval("SELECT count(*) FROM task_logs") >= 500
assert await connection.fetchval("SELECT count(*) FROM snapshots") >= 100
ceph_capacity = await connection.fetchval(
"SELECT capacity_bytes FROM storages WHERE storage_id = 'ceph-prod'"
)
assert ceph_capacity == 5 * 1024**5
profile = await connection.fetchval("SELECT metadata->>'profile' FROM clusters LIMIT 1")
assert profile == "demo-cluster"
finally:
await connection.close()
async def test_schema_readiness_and_optimistic_resource_repository() -> None:
url = os.environ["TEST_DATABASE_URL"]
connection = await asyncpg.connect(url)
+277
View File
@@ -0,0 +1,277 @@
"""TFA / OpenID / permissions access handlers."""
from __future__ import annotations
import json
import uuid
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from pydantic import SecretStr
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.db.pool import AsyncpgDatabase
from app.handlers.access_auth import register_access_auth_handlers
from app.security.auth import issue_ticket
class AuthPool:
def __init__(self) -> None:
self.principals = {
"root@pam": {
"id": uuid.uuid4(),
"tfa_locked_until": None,
"totp_locked": False,
}
}
self.tfa: dict[tuple[uuid.UUID, str], dict[str, Any]] = {}
self.realms = {
"sso": {
"kind": "openid",
"config": {
"issuer-url": "https://idp.example",
"client-id": "pve",
},
}
}
self.pending: dict[str, dict[str, str]] = {}
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
if "FROM principals p" in query and "LEFT JOIN tfa_entries" in query:
rows: list[dict[str, Any]] = []
for name, data in self.principals.items():
matches = [item for key, item in self.tfa.items() if key[0] == data["id"]]
if not matches:
rows.append(
{
"userid": name,
"tfa_locked_until": data["tfa_locked_until"],
"totp_locked": data["totp_locked"],
"entry_id": None,
"tfa_type": None,
"description": None,
"enable": None,
"created_at": 0,
}
)
for item in matches:
rows.append(
{
"userid": name,
"tfa_locked_until": data["tfa_locked_until"],
"totp_locked": data["totp_locked"],
**item,
}
)
return rows
if "FROM tfa_entries" in query and "DISTINCT" in query:
principal_id = arguments[0]
types = sorted(
{
item["tfa_type"]
for key, item in self.tfa.items()
if key[0] == principal_id and item["enable"]
}
)
return [{"tfa_type": value} for value in types]
if "FROM tfa_entries WHERE principal_id" in query or (
"FROM tfa_entries" in query and "principal_id=$1" in query and "DISTINCT" not in query
):
principal_id = arguments[0]
return [item for key, item in self.tfa.items() if key[0] == principal_id]
if "FROM acl_entries" in query:
return []
raise AssertionError(query)
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM principals WHERE name" in query:
userid = str(arguments[0])
data = self.principals.get(userid)
if data is None:
return None
return {"name": userid, **data}
if "FROM realms WHERE name" in query:
realm = str(arguments[0])
realm_data = self.realms.get(realm)
if realm_data is None:
return None
return {"name": realm, **realm_data}
if "FROM openid_pending WHERE state" in query:
return self.pending.get(str(arguments[0]))
if "FROM tfa_entries WHERE principal_id" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
item = self.tfa.get(key)
return item
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM principals" in query:
return str(arguments[0]) in self.principals
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "INSERT INTO openid_pending" in query:
self.pending[str(arguments[0])] = {
"realm": str(arguments[1]),
"redirect_url": str(arguments[2]),
}
return "INSERT 0 1"
if "DELETE FROM openid_pending" in query:
self.pending.pop(str(arguments[0]), None)
return "DELETE 1"
if "INSERT INTO principals" in query:
self.principals[str(arguments[0])] = {
"id": uuid.uuid4(),
"tfa_locked_until": None,
"totp_locked": False,
}
return "INSERT 0 1"
if "INSERT INTO tfa_entries" in query:
principal_id = cast(uuid.UUID, arguments[0])
entry_id = str(arguments[1])
self.tfa[(principal_id, entry_id)] = {
"entry_id": entry_id,
"tfa_type": str(arguments[2]),
"description": arguments[3],
"enable": True,
"created_at": 1_700_000_000,
"secret": arguments[4],
"metadata": json.loads(str(arguments[5])),
}
return "INSERT 0 1"
if "UPDATE tfa_entries SET enable" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
self.tfa[key]["enable"] = bool(arguments[2])
return "UPDATE 1"
if "UPDATE tfa_entries SET description" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
self.tfa[key]["description"] = arguments[2]
return "UPDATE 1"
if "DELETE FROM tfa_entries" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
if key not in self.tfa:
return "DELETE 0"
del self.tfa[key]
return "DELETE 1"
if "UPDATE principals" in query and "totp_locked" in query:
userid = str(arguments[0])
if userid not in self.principals:
return "UPDATE 0"
self.principals[userid]["tfa_locked_until"] = None
self.principals[userid]["totp_locked"] = False
return "UPDATE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: AuthPool) -> None:
self.pool = pool
def request(pool: AuthPool, principal: str = "root@pam") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
app.state.settings = Settings(ticket_signing_key=SecretStr("test-signing-key"))
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = principal
return result
def values(**items: object) -> dict[str, Any]:
return {"values": items, "provided": frozenset(items)}
async def test_tfa_lifecycle_and_unlock_persist() -> None:
registry = HandlerRegistry()
register_access_auth_handlers(registry)
pool = AuthPool()
http = request(pool)
create = registry.get("/access/tfa/{userid}", "POST")
listing = registry.get("/access/tfa/{userid}", "GET")
get = registry.get("/access/tfa/{userid}/{id}", "GET")
update = registry.get("/access/tfa/{userid}/{id}", "PUT")
delete = registry.get("/access/tfa/{userid}/{id}", "DELETE")
unlock = registry.get("/access/users/{userid}/unlock-tfa", "PUT")
types = registry.get("/access/users/{userid}/tfa", "GET")
assert create and listing and get and update and delete and unlock and types
created = await create(http, values(userid="root@pam", type="totp", description="phone"))
entry_id = created["id"]
assert await listing(http, values(userid="root@pam"))
fetched = await get(http, values(userid="root@pam", id=entry_id))
assert fetched["type"] == "totp"
await update(http, values(userid="root@pam", id=entry_id, enable=0))
assert (await get(http, values(userid="root@pam", id=entry_id)))["enable"] == 0
assert await unlock(http, values(userid="root@pam")) is True
assert (await types(http, values(userid="root@pam")))["types"] == []
await delete(http, values(userid="root@pam", id=entry_id))
with pytest.raises(ApiError):
await get(http, values(userid="root@pam", id=entry_id))
async def test_openid_auth_url_and_login_create_principal() -> None:
registry = HandlerRegistry()
register_access_auth_handlers(registry)
pool = AuthPool()
http = request(pool)
auth_url = registry.get("/access/openid/auth-url", "POST")
login = registry.get("/access/openid/login", "POST")
assert auth_url and login
url = await auth_url(
http,
values(realm="sso", **{"redirect-url": "https://pve.local/api2/json/access/openid/login"}),
)
assert "https://idp.example/authorize?" in url
assert pool.pending
state = next(iter(pool.pending))
result = await login(
http,
values(
code="abc1234567890",
state=state,
**{"redirect-url": "https://pve.local/api2/json/access/openid/login"},
),
)
assert result["ticket"].startswith("PVE:")
assert any(name.endswith("@sso") for name in pool.principals)
async def test_permissions_and_vncticket() -> None:
registry = HandlerRegistry()
register_access_auth_handlers(registry)
pool = AuthPool()
http = request(pool)
permissions = registry.get("/access/permissions", "GET")
vncticket = registry.get("/access/vncticket", "POST")
ticket_get = registry.get("/access/ticket", "GET")
assert permissions and vncticket and ticket_get
caps = await permissions(http, values())
assert "/" in caps
assert await ticket_get(http, values()) is None
ticket = issue_ticket("root@pam", b"test-signing-key")
await vncticket(
http,
values(
authid="root@pam",
path="/nodes/pve01/qemu/100/vncwebsocket",
privs="Sys.Console",
vncticket=ticket,
),
)
+135 -3
View File
@@ -1,5 +1,6 @@
"""API-token lifecycle handler tests without external services."""
import json
from typing import Any, cast
import pytest
@@ -44,12 +45,79 @@ class TokenPool:
return "DELETE 1"
class RealmPool:
def __init__(self) -> None:
self.realms: dict[str, dict[str, Any]] = {
"pam": {
"kind": "pam",
"config": {"comment": "Linux PAM standard authentication"},
},
"pve": {
"kind": "pve",
"config": {"comment": "Proxmox VE authentication server"},
},
}
self.principals: dict[str, str] = {"root@pam": "pam"}
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
del arguments
if "FROM realms ORDER BY name" in query:
return [
{"name": name, "kind": data["kind"], "config": dict(data["config"])}
for name, data in sorted(self.realms.items())
]
raise AssertionError(query)
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM realms WHERE name" in query:
realm = str(arguments[0])
data = self.realms.get(realm)
if data is None:
return None
return {"name": realm, "kind": data["kind"], "config": dict(data["config"])}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> bool:
realm = str(arguments[0])
if "EXISTS(SELECT 1 FROM realms" in query:
return realm in self.realms
if "EXISTS(SELECT 1 FROM principals" in query:
return any(value == realm for value in self.principals.values())
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "INSERT INTO realms" in query:
self.realms[str(arguments[0])] = {
"kind": str(arguments[1]),
"config": json.loads(str(arguments[2])),
}
return "INSERT 0 1"
if "UPDATE realms SET config=$2" in query:
realm = str(arguments[0])
self.realms[realm]["config"] = json.loads(str(arguments[1]))
return "UPDATE 1"
if "SET config = config - 'default'" in query:
skip = str(arguments[0]) if arguments else None
for name, data in self.realms.items():
if skip is not None and name == skip:
continue
data["config"].pop("default", None)
return "UPDATE 0"
if "DELETE FROM realms" in query:
realm = str(arguments[0])
if realm not in self.realms:
return "DELETE 0"
del self.realms[realm]
return "DELETE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: TokenPool) -> None:
def __init__(self, pool: TokenPool | RealmPool) -> None:
self.pool = pool
def request(pool: TokenPool, principal: str = "root@pam") -> Request:
def request(pool: TokenPool | RealmPool, principal: str = "root@pam") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
@@ -70,7 +138,7 @@ def request(pool: TokenPool, principal: str = "root@pam") -> Request:
def values(**items: object) -> dict[str, Any]:
return {"values": items}
return {"values": items, "provided": frozenset(items)}
async def test_token_lifecycle_returns_secret_once_and_persists_metadata() -> None:
@@ -113,3 +181,67 @@ async def test_token_lifecycle_rejects_non_owner() -> None:
with pytest.raises(ApiError) as denied:
await handler(request(TokenPool(), "auditor@pve"), values(userid="other@pve"))
assert denied.value.status_code == 403
async def test_domain_lifecycle_persists_realm_config() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = RealmPool()
http_request = request(pool)
create = registry.get("/access/domains", "POST")
listing = registry.get("/access/domains", "GET")
get = registry.get("/access/domains/{realm}", "GET")
update = registry.get("/access/domains/{realm}", "PUT")
delete = registry.get("/access/domains/{realm}", "DELETE")
assert create and listing and get and update and delete
await create(
http_request,
values(
realm="corp",
type="ldap",
comment="Corporate LDAP",
server1="ldap.example.com",
password="secret", # noqa: S106 - fixture secret for unit test
default=1,
),
)
listed = await listing(http_request, values())
assert any(item["realm"] == "corp" and item["type"] == "ldap" for item in listed)
created = await get(http_request, values(realm="corp"))
assert created["comment"] == "Corporate LDAP"
assert created["server1"] == "ldap.example.com"
assert created["default"] == 1
assert "password" not in created
await update(
http_request,
values(realm="corp", comment="Updated LDAP", delete="default"),
)
updated = await get(http_request, values(realm="corp"))
assert updated["comment"] == "Updated LDAP"
assert "default" not in updated
await delete(http_request, values(realm="corp"))
with pytest.raises(ApiError) as missing:
await get(http_request, values(realm="corp"))
assert missing.value.status_code == 404
async def test_domain_delete_rejects_builtin_and_in_use_realms() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = RealmPool()
http_request = request(pool)
delete = registry.get("/access/domains/{realm}", "DELETE")
assert delete
with pytest.raises(ApiError) as builtin:
await delete(http_request, values(realm="pam"))
assert builtin.value.status_code == 400
pool.realms["corp"] = {"kind": "ldap", "config": {}}
pool.principals["alice@corp"] = "corp"
with pytest.raises(ApiError) as in_use:
await delete(http_request, values(realm="corp"))
assert in_use.value.status_code == 400
+140
View File
@@ -0,0 +1,140 @@
"""Ceph pool/OSD mutation persistence tests."""
from __future__ import annotations
import json
from typing import Any, cast
from uuid import uuid4
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.ceph import register_ceph_handlers
from app.simulation.seed import CLUSTER_ID
class CephPool:
def __init__(self) -> None:
self.cluster_metadata: dict[str, Any] = {}
self.nodes = {"pve1": {"id": uuid4(), "metadata": {}}}
self.resources: dict[Any, dict[str, Any]] = {}
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
if "r.kind='ceph-osd'" in query and "ORDER BY" in query:
node = str(arguments[0])
node_id = self.nodes[node]["id"]
return [
{"external_id": item["external_id"], "state": item["state"]}
for item in self.resources.values()
if item["node_id"] == node_id
]
raise AssertionError(query)
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.cluster_metadata)}
if "SELECT metadata FROM nodes WHERE name" in query:
node = self.nodes.get(str(arguments[0]))
return None if node is None else {"metadata": json.dumps(node["metadata"])}
if "storage_type='ceph'" in query:
return {"capacity_bytes": 1000, "used_bytes": 100}
if "r.kind='ceph-osd'" in query:
node_name = str(arguments[0])
osdid = str(arguments[1])
node_id = self.nodes[node_name]["id"]
for item in self.resources.values():
if item["node_id"] == node_id and item["external_id"] in {
osdid,
f"osd.{osdid}",
arguments[2] if len(arguments) > 2 else "",
}:
return item
return None
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
if "SELECT id FROM nodes WHERE name" in query:
node = self.nodes.get(str(arguments[0]))
return None if node is None else node["id"]
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in query:
return len(self.resources)
if "COALESCE" in query and "ceph-osd" in query:
return len(self.resources)
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "jsonb_set" in query and "'{ceph}'" in query:
self.cluster_metadata["ceph"] = json.loads(str(arguments[1]))
return "UPDATE 1"
if "UPDATE nodes SET metadata" in query:
self.nodes[str(arguments[0])]["metadata"] = json.loads(str(arguments[1]))
return "UPDATE 1"
if "INSERT INTO resources" in query:
resource_id = uuid4()
self.resources[resource_id] = {
"id": resource_id,
"node_id": arguments[0],
"external_id": arguments[1],
"state": arguments[2],
}
return "INSERT 0 1"
if "UPDATE resources SET state" in query:
existing_id = arguments[0]
self.resources[existing_id]["state"] = arguments[1]
return "UPDATE 1"
if "DELETE FROM resources WHERE id" in query:
self.resources.pop(arguments[0], None)
return "DELETE 1"
raise AssertionError(query)
def request(pool: CephPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
async def test_ceph_pool_and_osd_mutations_persist() -> None:
registry = HandlerRegistry()
register_ceph_handlers(registry)
pool = CephPool()
http = request(pool)
create_pool = registry.get("/nodes/{node}/ceph/pool", "POST")
list_pool = registry.get("/nodes/{node}/ceph/pool", "GET")
create_osd = registry.get("/nodes/{node}/ceph/osd", "POST")
osd_out = registry.get("/nodes/{node}/ceph/osd/{osdid}/out", "POST")
assert create_pool and list_pool and create_osd and osd_out
await create_pool(http, {"values": {"node": "pve1", "name": "vms"}, "provided": frozenset()})
pools = await list_pool(http, {"values": {"node": "pve1"}, "provided": frozenset()})
assert any(item["pool"] == "vms" for item in pools)
assert "vms" in pool.cluster_metadata["ceph"]["pools"]
await create_osd(http, {"values": {"node": "pve1", "dev": "/dev/sdb"}, "provided": frozenset()})
assert len(pool.resources) == 1
resource_id = next(iter(pool.resources))
osdid = "0"
await osd_out(
http,
{"values": {"node": "pve1", "osdid": osdid}, "provided": frozenset()},
)
assert json.loads(pool.resources[resource_id]["state"])["in"] is False
assert CLUSTER_ID
+138
View File
@@ -0,0 +1,138 @@
"""Mapping / ACME / cluster-config durable handlers."""
from __future__ import annotations
import json
from typing import Any, cast
from uuid import uuid4
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.acme import register_acme_handlers
from app.handlers.cluster_config import register_cluster_config_handlers
from app.handlers.mapping import register_mapping_handlers
class MetaPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
self.nodes = {"pve1": {"status": "online"}}
self.cluster_name = "pve-simulator"
async def fetch(self, query: str, *_arguments: object) -> list[dict[str, Any]]:
if "FROM nodes" in query:
return [{"name": name, "status": data["status"]} for name, data in self.nodes.items()]
raise AssertionError(query)
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
if "UPDATE clusters" in query and "SET name" in query:
self.cluster_name = str(arguments[0])
return "UPDATE 1"
if "INSERT INTO nodes" in query:
self.nodes[str(arguments[0])] = {"status": "online"}
return "INSERT 0 1"
if "UPDATE nodes SET status" in query:
self.nodes[str(arguments[0])]["status"] = "offline"
return "UPDATE 1"
raise AssertionError(query)
async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any:
handler = registry.get(path, verb)
assert handler is not None
return await handler(http, inputs)
def request(pool: MetaPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_mapping_acme_config_persist() -> None:
registry = HandlerRegistry()
register_mapping_handlers(registry)
register_acme_handlers(registry)
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
await call(
registry,
"/cluster/mapping/pci",
"POST",
http,
{"values": {"id": "gpu0", "map": "0000:01:00.0"}, "provided": frozenset()},
)
pci = await call(
registry,
"/cluster/mapping/pci/{id}",
"GET",
http,
{"values": {"id": "gpu0"}, "provided": frozenset()},
)
assert pci["map"] == "0000:01:00.0"
await call(
registry,
"/cluster/acme/account",
"POST",
http,
{
"values": {"name": "default", "contact": "admin@example.com", "eab-hmac-key": "x"},
"provided": frozenset(),
},
)
account = await call(
registry,
"/cluster/acme/account/{name}",
"GET",
http,
{"values": {"name": "default"}, "provided": frozenset()},
)
assert account["name"] == "default"
assert "eab-hmac-key" not in account
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab"}, "provided": frozenset()},
)
assert pool.metadata["cluster_config"]["clustername"] == "lab"
assert pool.cluster_name == "lab"
totem = await call(
registry, "/cluster/config/totem", "GET", http, {"values": {}, "provided": frozenset()}
)
assert totem["cluster_name"] == "lab"
assert uuid4()
+43 -1
View File
@@ -1,12 +1,22 @@
"""Compatibility accounting tests."""
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
import pytest
from pydantic import SecretStr
from app.compatibility import CompatibilityDimension, EvidenceManifest, build_report
from app.compatibility import (
CompatibilityDimension,
EvidenceManifest,
build_report,
resolve_evidence_path,
)
from app.config import Settings
from app.contracts.model import Method, PathContract, Schema, Snapshot
from app.contracts.runtime import build_compatibility_for_snapshot
from app.handlers.core import build_core_handlers
def snapshot() -> Snapshot:
@@ -105,8 +115,40 @@ def test_evidence_manifest_requires_provenance_and_unique_methods() -> None:
evidence = manifest.dimension_map()
assert evidence[CompatibilityDimension.HTTP_STATUS] == frozenset({("/nodes/{node}", "GET")})
assert not evidence[CompatibilityDimension.PERMISSIONS]
assert manifest.verified_methods() == frozenset({("/nodes/{node}", "GET")})
assert manifest.observed_methods() == frozenset({("/nodes/{node}", "GET")})
duplicate = manifest.model_dump(mode="json")
duplicate["records"].append(duplicate["records"][0])
with pytest.raises(ValueError, match="duplicate methods"):
EvidenceManifest.model_validate(duplicate)
def test_resolve_evidence_path_prefers_per_version_ledger() -> None:
settings = Settings(compatibility_evidence=Path("evidence/pve-9.2.3.json"))
assert resolve_evidence_path("7.4-16", settings) == Path("evidence/pve-7.4-16.json").resolve()
assert resolve_evidence_path("9.2.3", settings) == Path("evidence/pve-9.2.3.json").resolve()
def test_build_compatibility_wires_verified_from_version_ledger() -> None:
snapshot = Snapshot.model_validate_json(
Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/"
"snapshot.json"
).read_bytes()
)
settings = Settings(
contract_snapshot=Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/"
"snapshot.json"
),
compatibility_evidence=Path("evidence/pve-9.2.3.json"),
ticket_signing_key=SecretStr("x" * 32),
)
handlers = build_core_handlers(settings)
report = build_compatibility_for_snapshot(snapshot, handlers, settings)
data = report.as_json()
levels = cast(dict[str, dict[str, object]], data["levels"])
assert levels["verified"]["count"] == data["total_declared"]
assert levels["observed"]["count"] == data["total_declared"]
assert levels["implemented"]["count"] == data["total_declared"]
+128
View File
@@ -0,0 +1,128 @@
"""Catalog-scoped compatibility payload tests."""
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
import pytest
from httpx import ASGITransport, AsyncClient
from app.compatibility import CompatibilityDimension, build_report
from app.config import Settings
from app.contracts.model import Method, PathContract, Schema, Snapshot
from app.main import create_app
from app.web.compatibility_catalog import compatibility_payload
from tests.unit.test_health import FakeDatabase
_BUNDLED = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_PVE7 = Path(
"contracts/2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f/snapshot.json"
)
def _snapshot(source_version: str, path: str) -> Snapshot:
method = Method(
verb="GET",
name="index",
returns=Schema(type="object"),
checksum="1" * 64,
)
return Snapshot(
source_version=source_version,
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=(PathContract(path=path, methods=(method,)),),
path_count=1,
method_count=1,
)
def test_catalog_compatibility_uses_selected_snapshot_version() -> None:
runtime_snapshot = _snapshot("9.2.3", "/version")
catalog_snapshot = _snapshot("7.4-16", "/nodes")
runtime_report = build_report(
runtime_snapshot,
implemented=frozenset({("/version", "GET")}),
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/version", "GET")})},
)
payload = compatibility_payload(
catalog_snapshot,
7,
implemented_methods=frozenset({("/nodes", "GET"), ("/version", "GET")}),
runtime_report=runtime_report,
runtime_version="9.2.3",
settings=None,
)
assert payload["catalog_version"] == "7.4-16"
assert payload["runtime_version"] == "9.2.3"
assert payload["evidence_scope"] == "catalog"
assert payload["total_declared"] == 1
levels = cast(dict[str, dict[str, object]], payload["levels"])
assert levels["implemented"]["count"] == 1
def test_catalog_compatibility_reuses_runtime_report_for_matching_version() -> None:
runtime_snapshot = _snapshot("9.2.3", "/version")
runtime_report = build_report(
runtime_snapshot,
implemented=frozenset({("/version", "GET")}),
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/version", "GET")})},
)
payload = compatibility_payload(
runtime_snapshot,
9,
implemented_methods=frozenset({("/version", "GET")}),
runtime_report=runtime_report,
runtime_version="9.2.3",
settings=None,
)
assert payload["catalog_version"] == "9.2.3"
assert payload["evidence_scope"] == "full"
async def test_ui_compatibility_endpoint_follows_selected_major() -> None:
if not _PVE7.is_file():
pytest.skip("PVE 7 bundled contract is unavailable")
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
major7 = await client.get("/ui/api/compatibility", params={"major": 7})
major9 = await client.get("/ui/api/compatibility", params={"major": 9})
assert major7.status_code == 200
assert major9.status_code == 200
body7 = major7.json()
body9 = major9.json()
pve7_snapshot = Snapshot.model_validate_json(_PVE7.read_bytes())
assert body7["catalog_version"] == pve7_snapshot.source_version
assert body9["catalog_version"] == "9.2.3"
assert body7["total_declared"] == pve7_snapshot.method_count
bundled = Snapshot.model_validate_json(_BUNDLED.read_bytes())
assert body9["total_declared"] == bundled.method_count
assert body7["major"] == 7
assert body9["major"] == 9
# Legacy aliases are kept in implemented_methods so older majors report full coverage.
assert body7["levels"]["implemented"]["count"] == body7["total_declared"]
assert body9["levels"]["implemented"]["count"] == body9["total_declared"]
async def test_ui_compatibility_covers_all_bundled_majors() -> None:
settings = Settings(contract_snapshot=_BUNDLED, compatibility_evidence=None)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
for major in (6, 7, 8, 9):
response = await client.get("/ui/api/compatibility", params={"major": major})
assert response.status_code == 200
body = response.json()
assert body["levels"]["implemented"]["count"] == body["total_declared"]
+1 -1
View File
@@ -48,7 +48,7 @@ async def client_for(tmp_path: Path) -> AsyncClient:
handlers.register("/nodes/{node}/test", "POST", handler)
app = create_app(
Settings(contract_snapshot=path),
Settings(contract_snapshot=path, compatibility_evidence=None),
lambda _settings: FakeDatabase(True),
handlers,
worker_factories=(),
+100
View File
@@ -0,0 +1,100 @@
"""Contract catalog helpers."""
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast
import pytest
from app.config import Settings
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
from app.web.contract_catalog import catalog_payload, list_majors, method_payload
def _snapshot() -> Snapshot:
method = Method(
verb="POST",
name="create",
description="Create a VM.",
parameters=(
Parameter(name="node", definition=Schema(type="string")),
Parameter(name="vmid", definition=Schema(type="integer", minimum=100)),
Parameter(name="name", definition=Schema(type="string")),
Parameter(name="memory", definition=Schema(type="integer", optional=True)),
Parameter(name="scsi[n]", definition=Schema(type="string", optional=True)),
),
returns=Schema(type="string"),
checksum="a" * 64,
)
return Snapshot(
source_version="9.2.3",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="b" * 64,
paths=(PathContract(path="/nodes/{node}/qemu", methods=(method,)),),
path_count=1,
method_count=1,
)
def test_list_majors_includes_latest_releases() -> None:
payload = list_majors(runtime_version="9.2.3")
majors_list = cast(list[dict[str, Any]], payload["majors"])
majors = {item["major"] for item in majors_list}
assert majors == {6, 7, 8, 9}
assert payload["runtime_version"] == "9.2.3"
def test_list_majors_includes_artifact_urls() -> None:
payload = list_majors(runtime_version="9.2.3")
majors_list = cast(list[dict[str, Any]], payload["majors"])
pve9 = next(item for item in majors_list if item["major"] == 9)
assert pve9["artifact_url"] == "https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js"
assert pve9["bundled"] is True
def test_list_majors_honors_settings_overrides() -> None:
settings = Settings(catalog_artifact_url_9="https://example.test/pve9/apidoc.js")
payload = list_majors(runtime_version=None, settings=settings)
majors_list = cast(list[dict[str, Any]], payload["majors"])
pve9 = next(item for item in majors_list if item["major"] == 9)
assert pve9["artifact_url"] == "https://example.test/pve9/apidoc.js"
def test_catalog_payload_groups_paths_by_tag() -> None:
payload = catalog_payload(
_snapshot(),
9,
implemented_methods=frozenset({("/nodes/{node}/qemu", "POST")}),
)
assert payload["source_version"] == "9.2.3"
assert cast(str, payload["artifact_url"]).endswith("apidoc.js")
assert payload["latest_version"] == "9.2.3"
assert payload["path_count"] == 1
categories = cast(list[dict[str, Any]], payload["categories"])
method = categories[0]["paths"][0]["methods"][0]
assert method["verb"] == "POST"
assert method["implemented"] is True
def test_method_payload_builds_examples() -> None:
payload = method_payload(
_snapshot(),
major=9,
path="/nodes/{node}/qemu",
verb="POST",
runtime_version="9.2.3",
implemented_methods=frozenset({("/nodes/{node}/qemu", "POST")}),
)
assert payload["resolved_path"] == "/nodes/pve01/qemu"
assert payload["body_example"] == {"vmid": 100, "name": "example"}
assert payload["implemented"] is True
@pytest.mark.asyncio
async def test_load_snapshot_uses_bundled_revision() -> None:
from app.web import contract_catalog
contract_catalog._SNAPSHOT_CACHE.clear()
root = Path("contracts")
snapshot = await contract_catalog.load_snapshot(9, root)
assert snapshot.source_version == "9.2.3"
+16 -1
View File
@@ -6,7 +6,11 @@ from pathlib import Path
import httpx
import pytest
from app.contracts.importer import RemoteSourceImporter, validate_remote_url
from app.contracts.importer import (
RemoteSourceImporter,
validate_public_addresses,
validate_remote_url,
)
from app.contracts.normalize import normalize_snapshot
from app.contracts.source import ApiViewerParser, SourceError
from app.contracts.store import RevisionStore
@@ -31,6 +35,17 @@ def test_remote_url_policy_rejects_unsafe_urls(url: str) -> None:
validate_remote_url(url, frozenset({"pve.proxmox.com"}))
@pytest.mark.parametrize(
"address",
[
"198.18.0.42",
"::ffff:198.18.0.42",
],
)
def test_validate_public_addresses_allows_proxy_fake_ip(address: str) -> None:
validate_public_addresses((address,))
async def test_remote_import_rejects_private_resolution() -> None:
async def private_resolver(_host: str) -> tuple[str, ...]:
return ("127.0.0.1",)
+95
View File
@@ -0,0 +1,95 @@
"""Runtime contract hot-swap tests."""
from pathlib import Path
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from tests.unit.test_health import FakeDatabase
_BUNDLED_9 = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
def _app() -> FastAPI:
settings = Settings(
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
)
return create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async def test_contract_apply_swaps_version_and_routes() -> None:
app = _app()
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
before = await client.get("/api2/json/version")
assert before.status_code == 200
assert before.json()["data"]["version"] == "9.2.3"
assert before.json()["data"]["release"] == "9.2"
versions = await client.get("/ui/api/versions")
assert versions.status_code == 200
assert versions.json()["runtime_version"] == "9.2.3"
applied = await client.post("/ui/api/contract/apply", params={"major": 7})
assert applied.status_code == 200
payload = applied.json()
assert payload["ok"] is True
assert payload["major"] == 7
assert payload["runtime_version"] == "7.4-16"
assert payload["path_count"] > 0
assert payload["method_count"] > 0
after = await client.get("/api2/json/version")
assert after.status_code == 200
assert after.json()["data"]["version"] == "7.4-16"
assert after.json()["data"]["release"] == "7.4"
versions_after = await client.get("/ui/api/versions")
assert versions_after.json()["runtime_version"] == "7.4-16"
# Still routed (handler or 501), not a missing route / 404.
nodes = await client.get("/api2/json/nodes")
assert nodes.status_code in {200, 401, 501}
restored = await client.post("/ui/api/contract/apply", params={"major": 9})
assert restored.status_code == 200
assert restored.json()["runtime_version"] == "9.2.3"
assert (await client.get("/api2/json/version")).json()["data"]["version"] == "9.2.3"
@pytest.mark.parametrize("major,version", [(6, "6.4-15"), (7, "7.4-16"), (8, "8.4.5")])
async def test_contract_apply_loads_per_major_verified_evidence(major: int, version: str) -> None:
app = _app()
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
applied = await client.post("/ui/api/contract/apply", params={"major": major})
assert applied.status_code == 200
assert applied.json()["runtime_version"] == version
report = await client.get("/admin/compatibility")
body = report.json()
assert body["source_version"] == version
assert body["levels"]["verified"]["count"] == body["total_declared"]
assert body["levels"]["verified"]["count"] > 0
async def test_contract_apply_requires_bootstrapped_contract() -> None:
app = create_app(
settings=Settings(contract_snapshot=None),
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/ui/api/contract/apply", params={"major": 7})
assert response.status_code == 503
+8
View File
@@ -25,6 +25,14 @@ def test_extract_api_schema_without_executing_trailing_javascript() -> None:
assert parsed.nodes[0]["path"] == "/x]y"
def test_extract_legacy_pveapi_declaration() -> None:
raw = b'var pveapi = [{"path":"/version","leaf":1}];'
parsed = ApiViewerParser().parse(raw)
assert parsed.nodes[0]["path"] == "/version"
@pytest.mark.parametrize(
"raw, message",
[
+3 -2
View File
@@ -166,7 +166,7 @@ async def test_core_login_and_read_endpoints(
write_snapshot(snapshot_path)
database = FakeDatabase()
app = create_app(
Settings(contract_snapshot=snapshot_path),
Settings(contract_snapshot=snapshot_path, compatibility_evidence=None),
lambda _settings: database,
worker_factories=(),
)
@@ -192,7 +192,8 @@ async def test_core_login_and_read_endpoints(
assert login.status_code == 200
assert login.json()["data"]["username"] == "root@pam"
assert "ticket" in login.json()["data"]
assert version.json()["data"]["release"] == "9.2"
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"
+7 -3
View File
@@ -42,7 +42,11 @@ async def request_app(
) -> tuple[dict[str, Any], dict[str, Any]]:
snapshot_path = tmp_path / "snapshot.json"
snapshot_path.write_bytes(contract_snapshot(get_method()).canonical_bytes())
settings = Settings(contract_snapshot=snapshot_path, contract_fallback=fallback)
settings = Settings(
contract_snapshot=snapshot_path,
contract_fallback=fallback,
compatibility_evidence=None,
)
database = FakeDatabase(True)
app = create_app(
settings,
@@ -75,8 +79,8 @@ async def test_explicit_fallback_modes(tmp_path: Path) -> None:
error_body, _ = await request_app(tmp_path, "error")
default_body, _ = await request_app(tmp_path, "schema-default")
assert error_body["errors"] == "method semantics are not implemented"
assert default_body == {"data": {"version": None}}
assert error_body["errors"] == "handler pending for this contract method"
assert default_body["data"]["version"] in {None, "example"}
def test_duplicate_snapshot_routes_are_rejected() -> None:
+180
View File
@@ -0,0 +1,180 @@
"""Tests for cluster, storage, pool and ceph handlers."""
from __future__ import annotations
import uuid
from typing import Any
import pytest
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.ceph import register_ceph_handlers
from app.handlers.cluster import register_cluster_handlers
from app.handlers.pools import register_pool_handlers
from app.handlers.storage import register_storage_handlers
class HandlerPool:
def __init__(self) -> None:
self.node_exists = True
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql and "ORDER BY name" in sql:
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
if "FROM storages" in sql and "DISTINCT storage_id" in sql:
return [{"storage_id": "local-lvm-pve01"}]
if "FROM storages s" in sql:
return [
{
"storage_id": "local-lvm-pve01",
"storage_type": "lvmthin",
"shared": False,
"capacity_bytes": 1_000_000,
"used_bytes": 250_000,
"config": '{"content":["images"]}',
}
]
if "ceph-osd" in sql:
return [
{
"external_id": "osd.0",
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0}',
}
]
if "FROM pools" in sql:
return [
{
"id": uuid.uuid4(),
"pool_id": "production",
"comment": "prod",
"metadata": '{"members":["100"]}',
}
]
if "FROM pool_members" in sql:
return [{"external_id": "100"}]
if "FROM task_logs" in sql:
return [{"message": "seeded task", "sequence": 1}]
if "FROM tasks" in sql:
return [{"upid": "UPID:pve01:1:1:1:qmstart:100:root@pam:"}]
if "FROM storage_contents" in sql or "FROM backups" in sql:
return []
raise AssertionError(sql)
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
del args
if "FROM nodes WHERE name" in sql:
return {"name": "pve01", "status": "online"} if self.node_exists else None
if "FROM clusters" in sql:
return {"metadata": '{"options":{"keyboard":"de-ch"}}'}
if "FROM storages" in sql:
return {
"storage_id": "local-lvm-pve01",
"storage_type": "lvmthin",
"shared": False,
"capacity_bytes": 1_000_000,
"used_bytes": 250_000,
"config": '{"content":["images"]}',
"node_name": "pve01",
"resource_id": uuid.uuid4(),
}
if "ceph-osd" in sql:
return {
"external_id": "osd.0",
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0,"size_bytes":1000}',
}
if "storage_type='ceph'" in sql:
return {"capacity_bytes": 5_000_000, "used_bytes": 3_000_000}
raise AssertionError(sql)
async def fetchval(self, sql: str, *args: object) -> Any:
del args
if "EXISTS(SELECT 1 FROM nodes" in sql:
return self.node_exists
if "MAX(external_id::integer)" in sql:
return 150
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in sql:
return 300
if "SELECT resource_id FROM storages" in sql:
return uuid.uuid4()
return False
async def execute(self, sql: str, *args: object) -> str:
del sql, args
return "UPDATE 1"
def _request(pool: HandlerPool) -> Request:
app = type("App", (), {})()
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"query_string": b"",
"client": ("test", 1234),
"server": ("test", 80),
"scheme": "http",
"root_path": "",
"app": app,
}
request = Request(scope)
request.state.principal = "root@pam"
return request
async def _call(handler: Any, values: dict[str, Any], pool: HandlerPool | None = None) -> Any:
return await handler(_request(pool or HandlerPool()), {"values": values})
@pytest.mark.asyncio
async def test_cluster_status_and_nextid() -> None:
registry = HandlerRegistry()
register_cluster_handlers(registry)
status = await _call(registry.get("/cluster/status", "GET"), {})
assert status[0]["name"] == "pve01"
nextid = await _call(registry.get("/cluster/nextid", "GET"), {})
assert nextid == 151
@pytest.mark.asyncio
async def test_storage_and_ceph_handlers() -> None:
registry = HandlerRegistry()
register_storage_handlers(registry)
register_ceph_handlers(registry)
storage = await _call(
registry.get("/nodes/{node}/storage", "GET"),
{"node": "pve01"},
)
assert storage[0]["storage"] == "local-lvm-pve01"
osds = await _call(
registry.get("/nodes/{node}/ceph/osd", "GET"),
{"node": "pve01"},
)
assert osds[0]["status"] == "up"
ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {})
assert ceph_status["osdmap"]["num_osds"] == 300
@pytest.mark.asyncio
async def test_pools_list() -> None:
registry = HandlerRegistry()
register_pool_handlers(registry)
pools = await _call(registry.get("/pools", "GET"), {})
assert pools[0]["poolid"] == "production"
assert pools[0]["members"] == ["100"]
@pytest.mark.asyncio
async def test_missing_node_returns_404() -> None:
registry = HandlerRegistry()
register_storage_handlers(registry)
pool = HandlerPool()
pool.node_exists = False
handler = registry.get("/nodes/{node}/storage", "GET")
assert handler is not None
with pytest.raises(ApiError, match="node does not exist"):
await handler(_request(pool), {"values": {"node": "missing"}})
+83
View File
@@ -0,0 +1,83 @@
"""Firewall aliases/ipset/group persistence tests."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.firewall import register_firewall_handlers
from app.simulation.seed import CLUSTER_ID
class FirewallPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "jsonb_set" in query:
# args: CLUSTER_ID, firewall json
self.metadata["firewall"] = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
def request(pool: FirewallPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
async def test_firewall_alias_and_ipset_persist() -> None:
registry = HandlerRegistry()
register_firewall_handlers(registry)
pool = FirewallPool()
http = request(pool)
create_alias = registry.get("/cluster/firewall/aliases", "POST")
list_alias = registry.get("/cluster/firewall/aliases", "GET")
create_ipset = registry.get("/cluster/firewall/ipset", "POST")
add_ip = registry.get("/cluster/firewall/ipset/{name}", "POST")
get_ipset = registry.get("/cluster/firewall/ipset/{name}", "GET")
assert create_alias and list_alias and create_ipset and add_ip and get_ipset
await create_alias(
http, {"values": {"name": "lan", "cidr": "10.0.0.0/8"}, "provided": frozenset()}
)
aliases = await list_alias(http, {"values": {}, "provided": frozenset()})
assert aliases[0]["name"] == "lan"
await create_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
await add_ip(
http,
{"values": {"name": "blacklist", "cidr": "203.0.113.10"}, "provided": frozenset()},
)
entries = await get_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
assert entries[0]["cidr"] == "203.0.113.10"
assert "scopes" in pool.metadata["firewall"]
assert CLUSTER_ID
+288
View File
@@ -0,0 +1,288 @@
"""Tests for gap-plan handler implementations."""
from __future__ import annotations
import json
import uuid
from typing import Any
import pytest
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.access import register_access_handlers
from app.handlers.cluster import register_cluster_handlers
from app.handlers.ha import register_ha_handlers
from app.handlers.storage import register_storage_handlers
class GapPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {
"options": {"keyboard": "en-us"},
"replication": [],
"ha_groups": {},
}
self.node_metadata: dict[str, Any] = {}
self.node_exists = True
self.storage_resource_id = uuid.uuid4()
self.storage_contents: list[dict[str, object]] = []
self.principals = {"root@pam": {"enabled": True, "realm": "pam"}}
self.groups = {"operators": {"comment": "ops", "users": ["root@pam"]}}
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql and "ORDER BY name" in sql:
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
if "FROM tasks" in sql:
return []
if "FROM task_logs" in sql:
return []
if "FROM resources r JOIN nodes" in sql and "kind='ha'" in sql:
return []
if "FROM storage_contents" in sql and "ORDER BY" in sql:
return list(self.storage_contents)
if "FROM backups" in sql and "ORDER BY created_at DESC" in sql and "OFFSET" not in sql:
return []
if "FROM principals p" in sql and "ORDER BY p.name" in sql:
return [
{
"name": name,
"realm_name": data["realm"],
"enabled": data["enabled"],
"realm_kind": data["realm"],
}
for name, data in self.principals.items()
]
if "FROM identity_groups g" in sql and "GROUP BY" in sql:
return [
{
"group_id": group_id,
"comment": data["comment"],
"users": data["users"],
}
for group_id, data in self.groups.items()
]
raise AssertionError(sql)
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
if "FROM clusters WHERE id" in sql:
return {"metadata": json.dumps(self.metadata)}
if "FROM nodes WHERE name" in sql and "metadata" in sql:
name = str(args[0])
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
if "FROM nodes WHERE name" in sql:
return {"name": "pve01", "id": uuid.uuid4()} if self.node_exists else None
if "FROM storages WHERE storage_id" in sql and "resource_id" in sql:
return {"resource_id": self.storage_resource_id}
if "FROM storages s" in sql and "JOIN" in sql:
return {
"storage_id": "local-lvm",
"storage_type": "lvmthin",
"shared": False,
"capacity_bytes": 1_000_000,
"used_bytes": 250_000,
"config": '{"content":["images"]}',
"node_name": "pve01",
"resource_id": self.storage_resource_id,
}
if "FROM storage_contents" in sql and "volume_id=$2" in sql:
volume = str(args[1])
for item in self.storage_contents:
if item["volume_id"] == volume:
return item
return {
"volume_id": "local-lvm:100/vm-100-disk-0.raw",
"content_type": "images",
"size_bytes": 1024,
"metadata": '{"format":"raw"}',
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
}
if "FROM principals" in sql and "WHERE" in sql and "name" in sql:
userid = str(args[0])
if userid not in self.principals:
return None
data = self.principals[userid]
return {
"name": userid,
"realm_name": data["realm"],
"enabled": data["enabled"],
"realm_kind": data["realm"],
"id": uuid.uuid4(),
}
if "FROM identity_groups WHERE group_id" in sql:
groupid = str(args[0])
if groupid not in self.groups:
return None
return {"id": uuid.uuid4(), "group_id": groupid}
if "FROM identity_groups g" in sql and "WHERE g.group_id" in sql:
groupid = str(args[0])
if groupid not in self.groups:
return None
group_data = self.groups[groupid]
return {
"group_id": groupid,
"comment": group_data["comment"],
"users": group_data["users"],
}
if "count(*) FILTER" in sql and "kind='ha'" in sql:
return {"started": 0, "total": 0}
raise AssertionError(sql)
async def fetchval(self, sql: str, *args: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in sql:
return self.node_exists
if "MAX(external_id::integer)" in sql:
return 150
if "SELECT resource_id FROM storages" in sql:
return self.storage_resource_id
if "EXISTS(SELECT 1 FROM principals" in sql:
return False
if "EXISTS(SELECT 1 FROM realms" in sql:
return True
if "EXISTS(SELECT 1 FROM identity_groups" in sql:
return False
if "EXISTS(SELECT 1 FROM resources WHERE kind='ha'" in sql:
return False
if "SELECT metadata FROM nodes" in sql:
return json.dumps(self.node_metadata.get(str(args[0]), {}))
if "SELECT name FROM nodes WHERE status" in sql:
return "pve01"
return False
async def execute(self, sql: str, *args: object) -> str:
if "UPDATE clusters SET metadata" in sql:
self.metadata = json.loads(str(args[1]))
return "UPDATE 1"
if "UPDATE nodes SET metadata" in sql:
self.node_metadata[str(args[0])] = json.loads(str(args[1]))
return "UPDATE 1"
if "INSERT INTO storage_contents" in sql:
self.storage_contents.append(
{
"volume_id": str(args[1]),
"content_type": str(args[2]),
"size_bytes": int(str(args[3])),
"metadata": str(args[4]),
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
}
)
return "INSERT 0 1"
if "INSERT INTO resources" in sql and "kind='ha'" in sql:
return "INSERT 0 1"
if "DELETE FROM" in sql:
return "DELETE 1"
return "UPDATE 1"
def _request(pool: GapPool) -> Request:
app = type("App", (), {})()
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"query_string": b"",
"client": ("test", 1234),
"server": ("test", 80),
"scheme": "http",
"root_path": "",
"app": app,
}
request = Request(scope)
request.state.principal = "root@pam"
return request
async def _call(handler: Any, values: dict[str, Any], pool: GapPool | None = None) -> Any:
return await handler(
_request(pool or GapPool()),
{"values": values, "provided": tuple(values)},
)
@pytest.mark.asyncio
async def test_cluster_index_and_replication_crud() -> None:
registry = HandlerRegistry()
register_cluster_handlers(registry)
pool = GapPool()
index = await _call(registry.get("/cluster", "GET"), {}, pool)
assert any(item["subdir"] == "replication" for item in index)
created = await _call(
registry.get("/cluster/replication", "POST"),
{"guest": "100", "target": "pve02"},
pool,
)
assert created["id"] == "repl-100"
jobs = await _call(registry.get("/cluster/replication", "GET"), {}, pool)
assert jobs[0]["guest"] == "100"
fetched = await _call(
registry.get("/cluster/replication/{id}", "GET"), {"id": "repl-100"}, pool
)
assert fetched["target"] == "pve02"
@pytest.mark.asyncio
async def test_ha_group_create_and_index() -> None:
registry = HandlerRegistry()
register_ha_handlers(registry)
pool = GapPool()
index = await _call(registry.get("/cluster/ha", "GET"), {}, pool)
assert any(item["subdir"] == "groups" for item in index)
await _call(
registry.get("/cluster/ha/groups", "POST"),
{"group": "lab", "nodes": "pve01,pve02"},
pool,
)
assert "lab" in pool.metadata["ha_groups"]
groups = await _call(registry.get("/cluster/ha/groups", "GET"), {}, pool)
assert groups[0]["group"] == "lab"
@pytest.mark.asyncio
async def test_access_user_and_group_detail() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = GapPool()
user = await _call(registry.get("/access/users/{userid}", "GET"), {"userid": "root@pam"}, pool)
assert user["userid"] == "root@pam"
group = await _call(
registry.get("/access/groups/{groupid}", "GET"),
{"groupid": "operators"},
pool,
)
assert group["users"] == ["root@pam"]
@pytest.mark.asyncio
async def test_storage_content_get_and_upload() -> None:
registry = HandlerRegistry()
register_storage_handlers(registry)
pool = GapPool()
item = await _call(
registry.get("/nodes/{node}/storage/{storage}/content/{volume}", "GET"),
{
"node": "pve01",
"storage": "local-lvm",
"volume": "local-lvm:100/vm-100-disk-0.raw",
},
pool,
)
assert item["content"] == "images"
upload = await _call(
registry.get("/nodes/{node}/storage/{storage}/upload", "POST"),
{"node": "pve01", "storage": "local-lvm", "filename": "image.iso"},
pool,
)
assert "uploadid" in upload
@pytest.mark.asyncio
async def test_replication_missing_returns_404() -> None:
registry = HandlerRegistry()
register_cluster_handlers(registry)
handler = registry.get("/cluster/replication/{id}", "GET")
with pytest.raises(ApiError, match="replication job does not exist"):
await _call(handler, {"id": "missing"}, GapPool())
+163
View File
@@ -0,0 +1,163 @@
"""Persistence tests for remaining gap handlers (nodes_extra / cluster_extra)."""
from __future__ import annotations
import json
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.cluster_extra import register_cluster_extra_handlers
from app.handlers.nodes_extra import register_nodes_extra_handlers
class GapRemainingPool:
def __init__(self) -> None:
self.cluster_metadata: dict[str, Any] = {}
self.node_metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "SELECT metadata FROM clusters" in query:
return {"metadata": json.dumps(self.cluster_metadata)}
if "SELECT metadata FROM nodes" in query:
name = str(arguments[0])
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.cluster_metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
if "UPDATE nodes SET metadata" in query:
self.node_metadata[str(arguments[0])] = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: GapRemainingPool) -> None:
self.pool = pool
def _request(pool: GapRemainingPool, *, method: str = "GET") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
{
"type": "http",
"app": app,
"method": method,
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
@pytest.mark.asyncio
async def test_disks_directory_create_persists() -> None:
registry = HandlerRegistry()
register_nodes_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/nodes/{node}/disks/directory", "POST")
assert create is not None
created = await create(
_request(pool, method="POST"),
{
"values": {"node": "pve01", "name": "tank", "device": "/dev/sdb"},
"provided": frozenset(),
},
)
assert created["name"] == "tank"
ops = pool.node_metadata["pve01"]["ops"]
assert any(item["name"] == "tank" for item in ops["disks"]["directory"])
@pytest.mark.asyncio
async def test_certificates_custom_create_does_not_echo_key() -> None:
registry = HandlerRegistry()
register_nodes_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/nodes/{node}/certificates/custom", "POST")
info = registry.get("/nodes/{node}/certificates/info", "GET")
assert create is not None and info is not None
await create(
_request(pool, method="POST"),
{
"values": {
"node": "pve01",
"certificates": "-----BEGIN CERTIFICATE-----\nSIM\n-----END CERTIFICATE-----",
"key": "-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----",
},
"provided": frozenset(),
},
)
stored = pool.node_metadata["pve01"]["ops"]["certificates"]["custom"]
assert stored["key"].startswith("-----BEGIN PRIVATE KEY-----")
listing = await info(
_request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
blob = json.dumps(listing)
assert "PRIVATE KEY" not in blob
assert "SECRET" not in blob
@pytest.mark.asyncio
async def test_realm_sync_job_create_persists() -> None:
registry = HandlerRegistry()
register_cluster_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/cluster/jobs/realm-sync/{id}", "POST")
listing = registry.get("/cluster/jobs/realm-sync", "GET")
assert create is not None and listing is not None
created = await create(
_request(pool, method="POST"),
{
"values": {"id": "pam-nightly", "realm": "pam", "schedule": "0 2 * * *"},
"provided": frozenset(),
},
)
assert created["id"] == "pam-nightly"
assert pool.cluster_metadata["jobs"]["realm_sync"]["pam-nightly"]["realm"] == "pam"
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
assert items[0]["id"] == "pam-nightly"
@pytest.mark.asyncio
async def test_metrics_server_create_persists() -> None:
registry = HandlerRegistry()
register_cluster_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/cluster/metrics/server/{id}", "POST")
listing = registry.get("/cluster/metrics/server", "GET")
assert create is not None and listing is not None
created = await create(
_request(pool, method="POST"),
{
"values": {
"id": "influx1",
"type": "influxdb",
"server": "10.0.0.20",
"port": 8089,
},
"provided": frozenset(),
},
)
assert created["id"] == "influx1"
assert pool.cluster_metadata["metrics"]["servers"]["influx1"]["server"] == "10.0.0.20"
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
assert items[0]["id"] == "influx1"
+6 -2
View File
@@ -42,7 +42,11 @@ async def test_health_endpoints(database_ready: bool, status_code: int) -> None:
del settings
return database
application = create_app(Settings(), factory)
application = create_app(
Settings(contract_snapshot=None, compatibility_evidence=None),
factory,
worker_factories=(),
)
async with application.router.lifespan_context(application):
async with AsyncClient(
transport=ASGITransport(app=application, raise_app_exceptions=False),
@@ -73,7 +77,7 @@ async def test_lifespan_starts_and_stops_injected_workers() -> None:
stopping.set()
application = create_app(
Settings(),
Settings(contract_snapshot=None, compatibility_evidence=None),
lambda _settings: database,
worker_factories=(lambda _database: Worker(),),
)
+155
View File
@@ -0,0 +1,155 @@
"""Persistent LXC semantic handler tests."""
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.lxc import register_lxc_handlers
class LxcPool:
def __init__(self) -> None:
self.resource_exists = False
self.missing = False
self.running = False
self.commands: list[str] = []
self.resource_id = uuid.uuid4()
async def fetchval(self, sql: str, *args: object) -> bool | int:
del args
if "pg_backend_pid" in sql:
return 123
if "extract(epoch" in sql:
return 1_700_000_000
if "FROM nodes" in sql:
return True
if "FROM resources" in sql:
return self.resource_exists
if "FROM snapshots" in sql:
return False
raise AssertionError(sql)
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM resources" in sql and "kind='lxc'" in sql:
return [{"vmid": 200, "state": '{"status":"stopped","name":"service"}'}]
assert "FROM snapshots" in sql
return [
{
"name": "baseline",
"parent_name": None,
"description": "stable",
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
}
]
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
del args
if self.missing:
return None
if "SELECT r.id, r.version" in sql:
return {
"id": self.resource_id,
"version": 1,
"state": '{"name":"old","status":"stopped"}',
"config": '{"name":"old"}',
}
if "SELECT r.id, r.state, c.config" in sql:
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
if "SELECT r.id, r.state FROM resources" in sql:
status = "running" if self.running else "stopped"
return {"id": self.resource_id, "state": f'{{"status":"{status}"}}'}
if "SELECT s.* FROM snapshots" in sql:
return {
"id": uuid.uuid4(),
"name": "baseline",
"parent_name": None,
"description": "stable",
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
"state": "{}",
}
raise AssertionError(sql)
async def execute(self, sql: str, *args: object) -> str:
del sql, args
self.commands.append("execute")
return "UPDATE 1"
class FakeDatabase:
def __init__(self, pool: LxcPool) -> None:
self.pool = pool
class FakeTaskRepository:
def __init__(self, pool: LxcPool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type(
"Task", (), {"upid": "UPID:pve1:00000001:00000001:1700000000:pctcreate:201:root@pam:"}
)()
def _request(pool: LxcPool) -> Request:
app = type("App", (), {"state": type("State", (), {"database": FakeDatabase(pool)})()})()
request = Request({"type": "http", "headers": [], "method": "POST", "path": "/"})
request.scope["app"] = app
request.state.principal = "root@pam"
return request
@pytest.fixture
def registry() -> HandlerRegistry:
handler_registry = HandlerRegistry()
register_lxc_handlers(handler_registry)
return handler_registry
async def test_lxc_list_returns_seeded_containers(registry: HandlerRegistry) -> None:
pool = LxcPool()
handler = registry.get("/nodes/{node}/lxc", "GET")
assert handler is not None
result = await handler(_request(pool), {"values": {"node": "pve1"}})
assert result == [{"vmid": 200, "status": "stopped", "name": "service"}]
async def test_lxc_create_rejects_duplicate_vmid(registry: HandlerRegistry) -> None:
pool = LxcPool()
pool.resource_exists = True
handler = registry.get("/nodes/{node}/lxc", "POST")
assert handler is not None
with pytest.raises(ApiError, match="VMID already exists"):
await handler(
_request(pool),
{"values": {"node": "pve1", "vmid": 201, "hostname": "app"}},
)
async def test_lxc_delete_requires_stopped_container(registry: HandlerRegistry) -> None:
pool = LxcPool()
pool.running = True
handler = registry.get("/nodes/{node}/lxc/{vmid}", "DELETE")
assert handler is not None
with pytest.raises(ApiError, match="cannot delete a running container"):
await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
async def test_lxc_start_creates_task(
monkeypatch: pytest.MonkeyPatch, registry: HandlerRegistry
) -> None:
pool = LxcPool()
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.lxc.TaskRepository", lambda _pool: repository)
handler = registry.get("/nodes/{node}/lxc/{vmid}/status/start", "POST")
assert handler is not None
upid = await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
assert upid.startswith("UPID:")
assert repository.created[0]["task_type"] == "lxc-start"
+3
View File
@@ -51,3 +51,6 @@ def test_repository_migration_defines_required_planes() -> None:
):
assert f"CREATE TABLE {table}" in domain
assert "CREATE TABLE group_acl_entries" in migrations[5].sql
assert "ADD COLUMN IF NOT EXISTS config jsonb" in migrations[6].sql
assert "CREATE TABLE tfa_entries" in migrations[7].sql
assert "CREATE TABLE openid_pending" in migrations[7].sql
+128
View File
@@ -0,0 +1,128 @@
"""Node ops handlers persist network/disks/services into nodes.metadata."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.nodes import register_node_ops_handlers
class NodePool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "SELECT metadata FROM nodes" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE nodes SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: NodePool) -> None:
self.pool = pool
def request(pool: NodePool, *, method: str = "GET", path: str = "/") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
{
"type": "http",
"app": app,
"method": method,
"path": path,
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
async def test_network_and_service_mutations_persist() -> None:
registry = HandlerRegistry()
register_node_ops_handlers(registry)
pool = NodePool()
create = registry.get("/nodes/{node}/network", "POST")
listing = registry.get("/nodes/{node}/network", "GET")
delete = registry.get("/nodes/{node}/network/{iface}", "DELETE")
stop = registry.get("/nodes/{node}/services/{service}/stop", "POST")
state = registry.get("/nodes/{node}/services/{service}/state", "GET")
assert create and listing and delete and stop and state
await create(
request(pool, method="POST", path="/api2/json/nodes/pve01/network"),
{"values": {"node": "pve01", "iface": "vmbr9", "type": "bridge"}, "provided": frozenset()},
)
items = await listing(
request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
assert any(item["iface"] == "vmbr9" for item in items)
await delete(
request(pool, method="DELETE", path="/api2/json/nodes/pve01/network/vmbr9"),
{"values": {"node": "pve01", "iface": "vmbr9"}, "provided": frozenset()},
)
items = await listing(
request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
assert all(item["iface"] != "vmbr9" for item in items)
await stop(
request(pool, method="POST", path="/api2/json/nodes/pve01/services/pveproxy/stop"),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
)
service = await state(
request(pool),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
)
assert service["state"] == "stopped"
assert "ops" in pool.metadata
async def test_disk_init_and_wipe_persist() -> None:
registry = HandlerRegistry()
register_node_ops_handlers(registry)
pool = NodePool()
initgpt = registry.get("/nodes/{node}/disks/initgpt", "POST")
wipe = registry.get("/nodes/{node}/disks/wipedisk", "PUT")
listing = registry.get("/nodes/{node}/disks/list", "GET")
assert initgpt and wipe and listing
await initgpt(
request(pool, method="POST"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
)
await wipe(
request(pool, method="PUT"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
)
disks = await listing(
request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
target = next(item for item in disks if item["devpath"] == "/dev/sdb")
assert target["wiped"] == 1
assert target["gpt"] == 0
+87
View File
@@ -0,0 +1,87 @@
"""Notification endpoints/matchers persistence."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.notifications import register_notifications_handlers
from app.simulation.seed import CLUSTER_ID
class NotesPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
def request(pool: NotesPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_notification_endpoint_and_matcher_persist() -> None:
registry = HandlerRegistry()
register_notifications_handlers(registry)
pool = NotesPool()
http = request(pool)
create = registry.get("/cluster/notifications/endpoints/gotify", "POST")
get = registry.get("/cluster/notifications/endpoints/gotify/{name}", "GET")
matchers = registry.get("/cluster/notifications/matchers", "POST")
targets = registry.get("/cluster/notifications/targets", "GET")
test = registry.get("/cluster/notifications/targets/{name}/test", "POST")
assert create and get and matchers and targets and test
await create(
http,
{
"values": {
"name": "ops",
"server": "https://gotify.local",
"token": "secret-token",
},
"provided": frozenset(),
},
)
payload = await get(http, {"values": {"name": "ops"}, "provided": frozenset()})
assert payload["server"] == "https://gotify.local"
assert "token" not in payload
await matchers(
http,
{
"values": {"name": "all-mail", "target": "ops", "mode": "all"},
"provided": frozenset(),
},
)
listed = await targets(http, {"values": {}, "provided": frozenset()})
assert listed[0]["name"] == "ops"
await test(http, {"values": {"name": "ops"}, "provided": frozenset()})
assert pool.metadata["notifications"]["tests"]
assert CLUSTER_ID
+25
View File
@@ -0,0 +1,25 @@
"""OpenAPI tag categorization tests."""
from app.api.openapi import contract_openapi_tag, contract_openapi_tags, openapi_tag_metadata
def test_contract_openapi_tag_groups_by_domain() -> None:
assert contract_openapi_tag("/version") == "Core"
assert contract_openapi_tag("/access/ticket") == "Access"
assert contract_openapi_tag("/nodes/{node}/qemu/{vmid}/config") == "Nodes · QEMU"
assert contract_openapi_tag("/nodes/{node}/lxc/{vmid}/config") == "Nodes · LXC"
assert contract_openapi_tag("/nodes/{node}/ceph/osd") == "Nodes · Ceph"
assert contract_openapi_tag("/cluster/ha/resources") == "Cluster · HA"
assert contract_openapi_tag("/pools") == "Pools"
def test_contract_openapi_tags_include_renderer() -> None:
assert contract_openapi_tags("/version", "json") == ["Core", "API2 JSON"]
assert contract_openapi_tags("/version", "extjs") == ["Core", "API2 ExtJS"]
def test_openapi_tag_metadata_is_deterministic() -> None:
names = [entry["name"] for entry in openapi_tag_metadata()]
assert names == sorted(names)
assert "Nodes · QEMU" in names
assert "Simulator" in names
+38 -1
View File
@@ -69,7 +69,7 @@ class QemuPool:
return {
"id": self.resource_id,
"state": f'{{"status":"{status}"}}',
"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=8G"}',
"config": ('{"agent":1,"name":"vm","scsi0":"local-lvm:vm-150-disk-0,size=8G"}'),
}
if "SELECT r.id, r.state, v.config" in sql:
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
@@ -325,3 +325,40 @@ async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch)
with pytest.raises(ApiError) as same_node:
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
assert same_node.value.status_code == 400
async def test_qemu_pending_and_agent_handlers() -> None:
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
pool.running = True
http_request = request(pool)
values = inputs(node="pve1", vmid=150)
pending = registry.get("/nodes/{node}/qemu/{vmid}/pending", "GET")
routes = {
"info": "/nodes/{node}/qemu/{vmid}/agent/info",
"os": "/nodes/{node}/qemu/{vmid}/agent/get-osinfo",
"host": "/nodes/{node}/qemu/{vmid}/agent/get-host-name",
"network": "/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces",
"time": "/nodes/{node}/qemu/{vmid}/agent/get-time",
"ping": "/nodes/{node}/qemu/{vmid}/agent/ping",
}
handlers = {
name: registry.get(path, "POST" if name == "ping" else "GET")
for name, path in routes.items()
}
assert pending and all(handlers.values())
async def call(name: str) -> dict[str, Any]:
handler = handlers[name]
assert handler is not None
return cast(dict[str, Any], await handler(http_request, values))
assert await pending(http_request, values) == []
assert (await call("info"))["result"]["version"]
assert (await call("os"))["result"]["machine"] == "x86_64"
assert (await call("host"))["result"]["host-name"] == "vm"
assert (await call("network"))["result"][0]["name"] == "eth0"
assert (await call("time"))["result"]["seconds"] > 0
assert (await call("ping"))["result"] == {}
+25
View File
@@ -0,0 +1,25 @@
"""Tests for contract example generation."""
from app.contracts.examples import path_param_example, schema_example
from app.contracts.model import Schema
def test_path_param_examples_use_known_placeholders() -> None:
assert path_param_example("node") == "pve01"
assert path_param_example("vmid") == 100
def test_schema_example_prefers_default_and_enum() -> None:
assert schema_example(Schema(type="string", default="custom")) == "custom"
assert schema_example(Schema(type="string", enum=("a", "b"))) == "a"
def test_schema_example_builds_object_and_array() -> None:
schema = Schema(
type="object",
properties={
"count": Schema(type="integer", minimum=2),
"enabled": Schema(type="boolean", optional=True),
},
)
assert schema_example(schema) == {"count": 2}
+128
View File
@@ -0,0 +1,128 @@
"""SDN zone/vnet/subnet persistence tests."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.sdn import register_sdn_handlers
class SdnPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
self.nodes = {"pve1"}
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any:
handler = registry.get(path, verb)
assert handler is not None
return await handler(http, inputs)
def request(pool: SdnPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_sdn_zone_vnet_subnet_and_node_views() -> None:
registry = HandlerRegistry()
register_sdn_handlers(registry)
pool = SdnPool()
http = request(pool)
await call(
registry,
"/cluster/sdn/zones",
"POST",
http,
{"values": {"zone": "localzone", "type": "simple"}, "provided": frozenset()},
)
await call(
registry,
"/cluster/sdn/vnets",
"POST",
http,
{
"values": {"vnet": "vnet0", "zone": "localzone", "type": "vnet"},
"provided": frozenset(),
},
)
await call(
registry,
"/cluster/sdn/vnets/{vnet}/subnets",
"POST",
http,
{
"values": {
"vnet": "vnet0",
"subnet": "10.0.0.0/24",
"gateway": "10.0.0.1",
},
"provided": frozenset(),
},
)
zones = await call(
registry, "/cluster/sdn/zones", "GET", http, {"values": {}, "provided": frozenset()}
)
assert zones[0]["zone"] == "localzone"
subnets = await call(
registry,
"/cluster/sdn/vnets/{vnet}/subnets",
"GET",
http,
{"values": {"vnet": "vnet0"}, "provided": frozenset()},
)
assert subnets[0]["subnet"] == "10.0.0.0/24"
node_zones = await call(
registry,
"/nodes/{node}/sdn/zones",
"GET",
http,
{"values": {"node": "pve1"}, "provided": frozenset()},
)
assert node_zones[0]["zone"] == "localzone"
assert pool.metadata["sdn"]["pending"] is True
await call(
registry,
"/cluster/sdn",
"PUT",
http,
{"values": {"release-lock": 1}, "provided": frozenset()},
)
assert pool.metadata["sdn"]["pending"] is False
+76 -2
View File
@@ -2,7 +2,13 @@
import pytest
from app.simulation.seed import build_profile, large_profile, small_profile, stable_id
from app.simulation.seed import (
build_profile,
clear_simulation_state,
large_profile,
small_profile,
stable_id,
)
def test_small_profile_matches_required_logical_shape() -> None:
@@ -12,7 +18,7 @@ def test_small_profile_matches_required_logical_shape() -> None:
assert first == second
state = first.logical_state()
assert state == second.logical_state()
assert state["nodes"] == [{"name": "pve1", "status": "online"}]
assert state["nodes"] == [{"name": "pve01", "status": "online"}]
resources = state["resources"]
assert isinstance(resources, list)
assert [resource["kind"] for resource in resources].count("qemu") == 2
@@ -48,6 +54,74 @@ def test_profile_validation() -> None:
large_profile(node_count=0, resource_count=1)
def test_demo_cluster_profile_shape() -> None:
profile = build_profile("demo-cluster")
assert profile.name == "demo-cluster"
assert len(profile.nodes) == 20
assert sum(resource.kind == "qemu" for resource in profile.resources) == 850
assert sum(resource.kind == "lxc" for resource in profile.resources) == 150
assert sum(resource.kind == "ceph-osd" for resource in profile.resources) == 300
assert sum(resource.kind == "storage" for resource in profile.resources) >= 62
assert len(profile.tasks) == 250
external_ids = {
resource.external_id for resource in profile.resources if resource.kind in {"qemu", "lxc"}
}
assert len(external_ids) == 1000
def test_demo_cluster_spreads_guests_evenly_across_nodes() -> None:
profile = build_profile("demo-cluster")
names = {node.id: node.name for node in profile.nodes}
def counts(kind: str) -> list[int]:
counter: dict[str, int] = {name: 0 for name in names.values()}
for resource in profile.resources:
if resource.kind == kind:
counter[names[resource.node_id]] += 1
return list(counter.values())
for kind, expected_total in (("qemu", 850), ("lxc", 150), ("ceph-osd", 300)):
values = counts(kind)
assert sum(values) == expected_total
assert max(values) - min(values) <= 1
guest_counts = counts("qemu")
guest_counts = [a + b for a, b in zip(guest_counts, counts("lxc"), strict=True)]
assert max(guest_counts) - min(guest_counts) <= 2
def test_minimal_profile() -> None:
profile = build_profile("minimal")
assert len(profile.nodes) == 1
assert not any(resource.kind in {"qemu", "lxc"} for resource in profile.resources)
def test_stable_ids_are_namespaced_and_repeatable() -> None:
assert stable_id("qemu:100") == stable_id("qemu:100")
assert stable_id("qemu:100") != stable_id("qemu:101")
@pytest.mark.asyncio
async def test_clear_simulation_state_wipes_api_created_identity() -> None:
executed: list[str] = []
class FakeConnection:
async def execute(self, sql: str, *args: object) -> str:
del args
executed.append(" ".join(sql.split()))
return "DELETE 0"
await clear_simulation_state(FakeConnection())
joined = "\n".join(executed)
for table in (
"resources",
"nodes",
"principals",
"identity_groups",
"roles",
"storage_contents",
"api_tokens",
):
assert f"DELETE FROM {table}" in joined # noqa: S608 - asserting SQL text
assert "DELETE FROM realms WHERE name NOT IN" in joined
assert any(sql.startswith("UPDATE clusters") for sql in executed)
+34
View File
@@ -0,0 +1,34 @@
"""Web asset loading tests."""
from app.web.assets import console_html
def test_console_html_is_read_from_disk() -> None:
html = console_html()
assert "Proxmox API Emulator" in html
assert 'id="catalog-drawer"' in html
assert "catalog-drawer" in html
assert 'id="catalog-coverage"' in html
assert "Implementation coverage" in html
for required_id in (
"method-desc",
"catalog-meta",
"stat-runtime",
"stat-catalog",
"stat-cluster-name",
"stat-nodes",
"stat-qemu",
"stat-lxc",
"implemented-only",
"btn-contract-apply",
"btn-catalog-refresh",
):
assert f'id="{required_id}"' in html, required_id
assert "Apply as runtime" in html
assert "CONTRACT_SNAPSHOT" in html
assert 'id="help-drawer"' in html
assert 'id="help-badge"' in html
assert 'id="data-badge"' in html
assert 'id="data-drawer"' in html
assert 'id="data-panel"' in html
assert 'id="ui-modal"' in html
+123
View File
@@ -0,0 +1,123 @@
"""Web console route tests."""
from pathlib import Path
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from tests.unit.test_health import FakeDatabase
_BUNDLED = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
async def test_root_console_is_served() -> None:
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/")
assert response.status_code == 200
assert "Proxmox API Emulator" in response.text
assert 'id="catalog-drawer"' in response.text
assert "catalog-drawer" in response.text
assert 'id="help-drawer"' in response.text
assert 'id="help-badge"' in response.text
assert 'id="data-badge"' in response.text
assert 'id="data-drawer"' in response.text
assert "data-badge-btn" in response.text
assert 'id="endpoints-badge-count"' in response.text
assert 'id="endpoints-drawer-count"' in response.text
assert 'id="ui-modal"' in response.text
assert 'role="alertdialog"' in response.text
assert "Request body" in response.text
async def test_ui_method_nodes_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
method = await client.get(
"/ui/api/method",
params={"major": 7, "path": "/nodes", "verb": "GET"},
)
assert method.status_code == 200
assert method.json()["implemented"] is True
async def test_ui_method_read_group_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
method = await client.get(
"/ui/api/method",
params={"major": 9, "path": "/access/groups/{groupid}", "verb": "GET"},
)
assert method.status_code == 200
payload = method.json()
assert payload["name"] == "read_group"
assert payload["implemented"] is True
async def test_ui_catalog_read_group_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
catalog = await client.get("/ui/api/catalog", params={"major": 9})
assert catalog.status_code == 200
methods = {
(path["path"], method["name"]): method["implemented"]
for category in catalog.json()["categories"]
for path in category["paths"]
for method in path["methods"]
}
assert methods[("/access/groups/{groupid}", "read_group")] is True
async def test_demo_api_requires_database() -> None:
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
state = await client.get("/ui/api/demo/state")
load = await client.post("/ui/api/demo/load")
assert state.status_code == 503
assert load.status_code == 503
async def test_ui_versions_and_catalog_endpoints() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
versions = await client.get("/ui/api/versions")
assert versions.status_code == 200
assert {item["major"] for item in versions.json()["majors"]} == {6, 7, 8, 9}
catalog = await client.get("/ui/api/catalog", params={"major": 9})
assert catalog.status_code == 200
assert catalog.json()["source_version"] == "9.2.3"
method = await client.get(
"/ui/api/method",
params={"major": 9, "path": "/version", "verb": "GET"},
)
assert method.status_code == 200
assert method.json()["path"] == "/version"