Initial commit: VMware vSphere API simulator scaffold.

Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Test package."""
+1
View File
@@ -0,0 +1 @@
"""External client compatibility tests."""
@@ -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)
+283
View File
@@ -0,0 +1,283 @@
"""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.pve_stub
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"]
+195
View File
@@ -0,0 +1,195 @@
"""Unmodified proxmoxer HTTPS smoke flow."""
import os
from threading import Event
from typing import Any, cast
import pytest
from proxmoxer import ProxmoxAPI, ResourceException # type: ignore[import-untyped]
pytestmark = pytest.mark.pve_stub
pytestmark = [
pytest.mark.compatibility,
pytest.mark.skipif(not os.getenv("PROXMOXER_HOST"), reason="running TLS simulator required"),
]
def wait_task(proxmox: Any, upid: str) -> dict[str, object]:
for _attempt in range(100):
task = proxmox.nodes("pve1").tasks(upid).status.get()
if task["status"] == "stopped":
return cast(dict[str, object], task)
Event().wait(0.05)
raise AssertionError("task did not finish")
def test_proxmoxer_read_and_qemu_task_flow() -> None:
proxmox = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="root@pam",
password=os.getenv("PROXMOXER_PASSWORD", "secret"),
verify_ssl=False,
)
assert proxmox.version.get()["version"] == "9.2.3"
assert any(node["node"] == "pve1" for node in proxmox.nodes.get())
assert any(vm["vmid"] == 101 for vm in proxmox.nodes("pve1").qemu.get())
token_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="root@pam",
token_name=os.getenv("PROXMOXER_TOKEN_NAME", "automation"),
token_value=os.getenv("PROXMOXER_TOKEN_SECRET", "automation-secret"),
verify_ssl=False,
)
assert any(node["node"] == "pve1" for node in token_api.nodes.get())
readonly_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="auditor@pve",
token_name=os.getenv("PROXMOXER_READONLY_TOKEN_NAME", "readonly"),
token_value=os.getenv("PROXMOXER_READONLY_TOKEN_SECRET", "readonly-secret"),
verify_ssl=False,
)
assert readonly_api.nodes.get()
assert readonly_api.nodes("pve1").status.get()["status"] == "online"
assert readonly_api.nodes("pve1").qemu("101").config.get()["vmid"] == 101
with pytest.raises(ResourceException) as denied:
readonly_api.nodes("pve1").qemu("101").status.start.post()
assert denied.value.status_code == 403
token_endpoint = proxmox.access.users("root@pam").token("ephemeral")
created = token_endpoint.post(comment="compatibility lifecycle", privsep=0)
assert created["full-tokenid"] == "root@pam!ephemeral"
ephemeral = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="root@pam",
token_name=os.getenv("PROXMOXER_EPHEMERAL_TOKEN_NAME", "ephemeral"),
token_value=created["value"],
verify_ssl=False,
)
assert ephemeral.nodes.get()
updated = token_endpoint.put(comment="updated", privsep=0)
assert updated["comment"] == "updated"
assert token_endpoint.get()["comment"] == "updated"
token_endpoint.delete()
with pytest.raises(ResourceException) as removed:
ephemeral.nodes.get()
assert removed.value.status_code == 401
storage_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="storage@pve",
token_name=os.getenv("PROXMOXER_STORAGE_TOKEN_NAME", "storage"),
token_value=os.getenv("PROXMOXER_STORAGE_TOKEN_SECRET", "storage-secret"),
verify_ssl=False,
)
for vmid in ("101", "999999"):
with pytest.raises(ResourceException) as hidden:
storage_api.nodes("pve1").qemu(vmid).config.get()
assert hidden.value.status_code == 403
create_upid = proxmox.nodes("pve1").qemu.post(
vmid=150,
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:
proxmox.nodes("pve1").qemu.post(vmid=150, name="duplicate")
assert duplicate_create.value.status_code == 409
assert wait_task(proxmox, create_upid)["exitstatus"] == "OK"
created_config = proxmox.nodes("pve1").qemu("150").config.get()
assert created_config["name"] == "created-by-proxmoxer"
assert created_config["cores"] == 2
assert proxmox.nodes("pve1").qemu("150").config.put(name="sync-update", cores=3) is None
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "sync-update"
update_upid = proxmox.nodes("pve1").qemu("150").config.post(name="async-update", memory=2048)
assert wait_task(proxmox, update_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
disk_api = proxmox.nodes("pve1").qemu("150")
assert disk_api.resize.put(disk="scsi0", size="+2G") is None
assert "size=10G" in disk_api.config.get()["scsi0"]
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")
assert wait_task(proxmox, snapshot_upid)["exitstatus"] == "OK"
assert any(item["name"] == "baseline" for item in snapshots.get())
baseline = snapshots("baseline")
assert baseline.get()["description"] == "before change"
assert baseline.config.put(description="stable baseline") is None
assert baseline.config.get()["description"] == "stable baseline"
assert proxmox.nodes("pve1").qemu("150").config.put(name="after-snapshot") is None
rollback_upid = baseline.rollback.post()
assert wait_task(proxmox, rollback_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
snapshot_delete_upid = baseline.delete()
assert wait_task(proxmox, snapshot_delete_upid)["exitstatus"] == "OK"
assert not snapshots.get()
clone_upid = (
proxmox.nodes("pve1").qemu("150").clone.post(newid=151, name="clone-by-proxmoxer", full=1)
)
assert wait_task(proxmox, clone_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve1").qemu("151").config.get()["name"] == "clone-by-proxmoxer"
migration = proxmox.nodes("pve1").qemu("151").migrate
assert migration.get(target="pve2")["local_disks"] == []
migrate_upid = migration.post(target="pve2", online=0)
assert wait_task(proxmox, migrate_upid)["exitstatus"] == "OK"
assert proxmox.nodes("pve2").qemu("151").config.get()["name"] == "clone-by-proxmoxer"
clone_delete_upid = proxmox.nodes("pve2").qemu("151").delete()
assert wait_task(proxmox, clone_delete_upid)["exitstatus"] == "OK"
delete_upid = proxmox.nodes("pve1").qemu("150").delete()
assert wait_task(proxmox, delete_upid)["exitstatus"] == "OK"
with pytest.raises(ResourceException) as deleted_vm:
proxmox.nodes("pve1").qemu("150").config.get()
assert deleted_vm.value.status_code == 404
if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
operator_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
port=int(os.getenv("PROXMOXER_PORT", "8007")),
user="operator@pve",
token_name=os.getenv("PROXMOXER_OPERATOR_TOKEN_NAME", "operator"),
token_value=os.getenv("PROXMOXER_OPERATOR_TOKEN_SECRET", "operator-secret"),
verify_ssl=False,
)
status_resource = operator_api.nodes("pve1").qemu("101").status
def run(operation: str, expected: str) -> None:
upid = status_resource(operation).post()
assert wait_task(operator_api, upid)["exitstatus"] == "OK"
assert status_resource.current.get()["status"] == expected
if status_resource.current.get()["status"] == "stopped":
run("start", "running")
run("reboot", "running")
run("reset", "running")
run("suspend", "paused")
run("resume", "running")
run("shutdown", "stopped")
run("start", "running")
run("stop", "stopped")
@@ -0,0 +1,73 @@
"""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
pytestmark = pytest.mark.pve_stub
_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")
@@ -0,0 +1,44 @@
"""Optional pyvmomi SmartConnect smoke (skipped when package uninstalled)."""
from __future__ import annotations
import os
import pytest
pytest.importorskip("pyVim")
pytest.importorskip("pyVmomi")
from pyVim.connect import Disconnect, SmartConnect # type: ignore[import-untyped]
from pyVmomi import vim # type: ignore[import-untyped]
pytestmark = pytest.mark.compatibility
def test_pyvmomi_inventory_and_power() -> None:
host = os.getenv("VSPHERE_HOST", "simulator")
port = int(os.getenv("VSPHERE_PORT", "8080"))
try:
si = SmartConnect(
host=host,
user="administrator@vsphere.local",
pwd="VMware1!",
port=port,
disableSslCertValidation=True,
)
except Exception as error:
pytest.skip(f"SmartConnect failed: {error}")
try:
content = si.RetrieveContent()
assert content.about.name
container = content.viewManager.CreateContainerView(
content.rootFolder, [vim.VirtualMachine], True
)
vms = list(container.view)
assert len(vms) >= 5
target = next((vm for vm in vms if vm.name == "app-01"), vms[0])
if target.runtime.powerState != vim.VirtualMachinePowerState.poweredOn:
task = target.PowerOn()
assert task is not None
finally:
Disconnect(si)
+48
View File
@@ -0,0 +1,48 @@
{
"info": {
"GET": {
"allowtoken": 1,
"description": "API version details, including some parts of the global datacenter config.",
"method": "GET",
"name": "version",
"parameters": {
"additionalProperties": 0
},
"permissions": {
"user": "all"
},
"returns": {
"properties": {
"console": {
"description": "The default console viewer to use.",
"enum": [
"applet",
"vv",
"html5",
"xtermjs"
],
"optional": 1,
"type": "string"
},
"release": {
"description": "The current Proxmox VE point release in `x.y` format.",
"type": "string"
},
"repoid": {
"description": "The short git revision from which this version was build.",
"pattern": "[0-9a-fA-F]{8,64}",
"type": "string"
},
"version": {
"description": "The full pve-manager package version of this node.",
"type": "string"
}
},
"type": "object"
}
}
},
"leaf": 1,
"path": "/version",
"text": "version"
}
@@ -0,0 +1,12 @@
{
"artifact_etag": "\"4144c0-655b144140900\"",
"artifact_last_modified": "Fri, 03 Jul 2026 09:08:20 GMT",
"artifact_sha256": "f2b77b57c71f3781a0993cc5062940ef31e0843fd9a6bcfdb4de4dd2001d6d9e",
"artifact_size": 4277440,
"artifact_url": "https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js",
"documentation_version": "9.2.3",
"fixture_json_pointer": "/5",
"fixture_sha256": "ad572969bbab259a10380ec11ac1c67f865e601be7c5aeec201fca368341c3fe",
"retrieved_at": "2026-07-12T23:08:59+03:00",
"viewer_url": "https://pve.proxmox.com/pve-docs/api-viewer/"
}
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL-backed integration tests."""
+130
View File
@@ -0,0 +1,130 @@
"""PostgreSQL migration acceptance checks."""
import os
import uuid
import asyncpg # type: ignore[import-untyped]
import pytest
from app.config import Settings
from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.db.repositories.resources import ResourceRepository
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"),
]
async def test_migration_is_repeatable_and_constraints_hold() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
assert await migrate(connection) == 0
node_id = uuid.uuid4()
await connection.execute(
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'online') ON CONFLICT DO NOTHING",
node_id,
f"test-{node_id}",
)
with pytest.raises(asyncpg.CheckViolationError):
async with connection.transaction():
await connection.execute(
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'invalid')",
uuid.uuid4(),
f"invalid-{node_id}",
)
finally:
await connection.close()
async def test_small_seed_is_idempotent() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
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 = 'pve01'") == 1
assert (
await connection.fetchval(
"""SELECT count(*) FROM resources
WHERE external_id IN ('100', '101', '200', 'local', 'local-lvm')"""
)
== 5
)
assert await connection.fetchval("SELECT count(*) FROM tasks WHERE status = 'success'") == 2
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 2
assert await connection.fetchval("SELECT count(*) FROM containers") == 1
assert await connection.fetchval("SELECT count(*) FROM storages") == 2
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
assert await connection.fetchval("SELECT count(*) FROM identity_groups") == 1
assert await connection.fetchval("SELECT count(*) FROM identity_group_members") == 1
assert await connection.fetchval("SELECT count(*) FROM group_acl_entries") == 1
assert await connection.fetchval("SELECT count(*) FROM roles") == 3
assert await connection.fetchval("SELECT count(*) FROM api_tokens") == 4
secrets = await connection.fetch("SELECT secret_hash FROM api_tokens")
assert all(str(row["secret_hash"]).startswith("scrypt$") for row in secrets)
assert all("-secret" not in str(row["secret_hash"]) for row in secrets)
finally:
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)
database = AsyncpgDatabase(Settings(database_url=url))
try:
await migrate(connection)
await apply_seed(connection, small_profile())
await database.connect()
assert await database.is_ready()
repository = ResourceRepository(database.pool)
resource = await repository.get(kind="qemu", external_id="101")
assert resource is not None
updated = await repository.update_state(
resource.id,
expected_version=resource.version,
state={**resource.state, "status": "running"},
)
assert updated.version == resource.version + 1
assert updated.state["status"] == "running"
with pytest.raises(ConflictError):
await repository.update_state(
resource.id,
expected_version=resource.version,
state=resource.state,
)
finally:
await database.close()
await connection.close()
+81
View File
@@ -0,0 +1,81 @@
"""Durable task concurrency and recovery tests."""
import asyncio
import os
import uuid
import asyncpg # type: ignore[import-untyped]
import pytest
from asyncpg import Pool
from app.db.migrations import migrate
from app.tasks.repository import TaskRepository
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
async def repository() -> tuple[Pool, TaskRepository]:
pool = await asyncpg.create_pool(os.environ["TEST_DATABASE_URL"], min_size=1, max_size=4)
async with pool.acquire() as connection:
await migrate(connection)
return pool, TaskRepository(pool)
async def test_two_worker_exclusion_idempotency_and_logs() -> None:
pool, tasks = await repository()
key = uuid.uuid4().hex
try:
created = await tasks.create(
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
task_type="test",
payload={"value": 1},
resource_key=f"vm:{key}",
idempotency_key=key,
)
repeated = await tasks.create(
upid=f"ignored-{key}", task_type="test", payload={}, idempotency_key=key
)
assert repeated.id == created.id
first, second = await asyncio.gather(
tasks.claim("worker-a", 30), tasks.claim("worker-b", 30)
)
claimed = first or second
assert claimed is not None
assert (first is None) != (second is None)
worker = "worker-a" if first is not None else "worker-b"
await tasks.append_log(claimed.id, "started")
await tasks.progress(claimed.id, worker, 50)
await tasks.finish(claimed.id, worker, status="success", result={"ok": True})
assert await tasks.logs(claimed.id) == ("started",)
finished = await tasks.get(claimed.id)
assert finished is not None
assert finished.status == "success"
finally:
await pool.close()
async def test_expired_lease_is_reclaimed_after_restart() -> None:
pool, tasks = await repository()
key = uuid.uuid4().hex
try:
created = await tasks.create(
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
task_type="test",
payload={},
)
assert await tasks.claim("dead-worker", 0) is not None
recovered = await tasks.claim("new-worker", 30)
assert recovered is not None
assert recovered.id == created.id
assert recovered.attempt == 2
await tasks.request_cancel(recovered.id)
cancelled = await tasks.get(recovered.id)
assert cancelled is not None
assert cancelled.cancel_requested
await tasks.finish(recovered.id, "new-worker", status="cancelled")
finally:
await pool.close()
+168
View File
@@ -0,0 +1,168 @@
"""Integration smoke for native vSphere REST + SOAP."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _session(client: AsyncClient) -> dict[str, str]:
login = await client.post(
"/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
assert login.status_code == 201
return {"vmware-api-session-id": login.json()}
async def test_rest_session_inventory_power_clone_tags(client: AsyncClient) -> None:
headers = await _session(client)
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
assert len(vms.json()) >= 5
vm_id = next(item["vm"] for item in vms.json() if item["name"] == "app-01")
power = await client.post(
f"/api/vcenter/vm/{vm_id}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 200
assert str(power.json().get("task") or "").startswith("task-")
snap = await client.post(
f"/api/vcenter/vm/{vm_id}/snapshots",
headers=headers,
json={"name": "pre-update", "description": "lab"},
)
assert snap.status_code == 200
assert "snapshot" in snap.json()
clone = await client.post(
f"/api/vcenter/vm/{vm_id}/clone",
headers=headers,
json={"name": "app-01-clone"},
)
assert clone.status_code == 200
assert clone.json()["vm"].startswith("vm-")
cat = await client.post(
"/api/cis/tagging/category",
headers=headers,
json={"name": "Owner-api-test", "associable_types": ["VirtualMachine"]},
)
assert cat.status_code == 200, cat.text
tag = await client.post(
"/api/cis/tagging/tag",
headers=headers,
json={"category_id": cat.json(), "name": "team-a"},
)
assert tag.status_code == 200
libs = await client.get("/api/content/library", headers=headers)
assert libs.status_code == 200
assert len(libs.json()) >= 1
versions = await client.get("/ui/api/versions")
assert versions.status_code == 200
assert versions.json()["plane"] == "vsphere-rest"
async def test_readonly_cannot_mutate(client: AsyncClient) -> None:
login = await client.post(
"/api/session",
auth=("readonly@vsphere.local", "VMware1!"),
)
assert login.status_code == 201
headers = {"vmware-api-session-id": login.json()}
session = await client.get("/api/session", headers=headers)
assert session.status_code == 200
assert session.content in (b"", b"null") or not session.text.strip()
assert "ReadOnly" in (session.headers.get("x-vmware-session-roles") or "")
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
assert len(vms.json()) >= 5
power = await client.post(
f"/api/vcenter/vm/{vms.json()[0]['vm']}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 403
async def test_soap_property_collector_and_wsdl(client: AsyncClient) -> None:
content = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body><RetrieveServiceContent xmlns="urn:vim25"><_this type="ServiceInstance">ServiceInstance</_this></RetrieveServiceContent></soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert content.status_code == 200
assert "SessionManager" in content.text
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200
cookie = login.headers.get("set-cookie") or ""
assert "vmware_soap_session" in cookie
updates = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<WaitForUpdatesEx xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<version></version>
</WaitForUpdatesEx>
</soapenv:Body>
</soapenv:Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie.split(";")[0]},
)
assert updates.status_code == 200
assert "WaitForUpdatesExResponse" in updates.text
assert "vm-101" in updates.text
wsdl = await client.get("/sdk/vimService.wsdl")
assert wsdl.status_code == 200
assert "VimService" in wsdl.text
@@ -0,0 +1,86 @@
"""Integration: seeded Automation API surface returns real DB-backed data."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="demo-cluster")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _session(client: AsyncClient) -> dict[str, str]:
login = await client.post("/api/session", auth=("administrator@vsphere.local", "VMware1!"))
assert login.status_code == 201, login.text
return {"vmware-api-session-id": login.json()}
async def test_demo_cluster_api_state_and_inventory(client: AsyncClient) -> None:
headers = await _session(client)
vm_list = await client.get("/api/vcenter/vm", headers=headers)
assert vm_list.status_code == 200
assert len(vm_list.json()) >= 1000
hosts = await client.get("/api/vcenter/host", headers=headers)
assert hosts.status_code == 200
assert len(hosts.json()) >= 20
ssh = await client.get("/api/appliance/access/ssh", headers=headers)
assert ssh.status_code == 200
assert ssh.json().get("enabled") is True
assert "stub" not in ssh.json()
supervisors = await client.get(
"/api/vcenter/namespace-management/supervisors/supervisor-1/summary",
headers=headers,
)
assert supervisors.status_code == 200
body = supervisors.json()
assert isinstance(body, dict)
assert "stub" not in body
assert body.get("status") == "ENABLED" or body.get("name") or body.get("id")
esx = await client.get("/api/esx/settings/clusters/domain-c21/software", headers=headers)
assert esx.status_code == 200
assert esx.json().get("status") == "COMPLIANT"
assert "domain-c21" in (esx.json().get("clusters") or [])
cdroms = await client.get("/api/vcenter/vm/vm-101/hardware/cdrom", headers=headers)
assert cdroms.status_code == 200
assert isinstance(cdroms.json(), list)
assert cdroms.json()[0]["cdrom"] == "3000"
libs = await client.get("/api/content/library", headers=headers)
assert libs.status_code == 200
assert len(libs.json()) >= 2
put = await client.put("/api/appliance/access/ssh", headers=headers, json={"enabled": False})
assert put.status_code in {200, 204}
if put.status_code == 200:
assert put.json().get("enabled") is False
ssh2 = await client.get("/api/appliance/access/ssh", headers=headers)
assert ssh2.json().get("enabled") is False
await client.put("/api/appliance/access/ssh", headers=headers, json={"enabled": True})
+450
View File
@@ -0,0 +1,450 @@
"""Full vSphere REST + SOAP + major-matrix surface tests."""
from __future__ import annotations
import os
import re
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major
from app.vsphere.rest.coverage import IMPLEMENTED, catalog_entries
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
_PATH_SUBS = {
"{vm}": "vm-101",
"{host}": "host-11",
"{datastore}": "datastore-31",
"{task}": "task-1",
"{snapshot}": "snapshot-missing",
"{category_id}": "cat-lab-1",
"{tag_id}": "tag-lab-1",
"{item_id}": "item-ubuntu",
"{library_id}": "lib-local-1",
"{session_id}": "session-lab-1",
"{folder}": "group-v23",
"{datacenter}": "datacenter-21",
"{cluster}": "domain-c21",
"{resource_pool}": "resgroup-22",
"{permission_id}": "1",
"{policy}": "policy-default",
"{supervisor}": "supervisor-1",
"{namespace}": "ns-lab-1",
"{provider}": "vsphere.local",
"{interface}": "nic0",
"{network}": "network-41",
"{cdrom}": "3000",
"{disk}": "2000",
"{nic}": "4000",
"{adapter}": "1000",
"{service}": "vsphere-ui",
"{domain}": "lab.local",
}
def _concrete(path: str) -> str:
out = path
for key, value in _PATH_SUBS.items():
out = out.replace(key, value)
return out
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _session(
client: AsyncClient, user: str = "administrator@vsphere.local"
) -> dict[str, str]:
login = await client.post("/api/session", auth=(user, "VMware1!"))
assert login.status_code == 201, login.text
sid = login.json()
assert isinstance(sid, str) and sid
return {"vmware-api-session-id": sid}
@pytest.mark.parametrize("major", sorted(VERSIONS))
async def test_ui_catalog_and_method_fields_for_every_major(
client: AsyncClient, major: int
) -> None:
catalog = await client.get("/ui/api/catalog", params={"major": major})
assert catalog.status_code == 200
body = catalog.json()
assert body["plane"] == "vsphere-rest"
assert body["method_count"] == len(catalog_entries_for_major(major))
# Spot-check a path with params on majors that include VM get.
method = await client.get(
"/ui/api/method",
params={"major": major, "path": "/api/vcenter/vm/{vm}", "verb": "GET"},
)
assert method.status_code == 200
payload = method.json()
if payload.get("implemented"):
assert any(f["name"] == "vm" for f in payload["path_fields"])
async def test_all_coverage_routes_no_server_error(client: AsyncClient) -> None:
headers = await _session(client)
failures: list[str] = []
for entry in catalog_entries():
verb = entry["verb"]
path = entry["path"]
if verb == "DELETE" and path == "/api/session":
continue
url = _concrete(path)
kwargs: dict = {"headers": headers}
if verb in {"POST", "PATCH", "PUT"}:
kwargs["headers"] = {**headers, "Content-Type": "application/json"}
if path.endswith("/power"):
url = f"{url}?action=start"
kwargs["json"] = {}
elif "tag-association" in path:
kwargs["json"] = {
"action": "list-attached-tags",
"tag_id": "x",
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
}
else:
kwargs["json"] = {}
response = await client.request(verb, url, **kwargs)
if response.status_code >= 500:
failures.append(f"{verb} {path} -> {response.status_code} {response.text[:160]}")
assert failures == [], "\n".join(failures)
async def test_rest_inventory_returns_seed_data(client: AsyncClient) -> None:
headers = await _session(client)
for path, min_count in (
("/api/vcenter/vm", 5),
("/api/vcenter/host", 3),
("/api/vcenter/datastore", 1),
("/api/vcenter/network", 1),
("/api/vcenter/datacenter", 1),
("/api/vcenter/cluster", 1),
("/api/vcenter/folder", 1),
):
response = await client.get(path, headers=headers)
assert response.status_code == 200, path
assert len(response.json()) >= min_count, path
async def test_legacy_rest_wrappers(client: AsyncClient) -> None:
headers = await _session(client)
for path in (
"/rest/vcenter/vm",
"/rest/vcenter/host",
"/rest/vcenter/datastore",
"/rest/vcenter/network",
"/rest/vcenter/datacenter",
"/rest/vcenter/cluster",
"/rest/appliance/system/version",
):
response = await client.get(path, headers=headers)
assert response.status_code == 200, path
body = response.json()
assert "value" in body
async def test_session_contracts(client: AsyncClient) -> None:
headers = await _session(client)
get_session = await client.get("/api/session", headers=headers)
assert get_session.status_code == 200
assert get_session.content in (b"", b"null") or not get_session.text.strip()
assert "Administrator" in (get_session.headers.get("x-vmware-session-roles") or "")
legacy = await client.post(
"/rest/com/vmware/cis/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
assert legacy.status_code in {200, 201}
assert legacy.json()["value"]
legacy_get = await client.get(
"/rest/com/vmware/cis/session",
headers={"vmware-api-session-id": legacy.json()["value"]},
)
assert legacy_get.status_code == 200
assert legacy_get.json()["value"]
async def test_authz_readonly_forbidden_on_power(client: AsyncClient) -> None:
headers = await _session(client, "readonly@vsphere.local")
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
vm = vms.json()[0]["vm"]
power = await client.post(
f"/api/vcenter/vm/{vm}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code == 403
async def test_appliance_version_public_and_health(client: AsyncClient) -> None:
version = await client.get("/api/appliance/system/version")
assert version.status_code == 200
assert version.json()["version"]
headers = await _session(client)
health = await client.get("/api/appliance/health/system", headers=headers)
assert health.status_code == 200
assert health.json()["status"] == "green"
networking = await client.get("/api/appliance/networking", headers=headers)
assert networking.status_code == 200
assert networking.json()["hostname"]
async def test_soap_login_service_content_and_inventory(client: AsyncClient) -> None:
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200
assert "LoginResponse" in login.text
cookie = (login.headers.get("set-cookie") or "").split(";")[0]
assert "vmware_soap_session" in cookie
content = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<RetrieveServiceContent xmlns="urn:vim25">
<_this type="ServiceInstance">ServiceInstance</_this>
</RetrieveServiceContent>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert content.status_code == 200
assert "propertyCollector" in content.text
assert "eventManager" in content.text
props = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<RetrieveProperties xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<specSet>
<propSet><type>Folder</type><pathSet>childEntity</pathSet><pathSet>name</pathSet></propSet>
<objectSet>
<obj type="Folder">group-d1</obj>
<selectSet xsi:type="TraversalSpec">
<type>Folder</type><path>childEntity</path>
</selectSet>
</objectSet>
</specSet>
</RetrieveProperties>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert props.status_code == 200
assert "datacenter-21" in props.text or "Datacenter" in props.text
events = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<QueryEvents xmlns="urn:vim25">
<_this type="EventManager">EventManager</_this>
</QueryEvents>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert events.status_code == 200
assert "QueryEventsResponse" in events.text
async def test_vm_lifecycle_and_power(client: AsyncClient) -> None:
headers = await _session(client)
created = await client.post(
"/api/vcenter/vm",
headers={**headers, "Content-Type": "application/json"},
json={
"name": "full-api-lifecycle",
"placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
"cpu_count": 1,
"memory_size_MiB": 512,
},
)
assert created.status_code in {200, 201}, created.text
vm = created.json()
if isinstance(vm, dict):
vm = vm.get("vm") or vm.get("value") or vm
assert isinstance(vm, str)
detail = await client.get(f"/api/vcenter/vm/{vm}", headers=headers)
assert detail.status_code == 200
power = await client.post(
f"/api/vcenter/vm/{vm}/power",
params={"action": "start"},
headers=headers,
)
assert power.status_code in {200, 204}, power.text
# powered-on VMs cannot be deleted — stop first (vSphere semantics)
stop = await client.post(
f"/api/vcenter/vm/{vm}/power",
params={"action": "stop"},
headers=headers,
)
assert stop.status_code in {200, 204}, stop.text
deleted = await client.delete(f"/api/vcenter/vm/{vm}", headers=headers)
assert deleted.status_code in {200, 204}, deleted.text
async def test_coverage_registry_matches_implemented_constant() -> None:
assert len(catalog_entries()) == len(IMPLEMENTED)
for verb, path in IMPLEMENTED:
assert re.match(r"^/(api|rest)/", path), path
assert verb in {"GET", "POST", "PUT", "PATCH", "DELETE"}
@pytest.mark.parametrize("major", sorted(VERSIONS))
async def test_major_matrix_all_verbs_no_server_error(client: AsyncClient, major: int) -> None:
"""Apply each catalog major and exercise every registered GET/POST/PATCH/DELETE."""
headers = await _session(client)
apply = await client.post("/ui/api/contract/apply", params={"major": major})
assert apply.status_code == 200, apply.text
failures: list[str] = []
order = {"GET": 0, "PUT": 1, "PATCH": 2, "POST": 3, "DELETE": 4}
entries = sorted(
catalog_entries_for_major(major),
key=lambda item: (order.get(item["verb"], 9), item["path"]),
)
for entry in entries:
verb = entry["verb"]
path = entry["path"]
if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
continue
url = _concrete(path)
kwargs: dict = {"headers": {**headers}}
if verb in {"POST", "PATCH", "PUT"}:
kwargs["headers"] = {**headers, "Content-Type": "application/json"}
if path.endswith("/power") and "/guest/" not in path:
url = f"{url}?action=start"
kwargs["json"] = {}
elif path.endswith("/guest/power"):
url = f"{url}?action=reboot"
kwargs["json"] = {}
elif path.endswith("/maintenance"):
url = f"{url}?action=enter"
kwargs["json"] = {}
elif path == "/api/vcenter/folder/{folder}":
url = f"{url}?action=rename"
kwargs["json"] = {"name": "renamed-by-matrix"}
elif path == "/api/content/local-library":
kwargs["json"] = {"create_spec": {"name": f"lib-m{major}-{os.urandom(3).hex()}"}}
elif path == "/api/cis/tagging/category":
kwargs["json"] = {
"create_spec": {
"name": f"cat-m{major}-{os.urandom(3).hex()}",
"cardinality": "MULTIPLE",
"associable_types": [],
}
}
elif path == "/api/cis/tagging/tag":
kwargs["json"] = {
"create_spec": {
"name": f"tag-m{major}-{os.urandom(3).hex()}",
"category_id": "missing-category",
}
}
elif "tag-association" in path:
kwargs["json"] = {
"action": "list-attached-tags",
"tag_id": "x",
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
}
elif path == "/api/vcenter/network/dvpg":
kwargs["json"] = {
"name": f"dvpg-m{major}-{os.urandom(2).hex()}",
"dvs": "dvs-51",
"vlan_id": 20,
}
elif path == "/api/content/library/item":
kwargs["json"] = {
"create_spec": {
"library_id": "lib-missing",
"name": f"item-m{major}-{os.urandom(2).hex()}",
"type": "ovf",
}
}
elif path == "/api/vcenter/authorization/permissions":
kwargs["json"] = {
"principal": "readonly@vsphere.local",
"role": "ReadOnly",
"entity": "datacenter-21",
}
elif path == "/api/vcenter/datastore/{datastore}/files":
kwargs["json"] = {
"path": f"/probe-m{major}-{os.urandom(2).hex()}.txt",
"size": 1,
"type": "FILE",
}
elif path.endswith("/hardware/cpu"):
kwargs["json"] = {"count": 2}
elif path.endswith("/hardware/memory"):
kwargs["json"] = {"size_MiB": 1024}
elif path.endswith("/hardware/disk"):
kwargs["json"] = {"type": "SCSI", "new_vmdk": {"capacity": 1024}}
elif path.endswith("/hardware/ethernet"):
kwargs["json"] = {
"type": "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
}
elif path.endswith("/snapshots") and verb == "POST":
kwargs["json"] = {"name": f"snap-m{major}-{os.urandom(2).hex()}"}
elif "/snapshots/" in path and verb == "POST":
kwargs["json"] = {"action": "revert"}
elif path.endswith("/clone"):
kwargs["json"] = {
"name": f"clone-m{major}-{os.urandom(2).hex()}",
"placement": {"folder": "group-v23", "host": "host-11"},
}
elif path.endswith("/relocate"):
kwargs["json"] = {"placement": {"host": "host-12"}}
elif path.endswith("/console/tickets"):
kwargs["json"] = {"type": "WEBMKS"}
elif path.endswith("/guest/customization"):
kwargs["json"] = {"name": {"name": f"guest-m{major}"}}
elif path == "/api/vcenter/vm/{vm}" and verb == "POST":
kwargs["json"] = {"action": "unregister"}
else:
kwargs["json"] = {"name": f"probe-{major}-{os.urandom(2).hex()}"}
if verb == "DELETE" and path.endswith("{vm}"):
url = "/api/vcenter/vm/vm-missing-matrix"
response = await client.request(verb, url, **kwargs)
if response.status_code >= 500:
failures.append(f"{verb} {path} -> {response.status_code} {response.text[:160]}")
assert failures == [], "\n".join(failures)
@@ -0,0 +1,135 @@
"""SOAP CreateVM / FindChild / guest filesystem REST parity."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _soap_login(client: AsyncClient) -> str:
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200, login.text
session = login.headers.get("vmware-api-session-id")
assert session
return session
async def test_soap_create_vm_and_find_child(client: AsyncClient) -> None:
session = await _soap_login(client)
headers = {
"Content-Type": "text/xml",
"vmware-api-session-id": session,
"Cookie": f'vmware_soap_session="{session}"',
}
create = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<CreateVM_Task xmlns="urn:vim25">
<_this type="Folder">group-v23</_this>
<config>
<name>soap-create-lab</name>
<guestId>otherGuest64</guestId>
<numCPUs>2</numCPUs>
<memoryMB>1024</memoryMB>
<files><vmPathName>[datastore1]</vmPathName></files>
</config>
<pool type="ResourcePool">resgroup-22</pool>
<host type="HostSystem">host-11</host>
</CreateVM_Task>
</soapenv:Body>
</soapenv:Envelope>""",
headers=headers,
)
assert create.status_code == 200, create.text
assert "CreateVM_TaskResponse" in create.text
assert "task-" in create.text
find = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<FindChild xmlns="urn:vim25">
<_this type="SearchIndex">SearchIndex</_this>
<entity type="Folder">group-v23</entity>
<name>soap-create-lab</name>
</FindChild>
</soapenv:Body>
</soapenv:Envelope>""",
headers=headers,
)
assert find.status_code == 200, find.text
assert "VirtualMachine" in find.text
async def test_rest_guest_filesystem_roundtrip(client: AsyncClient) -> None:
login = await client.post(
"/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
assert login.status_code in {200, 201}
headers = {"vmware-api-session-id": login.json()}
vms = await client.get("/api/vcenter/vm", headers=headers)
assert vms.status_code == 200
vm = next(item["vm"] for item in vms.json() if item["name"] == "web-01")
put = await client.put(
f"/api/vcenter/vm/{vm}/guest/filesystem",
params={"path": "/tmp/fs-test"},
headers=headers,
json={"content": "hello-lab"},
)
assert put.status_code == 204
get = await client.get(
f"/api/vcenter/vm/{vm}/guest/filesystem",
params={"path": "/tmp/fs-test"},
headers=headers,
)
assert get.status_code == 200
assert get.json()["content"] == "hello-lab"
listing = await client.get(
f"/api/vcenter/vm/{vm}/guest/filesystem/files",
params={"path": "/tmp"},
headers=headers,
)
assert listing.status_code == 200
assert any(item["path"] == "/tmp/fs-test" for item in listing.json())
@@ -0,0 +1,165 @@
"""SOAP PropertyCollector / TaskManager fidelity tests."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.seed import seed_vsphere_inventory
pytestmark = pytest.mark.integration
@pytest.fixture
async def client() -> AsyncClient:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
settings = Settings(
database_url=database_url, # type: ignore[arg-type]
contract_snapshot=None,
enable_pve_stub=False,
)
app = create_app(settings=settings, worker_factories=())
async with app.router.lifespan_context(app):
await seed_vsphere_inventory(app.state.database, force=True, profile="small")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http:
yield http
async def _soap_login(client: AsyncClient) -> str:
login = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<Login xmlns="urn:vim25">
<_this type="SessionManager">SessionManager</_this>
<userName>administrator@vsphere.local</userName>
<password>VMware1!</password>
</Login>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml"},
)
assert login.status_code == 200
assert "LoginResponse" in login.text
cookie = login.headers.get("set-cookie") or ""
assert "vmware_soap_session" in cookie
return cookie.split(";")[0]
async def test_folder_child_entity_and_path(client: AsyncClient) -> None:
cookie = await _soap_login(client)
props = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<RetrieveProperties xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<specSet>
<propSet><type>Folder</type><pathSet>childEntity</pathSet><pathSet>name</pathSet></propSet>
<objectSet><obj type="Folder">group-d1</obj></objectSet>
</specSet>
</RetrieveProperties>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert props.status_code == 200
assert "childEntity" in props.text
assert "datacenter-21" in props.text
path = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<FindByInventoryPath xmlns="urn:vim25">
<_this type="SearchIndex">SearchIndex</_this>
<inventoryPath>/Datacenters/Datacenter/vm/web-01</inventoryPath>
</FindByInventoryPath>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert path.status_code == 200
assert "vm-101" in path.text
async def test_wait_for_updates_version_and_power_task_id(client: AsyncClient) -> None:
cookie = await _soap_login(client)
first = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<WaitForUpdatesEx xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<version></version>
</WaitForUpdatesEx>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert first.status_code == 200
assert "<version>1</version>" in first.text
assert "enter" in first.text
second = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<WaitForUpdatesEx xmlns="urn:vim25">
<_this type="PropertyCollector">propertyCollector</_this>
<version>1</version>
</WaitForUpdatesEx>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert second.status_code == 200
assert "enter" not in second.text
power = await client.post(
"/sdk",
content="""<?xml version="1.0"?>
<Envelope xmlns="http://schemas.xmlsoap.org/soap/envelope/">
<Body>
<PowerOnVM_Task xmlns="urn:vim25">
<_this type="VirtualMachine">vm-104</_this>
</PowerOnVM_Task>
</Body>
</Envelope>""",
headers={"Content-Type": "text/xml", "Cookie": cookie},
)
assert power.status_code == 200
assert "task-" in power.text
assert "task-1<" not in power.text
about = await client.get("/sdk/about.do")
assert about.status_code == 200
assert "vCenter" in about.text
wsdl = await client.get("/sdk/vimService.wsdl")
assert "WaitForUpdatesEx" in wsdl.text
assert "CancelTask" in wsdl.text
async def test_legacy_rest_value_wrapper(client: AsyncClient) -> None:
login = await client.post(
"/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
)
headers = {"vmware-api-session-id": login.json()}
resp = await client.get("/rest/vcenter/vm", headers=headers, params={"limit": 2})
assert resp.status_code == 200
body = resp.json()
assert "value" in body
assert len(body["value"]) == 2
+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,
),
)
+247
View File
@@ -0,0 +1,247 @@
"""API-token lifecycle handler tests without external services."""
import json
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.access import register_access_handlers
class TokenPool:
def __init__(self) -> None:
self.token: dict[str, Any] | None = None
async def fetch(self, _query: str, _userid: str) -> list[dict[str, Any]]:
return [] if self.token is None else [{"token_id": "test", **self.token}]
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "INSERT INTO" in query:
self.token = {
"comment": arguments[3],
"privilege_separation": arguments[5],
"expire": arguments[4],
}
return self.token
if "UPDATE api_tokens" in query:
if self.token is None:
return None
self.token["comment"] = arguments[2]
self.token["privilege_separation"] = arguments[4]
return self.token
return self.token
async def fetchval(self, _query: str, _userid: str) -> bool:
return True
async def execute(self, _query: str, _userid: str, _tokenid: str) -> str:
if self.token is None:
return "DELETE 0"
self.token = None
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 | RealmPool) -> None:
self.pool = pool
def request(pool: TokenPool | RealmPool, principal: str = "root@pam") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(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 = principal
return result
def values(**items: object) -> dict[str, Any]:
return {"values": items, "provided": frozenset(items)}
async def test_token_lifecycle_returns_secret_once_and_persists_metadata() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = TokenPool()
http_request = request(pool)
create = registry.get("/access/users/{userid}/token/{tokenid}", "POST")
get = registry.get("/access/users/{userid}/token/{tokenid}", "GET")
update = registry.get("/access/users/{userid}/token/{tokenid}", "PUT")
delete = registry.get("/access/users/{userid}/token/{tokenid}", "DELETE")
list_tokens = registry.get("/access/users/{userid}/token", "GET")
assert create and get and update and delete and list_tokens
created = await create(
http_request,
values(userid="root@pam", tokenid="test", comment="first", privsep=True),
)
assert created["full-tokenid"] == "root@pam!test"
assert created["value"]
assert "value" not in await get(http_request, values(userid="root@pam", tokenid="test"))
assert await list_tokens(http_request, values(userid="root@pam"))
updated = await update(
http_request,
values(userid="root@pam", tokenid="test", comment="second", privsep=False),
)
assert updated["comment"] == "second"
await delete(http_request, values(userid="root@pam", tokenid="test"))
with pytest.raises(ApiError) as missing:
await get(http_request, values(userid="root@pam", tokenid="test"))
assert missing.value.status_code == 404
async def test_token_lifecycle_rejects_non_owner() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
handler = registry.get("/access/users/{userid}/token", "GET")
assert handler
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",
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
+61
View File
@@ -0,0 +1,61 @@
"""ACL propagation, token separation, and contract mapping tests."""
from app.contracts.model import Permissions
from app.security.acl import AclEntry, authorize, effective_privileges, requirement_from_contract
ENTRIES = (
AclEntry("alice@pve", "/vms", frozenset({"VM.Audit", "VM.PowerMgmt"})),
AclEntry("alice@pve", "/vms/200", frozenset({"VM.Config"}), propagate=False),
)
def test_acl_propagation_matrix() -> None:
assert effective_privileges("alice@pve", "/vms/100", ENTRIES) == frozenset(
{"VM.Audit", "VM.PowerMgmt"}
)
assert "VM.Config" in effective_privileges("alice@pve", "/vms/200", ENTRIES)
assert "VM.Config" not in effective_privileges("alice@pve", "/vms/200/snapshot", ENTRIES)
assert not effective_privileges("bob@pve", "/vms/100", ENTRIES)
def test_api_token_privileges_are_intersection_not_escalation() -> None:
assert authorize(
"alice@pve",
"/vms/100",
frozenset({"VM.Audit"}),
ENTRIES,
token_privileges=frozenset({"VM.Audit"}),
)
assert not authorize(
"alice@pve",
"/vms/100",
frozenset({"VM.PowerMgmt"}),
ENTRIES,
token_privileges=frozenset({"VM.Audit"}),
)
def test_contract_permission_maps_to_capability_requirement() -> None:
permissions = Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]})
requirement = requirement_from_contract(permissions, {"vmid": "100"})
assert requirement is not None
assert requirement.path == "/vms/100"
assert requirement.privileges == frozenset({"VM.PowerMgmt"})
any_permission = Permissions(
expression={
"check": ["perm", "/vms/{vmid}", ["VM.Config.CPU", "VM.Config.Memory"], "any", 1]
}
)
any_requirement = requirement_from_contract(any_permission, {"vmid": "100"})
assert any_requirement is not None
assert not any_requirement.require_all
assert authorize(
"alice@pve",
"/vms/100",
any_requirement.privileges,
(AclEntry("alice@pve", "/vms", frozenset({"VM.Config.CPU"})),),
require_all=any_requirement.require_all,
)
+106
View File
@@ -0,0 +1,106 @@
"""HTTP-boundary API-token and contract permission tests."""
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import _authenticate
from app.config import Settings
from app.contracts.model import Method, Permissions, Schema
from app.db.pool import AsyncpgDatabase
from app.security.auth import hash_secret
class FakePool:
def __init__(self, secret: str, token_privileges: list[str]) -> None:
self.secret_hash = hash_secret(secret, salt=b"boundary-token-v1")
self.token_privileges = token_privileges
async def fetchrow(self, _query: str, principal: str, token_id: str) -> dict[str, Any] | None:
if principal != "operator@pve" or token_id != "api":
return None
return {
"name": principal,
"secret_hash": self.secret_hash,
"privileges": self.token_privileges,
"privilege_separation": True,
}
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]:
return [
{
"path": "/vms",
"propagate": True,
"privileges": ["VM.Audit", "VM.PowerMgmt"],
"principal": principal,
}
]
class FakeDatabase:
def __init__(self, pool: FakePool) -> None:
self.pool = pool
def token_request(secret: str, token_privileges: list[str]) -> Request:
app = FastAPI()
app.state.settings = Settings()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(FakePool("valid", token_privileges)))
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/api2/json/nodes/pve1/qemu/101/status/start",
"headers": [(b"authorization", f"PVEAPIToken=operator@pve!api={secret}".encode())],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
def power_method() -> Method:
return Method(
verb="POST",
name="start",
returns=Schema(type="string"),
permissions=Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]}),
checksum="1" * 64,
)
async def test_api_token_skips_csrf_but_honors_separated_privileges() -> None:
allowed = token_request("valid", ["VM.PowerMgmt"])
await _authenticate(
allowed,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert allowed.state.principal == "operator@pve"
denied = token_request("valid", ["VM.Audit"])
with pytest.raises(ApiError) as error:
await _authenticate(
denied,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert error.value.status_code == 403
async def test_api_token_rejects_unknown_or_wrong_secret() -> None:
request = token_request("wrong", ["VM.PowerMgmt"])
with pytest.raises(ApiError) as error:
await _authenticate(
request,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert error.value.status_code == 401
+25
View File
@@ -0,0 +1,25 @@
"""Offline checks for the researched API Viewer sample."""
import hashlib
import json
from pathlib import Path
from typing import Any, cast
import pytest
pytestmark = pytest.mark.pve_stub
FIXTURES = Path(__file__).parents[1] / "fixtures" / "api-viewer"
def test_version_fixture_matches_provenance() -> None:
fixture_path = FIXTURES / "pve-9.2.3-version.json"
provenance_path = FIXTURES / "pve-9.2.3-version.provenance.json"
fixture_bytes = fixture_path.read_bytes()
fixture = cast(dict[str, Any], json.loads(fixture_bytes))
provenance = cast(dict[str, Any], json.loads(provenance_path.read_bytes()))
assert fixture["path"] == "/version"
assert fixture["info"]["GET"]["method"] == "GET"
assert hashlib.sha256(fixture_bytes).hexdigest() == provenance["fixture_sha256"]
+68
View File
@@ -0,0 +1,68 @@
"""Authentication, CSRF, token, and redaction matrices."""
import pytest
from starlette.responses import Response
from app.security.auth import (
AuthenticationError,
csrf_token,
hash_secret,
issue_ticket,
parse_api_token,
redact_secrets,
set_ticket_cookie,
verify_csrf,
verify_secret,
verify_ticket,
)
KEY = b"test-signing-key-with-at-least-32-bytes"
def test_password_and_token_hashes_do_not_store_plaintext() -> None:
encoded = hash_secret("correct horse", salt=b"0123456789abcdef")
assert "correct horse" not in encoded
assert verify_secret("correct horse", encoded)
assert not verify_secret("wrong", encoded)
assert not verify_secret("correct horse", "unknown$format")
def test_signed_ticket_expiry_and_csrf() -> None:
ticket = issue_ticket("root@pam", KEY, now=100, ttl=60)
assert verify_ticket(ticket, KEY, now=120).principal == "root@pam"
token = csrf_token(ticket, KEY)
assert verify_csrf(ticket, token, KEY)
assert not verify_csrf(ticket, token + "x", KEY)
with pytest.raises(AuthenticationError, match="expired"):
verify_ticket(ticket, KEY, now=161)
with pytest.raises(AuthenticationError, match="invalid"):
verify_ticket(ticket + "x", KEY, now=120)
def test_ticket_cookie_is_http_only_and_secure() -> None:
response = Response()
set_ticket_cookie(response, "ticket")
header = response.headers["set-cookie"]
assert "PVEAuthCookie=ticket" in header
assert "HttpOnly" in header
assert "Secure" in header
assert "SameSite=strict" in header
def test_api_token_parsing_and_log_redaction() -> None:
token = parse_api_token("PVEAPIToken=user@pve!automation=supersecret")
assert token.principal == "user@pve"
assert token.token_id == "automation"
assert token.secret == "supersecret"
redacted = redact_secrets(
"PVEAPIToken=user@pve!automation=supersecret password=hunter2 token=abc"
)
assert "supersecret" not in redacted
assert "hunter2" not in redacted
assert "token=abc" not in redacted
with pytest.raises(AuthenticationError):
parse_api_token("Bearer secret")
+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
+28
View File
@@ -0,0 +1,28 @@
"""Simulation clock behavior."""
import asyncio
from datetime import UTC, datetime
import pytest
from app.simulation.clock import AcceleratedClock, ManualClock
async def test_manual_clock_releases_sleep_only_after_advance() -> None:
clock = ManualClock(datetime(2026, 1, 1, tzinfo=UTC))
sleeper = asyncio.create_task(clock.sleep(10))
await asyncio.sleep(0)
assert not sleeper.done()
await clock.advance(9)
assert not sleeper.done()
await clock.advance(1)
await sleeper
assert await clock.now() == datetime(2026, 1, 1, 0, 0, 10, tzinfo=UTC)
def test_clocks_reject_invalid_configuration() -> None:
with pytest.raises(ValueError):
AcceleratedClock(0)
with pytest.raises(ValueError):
ManualClock(datetime(2026, 1, 1))
+141
View File
@@ -0,0 +1,141 @@
"""Mapping / ACME / cluster-config durable handlers."""
from __future__ import annotations
import json
from typing import Any, cast
from uuid import uuid4
import pytest
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
pytestmark = pytest.mark.pve_stub
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()
+156
View File
@@ -0,0 +1,156 @@
"""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,
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
pytestmark = pytest.mark.pve_stub
def snapshot() -> Snapshot:
methods = (
Method(
verb="GET",
name="version",
returns=Schema(type="object"),
checksum="1" * 64,
),
Method(
verb="POST",
name="update",
returns=Schema(type="null"),
checksum="2" * 64,
),
)
return Snapshot(
source_version="9.2.3",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=(PathContract(path="/nodes/{node}", methods=methods),),
path_count=1,
method_count=2,
)
def test_report_scores_levels_and_groups_independently() -> None:
report = build_report(
snapshot(),
implemented=frozenset({("/nodes/{node}", "GET")}),
observed=frozenset({("/nodes/{node}", "GET"), ("/nodes/{node}", "POST")}),
verified=frozenset({("/nodes/{node}", "GET")}),
)
data = report.as_json()
assert data["total_declared"] == 2
levels = data["levels"]
assert isinstance(levels, dict)
assert levels["implemented"]["score"] == 0.5
assert levels["observed"]["score"] == 1.0
assert data["groups"] == {"nodes": {"declared": 2, "implemented": 1, "verified": 1}}
assert "| implemented | 1 | 50.00% |" in report.as_markdown()
def test_report_rejects_unbound_evidence() -> None:
with pytest.raises(ValueError, match="undeclared"):
build_report(snapshot(), verified=frozenset({("/missing", "GET")}))
def test_all_thirteen_dimensions_have_independent_evidence_and_renderers() -> None:
method = frozenset({("/nodes/{node}", "GET")})
report = build_report(
snapshot(),
implemented=method,
dimensions={dimension: method for dimension in CompatibilityDimension},
)
payload = report.as_json()
dimensions = cast(dict[str, dict[str, object]], payload["dimensions"])
assert list(dimensions) == [dimension.value for dimension in CompatibilityDimension]
assert len(dimensions) == 13
assert all(item["count"] == 1 for item in dimensions.values())
assert payload["dimension_groups"]
classifications = cast(dict[str, list[str]], payload["classifications"])
assert classifications["fully_compatible"] == ["GET /nodes/{node}"]
assert not classifications["partially_compatible"]
assert "| permissions | 1 |" in report.as_markdown()
assert "<td>long_task_behavior</td><td>1</td>" in report.as_html()
assert report.canonical_json() == report.canonical_json()
def test_dimension_evidence_must_reference_declared_method() -> None:
with pytest.raises(ValueError, match="permissions evidence"):
build_report(
snapshot(),
dimensions={CompatibilityDimension.PERMISSIONS: frozenset({("/missing", "GET")})},
)
def test_evidence_manifest_requires_provenance_and_unique_methods() -> None:
manifest = EvidenceManifest.model_validate(
{
"profile": "pve-9.2",
"source_version": "9.2.3",
"records": [
{
"path": "/nodes/{node}",
"verb": "GET",
"dimensions": ["http_status", "json_structure"],
"sources": ["tests/compatibility/test_proxmoxer.py"],
}
],
}
)
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"]
+125
View File
@@ -0,0 +1,125 @@
"""Catalog-scoped compatibility payload tests (vSphere plane)."""
from datetime import UTC, datetime
from typing import cast
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.vsphere.contracts.matrix import VERSIONS
from app.web.compatibility_catalog import compatibility_payload
from tests.unit.test_health import FakeDatabase
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("8.0.2", "/api/vcenter/vm")
catalog_snapshot = _snapshot("7.0.3", "/api/vcenter/host")
runtime_report = build_report(
runtime_snapshot,
implemented=frozenset({("/api/vcenter/vm", "GET")}),
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/api/vcenter/vm", "GET")})},
)
payload = compatibility_payload(
catalog_snapshot,
7,
implemented_methods=frozenset({("/api/vcenter/host", "GET"), ("/api/vcenter/vm", "GET")}),
runtime_report=runtime_report,
runtime_version="8.0.2",
settings=None,
)
assert payload["catalog_version"] == "7.0.3"
assert payload["runtime_version"] == "8.0.2"
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("8.0.2", "/api/vcenter/vm")
runtime_report = build_report(
runtime_snapshot,
implemented=frozenset({("/api/vcenter/vm", "GET")}),
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/api/vcenter/vm", "GET")})},
)
payload = compatibility_payload(
runtime_snapshot,
9,
implemented_methods=frozenset({("/api/vcenter/vm", "GET")}),
runtime_report=runtime_report,
runtime_version="8.0.2",
settings=None,
)
assert payload["catalog_version"] == "8.0.2"
assert payload["evidence_scope"] == "full"
async def test_ui_compatibility_endpoint_follows_selected_major() -> None:
settings = Settings()
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()
assert body7["plane"] == "vsphere-rest"
assert body9["plane"] == "vsphere-rest"
assert body7["catalog_version"] == VERSIONS[7]["version"]
assert body9["catalog_version"] == VERSIONS[9]["version"]
assert body7["major"] == 7
assert body9["major"] == 9
assert body7["total_declared"] > 0
assert body9["total_declared"] > 0
# Catalog floor: older majors report a subset; major 9 covers the full registry.
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()
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["plane"] == "vsphere-rest"
assert body["catalog_version"] == VERSIONS[major]["version"]
implemented = body["levels"]["implemented"]["count"]
declared = body["total_declared"]
assert implemented > 0
assert implemented <= declared
if major == 9:
assert implemented == declared
+113
View File
@@ -0,0 +1,113 @@
"""Golden HTTP input/output compatibility checks."""
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from fastapi import Request
from httpx import ASGITransport, AsyncClient
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
from app.main import create_app
from app.security.auth import csrf_token, issue_ticket
from tests.unit.test_health import FakeDatabase
pytestmark = pytest.mark.pve_stub
async def client_for(tmp_path: Path) -> AsyncClient:
method = Method(
verb="POST",
name="update",
parameters=(
Parameter(name="node", definition=Schema(type="string")),
Parameter(name="count", definition=Schema(type="integer", minimum=1)),
Parameter(name="force", definition=Schema(type="boolean", optional=True)),
Parameter(name="scsi[n]", definition=Schema(type="string", optional=True)),
),
returns=Schema(type="null"),
checksum="1" * 64,
)
snapshot = Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=(PathContract(path="/nodes/{node}/test", methods=(method,)),),
path_count=1,
method_count=1,
)
path = tmp_path / "snapshot.json"
path.write_bytes(snapshot.canonical_bytes())
handlers = HandlerRegistry()
async def handler(_request: Request, inputs: dict[str, Any]) -> None:
assert inputs["values"]["count"] >= 1
if "scsi0" in inputs["values"]:
assert inputs["values"]["scsi0"] == "local:disk,size=8G"
return None
handlers.register("/nodes/{node}/test", "POST", handler)
app = create_app(
Settings(contract_snapshot=path, compatibility_evidence=None),
lambda _settings: FakeDatabase(True),
handlers,
worker_factories=(),
)
key = Settings().ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket("root@pam", key)
return AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
cookies={"PVEAuthCookie": ticket},
headers={"CSRFPreventionToken": csrf_token(ticket, key)},
)
async def test_json_input_and_null_envelope(tmp_path: Path) -> None:
async with await client_for(tmp_path) as client:
response = await client.post("/api2/json/nodes/pve/test", json={"count": 2, "force": True})
assert response.status_code == 200
assert response.json() == {"data": None}
async def test_form_input_and_validation_error_shape(tmp_path: Path) -> None:
async with await client_for(tmp_path) as client:
valid = await client.post(
"/api2/json/nodes/pve/test",
content="count=1&force=yes",
headers={"content-type": "application/x-www-form-urlencoded"},
)
invalid = await client.post("/api2/json/nodes/pve/test", json={"count": 0, "unknown": "x"})
assert valid.status_code == 200
assert invalid.status_code == 400
assert invalid.json() == {
"data": None,
"message": "parameter verification failed",
"errors": {
"count": "value must be at least 1",
"unknown": "property is not defined in schema",
},
}
async def test_non_object_json_is_rejected_without_fastapi_body(tmp_path: Path) -> None:
async with await client_for(tmp_path) as client:
response = await client.post("/api2/json/nodes/pve/test", json=[1, 2])
assert response.status_code == 400
assert response.json()["errors"] == {"body": "expected an object"}
assert "detail" not in response.json()
async def test_indexed_contract_parameter_accepts_concrete_device(tmp_path: Path) -> None:
async with await client_for(tmp_path) as client:
response = await client.post(
"/api2/json/nodes/pve/test", json={"count": 1, "scsi0": "local:disk,size=8G"}
)
assert response.status_code == 200
+109
View File
@@ -0,0 +1,109 @@
"""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}
series = {item["series"] for item in majors_list}
assert majors == {6, 7, 8, 9}
assert series == {
"vSphere 7.0",
"vSphere 7.0 U3",
"vSphere 8.0",
"vSphere 8.0 U2",
}
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"])
release = next(item for item in majors_list if item["major"] == 9)
assert release["series"] == "vSphere 8.0 U2"
assert release["artifact_url"] == "stub://vmware/vsphere-8.0u2/api-contract"
assert release["bundled"] is True
def test_list_majors_honors_settings_overrides() -> None:
settings = Settings(catalog_artifact_url_9="https://example.test/vsphere/apidoc.js")
payload = list_majors(runtime_version=None, settings=settings)
majors_list = cast(list[dict[str, Any]], payload["majors"])
release = next(item for item in majors_list if item["major"] == 9)
assert release["artifact_url"] == "https://example.test/vsphere/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 payload["series"] == "vSphere 8.0 U2"
assert cast(str, payload["artifact_url"]).endswith("vsphere-8.0u2/api-contract")
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"
+54
View File
@@ -0,0 +1,54 @@
"""Offline command workflows for contract management."""
import argparse
import json
from pathlib import Path
import pytest
from app.contracts.cli import parser, run
pytestmark = pytest.mark.pve_stub
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
async def test_validate_command_reports_source_counts(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
arguments = argparse.Namespace(command="validate", store=tmp_path, file=FIXTURE)
assert await run(arguments) == 0
output = capsys.readouterr().out
assert json.loads(output) == {"nodes": 1, "warnings": 0}
async def test_local_import_list_and_show(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
import_arguments = argparse.Namespace(
command="import",
store=tmp_path,
file=FIXTURE,
url=None,
version="9.2.3",
)
assert await run(import_arguments) == 0
revision = Path(capsys.readouterr().out.strip()).name
assert await run(argparse.Namespace(command="list", store=tmp_path)) == 0
assert capsys.readouterr().out.strip() == revision
assert await run(argparse.Namespace(command="show", store=tmp_path, revision=revision)) == 0
manifest = json.loads(capsys.readouterr().out)
assert manifest["source_version"] == "9.2.3"
assert manifest["snapshot_sha256"] == revision
def test_cli_parser_accepts_local_import() -> None:
arguments = parser().parse_args(
["--store", "saved", "import", "--file", str(FIXTURE), "--version", "9.2.3"]
)
assert arguments.command == "import"
assert arguments.store == Path("saved")
+87
View File
@@ -0,0 +1,87 @@
"""Semantic contract diff classification and rendering tests."""
import json
from datetime import UTC, datetime
from app.contracts.diff import (
Severity,
compare_snapshots,
has_breaking_changes,
render_html,
render_json,
render_markdown,
render_text,
)
from app.contracts.model import Method, PathContract, Schema, Snapshot
def snapshot(paths: tuple[PathContract, ...]) -> Snapshot:
return Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=paths,
path_count=len(paths),
method_count=sum(len(path.methods) for path in paths),
)
def method(description: str = "old", returns: Schema | None = None) -> Method:
return Method(
verb="GET",
name="read",
description=description,
returns=returns or Schema(type="string"),
checksum="1" * 64,
)
def test_classifies_added_removed_and_changed_contracts() -> None:
before = snapshot(
(
PathContract(path="/removed", methods=(method(),)),
PathContract(path="/version", methods=(method(),)),
)
)
after = snapshot(
(
PathContract(path="/added", methods=(method(),)),
PathContract(
path="/version",
methods=(method("new", Schema(type="integer", minimum=1)),),
),
)
)
changes = compare_snapshots(before, after)
assert changes == tuple(sorted(changes))
assert {change.category for change in changes} >= {
"path",
"method",
"documentation",
"schema",
"constraint",
}
assert has_breaking_changes(changes)
assert any(change.severity is Severity.NON_BREAKING for change in changes)
def test_renderers_are_stable_and_escape_html() -> None:
before = snapshot((PathContract(path="/<old>", methods=(method(),)),))
after = snapshot(())
changes = compare_snapshots(before, after)
assert render_text(changes).startswith("breaking:")
assert "| breaking |" in render_markdown(changes)
assert "&lt;old&gt;" in render_html(changes)
decoded = json.loads(render_json(changes))
assert decoded[0]["severity"] == "breaking"
assert render_json(changes) == render_json(changes)
def test_no_changes_has_clean_ci_policy() -> None:
value = snapshot((PathContract(path="/version", methods=(method(),)),))
assert compare_snapshots(value, value) == ()
assert not has_breaking_changes(())
+107
View File
@@ -0,0 +1,107 @@
"""Security and idempotency tests for contract imports."""
from datetime import UTC, datetime
from pathlib import Path
import httpx
import pytest
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
pytestmark = pytest.mark.pve_stub
async def public_resolver(_host: str) -> tuple[str, ...]:
return ("93.184.216.34",)
@pytest.mark.parametrize(
"url",
[
"http://pve.proxmox.com/apidoc.js",
"https://evil.example/apidoc.js",
"https://pve.proxmox.com.evil.example/apidoc.js",
"https://user@pve.proxmox.com/apidoc.js",
"https://pve.proxmox.com:444/apidoc.js",
],
)
def test_remote_url_policy_rejects_unsafe_urls(url: str) -> None:
with pytest.raises(SourceError):
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",)
importer = RemoteSourceImporter(
"https://pve.proxmox.com/apidoc.js",
resolver=private_resolver,
transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"[]")),
)
with pytest.raises(SourceError, match="non-public"):
await importer.load()
async def test_redirect_is_revalidated() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(302, headers={"location": "https://evil.example/private"})
importer = RemoteSourceImporter(
"https://pve.proxmox.com/apidoc.js",
resolver=public_resolver,
transport=httpx.MockTransport(handler),
)
with pytest.raises(SourceError, match="allowlist"):
await importer.load()
async def test_remote_import_enforces_size_limit() -> None:
importer = RemoteSourceImporter(
"https://pve.proxmox.com/apidoc.js",
max_bytes=2,
resolver=public_resolver,
transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"[]\n")),
)
with pytest.raises(SourceError, match="size"):
await importer.load()
def test_revision_store_is_idempotent(tmp_path: Path) -> None:
raw = b'[{"path":"/version","info":{}}]'
parsed = ApiViewerParser().parse(raw)
snapshot, manifest = normalize_snapshot(
parsed,
raw=raw,
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
)
store = RevisionStore(tmp_path)
first = store.save(raw, snapshot, manifest)
second = store.save(raw, snapshot, manifest)
assert first == second
assert store.list() == (manifest.snapshot_sha256,)
assert store.manifest(manifest.snapshot_sha256) == manifest
+94
View File
@@ -0,0 +1,94 @@
"""Determinism and validation checks for normalized contracts."""
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from hypothesis import given
from hypothesis import strategies as st
from pydantic import ValidationError
from app.contracts.model import Snapshot, canonical_json
from app.contracts.normalize import normalize_snapshot
from app.contracts.source import ApiViewerParser
pytestmark = pytest.mark.pve_stub
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
RETRIEVED_AT = datetime(2026, 7, 12, 20, 8, 59, tzinfo=UTC)
def make_snapshot() -> Snapshot:
raw = FIXTURE.read_bytes()
parsed = ApiViewerParser().parse(raw)
snapshot, _ = normalize_snapshot(
parsed, raw=raw, source_version="9.2.3", retrieved_at=RETRIEVED_AT
)
return snapshot
def test_normalization_is_deterministic_and_round_trips() -> None:
first = make_snapshot()
second = make_snapshot()
assert first.canonical_bytes() == second.canonical_bytes()
assert first.checksum() == second.checksum()
assert Snapshot.model_validate_json(first.canonical_bytes()) == first
assert first.paths[0].methods[0].checksum == second.paths[0].methods[0].checksum
def test_snapshot_validates_declared_counts() -> None:
data = make_snapshot().model_dump(mode="json")
data["method_count"] = 99
with pytest.raises(ValidationError, match="method_count"):
Snapshot.model_validate(data)
def test_unknown_schema_fields_are_retained() -> None:
raw = json.dumps(
[
{
"path": "/future",
"info": {
"GET": {
"name": "future",
"returns": {"type": "string", "futureKeyword": {"x": 1}},
}
},
}
]
).encode()
snapshot, _ = normalize_snapshot(
ApiViewerParser().parse(raw),
raw=raw,
source_version="test",
retrieved_at=RETRIEVED_AT,
)
assert snapshot.paths[0].methods[0].returns.extra["futureKeyword"] == {"x": 1}
def test_nullable_source_collections_normalize_as_empty() -> None:
raw = (
b'[{"path":"/nullable","info":{"GET":{"parameters":{"properties":null},'
b'"returns":{"type":"string","enum":null}}}}]'
)
snapshot, _ = normalize_snapshot(
ApiViewerParser().parse(raw),
raw=raw,
source_version="test",
retrieved_at=RETRIEVED_AT,
)
method = snapshot.paths[0].methods[0]
assert method.parameters == ()
assert method.returns.enum == ()
@given(st.dictionaries(st.text(min_size=1), st.integers(), max_size=10))
def test_canonical_json_is_independent_of_mapping_order(values: dict[str, int]) -> None:
reversed_values = dict(reversed(tuple(values.items())))
assert canonical_json(values) == canonical_json(reversed_values)
+97
View File
@@ -0,0 +1,97 @@
"""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
pytestmark = pytest.mark.pve_stub
_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
+67
View File
@@ -0,0 +1,67 @@
"""Tests for safe API Viewer source parsing."""
import json
from pathlib import Path
import pytest
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceError
pytestmark = pytest.mark.pve_stub
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
def test_parse_saved_json_fixture() -> None:
parsed = ApiViewerParser().parse(FIXTURE.read_bytes())
assert parsed.nodes[0]["path"] == "/version"
assert parsed.warnings == ()
def test_extract_api_schema_without_executing_trailing_javascript() -> None:
raw = b'const apiSchema = [{"path":"/x]y","leaf":1}]; throw new Error("no");'
parsed = ApiViewerParser().parse(raw)
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",
[
(b"", "empty"),
(b"const other = [];", "not found"),
(b"const apiSchema = [", "truncated"),
(b"const apiSchema = [}];", "invalid"),
(b"42", "not found"),
],
)
def test_reject_malformed_sources(raw: bytes, message: str) -> None:
with pytest.raises(SourceError, match=message):
ApiViewerParser().parse(raw)
def test_preserve_unknown_fields_and_warn() -> None:
raw = json.dumps([{"path": "/version", "future": {"enabled": True}}]).encode()
parsed = ApiViewerParser().parse(raw)
assert parsed.nodes[0]["future"] == {"enabled": True}
assert parsed.warnings[0].code == "unknown-node-field"
assert parsed.warnings[0].path == "/0/future"
async def test_local_file_importer(tmp_path: Path) -> None:
artifact = tmp_path / "api.json"
artifact.write_bytes(b"[]")
assert await LocalFileImporter(artifact).load() == b"[]"
+204
View File
@@ -0,0 +1,204 @@
"""First vertical read/login handler tests."""
import uuid
from datetime import UTC, datetime
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
from app.main import create_app
from app.security.auth import hash_secret
from app.tasks.repository import Task
pytestmark = pytest.mark.pve_stub
class FakePool:
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
if "principals" in sql and args[0] == "root@pam":
return {
"name": "root@pam",
"password_hash": hash_secret("secret", salt=b"pve-simulator-v1"),
}
if "FROM nodes" in sql and args[0] == "pve1":
return {"name": "pve1", "status": "online"}
if "FROM resources r" in sql and args == ("pve1", "100"):
if "SELECT r.id" in sql:
return {
"id": uuid.UUID("00000000-0000-0000-0000-000000000100"),
"state": '{"name":"demo","status":"stopped"}',
}
return {
"config": '{"name":"demo"}',
"state": '{"name":"demo","status":"stopped"}',
}
return None
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql:
return [{"node": "pve1", "status": "online"}]
if "r.kind='qemu'" in sql:
return [{"vmid": 100, "state": '{"name":"demo","status":"stopped"}'}]
return [
{
"type": "qemu",
"external_id": "100",
"state": '{"status":"stopped"}',
"node": "pve1",
}
]
async def fetchval(self, sql: str) -> int:
return 100 if "pg_backend_pid" in sql else 1_700_000_000
class FakeDatabase:
pool = FakePool()
async def connect(self) -> None:
pass
async def close(self) -> None:
pass
async def is_ready(self) -> bool:
return True
def method(verb: str, name: str, parameters: tuple[Parameter, ...] = ()) -> Method:
return Method(
verb=verb,
name=name,
parameters=parameters,
returns=Schema(type="object"),
checksum=(name[0] * 64),
)
def write_snapshot(path: Path) -> None:
string = Schema(type="string")
paths = (
PathContract(path="/version", methods=(method("GET", "version"),)),
PathContract(
path="/access/ticket",
methods=(
method(
"POST",
"ticket",
(
Parameter(name="username", definition=string),
Parameter(name="password", definition=string),
),
),
),
),
PathContract(path="/nodes", methods=(method("GET", "nodes"),)),
PathContract(
path="/nodes/{node}/status",
methods=(method("GET", "status", (Parameter(name="node", definition=string),)),),
),
PathContract(path="/cluster/resources", methods=(method("GET", "resources"),)),
PathContract(
path="/nodes/{node}/qemu",
methods=(method("GET", "qemu", (Parameter(name="node", definition=string),)),),
),
PathContract(
path="/nodes/{node}/qemu/{vmid}/config",
methods=(
method(
"GET",
"config",
(
Parameter(name="node", definition=string),
Parameter(name="vmid", definition=Schema(type="integer")),
),
),
),
),
PathContract(
path="/nodes/{node}/qemu/{vmid}/status/start",
methods=(
method(
"POST",
"start",
(
Parameter(name="node", definition=string),
Parameter(name="vmid", definition=Schema(type="integer")),
),
),
),
),
)
snapshot = Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=paths,
path_count=len(paths),
method_count=sum(len(item.methods) for item in paths),
)
path.write_bytes(snapshot.canonical_bytes())
async def test_core_login_and_read_endpoints(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: object) -> Task:
return Task(
uuid.uuid4(),
str(kwargs["upid"]),
str(kwargs["task_type"]),
"queued",
{},
0,
False,
0,
)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
snapshot_path = tmp_path / "snapshot.json"
write_snapshot(snapshot_path)
database = FakeDatabase()
app = create_app(
Settings(contract_snapshot=snapshot_path, compatibility_evidence=None),
lambda _settings: database,
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
login = await client.post(
"/api2/json/access/ticket",
content="username=root%40pam&password=secret",
headers={"content-type": "application/x-www-form-urlencoded"},
)
csrf = login.json()["data"]["CSRFPreventionToken"]
version = await client.get("/api2/json/version")
nodes = await client.get("/api2/json/nodes")
status = await client.get("/api2/json/nodes/pve1/status")
resources = await client.get("/api2/json/cluster/resources")
qemu = await client.get("/api2/json/nodes/pve1/qemu")
config = await client.get("/api2/json/nodes/pve1/qemu/100/config")
start = await client.post(
"/api2/json/nodes/pve1/qemu/100/status/start",
headers={"CSRFPreventionToken": csrf},
)
assert login.status_code == 200
assert login.json()["data"]["username"] == "root@pam"
assert "ticket" in login.json()["data"]
assert version.json()["data"]["version"] == "test"
assert version.json()["data"]["release"] == "test"
assert nodes.json()["data"][0]["node"] == "pve1"
assert status.json()["data"]["status"] == "online"
assert resources.json()["data"][0]["type"] == "qemu"
assert qemu.json()["data"][0]["vmid"] == 100
assert config.json()["data"]["name"] == "demo"
assert start.json()["data"].startswith("UPID:pve1:")
+59
View File
@@ -0,0 +1,59 @@
"""Database primitive behavior independent of PostgreSQL."""
import asyncpg # type: ignore[import-untyped]
import pytest
from app.db.primitives import (
ConflictError,
DatabaseOperationError,
ReferenceError,
RetryPolicy,
TransientDatabaseError,
map_database_error,
require_affected,
retry_transient,
)
def test_error_mapping_is_stable_and_safe() -> None:
assert isinstance(map_database_error(asyncpg.UniqueViolationError("secret")), ConflictError)
assert isinstance(
map_database_error(asyncpg.ForeignKeyViolationError("secret")), ReferenceError
)
assert isinstance(
map_database_error(asyncpg.SerializationError("secret")), TransientDatabaseError
)
assert "secret" not in str(map_database_error(asyncpg.PostgresError("secret")))
def test_affected_row_checks() -> None:
require_affected("UPDATE 1")
with pytest.raises(DatabaseOperationError, match="expected 1"):
require_affected("UPDATE 0")
with pytest.raises(DatabaseOperationError, match="unrecognized"):
require_affected("BROKEN")
async def test_transient_retry_is_bounded() -> None:
calls = 0
async def operation() -> str:
nonlocal calls
calls += 1
if calls < 3:
raise TransientDatabaseError("retry")
return "ok"
assert await retry_transient(operation, RetryPolicy(attempts=3, base_delay_seconds=0)) == "ok"
assert calls == 3
async def test_transient_retry_propagates_final_failure() -> None:
async def operation() -> None:
raise TransientDatabaseError("retry")
with pytest.raises(TransientDatabaseError):
await retry_transient(operation, RetryPolicy(attempts=2, base_delay_seconds=0))
with pytest.raises(ValueError, match="positive"):
await retry_transient(operation, RetryPolicy(attempts=0))
+101
View File
@@ -0,0 +1,101 @@
"""Contract-driven route registry tests."""
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from fastapi import Request
from httpx import ASGITransport, AsyncClient
from pydantic import ValidationError
from app.api.registry import HandlerRegistry, RouteCollisionError
from app.config import Settings
from app.contracts.model import Method, PathContract, Schema, Snapshot
from app.main import create_app
from tests.unit.test_health import FakeDatabase
pytestmark = pytest.mark.pve_stub
def contract_snapshot(*methods: Method) -> Snapshot:
paths = (PathContract(path="/version", methods=methods),)
return Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=paths,
path_count=1,
method_count=len(methods),
)
def get_method() -> Method:
return Method(
verb="GET",
name="version",
returns=Schema(type="object", properties={"version": Schema(type="string")}),
checksum="1" * 64,
)
async def request_app(
tmp_path: Path, fallback: str, handlers: HandlerRegistry | None = None
) -> 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,
compatibility_evidence=None,
)
database = FakeDatabase(True)
app = create_app(
settings,
lambda _settings: database,
handlers if handlers is not None else HandlerRegistry(),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
json_response = await client.get("/api2/json/version")
extjs_response = await client.get("/api2/extjs/version")
return json_response.json(), extjs_response.json()
async def test_registered_handler_serves_both_renderers(tmp_path: Path) -> None:
handlers = HandlerRegistry()
async def version(_request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
return {"version": "9.2.3"}
handlers.register("/version", "GET", version)
json_body, extjs_body = await request_app(tmp_path, "error", handlers)
assert json_body == {"data": {"version": "9.2.3"}}
assert extjs_body == {"data": {"version": "9.2.3"}, "success": True}
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"] == "handler pending for this contract method"
assert default_body["data"]["version"] in {None, "example"}
def test_duplicate_snapshot_routes_are_rejected() -> None:
with pytest.raises(ValidationError, match="duplicate"):
contract_snapshot(get_method(), get_method())
def test_duplicate_semantic_handlers_are_rejected() -> None:
handlers = HandlerRegistry()
async def handler(_request: Request, _inputs: dict[str, Any]) -> None:
return None
handlers.register("/version", "GET", handler)
with pytest.raises(RouteCollisionError, match="duplicate"):
handlers.register("/version", "GET", handler)
+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"
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import asyncio
from typing import Self
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db.pool import Database
from app.main import create_app
class FakeDatabase:
def __init__(self, ready: bool) -> None:
self.ready = ready
self.connected = False
self.closed = False
async def connect(self) -> None:
self.connected = True
async def close(self) -> None:
self.closed = True
async def is_ready(self) -> bool:
return self.ready
async def __aenter__(self) -> Self:
await self.connect()
return self
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
await self.close()
@pytest.mark.parametrize(("database_ready", "status_code"), [(True, 200), (False, 503)])
async def test_health_endpoints(database_ready: bool, status_code: int) -> None:
database = FakeDatabase(database_ready)
def factory(settings: Settings) -> Database:
del settings
return database
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),
base_url="http://test",
) as client:
live = await client.get("/health/live")
ready = await client.get("/health/ready", headers={"X-Request-ID": "test-request"})
assert live.status_code == 200
assert live.json() == {"status": "ok"}
assert ready.status_code == status_code
assert ready.headers["X-Request-ID"] == "test-request"
assert database.connected
assert database.closed
async def test_lifespan_starts_and_stops_injected_workers() -> None:
database = FakeDatabase(True)
started = asyncio.Event()
stopping = asyncio.Event()
class Worker:
async def run(self) -> None:
started.set()
await stopping.wait()
def stop(self) -> None:
stopping.set()
application = create_app(
Settings(contract_snapshot=None, compatibility_evidence=None),
lambda _settings: database,
worker_factories=(lambda _database: Worker(),),
)
async with application.router.lifespan_context(application):
await started.wait()
assert stopping.is_set()
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import json
import logging
from app.logging import JsonFormatter
def test_json_formatter_emits_structured_fields() -> None:
record = logging.LogRecord("test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
record.request_id = "request-1"
payload = json.loads(JsonFormatter().format(record))
assert payload["message"] == "hello world"
assert payload["request_id"] == "request-1"
assert payload["level"] == "INFO"
+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"
+56
View File
@@ -0,0 +1,56 @@
"""Migration discovery and checksum tests."""
from pathlib import Path
from app.db.migrations import load_migrations
def test_load_migrations_is_ordered_and_checksummed(tmp_path: Path) -> None:
(tmp_path / "002_second.sql").write_text("SELECT 2;")
(tmp_path / "001_first.sql").write_text("SELECT 1;")
migrations = load_migrations(tmp_path)
assert [migration.version for migration in migrations] == [1, 2]
assert migrations[0].name == "001_first"
assert len(migrations[0].checksum) == 64
def test_repository_migration_defines_required_planes() -> None:
migrations = load_migrations()
migration = migrations[0]
for table in (
"contract_snapshots",
"nodes",
"resources",
"principals",
"acl_entries",
"tasks",
"scenarios",
"audit_events",
):
assert f"CREATE TABLE {table}" in migration.sql
assert "CREATE TABLE realms" in migrations[1].sql
assert "CREATE TABLE api_tokens" in migrations[1].sql
domain = migrations[3].sql
for table in (
"clusters",
"virtual_machines",
"containers",
"storages",
"storage_contents",
"snapshots",
"backups",
"pools",
"identity_groups",
"contract_paths",
"observed_contracts",
"scenario_rules",
"fault_injections",
):
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
+37
View File
@@ -0,0 +1,37 @@
"""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 "Simulator" in names
assert "vSphere REST" in names
assert "Nodes · QEMU" not in names
assert "API2 JSON" not in names
assert "Access" not in names
def test_openapi_tag_metadata_includes_pve_when_requested() -> None:
names = [entry["name"] for entry in openapi_tag_metadata(include_pve=True)]
assert names == sorted(names)
assert "Nodes · QEMU" in names
assert "API2 JSON" in names
assert "Access" in names
assert "Simulator" in names
+131
View File
@@ -0,0 +1,131 @@
"""Unit tests for SOAP PropertyCollector helpers."""
from __future__ import annotations
import os
import pytest
from app.vsphere.inventory import ManagedObject
from app.vsphere.soap.property_collector import (
_descendants,
_one_level_traverse,
_type_matches,
_wants_parent_traversal,
build_prop_map,
clear_pc_state,
resolve_inventory_path,
store_page_token,
take_page_token,
take_page_token_full,
view_moids_from_object,
wait_updates_xml,
)
def _obj(
moid: str,
type_name: str,
name: str,
parent: str | None,
props: dict | None = None,
) -> ManagedObject:
return ManagedObject(
moid=moid,
type=type_name,
name=name,
parent_moid=parent,
props=props or {},
)
def test_type_matches_and_parent_traversal() -> None:
assert _type_matches("VirtualMachine", {"ManagedEntity"})
assert _wants_parent_traversal("<path>parent</path>")
def test_descendants() -> None:
objects = [
_obj("group-d1", "Folder", "Datacenters", None),
_obj("datacenter-21", "Datacenter", "DC", "group-d1"),
_obj("group-v23", "Folder", "vm", "datacenter-21"),
_obj("vm-101", "VirtualMachine", "web-01", "group-v23"),
]
expanded = _descendants([objects[0]], objects)
assert {o.moid for o in expanded} >= {"group-d1", "datacenter-21", "group-v23", "vm-101"}
def test_view_moids_from_object_props() -> None:
view = _obj("view-1", "ContainerView", "view-1", None, {"view_moids": ["vm-101", "vm-102"]})
assert view_moids_from_object(view) == ["vm-101", "vm-102"]
props = build_prop_map(
view,
children=[],
all_by_moid={
"vm-101": _obj("vm-101", "VirtualMachine", "a", "group-v23"),
"vm-102": _obj("vm-102", "VirtualMachine", "b", "group-v23"),
},
)
assert "view" in props
@pytest.mark.integration
@pytest.mark.asyncio
async def test_page_token_roundtrip_db() -> None:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
from app.config import Settings
from app.db.pool import AsyncpgDatabase
database = AsyncpgDatabase(Settings(database_url=database_url)) # type: ignore[arg-type]
await database.connect()
try:
await clear_pc_state(database)
token = await store_page_token(database, ["vm-1", "vm-2"], path_sets=["name"])
assert await take_page_token_full(database, token) == (["vm-1", "vm-2"], ["name"])
token2 = await store_page_token(database, ["vm-3"])
assert await take_page_token(database, token2) == ["vm-3"]
assert await take_page_token(database, token2) is None
finally:
await database.close()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wait_updates_versions_db() -> None:
database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
pytest.skip("TEST_DATABASE_URL / DATABASE_URL required")
from app.config import Settings
from app.db.pool import AsyncpgDatabase
database = AsyncpgDatabase(Settings(database_url=database_url)) # type: ignore[arg-type]
await database.connect()
try:
await clear_pc_state(database)
objects = [_obj("vm-101", "VirtualMachine", "web-01", "group-v23")]
first = await wait_updates_xml(
database, session_key="sess-1", body="<version></version>", objects=objects
)
assert "<version>1</version>" in first
idle = await wait_updates_xml(
database, session_key="sess-1", body="<version>1</version>", objects=objects
)
assert "<version>1</version>" in idle
assert "objectSet" not in idle
finally:
await database.close()
def test_resolve_inventory_path_and_one_level() -> None:
objects = [
_obj("group-d1", "Folder", "Datacenters", None),
_obj("datacenter-21", "Datacenter", "DC1", "group-d1", {"vm_folder": "group-v23"}),
_obj("group-v23", "Folder", "vm", "datacenter-21"),
_obj("vm-101", "VirtualMachine", "web-01", "group-v23"),
]
hit = resolve_inventory_path("/DC1/vm/web-01", objects)
assert hit is not None and hit.moid == "vm-101"
kids = _one_level_traverse([objects[2]], objects, "<path>childEntity</path>")
assert any(o.moid == "vm-101" for o in kids)
+364
View File
@@ -0,0 +1,364 @@
"""Persistent QEMU CRUD semantic handler tests."""
import uuid
from datetime import UTC, datetime
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.handlers.qemu import register_qemu_handlers
from app.tasks.repository import Task
class QemuPool:
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:
return [{"vmid": 150, "state": '{"status":"stopped","name":"vm"}'}]
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.state, v.config" in sql:
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
if "SELECT r.id, r.state" in sql:
status = "running" if self.running else "stopped"
return {
"id": self.resource_id,
"state": f'{{"status":"{status}"}}',
"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": "{}"}
if "SELECT s.* FROM snapshots" in sql:
return {
"id": uuid.uuid4(),
"name": "baseline",
"parent_name": None,
"description": "stable",
"state": '{"config":{"name":"old"}}',
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
}
raise AssertionError(sql)
async def execute(self, sql: str, *args: object) -> str:
del args
self.commands.append(sql)
return "UPDATE 1"
class FakeDatabase:
def __init__(self, pool: QemuPool) -> None:
self.pool = pool
def request(pool: QemuPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(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
def inputs(**values: object) -> dict[str, Any]:
return {"values": values, "provided": tuple(values)}
async def test_qemu_create_sync_async_update_and_delete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
created_payloads: list[dict[str, Any]] = []
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: Any) -> Task:
created_payloads.append(kwargs)
return Task(
uuid.uuid4(),
str(kwargs["upid"]),
str(kwargs["task_type"]),
"queued",
dict(kwargs["payload"]),
0,
False,
0,
)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
create = registry.get("/nodes/{node}/qemu", "POST")
listing = registry.get("/nodes/{node}/qemu", "GET")
config = registry.get("/nodes/{node}/qemu/{vmid}/config", "GET")
current = registry.get("/nodes/{node}/qemu/{vmid}/status/current", "GET")
update_sync = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
update_async = registry.get("/nodes/{node}/qemu/{vmid}/config", "POST")
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
assert create and listing and config and current and update_sync and update_async and delete
assert (await listing(http_request, inputs(node="pve1")))[0]["name"] == "vm"
assert (await config(http_request, inputs(node="pve1", vmid=150)))["name"] == "vm"
assert (await current(http_request, inputs(node="pve1", vmid=150)))["status"] == "stopped"
create_upid = await create(
http_request,
inputs(node="pve1", vmid=150, name="new", cores=2),
)
assert create_upid.startswith("UPID:pve1:")
assert created_payloads[-1]["task_type"] == "qemu-create"
assert (
await update_sync(
http_request,
inputs(node="pve1", vmid=150, name="sync", delete="unused"),
)
is None
)
assert len(pool.commands) == 2
update_upid = await update_async(
http_request,
inputs(node="pve1", vmid=150, memory="2048"),
)
assert update_upid.startswith("UPID:pve1:")
assert created_payloads[-1]["task_type"] == "qemu-update"
delete_upid = await delete(http_request, inputs(node="pve1", vmid=150))
assert delete_upid.startswith("UPID:pve1:")
assert created_payloads[-1]["task_type"] == "qemu-delete"
async def test_qemu_crud_conflicts_and_missing_resources(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class ConflictingRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **_kwargs: object) -> Task:
raise ConflictError("resource is locked")
monkeypatch.setattr("app.handlers.qemu.TaskRepository", ConflictingRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
create = registry.get("/nodes/{node}/qemu", "POST")
update = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
assert create and update and delete
with pytest.raises(ApiError) as locked:
await create(http_request, inputs(node="pve1", vmid=150))
assert locked.value.status_code == 409
pool.resource_exists = True
with pytest.raises(ApiError) as duplicate:
await create(http_request, inputs(node="pve1", vmid=150))
assert duplicate.value.status_code == 409
pool.missing = True
with pytest.raises(ApiError) as missing:
await update(http_request, inputs(node="pve1", vmid=150, name="missing"))
assert missing.value.status_code == 404
pool.missing = False
pool.running = True
with pytest.raises(ApiError) as running:
await delete(http_request, inputs(node="pve1", vmid=150))
assert running.value.status_code == 409
async def test_qemu_snapshot_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
tasks: list[str] = []
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: Any) -> Task:
tasks.append(str(kwargs["task_type"]))
return Task(uuid.uuid4(), str(kwargs["upid"]), tasks[-1], "queued", {}, 0, False, 0)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
base = "/nodes/{node}/qemu/{vmid}/snapshot"
listing = registry.get(base, "GET")
create = registry.get(base, "POST")
get = registry.get(f"{base}/{{snapname}}", "GET")
delete = registry.get(f"{base}/{{snapname}}", "DELETE")
config_get = registry.get(f"{base}/{{snapname}}/config", "GET")
config_put = registry.get(f"{base}/{{snapname}}/config", "PUT")
rollback = registry.get(f"{base}/{{snapname}}/rollback", "POST")
assert listing and create and get and delete and config_get and config_put and rollback
common = inputs(node="pve1", vmid=150, snapname="baseline")
assert (await listing(http_request, inputs(node="pve1", vmid=150)))[0]["name"] == "baseline"
assert (await get(http_request, common))["description"] == "stable"
assert (await config_get(http_request, common))["config"] == {"name": "old"}
assert await config_put(http_request, inputs(**common["values"], description="updated")) is None
assert (
await create(http_request, inputs(**common["values"], description="stable"))
).startswith("UPID:pve1:")
assert (await rollback(http_request, common)).startswith("UPID:pve1:")
assert (await delete(http_request, common)).startswith("UPID:pve1:")
assert tasks == ["qemu-snapshot-create", "qemu-snapshot-rollback", "qemu-snapshot-delete"]
async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
tasks: list[dict[str, Any]] = []
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: Any) -> Task:
tasks.append(kwargs)
return Task(
uuid.uuid4(),
str(kwargs["upid"]),
str(kwargs["task_type"]),
"queued",
{},
0,
False,
0,
)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
resize = registry.get("/nodes/{node}/qemu/{vmid}/resize", "PUT")
move = registry.get("/nodes/{node}/qemu/{vmid}/move_disk", "POST")
assert clone and migrate_get and migrate and resize and move
clone_upid = await clone(
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
)
assert clone_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-clone"
assert (await migrate_get(http_request, inputs(node="pve1", vmid=150, target="pve2")))[
"local_disks"
] == []
migrate_upid = await migrate(
http_request, inputs(node="pve1", vmid=150, target="pve2", online=False)
)
assert migrate_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-migrate"
assert (
await resize(http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")) is None
)
move_upid = await move(
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
)
assert move_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-move-disk"
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"] == {}
+284
View File
@@ -0,0 +1,284 @@
"""QEMU worker transition semantics."""
import uuid
from datetime import UTC, datetime
from typing import cast
from app.simulation.clock import Clock
from app.tasks.qemu import qemu_handler
from app.tasks.repository import Task, TaskRepository
class ImmediateClock:
async def now(self) -> datetime:
return datetime(2026, 1, 1, tzinfo=UTC)
async def sleep(self, seconds: float) -> None:
assert seconds == 1.0
class Connection:
def __init__(self) -> None:
self.states: list[str] = []
async def fetchrow(self, sql: str, resource_id: uuid.UUID) -> dict[str, object]:
del sql, resource_id
return {"state": '{"status":"stopped"}'}
async def execute(self, sql: str, resource_id: uuid.UUID, state: str) -> str:
del sql, resource_id
self.states.append(state)
return "UPDATE 1"
class Acquire:
def __init__(self, connection: Connection) -> None:
self.connection = connection
async def __aenter__(self) -> Connection:
return self.connection
async def __aexit__(self, *args: object) -> None:
return None
class Pool:
def __init__(self, connection: Connection) -> None:
self.connection = connection
def acquire(self) -> Acquire:
return Acquire(self.connection)
class Repository:
def __init__(self) -> None:
self.connection = Connection()
self.pool = Pool(self.connection)
self.logs: list[str] = []
async def append_log(self, task_id: uuid.UUID, message: str) -> None:
del task_id
self.logs.append(message)
class Transaction:
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *args: object) -> None:
return None
class CrudConnection:
def __init__(self) -> None:
self.commands: list[str] = []
def transaction(self) -> Transaction:
return Transaction()
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
del args
if "FROM nodes" in sql:
return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()}
if "JOIN virtual_machines" in sql:
return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'}
if "SELECT state FROM resources" in sql:
return {"state": '{"status":"stopped","name":"old"}'}
if "SELECT config FROM virtual_machines" in sql:
return {"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=10G"}'}
if "FROM snapshots" in sql:
return {
"state": (
'{"resource_state":{"status":"stopped","name":"old"},"config":{"name":"old"}}'
)
}
return None
async def execute(self, sql: str, *args: object) -> str:
del args
self.commands.append(sql)
return "DELETE 1" if sql.startswith("DELETE") else "UPDATE 1"
class CrudRepository:
def __init__(self) -> None:
self.connection = CrudConnection()
self.pool = Pool(cast(Connection, self.connection))
self.logs: list[str] = []
async def append_log(self, _task_id: uuid.UUID, message: str) -> None:
self.logs.append(message)
async def test_qemu_worker_applies_intermediate_and_final_states() -> None:
repository = Repository()
task = Task(
uuid.uuid4(),
"UPID:test",
"qemu-start",
"running",
{"resource_id": str(uuid.uuid4())},
0,
False,
1,
)
result = await qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))(
task
)
assert result == {"status": "running"}
assert '"starting"' in repository.connection.states[0]
assert '"running"' in repository.connection.states[1]
assert repository.logs == ["VM start started", "VM start completed"]
async def test_qemu_worker_create_update_and_delete_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
created = await handler(
Task(
uuid.uuid4(),
"UPID:create",
"qemu-create",
"running",
{"node": "pve1", "vmid": 150, "config": {"name": "new"}},
0,
False,
1,
)
)
updated = await handler(
Task(
uuid.uuid4(),
"UPID:update",
"qemu-update",
"running",
{
"resource_id": str(resource_id),
"changes": {"name": "changed", "cores": 4},
"delete": "unused",
},
0,
False,
1,
)
)
deleted = await handler(
Task(
uuid.uuid4(),
"UPID:delete",
"qemu-delete",
"running",
{"resource_id": str(resource_id)},
0,
False,
1,
)
)
assert created == {"vmid": 150, "status": "stopped"}
assert updated == {"updated": ["cores", "name"], "deleted": ["unused"]}
assert deleted == {"deleted": True}
assert any("INSERT INTO resources" in command for command in repository.connection.commands)
assert any("UPDATE virtual_machines" in command for command in repository.connection.commands)
assert any("DELETE FROM resources" in command for command in repository.connection.commands)
async def test_qemu_worker_snapshot_create_rollback_and_delete_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
async def run(operation: str, **payload: object) -> dict[str, object]:
result = await handler(
Task(
uuid.uuid4(),
f"UPID:{operation}",
f"qemu-snapshot-{operation}",
"running",
{"resource_id": str(resource_id), "snapname": "baseline", **payload},
0,
False,
1,
)
)
assert result is not None
return cast(dict[str, object], result)
assert await run("create", description="stable") == {
"snapshot": "baseline",
"operation": "create",
}
assert await run("rollback", start=True) == {
"snapshot": "baseline",
"operation": "rollback",
}
assert await run("delete") == {"snapshot": "baseline", "operation": "delete"}
commands = repository.connection.commands
assert any("INSERT INTO snapshots" in command for command in commands)
assert any("UPDATE virtual_machines" in command for command in commands)
assert any("DELETE FROM snapshots" in command for command in commands)
async def test_qemu_worker_clone_and_migrate_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
cloned = await handler(
Task(
uuid.uuid4(),
"UPID:clone",
"qemu-clone",
"running",
{
"source_resource_id": str(resource_id),
"node": "pve1",
"vmid": 151,
"name": "clone",
},
0,
False,
1,
)
)
migrated = await handler(
Task(
uuid.uuid4(),
"UPID:migrate",
"qemu-migrate",
"running",
{"resource_id": str(resource_id), "target": "pve2"},
0,
False,
1,
)
)
moved = await handler(
Task(
uuid.uuid4(),
"UPID:move",
"qemu-move-disk",
"running",
{
"resource_id": str(resource_id),
"disk": "scsi0",
"target_disk": "scsi0",
"storage": "local",
"delete": True,
},
0,
False,
1,
)
)
assert cloned == {"vmid": 151, "node": "pve1"}
assert migrated == {"node": "pve2", "status": "stopped"}
assert moved == {"disk": "scsi0", "storage": "local"}
commands = repository.connection.commands
assert any("INSERT INTO resources" in command for command in commands)
assert any("node_id=$2" in command for command in commands)
+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
+127
View File
@@ -0,0 +1,127 @@
"""Deterministic seed profile tests."""
import pytest
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:
first = small_profile()
second = small_profile()
assert first == second
state = first.logical_state()
assert state == second.logical_state()
assert state["nodes"] == [{"name": "pve01", "status": "online"}]
resources = state["resources"]
assert isinstance(resources, list)
assert [resource["kind"] for resource in resources].count("qemu") == 2
assert [resource["kind"] for resource in resources].count("lxc") == 1
assert [resource["kind"] for resource in resources].count("storage") == 2
tasks = state["tasks"]
assert isinstance(tasks, list)
assert len(tasks) == 2
def test_medium_and_fault_profiles_are_deterministic() -> None:
medium = build_profile("medium")
assert len(medium.nodes) == 3
assert sum(resource.kind == "qemu" for resource in medium.resources) == 50
assert sum(resource.kind == "lxc" for resource in medium.resources) == 20
assert build_profile("ha-demo") == build_profile("ha-demo")
broken = build_profile("broken-storage")
assert any(resource.state.get("status") == "offline" for resource in broken.resources)
def test_large_profile_is_configurable_and_stable() -> None:
first = large_profile(node_count=4, resource_count=1_000)
second = large_profile(node_count=4, resource_count=1_000)
assert first == second
assert len(first.nodes) == 4
assert len(first.resources) == 1_000
def test_profile_validation() -> None:
with pytest.raises(ValueError, match="unknown seed profile"):
build_profile("missing")
with pytest.raises(ValueError, match="positive"):
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)
+100
View File
@@ -0,0 +1,100 @@
"""Bounded task worker outcome tests."""
import asyncio
import uuid
from typing import cast
from app.tasks.repository import Task, TaskRepository
from app.tasks.worker import TaskWorker
class FakeRepository:
def __init__(self, task: Task) -> None:
self.task = task
self.finishes: list[tuple[str, str | None]] = []
async def get(self, _task_id: uuid.UUID) -> Task:
return self.task
async def finish(
self,
_task_id: uuid.UUID,
_worker_id: str,
*,
status: str,
result: dict[str, object] | None = None,
error: str | None = None,
) -> None:
del result
self.finishes.append((status, error))
def make_task(*, task_type: str = "test", cancelled: bool = False) -> Task:
return Task(uuid.uuid4(), "UPID:test", task_type, "running", {}, 0, cancelled, 1)
async def test_worker_persists_success_error_and_unsupported() -> None:
task = make_task()
repository = FakeRepository(task)
async def success(_task: Task) -> dict[str, object]:
return {"ok": True}
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": success})
await worker._execute(task)
assert repository.finishes == [("success", None)]
unsupported = make_task(task_type="missing")
repository.task = unsupported
await worker._execute(unsupported)
assert repository.finishes[-1] == ("error", "unsupported task type")
async def failure(_task: Task) -> None:
raise RuntimeError("private detail")
failed = make_task()
repository.task = failed
worker.handlers["test"] = failure
await worker._execute(failed)
assert repository.finishes[-1] == ("error", "RuntimeError")
async def test_worker_honors_persisted_cancellation() -> None:
task = make_task(cancelled=True)
repository = FakeRepository(task)
called = False
async def handler(_task: Task) -> None:
nonlocal called
called = True
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": handler})
await worker._execute(task)
assert not called
assert repository.finishes == [("cancelled", None)]
async def test_worker_retries_after_claim_failure() -> None:
class RecoveringRepository:
attempts = 0
async def claim(self, _worker_id: str, _lease_seconds: float) -> None:
self.attempts += 1
if self.attempts == 1:
raise RuntimeError("database schema is not ready")
return None
repository = RecoveringRepository()
worker = TaskWorker(
cast(TaskRepository, repository),
"worker",
{},
poll_seconds=0.001,
)
running = asyncio.create_task(worker.run())
await asyncio.sleep(0.01)
worker.stop()
await running
assert repository.attempts > 1
+50
View File
@@ -0,0 +1,50 @@
"""VM state-machine and deterministic fault properties."""
import pytest
from hypothesis import given
from hypothesis import strategies as st
from app.simulation.scenarios import FaultContext, FaultRule, matches
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
@pytest.mark.parametrize(
("state", "operation", "final"),
[
(VmState.STOPPED, "start", VmState.RUNNING),
(VmState.RUNNING, "stop", VmState.STOPPED),
(VmState.RUNNING, "shutdown", VmState.STOPPED),
(VmState.RUNNING, "reboot", VmState.RUNNING),
(VmState.RUNNING, "reset", VmState.RUNNING),
(VmState.RUNNING, "suspend", VmState.PAUSED),
(VmState.RUNNING, "pause", VmState.PAUSED),
(VmState.PAUSED, "resume", VmState.RUNNING),
(VmState.RUNNING, "snapshot", VmState.RUNNING),
(VmState.STOPPED, "migrate", VmState.STOPPED),
],
)
def test_valid_transitions(state: VmState, operation: str, final: VmState) -> None:
transition = plan_transition(state, operation)
assert transition.before is state
assert transition.after is final
assert transition.intermediate is not state
@given(st.sampled_from(tuple(VmState)), st.text(min_size=1, max_size=12))
def test_transition_result_is_declared_or_rejected(state: VmState, operation: str) -> None:
try:
transition = plan_transition(state, operation)
except InvalidTransitionError:
return
assert transition.before is state
def test_fault_evaluation_is_seeded_and_filtered() -> None:
context = FaultContext("POST", "/nodes/pve1/qemu/100/status/start", node="pve1")
certain = FaultRule("task-failure", method="POST", node="pve1")
impossible = FaultRule("task-failure", probability=0)
assert matches(certain, context, seed=42)
assert not matches(impossible, context, seed=42)
probabilistic = FaultRule("task-failure", probability=0.5)
assert matches(probabilistic, context, 42) == matches(probabilistic, context, 42)
+71
View File
@@ -0,0 +1,71 @@
"""UPID examples and round-trip properties."""
import pytest
from hypothesis import given
from hypothesis import strategies as st
from app.tasks.upid import Upid
SAFE = st.from_regex(r"[a-z0-9][a-z0-9_-]{0,19}", fullmatch=True)
@given(
node=SAFE,
pid=st.integers(min_value=0, max_value=0xFFFFFFFF),
process_start=st.integers(min_value=0, max_value=0xFFFFFFFF),
start_time=st.integers(min_value=0, max_value=0xFFFFFFFF),
task_type=SAFE,
task_id=st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789_-", max_size=20),
user=SAFE,
)
def test_upid_round_trip(
node: str,
pid: int,
process_start: int,
start_time: int,
task_type: str,
task_id: str,
user: str,
) -> None:
upid = Upid(node, pid, process_start, start_time, task_type, task_id, user)
assert Upid.parse(str(upid)) == upid
def test_known_upid_shape() -> None:
value = "UPID:pve1:0000002A:00000010:65A1B2C3:qmstart:100:root@pam:"
parsed = Upid.parse(value)
assert parsed.pid == 42
assert parsed.task_id == "100"
assert str(parsed) == value
@pytest.mark.parametrize("value", ["", "UPID:broken", "UPID:pve:GGGGGGGG:00000000:00000000:x::u:"])
def test_invalid_upids_are_rejected(value: str) -> None:
with pytest.raises(ValueError):
Upid.parse(value)
@pytest.mark.parametrize(
"kwargs",
[
{"pid": -1},
{"node": "bad:node"},
{"task_id": "bad:id"},
],
)
def test_invalid_upid_components_are_rejected(kwargs: dict[str, object]) -> None:
values: dict[str, object] = {
"node": "pve1",
"pid": 1,
"process_start": 1,
"start_time": 1,
"task_type": "test",
"task_id": "100",
"user": "root@pam",
}
values.update(kwargs)
with pytest.raises(ValueError):
Upid(**values) # type: ignore[arg-type]
+48
View File
@@ -0,0 +1,48 @@
"""Native vSphere console catalog tests."""
from app.vsphere.contracts.catalog import (
list_vsphere_majors,
vsphere_catalog_payload,
vsphere_method_payload,
)
from app.vsphere.rest.coverage import catalog_entries, is_implemented
def test_list_vsphere_majors() -> None:
payload = list_vsphere_majors(runtime_version="8.0.2")
series = {item["series"] for item in payload["majors"]}
assert series == {
"vSphere 7.0",
"vSphere 7.0 U3",
"vSphere 8.0",
"vSphere 8.0 U2",
}
assert payload["plane"] == "vsphere-rest"
def test_vsphere_catalog_marks_implemented_methods() -> None:
payload = vsphere_catalog_payload(9)
assert payload["source_version"] == "8.0.2"
assert payload["method_count"] == len(catalog_entries())
assert is_implemented("GET", "/api/vcenter/vm")
assert is_implemented("POST", "/api/cis/tagging/category")
assert vsphere_catalog_payload(6)["method_count"] < payload["method_count"]
# Methods for the same path must be merged (GET+POST+DELETE on one path entry).
vm_paths = [
p for cat in payload["categories"] for p in cat["paths"] if p["path"] == "/api/vcenter/vm"
]
assert len(vm_paths) == 1
assert {m["verb"] for m in vm_paths[0]["methods"]} >= {"GET", "POST"}
def test_vsphere_method_payload_extracts_path_fields() -> None:
payload = vsphere_method_payload(
major=9,
path="/api/vcenter/vm/{vm}",
verb="GET",
runtime_version="8.0.2",
)
assert payload["implemented"] is True
assert len(payload["path_fields"]) == 1
assert payload["path_fields"][0]["name"] == "vm"
assert payload["resolved_path"] == "/api/vcenter/vm/vm-111"
+27
View File
@@ -0,0 +1,27 @@
"""vSphere Implementation coverage payload tests."""
from app.vsphere.contracts.compatibility import evidence_ledger, vsphere_compatibility_payload
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major
from app.vsphere.rest.coverage import catalog_entries
def test_compatibility_payload_matches_matrix() -> None:
universe = len(catalog_entries())
for major in VERSIONS:
payload = vsphere_compatibility_payload(major)
implemented = len(catalog_entries_for_major(major))
assert payload["total_declared"] == universe
assert payload["levels"]["implemented"]["count"] == implemented
assert payload["levels"]["declared"]["count"] == universe
assert payload["levels"]["gated"]["count"] == universe - implemented
assert payload["levels"]["schema_only"]["count"] == universe - implemented
assert payload["summary"]["coverage"] == round(implemented / universe, 4)
assert payload["summary"]["universe_by_verb"]
assert "GET" in payload["summary"]["by_verb"] or implemented == 0
def test_evidence_ledger_includes_levels() -> None:
ledger = evidence_ledger(9)
assert ledger["summary"]["implemented_methods"] == len(catalog_entries())
assert ledger["summary"]["coverage"] == 1.0
assert ledger["levels"]["implemented"]["count"] == ledger["summary"]["universe_methods"]
+57
View File
@@ -0,0 +1,57 @@
"""vSphere catalog hot-swap + compatibility UI (offline ASGI)."""
from __future__ import annotations
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.contracts.matrix import VERSIONS
from tests.unit.test_health import FakeDatabase
def _app():
return create_app(
settings=Settings(),
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async def test_vsphere_contract_apply_swaps_catalog_major() -> 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": 7})
assert applied.status_code == 200
payload = applied.json()
assert payload["ok"] is True
assert payload["major"] == 7
assert payload["plane"] == "vsphere-rest"
assert payload["runtime_version"] == VERSIONS[7]["version"]
assert payload["method_count"] > 0
report = await client.get("/ui/api/compatibility", params={"major": 7})
assert report.status_code == 200
body = report.json()
assert body["catalog_version"] == VERSIONS[7]["version"]
assert body["levels"]["implemented"]["count"] == payload["method_count"]
restored = await client.post("/ui/api/contract/apply", params={"major": 9})
assert restored.status_code == 200
assert restored.json()["runtime_version"] == VERSIONS[9]["version"]
async def test_vsphere_hot_swap_reports_full_registry_at_major_9() -> 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": 9})
assert applied.status_code == 200
report = await client.get("/ui/api/compatibility", params={"major": 9})
assert report.status_code == 200
body = report.json()
declared = body["total_declared"]
assert declared > 0
assert body["levels"]["implemented"]["count"] == declared
assert body["plane"] == "vsphere-rest"
+39
View File
@@ -0,0 +1,39 @@
"""vSphere REST mapper unit tests."""
from app.vsphere.inventory import ManagedObject
from app.vsphere.rest import mappers
def test_vm_summary_maps_power_and_hardware() -> None:
obj = ManagedObject(
moid="vm-101",
type="VirtualMachine",
name="web-01",
parent_moid="group-v23",
props={"power_state": "POWERED_ON", "cpu_count": 2, "memory_size_mib": 4096},
)
summary = mappers.vm_summary(obj)
assert summary["vm"] == "vm-101"
assert summary["name"] == "web-01"
assert summary["power_state"] == "POWERED_ON"
assert summary["cpu_count"] == 2
assert summary["memory_size_MiB"] == 4096
def test_host_and_datastore_summaries() -> None:
host = ManagedObject(
moid="host-11",
type="HostSystem",
name="esxi01.lab.local",
parent_moid="domain-c21",
props={"connection_state": "CONNECTED", "power_state": "POWERED_ON"},
)
ds = ManagedObject(
moid="datastore-31",
type="Datastore",
name="datastore1",
parent_moid="group-s23",
props={"type": "VMFS", "capacity": 100, "free_space": 40},
)
assert mappers.host_summary(host)["host"] == "host-11"
assert mappers.datastore_summary(ds)["free_space"] == 40
+69
View File
@@ -0,0 +1,69 @@
"""Version matrix and hot-swap gating."""
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from app.vsphere.contracts.matrix import (
VERSIONS,
available_for_request,
catalog_entries_for_major,
methods_for_major,
)
from tests.unit.test_health import FakeDatabase
def test_major_6_smaller_than_major_9() -> None:
assert len(methods_for_major(6)) < len(methods_for_major(9))
assert len(methods_for_major(7)) <= len(methods_for_major(8))
assert len(methods_for_major(8)) <= len(methods_for_major(9))
def test_runtime_serves_all_registered_regardless_of_major() -> None:
"""Catalog floors remain for browse; runtime never 501s known paths."""
assert available_for_request("POST", "/api/cis/tagging/category", 6) is True
assert available_for_request("GET", "/api/content/library", 6) is True
assert available_for_request("POST", "/api/appliance/networking/dns/hostname", 6) is True
def test_catalog_floor_still_shrinks_browse_list() -> None:
from app.vsphere.contracts.matrix import methods_for_major
assert ("POST", "/api/cis/tagging/category") not in methods_for_major(6)
assert ("POST", "/api/cis/tagging/category") in methods_for_major(7)
assert ("GET", "/api/content/library") not in methods_for_major(7)
assert ("GET", "/api/content/library") in methods_for_major(8)
def test_literal_path_beats_param_template() -> None:
"""`/api/content/library/item` must not resolve as `/{library_id}`."""
from app.vsphere.contracts.matrix import resolve_template
assert resolve_template("GET", "/api/content/library/item") == "/api/content/library/item"
assert available_for_request("GET", "/api/content/library/item", 8) is True
def test_catalog_entries_match_version() -> None:
for major in VERSIONS:
entries = catalog_entries_for_major(major)
assert all(e["status"] in {"implemented", "stub"} for e in entries)
assert len(entries) == len(methods_for_major(major))
async def test_hot_swap_does_not_501_registered_paths() -> None:
app = create_app(
settings=Settings(enable_pve_stub=False),
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:
apply = await client.post("/ui/api/contract/apply", params={"major": 6})
assert apply.status_code == 200
assert apply.json()["runtime_version"] == "7.0.0"
# Session required for library — but must not be version-gated 501.
library = await client.get("/api/content/library")
assert library.status_code != 501
restore = await client.post("/ui/api/contract/apply", params={"major": 9})
assert restore.status_code == 200
+52
View File
@@ -0,0 +1,52 @@
"""vSphere seed profile shape tests (no database)."""
from app.vsphere.profiles import build_vsphere_profile, large_vsphere_profile, small_vsphere_profile
from app.vsphere.security.authz import has_privilege, privileges_for_roles
def test_small_profile_has_named_vms() -> None:
profile = small_vsphere_profile()
assert profile.vm_count == 5
names = {obj.name for obj in profile.objects if obj.type == "VirtualMachine"}
assert {"web-01", "app-01", "db-01"} <= names
def test_large_profile_1000_vms() -> None:
profile = large_vsphere_profile(host_count=10, vm_count=1000)
assert profile.vm_count == 1000
assert profile.host_count == 10
vms = [obj for obj in profile.objects if obj.type == "VirtualMachine"]
hosts = [obj for obj in profile.objects if obj.type == "HostSystem"]
assert len(vms) == 1000
assert len(hosts) == 10
# Named cookbooks survive at the front of large inventories.
assert any(obj.name == "web-01" for obj in vms)
# Even spread across hosts
by_host: dict[str, int] = {}
for vm in vms:
host = str(vm.props.get("host"))
by_host[host] = by_host.get(host, 0) + 1
assert len(by_host) == 10
assert min(by_host.values()) >= 90
assert max(by_host.values()) <= 110
def test_demo_cluster_profile() -> None:
profile = build_vsphere_profile("demo-cluster")
assert profile.vm_count == 1000
assert profile.host_count == 20
def test_lab_credentials_include_readonly() -> None:
users = {c.username: c.roles for c in large_vsphere_profile().credentials}
assert "readonly@vsphere.local" in users
assert "ReadOnly" in users["readonly@vsphere.local"]
assert "Administrator" in users["administrator@vsphere.local"]
def test_readonly_cannot_power() -> None:
assert has_privilege(["ReadOnly"], "System.Read")
assert not has_privilege(["ReadOnly"], "VirtualMachine.Interact.PowerOn")
assert has_privilege(["VirtualMachinePowerUser"], "VirtualMachine.Interact.PowerOn")
assert "*" not in privileges_for_roles(["Administrator"])
assert "Authorization.ModifyPermissions" in privileges_for_roles(["Administrator"])
+51
View File
@@ -0,0 +1,51 @@
"""Broadcom universe registry coverage."""
from fastapi.routing import APIRoute
from app.vsphere.rest.coverage import (
CORE_IMPLEMENTED,
IMPLEMENTED,
catalog_entries,
reload_coverage,
universe_stats,
)
from app.vsphere.rest.stub_surface import router as stub_surface_router
def test_universe_covers_broadcom_operations_index() -> None:
reload_coverage()
stats = universe_stats()
assert stats["broadcom_operations"] == 1348
assert int(stats["unique_routes"]) >= 1000
assert int(stats["registry_methods"]) >= int(stats["unique_routes"])
assert int(stats["core_methods"]) == len(CORE_IMPLEMENTED)
assert int(stats["stub_methods"]) >= 850
def test_registry_includes_put_and_all_core_routes() -> None:
reload_coverage()
entries = {(e["verb"], e["path"]): e["status"] for e in catalog_entries()}
assert any(verb == "PUT" for verb, _path in entries)
for key, status in CORE_IMPLEMENTED.items():
assert entries[key] == status
def test_stub_surface_registers_each_contract_path_separately() -> None:
"""Universe stubs are individual FastAPI routes, not a catch-all."""
stub_routes = [
route
for route in stub_surface_router.routes
if isinstance(route, APIRoute) and str(route.name or "").startswith("vsphere-stub:")
]
expected = {(verb, path) for (verb, path), status in IMPLEMENTED.items() if status == "stub"}
registered: set[tuple[str, str]] = set()
for route in stub_routes:
methods = {
method for method in (route.methods or set()) if method not in {"HEAD", "OPTIONS"}
}
assert len(methods) == 1, route.path
registered.add((next(iter(methods)), route.path))
assert len(stub_routes) == len(expected)
assert registered == expected
assert not any("{full_path" in route.path for route in stub_routes)
+44
View File
@@ -0,0 +1,44 @@
"""Web asset loading tests."""
from app.web.assets import console_html
def test_console_html_is_read_from_disk() -> None:
html = console_html()
assert "VMware API Emulator" in html
assert "workspace-brand-name-text" in html
assert "workspace-brand-vm" in html
assert "workspace-brand-ware" in html
assert "#8EC368" in html or "8EC368" in html
assert "vmware-sim-theme" 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
assert 'id="params-badge"' in html
assert 'id="params-header-badge"' not in html
assert 'id="params-meta-badge"' not in html
assert "methodHasParams(state.method)" in html or "methodHasParams(" in html
assert 'id="params-drawer"' in html
+119
View File
@@ -0,0 +1,119 @@
"""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 "VMware API Emulator" in response.text
assert "vmware" 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_vm_is_implemented() -> None:
app = create_app(
settings=Settings(enable_pve_stub=False),
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": "/api/vcenter/vm", "verb": "GET"},
)
assert method.status_code == 200
assert method.json()["implemented"] is True
detail = await client.get(
"/ui/api/method",
params={"major": 9, "path": "/api/vcenter/vm/{vm}", "verb": "GET"},
)
assert detail.status_code == 200
assert detail.json()["path_fields"][0]["name"] == "vm"
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:
app = create_app(
settings=Settings(enable_pve_stub=False),
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
body = versions.json()
assert body["plane"] == "vsphere-rest"
assert {item["major"] for item in body["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"] == "8.0.2"
method = await client.get(
"/ui/api/method",
params={"major": 9, "path": "/api/session", "verb": "POST"},
)
assert method.status_code == 200
assert method.json()["implemented"] is True
compat9 = await client.get("/ui/api/compatibility", params={"major": 9})
assert compat9.status_code == 200
c9 = compat9.json()
assert c9["levels"]["implemented"]["count"] == c9["total_declared"]
assert c9["levels"]["implemented"]["score"] == 1.0
compat6 = await client.get("/ui/api/compatibility", params={"major": 6})
c6 = compat6.json()
assert c6["levels"]["implemented"]["count"] < c6["total_declared"]
assert 0 < c6["levels"]["implemented"]["score"] < 1
async def test_pve_stub_plane_still_optional() -> None:
if not _BUNDLED.is_file():
return
settings = Settings(enable_pve_stub=True, 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