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:
@@ -0,0 +1 @@
|
||||
"""Test package."""
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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/"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""PostgreSQL-backed integration tests."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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 "<old>" 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(())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"[]"
|
||||
@@ -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:")
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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"}})
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"] == {}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user