Initial commit: stateful OpenStack API laboratory simulator.

Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm
packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
2026-07-18 04:26:48 +03:00
commit 6033967e6a
509 changed files with 464404 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Test package."""
+1
View File
@@ -0,0 +1 @@
"""External client compatibility tests."""
@@ -0,0 +1,38 @@
"""CI gate: every declared method on majors 6-9 is callable without critical failures.
Critical = HTTP 501, server 5xx, unhandled exceptions, or emulator-limitation
strings. Synthetic 4xx (missing object / incomplete payload) are allowed.
Requires PostgreSQL via ``TEST_DATABASE_URL``.
"""
from __future__ import annotations
import os
import pytest
from app.surface_probe import run_probe
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
@pytest.mark.asyncio
async def test_all_majors_surface_has_zero_critical_failures() -> None:
results = await run_probe()
assert len(results) == 4
for item in results:
version = item["version"]
declared = item["declared"]
assert item["implemented"] == declared, version
assert item["verified"] == declared, version
assert item["dimensions_min"] == declared, version
assert item["failure_count"] == 0, f"{version} critical failures: {item.get('failures')}"
by_verb = item["by_verb"]
for verb, buckets in by_verb.items():
assert buckets.get("unimplemented_501", 0) == 0, (version, verb, buckets)
assert buckets.get("unsupported_message", 0) == 0, (version, verb, buckets)
assert buckets.get("server_5xx", 0) == 0, (version, verb, buckets)
assert buckets.get("exception", 0) == 0, (version, verb, buckets)
+281
View File
@@ -0,0 +1,281 @@
"""Group-level API smoke with real PostgreSQL persistence.
Exercises representative create/update/read paths per major API group so the
surface verified ledger is backed by working handlers, not only route presence.
Requires ``TEST_DATABASE_URL`` (same as other integration tests).
"""
from __future__ import annotations
import asyncio
import os
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any, cast
import asyncpg # type: ignore[import-untyped]
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from app.config import Settings
from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase
from app.main import create_app
from app.simulation.seed import apply_seed, small_profile
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
_BUNDLED_9 = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
_NODE = "pve01"
async def _prepare_database(url: str) -> None:
connection = await asyncpg.connect(url)
try:
await migrate(connection)
await apply_seed(connection, small_profile())
finally:
await connection.close()
async def _login(client: AsyncClient) -> str:
response = await client.post(
"/api2/json/access/ticket",
content="username=root%40pam&password=secret",
headers={"content-type": "application/x-www-form-urlencoded"},
)
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["username"] == "root@pam"
ticket = data["ticket"]
client.cookies.set("PVEAuthCookie", ticket)
return str(data["CSRFPreventionToken"])
async def _wait_task(client: AsyncClient, upid: str, *, node: str = _NODE) -> dict[str, Any]:
for _ in range(100):
response = await client.get(f"/api2/json/nodes/{node}/tasks/{upid}/status")
assert response.status_code == 200, response.text
task = cast(dict[str, Any], response.json()["data"])
if task.get("status") == "stopped":
return task
await asyncio.sleep(0.05)
raise AssertionError(f"task did not finish: {upid}")
@pytest.fixture
async def api_client() -> AsyncIterator[tuple[AsyncClient, str]]:
url = os.environ["TEST_DATABASE_URL"]
await _prepare_database(url)
settings = Settings(
database_url=SecretStr(url),
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
ticket_signing_key=SecretStr("development-only-signing-key-change-me"),
)
def database_factory(resolved: Settings) -> AsyncpgDatabase:
return AsyncpgDatabase(resolved)
app = create_app(settings=settings, database_factory=database_factory)
async with app.router.lifespan_context(app):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
csrf = await _login(client)
yield client, csrf
async def test_access_group_realm_and_user_persist(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
realm = "smoke-ldap"
create_realm = await client.post(
"/api2/json/access/domains",
data={
"realm": realm,
"type": "ldap",
"server1": "ldap.smoke.local",
"base_dn": "dc=smoke,dc=local",
"comment": "group smoke realm",
},
headers={"CSRFPreventionToken": csrf},
)
assert create_realm.status_code == 200, create_realm.text
listed = await client.get("/api2/json/access/domains")
assert listed.status_code == 200
names = {item["realm"] for item in listed.json()["data"]}
assert realm in names
detail = await client.get(f"/api2/json/access/domains/{realm}")
assert detail.status_code == 200
assert detail.json()["data"]["type"] == "ldap"
user = "smoke-user@pam"
create_user = await client.post(
"/api2/json/access/users",
data={"userid": user, "comment": "group smoke user", "enable": 1},
headers={"CSRFPreventionToken": csrf},
)
assert create_user.status_code == 200, create_user.text
got_user = await client.get(f"/api2/json/access/users/{user}")
assert got_user.status_code == 200
assert got_user.json()["data"]["userid"] == user
delete_realm = await client.delete(
f"/api2/json/access/domains/{realm}",
headers={"CSRFPreventionToken": csrf},
)
assert delete_realm.status_code == 200, delete_realm.text
async def test_qemu_group_create_config_and_power(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
vmid = 9101
create = await client.post(
f"/api2/json/nodes/{_NODE}/qemu",
data={
"vmid": str(vmid),
"name": "smoke-qemu",
"cores": "1",
"memory": "512",
},
headers={"CSRFPreventionToken": csrf},
)
assert create.status_code == 200, create.text
upid = create.json()["data"]
assert isinstance(upid, str) and upid.startswith("UPID:")
task = await _wait_task(client, upid)
assert task.get("exitstatus") == "OK"
config = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config")
assert config.status_code == 200
assert config.json()["data"]["name"] == "smoke-qemu"
update = await client.put(
f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config",
data={"name": "smoke-qemu-renamed", "cores": "2"},
headers={"CSRFPreventionToken": csrf},
)
assert update.status_code == 200, update.text
config2 = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/config")
assert config2.json()["data"]["name"] == "smoke-qemu-renamed"
assert int(config2.json()["data"]["cores"]) == 2
start = await client.post(
f"/api2/json/nodes/{_NODE}/qemu/{vmid}/status/start",
headers={"CSRFPreventionToken": csrf},
)
assert start.status_code == 200, start.text
start_task = await _wait_task(client, start.json()["data"])
assert start_task.get("exitstatus") == "OK"
status = await client.get(f"/api2/json/nodes/{_NODE}/qemu/{vmid}/status/current")
assert status.status_code == 200
assert status.json()["data"]["status"] in {"running", "started"}
async def test_lxc_group_create_and_status(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
vmid = 9201
create = await client.post(
f"/api2/json/nodes/{_NODE}/lxc",
data={
"vmid": str(vmid),
"hostname": "smoke-lxc",
"ostemplate": "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst",
"memory": "256",
"rootfs": "local-lvm:4",
},
headers={"CSRFPreventionToken": csrf},
)
assert create.status_code == 200, create.text
upid = create.json()["data"]
task = await _wait_task(client, upid)
assert task.get("exitstatus") == "OK"
config = await client.get(f"/api2/json/nodes/{_NODE}/lxc/{vmid}/config")
assert config.status_code == 200
cfg = config.json()["data"]
assert "hostname" in cfg or cfg.get("hostname") == "smoke-lxc"
status = await client.get(f"/api2/json/nodes/{_NODE}/lxc/{vmid}/status/current")
assert status.status_code == 200
assert "status" in status.json()["data"]
async def test_storage_and_cluster_groups_mutate(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
storages = await client.get("/api2/json/storage")
if storages.status_code == 404:
storages = await client.get(f"/api2/json/nodes/{_NODE}/storage")
assert storages.status_code == 200, storages.text
assert storages.json()["data"]
content = await client.get(f"/api2/json/nodes/{_NODE}/storage/local/content")
assert content.status_code == 200, content.text
assert isinstance(content.json()["data"], list)
resources = await client.get("/api2/json/cluster/resources")
assert resources.status_code == 200
assert resources.json()["data"]
notify = await client.post(
"/api2/json/cluster/notifications/endpoints/gotify",
data={
"name": "smoke-gotify",
"server": "https://gotify.smoke.local",
"token": "smoke-token",
},
headers={"CSRFPreventionToken": csrf},
)
assert notify.status_code == 200, notify.text
got = await client.get("/api2/json/cluster/notifications/endpoints/gotify/smoke-gotify")
assert got.status_code == 200
assert got.json()["data"]["name"] == "smoke-gotify"
# secret must not be echoed
assert "token" not in got.json()["data"] or got.json()["data"].get("token") in {None, ""}
async def test_sdn_and_node_ops_groups_persist(api_client: tuple[AsyncClient, str]) -> None:
client, csrf = api_client
zone = await client.post(
"/api2/json/cluster/sdn/zones",
data={"zone": "smokecn", "type": "simple", "mtu": "1500"},
headers={"CSRFPreventionToken": csrf},
)
assert zone.status_code == 200, zone.text
zones = await client.get("/api2/json/cluster/sdn/zones")
assert zones.status_code == 200
names = {item.get("zone") or item.get("id") for item in zones.json()["data"]}
assert "smokecn" in names
network_put = await client.put(
f"/api2/json/nodes/{_NODE}/network",
data={},
headers={"CSRFPreventionToken": csrf},
)
# Apply/reload may return null/UPID; must not be 501.
assert network_put.status_code == 200, network_put.text
dns = await client.get(f"/api2/json/nodes/{_NODE}/dns")
assert dns.status_code == 200
assert isinstance(dns.json()["data"], dict)
dns_put = await client.put(
f"/api2/json/nodes/{_NODE}/dns",
data={"search": "smoke.local", "dns1": "1.1.1.1"},
headers={"CSRFPreventionToken": csrf},
)
assert dns_put.status_code == 200, dns_put.text
dns2 = await client.get(f"/api2/json/nodes/{_NODE}/dns")
assert dns2.json()["data"].get("search") == "smoke.local" or "dns1" in dns2.json()["data"]
+193
View File
@@ -0,0 +1,193 @@
"""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.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,71 @@
"""Durable full-surface verified ledger for bundled Proxmox majors 6-9.
These tests stay offline (no TLS gateway). When a new contract snapshot is
imported, regenerate ledgers with ``make evidence`` and commit the updated
``evidence/pve-*.json`` files so this suite stays green.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.evidence_gen import generate_all
from app.main import create_app
from tests.unit.test_health import FakeDatabase
_BUNDLED_9 = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
_MAJORS = (6, 7, 8, 9)
def _app() -> FastAPI:
settings = Settings(
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
)
return create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
@pytest.mark.parametrize("major", _MAJORS)
async def test_hot_swap_reports_full_verified_surface(major: int) -> None:
app = _app()
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
applied = await client.post("/ui/api/contract/apply", params={"major": major})
assert applied.status_code == 200
assert applied.json()["ok"] is True
report = await client.get("/admin/compatibility")
assert report.status_code == 200
body = report.json()
declared = body["total_declared"]
levels = body["levels"]
assert declared > 0
assert levels["implemented"]["count"] == declared
assert levels["observed"]["count"] == declared
assert levels["verified"]["count"] == declared
assert levels["verified"]["score"] == pytest.approx(1.0)
for name, dimension in (body.get("dimensions") or {}).items():
assert dimension["count"] == declared, name
assert dimension["score"] == pytest.approx(1.0), name
assert len(body.get("classifications", {}).get("fully_compatible") or []) == declared
def test_committed_evidence_matches_generator(tmp_path: Path) -> None:
written = generate_all(out_dir=tmp_path)
assert set(written) == {"6.4-15", "7.4-16", "8.4.5", "9.2.3"}
for version, generated in written.items():
committed = Path("evidence") / f"pve-{version}.json"
assert committed.is_file(), f"missing committed ledger for {version}"
assert generated.read_text(encoding="utf-8") == committed.read_text(encoding="utf-8")
+48
View File
@@ -0,0 +1,48 @@
{
"info": {
"GET": {
"allowtoken": 1,
"description": "API version details, including some parts of the global datacenter config.",
"method": "GET",
"name": "version",
"parameters": {
"additionalProperties": 0
},
"permissions": {
"user": "all"
},
"returns": {
"properties": {
"console": {
"description": "The default console viewer to use.",
"enum": [
"applet",
"vv",
"html5",
"xtermjs"
],
"optional": 1,
"type": "string"
},
"release": {
"description": "The current Proxmox VE point release in `x.y` format.",
"type": "string"
},
"repoid": {
"description": "The short git revision from which this version was build.",
"pattern": "[0-9a-fA-F]{8,64}",
"type": "string"
},
"version": {
"description": "The full pve-manager package version of this node.",
"type": "string"
}
},
"type": "object"
}
}
},
"leaf": 1,
"path": "/version",
"text": "version"
}
@@ -0,0 +1,12 @@
{
"artifact_etag": "\"4144c0-655b144140900\"",
"artifact_last_modified": "Fri, 03 Jul 2026 09:08:20 GMT",
"artifact_sha256": "f2b77b57c71f3781a0993cc5062940ef31e0843fd9a6bcfdb4de4dd2001d6d9e",
"artifact_size": 4277440,
"artifact_url": "https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js",
"documentation_version": "9.2.3",
"fixture_json_pointer": "/5",
"fixture_sha256": "ad572969bbab259a10380ec11ac1c67f865e601be7c5aeec201fca368341c3fe",
"retrieved_at": "2026-07-12T23:08:59+03:00",
"viewer_url": "https://pve.proxmox.com/pve-docs/api-viewer/"
}
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL-backed integration tests."""
+130
View File
@@ -0,0 +1,130 @@
"""PostgreSQL migration acceptance checks."""
import os
import uuid
import asyncpg # type: ignore[import-untyped]
import pytest
from app.config import Settings
from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.db.repositories.resources import ResourceRepository
from app.simulation.seed import apply_seed, small_profile
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
async def test_migration_is_repeatable_and_constraints_hold() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
assert await migrate(connection) == 0
node_id = uuid.uuid4()
await connection.execute(
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'online') ON CONFLICT DO NOTHING",
node_id,
f"test-{node_id}",
)
with pytest.raises(asyncpg.CheckViolationError):
async with connection.transaction():
await connection.execute(
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'invalid')",
uuid.uuid4(),
f"invalid-{node_id}",
)
finally:
await connection.close()
async def test_small_seed_is_idempotent() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
await apply_seed(connection, small_profile())
await apply_seed(connection, small_profile())
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve01'") == 1
assert (
await connection.fetchval(
"""SELECT count(*) FROM resources
WHERE external_id IN ('100', '101', '200', 'local', 'local-lvm')"""
)
== 5
)
assert await connection.fetchval("SELECT count(*) FROM tasks WHERE status = 'success'") == 2
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 2
assert await connection.fetchval("SELECT count(*) FROM containers") == 1
assert await connection.fetchval("SELECT count(*) FROM storages") == 2
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
assert await connection.fetchval("SELECT count(*) FROM identity_groups") == 1
assert await connection.fetchval("SELECT count(*) FROM identity_group_members") == 1
assert await connection.fetchval("SELECT count(*) FROM group_acl_entries") == 1
assert await connection.fetchval("SELECT count(*) FROM roles") == 3
assert await connection.fetchval("SELECT count(*) FROM api_tokens") == 4
secrets = await connection.fetch("SELECT secret_hash FROM api_tokens")
assert all(str(row["secret_hash"]).startswith("scrypt$") for row in secrets)
assert all("-secret" not in str(row["secret_hash"]) for row in secrets)
finally:
await connection.close()
async def test_demo_cluster_seed_populates_realistic_state() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try:
await migrate(connection)
from app.simulation.seed import build_profile
await apply_seed(connection, build_profile("demo-cluster"))
assert await connection.fetchval("SELECT count(*) FROM nodes") == 20
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 850
assert await connection.fetchval("SELECT count(*) FROM containers") == 150
assert (
await connection.fetchval("SELECT count(*) FROM resources WHERE kind = 'ceph-osd'")
== 300
)
assert await connection.fetchval("SELECT count(*) FROM backups") >= 400
assert await connection.fetchval("SELECT count(*) FROM task_logs") >= 500
assert await connection.fetchval("SELECT count(*) FROM snapshots") >= 100
ceph_capacity = await connection.fetchval(
"SELECT capacity_bytes FROM storages WHERE storage_id = 'ceph-prod'"
)
assert ceph_capacity == 5 * 1024**5
profile = await connection.fetchval("SELECT metadata->>'profile' FROM clusters LIMIT 1")
assert profile == "demo-cluster"
finally:
await connection.close()
async def test_schema_readiness_and_optimistic_resource_repository() -> None:
url = os.environ["TEST_DATABASE_URL"]
connection = await asyncpg.connect(url)
database = AsyncpgDatabase(Settings(database_url=url))
try:
await migrate(connection)
await apply_seed(connection, small_profile())
await database.connect()
assert await database.is_ready()
repository = ResourceRepository(database.pool)
resource = await repository.get(kind="qemu", external_id="101")
assert resource is not None
updated = await repository.update_state(
resource.id,
expected_version=resource.version,
state={**resource.state, "status": "running"},
)
assert updated.version == resource.version + 1
assert updated.state["status"] == "running"
with pytest.raises(ConflictError):
await repository.update_state(
resource.id,
expected_version=resource.version,
state=resource.state,
)
finally:
await database.close()
await connection.close()
+81
View File
@@ -0,0 +1,81 @@
"""Durable task concurrency and recovery tests."""
import asyncio
import os
import uuid
import asyncpg # type: ignore[import-untyped]
import pytest
from asyncpg import Pool
from app.db.migrations import migrate
from app.tasks.repository import TaskRepository
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
]
async def repository() -> tuple[Pool, TaskRepository]:
pool = await asyncpg.create_pool(os.environ["TEST_DATABASE_URL"], min_size=1, max_size=4)
async with pool.acquire() as connection:
await migrate(connection)
return pool, TaskRepository(pool)
async def test_two_worker_exclusion_idempotency_and_logs() -> None:
pool, tasks = await repository()
key = uuid.uuid4().hex
try:
created = await tasks.create(
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
task_type="test",
payload={"value": 1},
resource_key=f"vm:{key}",
idempotency_key=key,
)
repeated = await tasks.create(
upid=f"ignored-{key}", task_type="test", payload={}, idempotency_key=key
)
assert repeated.id == created.id
first, second = await asyncio.gather(
tasks.claim("worker-a", 30), tasks.claim("worker-b", 30)
)
claimed = first or second
assert claimed is not None
assert (first is None) != (second is None)
worker = "worker-a" if first is not None else "worker-b"
await tasks.append_log(claimed.id, "started")
await tasks.progress(claimed.id, worker, 50)
await tasks.finish(claimed.id, worker, status="success", result={"ok": True})
assert await tasks.logs(claimed.id) == ("started",)
finished = await tasks.get(claimed.id)
assert finished is not None
assert finished.status == "success"
finally:
await pool.close()
async def test_expired_lease_is_reclaimed_after_restart() -> None:
pool, tasks = await repository()
key = uuid.uuid4().hex
try:
created = await tasks.create(
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
task_type="test",
payload={},
)
assert await tasks.claim("dead-worker", 0) is not None
recovered = await tasks.claim("new-worker", 30)
assert recovered is not None
assert recovered.id == created.id
assert recovered.attempt == 2
await tasks.request_cancel(recovered.id)
cancelled = await tasks.get(recovered.id)
assert cancelled is not None
assert cancelled.cancel_requested
await tasks.finish(recovered.id, "new-worker", status="cancelled")
finally:
await pool.close()
View File
@@ -0,0 +1,57 @@
"""Live gateway probe: every pack operation must be handled (no 5xx / 501)."""
from __future__ import annotations
import os
import urllib.request
import pytest
from app.openstack.surface_probe import format_report, probe_series
pytestmark = pytest.mark.integration
def _pick_host() -> str:
candidates = [
os.environ.get("OS_PROBE_HOST"),
os.environ.get("OS_HOST"),
"http://127.0.0.1:5000",
"http://api-gateway:5000",
"http://localhost:5000",
]
for host in candidates:
if not host:
continue
try:
with urllib.request.urlopen(f"{host.rstrip('/')}/health/live", timeout=3) as res:
if res.status == 200:
return host.rstrip("/")
except Exception:
continue
return ""
HOST = _pick_host()
@pytest.fixture(scope="module", autouse=True)
def _require_gateway():
if not HOST:
pytest.skip("OpenStack gateway unreachable")
@pytest.mark.parametrize("series", ["yoga", "antelope", "caracal", "dalmatian"])
def test_all_get_collections_live(series: str) -> None:
report = probe_series(series, host=HOST, collections_only=True)
assert report.results, series
if report.failures:
pytest.fail(format_report(report))
@pytest.mark.parametrize("series", ["yoga", "antelope", "caracal", "dalmatian"])
def test_all_operations_live(series: str) -> None:
report = probe_series(series, host=HOST)
assert len(report.results) >= 900
if report.failures:
pytest.fail(format_report(report))
@@ -0,0 +1,325 @@
"""Live gateway tests: real DB-backed GET/PUT/POST/DELETE after demo seed."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from typing import Any
import pytest
pytestmark = pytest.mark.integration
def _pick_host() -> str:
candidates = [
os.environ.get("OS_PROBE_HOST"),
os.environ.get("OS_HOST"),
"http://127.0.0.1:5000",
"http://api-gateway:5000",
"http://localhost:5000",
]
for host in candidates:
if not host:
continue
try:
with urllib.request.urlopen(f"{host.rstrip('/')}/health/live", timeout=3) as res:
if res.status == 200:
return host.rstrip("/")
except Exception:
continue
return ""
HOST = _pick_host()
@pytest.fixture(scope="module", autouse=True)
def _require_gateway():
if not HOST:
pytest.skip("OpenStack gateway unreachable")
def _request(
method: str,
path: str,
*,
token: str | None = None,
service: str | None = None,
data: dict[str, Any] | None = None,
) -> tuple[int, Any]:
body = None if data is None else json.dumps(data).encode()
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["X-Auth-Token"] = token
if service:
headers["X-OpenStack-Route-Service"] = service
req = urllib.request.Request(f"{HOST}{path}", data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as res:
raw = res.read().decode()
return res.status, json.loads(raw) if raw else None
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return exc.code, parsed
def _auth() -> tuple[str, str]:
status, body = _request(
"POST",
"/v3/auth/tokens",
service="keystone",
data={
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": "admin",
"domain": {"name": "Default"},
"password": "secret",
}
},
},
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
}
},
)
# urllib may not expose subject token via our helper — re-auth with headers
payload = json.dumps(
{
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": "admin",
"domain": {"name": "Default"},
"password": "secret",
}
},
},
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
}
}
).encode()
req = urllib.request.Request(
f"{HOST}/v3/auth/tokens",
data=payload,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"X-OpenStack-Route-Service": "keystone",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as res:
token = res.headers.get("X-Subject-Token") or res.headers.get("x-subject-token")
parsed = json.loads(res.read().decode() or "{}")
assert token, (status, body)
project_id = str(((parsed.get("token") or {}).get("project") or {}).get("id") or "")
assert project_id
return token, project_id
@pytest.fixture(scope="module")
def auth_ctx():
# Ensure demo inventory is present for density assertions.
from app.openstack.surface_probe import http_request
http_request("POST", f"{HOST}/ui/api/demo/load", data={})
return _auth()
def test_demo_collections_have_real_density(auth_ctx: tuple[str, str]) -> None:
token, _pid = auth_ctx
expectations = [
("nova", "/v2.1/servers", "servers", 50),
("nova", "/v2.1/flavors", "flavors", 4),
("nova", "/v2.1/os-keypairs", "keypairs", 3),
("nova", "/v2.1/os-server-groups", "server_groups", 4),
("neutron", "/v2.0/networks", "networks", 3),
("neutron", "/v2.0/subnets", "subnets", 3),
("neutron", "/v2.0/routers", "routers", 2),
("neutron", "/v2.0/security-groups", "security_groups", 3),
("neutron", "/v2.0/ports", "ports", 50),
("neutron", "/v2.0/quotas", "quotas", 1),
("glance", "/v2/images", "images", 2),
(
"cinder",
"/v3/volumes/detail",
"volumes",
20,
), # project-scoped list also on /v3/{pid}/...
("placement", "/resource_providers", "resource_providers", 4),
("octavia", "/v2/lbaas/providers", "providers", 3),
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", 1),
("barbican", "/v1/secrets", "secrets", 4),
("heat", f"/v1/{_pid}/stacks", "stacks", 1),
("heat", f"/v1/{_pid}/software_configs", "software_configs", 4),
("heat", f"/v1/{_pid}/software_deployments", "software_deployments", 4),
]
for service, path, key, minimum in expectations:
status, body = _request("GET", path, token=token, service=service)
assert status == 200, (service, path, status, body)
assert isinstance(body, dict), (service, path, body)
items = body.get(key)
assert isinstance(items, list), (service, path, key, body)
assert len(items) >= minimum, f"{service} {path} {key}: got {len(items)} < {minimum}"
def test_network_crud_persists_in_db(auth_ctx: tuple[str, str]) -> None:
token, _pid = auth_ctx
name = "real-db-net"
status, created = _request(
"POST",
"/v2.0/networks",
token=token,
service="neutron",
data={"network": {"name": name, "admin_state_up": True}},
)
assert status in {200, 201}, created
net_id = (created or {}).get("network", {}).get("id")
assert net_id
status, shown = _request("GET", f"/v2.0/networks/{net_id}", token=token, service="neutron")
assert status == 200
assert shown["network"]["name"] == name
status, updated = _request(
"PUT",
f"/v2.0/networks/{net_id}",
token=token,
service="neutron",
data={"network": {"name": f"{name}-upd"}},
)
assert status == 200
assert updated["network"]["name"] == f"{name}-upd"
status, listed = _request("GET", "/v2.0/networks", token=token, service="neutron")
assert status == 200
names = {n.get("name") for n in listed.get("networks") or []}
assert f"{name}-upd" in names
status, _ = _request("DELETE", f"/v2.0/networks/{net_id}", token=token, service="neutron")
assert status in {200, 202, 204}
status, _ = _request("GET", f"/v2.0/networks/{net_id}", token=token, service="neutron")
assert status == 404
def test_server_metadata_persists_roundtrip(auth_ctx: tuple[str, str]) -> None:
token, _pid = auth_ctx
status, servers = _request("GET", "/v2.1/servers", token=token, service="nova")
assert status == 200
server_id = (servers.get("servers") or [{}])[0].get("id")
assert server_id
status, _ = _request(
"POST",
f"/v2.1/servers/{server_id}/metadata",
token=token,
service="nova",
data={"metadata": {"audit": "yes", "tier": "web"}},
)
assert status in {200, 201}
status, meta = _request(
"GET", f"/v2.1/servers/{server_id}/metadata", token=token, service="nova"
)
assert status == 200
assert meta["metadata"].get("audit") == "yes"
assert meta["metadata"].get("tier") == "web"
status, _ = _request(
"PUT",
f"/v2.1/servers/{server_id}/tags",
token=token,
service="nova",
data={"tags": ["audit", "web", "demo"]},
)
assert status in {200, 201}
status, tags = _request("GET", f"/v2.1/servers/{server_id}/tags", token=token, service="nova")
assert status == 200
assert set(tags.get("tags") or []) >= {"audit", "web", "demo"}
def test_schema_secret_crud_persists(auth_ctx: tuple[str, str]) -> None:
token, _pid = auth_ctx
status, created = _request(
"POST",
"/v1/secrets",
token=token,
service="barbican",
data={"name": "real-db-secret", "secret_type": "passphrase"},
)
assert status in {200, 201}, created
secret_id = None
if isinstance(created, dict):
secret_id = created.get("id") or (created.get("secret") or {}).get("id")
ref = created.get("secret_ref")
if not secret_id and isinstance(ref, str):
secret_id = ref.rstrip("/").split("/")[-1]
assert secret_id
status, shown = _request("GET", f"/v1/secrets/{secret_id}", token=token, service="barbican")
assert status == 200
body = shown.get("secret") if isinstance(shown, dict) and "secret" in shown else shown
assert isinstance(body, dict)
assert body.get("name") == "real-db-secret" or body.get("id") == secret_id
status, listed = _request("GET", "/v1/secrets", token=token, service="barbican")
assert status == 200
ids = []
for item in listed.get("secrets") or []:
if isinstance(item, dict):
ids.append(str(item.get("id") or ""))
href = item.get("secret_ref") or item.get("href")
if isinstance(href, str):
ids.append(href.rstrip("/").split("/")[-1])
assert secret_id in ids
status, _ = _request("DELETE", f"/v1/secrets/{secret_id}", token=token, service="barbican")
assert status in {200, 202, 204}
status, _ = _request("GET", f"/v1/secrets/{secret_id}", token=token, service="barbican")
assert status == 404
def test_nested_demo_resources_populated(auth_ctx: tuple[str, str]) -> None:
token, pid = auth_ctx
status, servers = _request("GET", "/v2.1/servers", token=token, service="nova")
sid = (servers.get("servers") or [{}])[0].get("id")
status, routers = _request("GET", "/v2.0/routers", token=token, service="neutron")
rid = (routers.get("routers") or [{}])[0].get("id")
status, fips = _request("GET", "/v2.0/floatingips", token=token, service="neutron")
fid = (fips.get("floatingips") or [{}])[0].get("id")
status, images = _request("GET", "/v2/images", token=token, service="glance")
iid = (images.get("images") or [{}])[0].get("id")
assert all([sid, rid, fid, iid])
checks = [
("nova", f"/v2.1/servers/{sid}/os-volume_attachments", "volumeAttachments", 1),
("nova", f"/v2.1/servers/{sid}/os-interface", "interfaceAttachments", 1),
("nova", f"/v2.1/servers/{sid}/metadata", "metadata", 1),
("nova", f"/v2.1/servers/{sid}/tags", "tags", 1),
("neutron", f"/v2.0/routers/{rid}/conntrack_helpers", "conntrack_helpers", 4),
("neutron", f"/v2.0/floatingips/{fid}/port_forwardings", "port_forwardings", 4),
("glance", f"/v2/images/{iid}/members", "members", 4),
("placement", f"/allocations/{sid}", "allocations", 1),
("heat", f"/v1/{pid}/software_deployments", "software_deployments", 4),
]
for service, path, key, minimum in checks:
status, body = _request("GET", path, token=token, service=service)
assert status == 200, (path, status, body)
val = body.get(key)
if isinstance(val, dict):
assert len(val) >= minimum, (path, key, val)
else:
assert isinstance(val, list) and len(val) >= minimum, (path, key, val)
@@ -0,0 +1,81 @@
"""Conformance: every pack operation has method+path and core services are complete."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.openstack.contract_loader import contracts_root, load_series_pack
CORE = ("keystone", "nova", "neutron", "glance", "cinder", "placement")
EXTRA = ("heat", "swift", "ironic", "octavia")
REMAINING = (
"barbican",
"manila",
"designate",
"magnum",
"zun",
"trove",
"mistral",
"aodh",
"cloudkitty",
"freezer",
"blazar",
"vitrage",
"masakari",
"tacker",
"adjutant",
"heat-cfn",
"watcher",
"zaqar",
)
@pytest.mark.parametrize("series", ["yoga", "antelope", "caracal", "dalmatian"])
def test_pack_operations_are_well_formed(series: str) -> None:
packs = load_series_pack(series)
for name, pack in packs.items():
assert pack.port > 0
assert pack.operations, name
seen: set[tuple[str, str]] = set()
for op in pack.operations:
assert op.method in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}
assert op.path.startswith("/"), op.path
assert op.operation_id
key = (op.method, op.path)
# duplicate method+path only allowed if both are actions collapsing
if key in seen:
assert op.kind == "action"
seen.add(key)
@pytest.mark.parametrize("service", CORE)
def test_core_services_have_nested_or_actions(service: str) -> None:
pack = load_series_pack("dalmatian")[service]
paths = {op.path for op in pack.operations}
assert any("{" in p for p in paths) or service == "keystone"
if service == "nova":
assert "/v2.1/servers/{id}/action" in paths
if service == "neutron":
assert "/v2.0/routers/{id}/add_router_interface" in paths or any(
"add_router_interface" in p for p in paths
)
@pytest.mark.parametrize("service", EXTRA + REMAINING)
def test_extended_services_present(service: str) -> None:
packs = load_series_pack("dalmatian")
assert service in packs
assert packs[service].operation_count() >= 3
def test_coverage_doc_matches_manifest() -> None:
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
doc = Path(__file__).resolve().parents[3] / "docs" / "api_coverage.md"
if not doc.is_file():
pytest.skip("docs/api_coverage.md not generated yet")
text = doc.read_text()
assert str(man["operation_count"]) in text
assert "nova" in text
+73
View File
@@ -0,0 +1,73 @@
"""Compare simulator packs with published OpenStack 2024.2 API surface expectations."""
from __future__ import annotations
import json
from pathlib import Path
from app.openstack.contract_loader import contracts_root, load_series_pack
# Services listed on https://docs.openstack.org/2024.2/api/index.html
DALMATIAN_API_INDEX_SERVICES = {
"ironic",
"cinder",
"nova",
"magnum",
"zun",
"trove",
"designate",
"keystone",
"glance",
"watcher",
"masakari",
"barbican",
"octavia",
"zaqar",
"neutron",
"tacker",
"swift",
"heat",
"placement",
"cloudkitty",
"blazar",
"manila",
}
def test_dalmatian_covers_official_2024_2_api_index_services() -> None:
packs = load_series_pack("dalmatian")
missing = sorted(DALMATIAN_API_INDEX_SERVICES - set(packs))
assert missing == [], f"missing official 2024.2 API index services: {missing}"
def test_dalmatian_surface_beats_prior_baseline() -> None:
"""Baseline before watcher/zaqar + neutron/nova expansion was 1144 / 26."""
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
assert man["service_count"] >= 28
assert man["operation_count"] >= 1300
def test_neutron_and_nova_closer_to_api_ref_counts() -> None:
"""Public Neutron API-ref lists ~315 unique method+path pairs; Nova ~200+.
Packs are surface-complete CRUD expansions (not every microversion quirk),
so we assert meaningful floors rather than bit-identical counts.
"""
packs = load_series_pack("dalmatian")
assert packs["neutron"].operation_count() >= 280
assert packs["nova"].operation_count() >= 120
neutron_paths = {op.path for op in packs["neutron"].operations}
assert "/v2.0/address-groups" in neutron_paths
assert "/v2.0/bgp-speakers" in neutron_paths
assert "/v2.0/segments" in neutron_paths
def test_coverage_doc_lists_new_services() -> None:
doc = Path(__file__).resolve().parents[2] / "docs" / "api_coverage.md"
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
text = doc.read_text()
assert "watcher" in text
assert "zaqar" in text
assert str(man["operation_count"]) in text
+62
View File
@@ -0,0 +1,62 @@
"""Unit tests for OpenStack contract packs and loader."""
from __future__ import annotations
import json
from pathlib import Path
from app.openstack.contract_loader import (
contracts_root,
list_series,
load_series_pack,
major_for_series,
)
def test_all_series_packs_exist() -> None:
series = {s["series"] for s in list_series()}
assert {"yoga", "antelope", "caracal", "dalmatian"} <= series
def test_dalmatian_core_minimums() -> None:
man = json.loads((contracts_root() / "dalmatian" / "manifest.json").read_text())
by_name = {s["name"]: s for s in man["services"]}
for svc, minimum in man["min_core_operations"].items():
assert by_name[svc]["operation_count"] >= minimum
assert man["operation_count"] >= 1300
assert man["service_count"] == 28
by_name = {s["name"]: s for s in man["services"]}
assert "watcher" in by_name
assert "zaqar" in by_name
assert by_name["neutron"]["operation_count"] >= 250
assert by_name["nova"]["operation_count"] >= 110
def test_load_series_pack_operations() -> None:
packs = load_series_pack("dalmatian")
assert "nova" in packs
assert "neutron" in packs
assert "watcher" in packs
assert "zaqar" in packs
nova = packs["nova"]
methods = {(op.method, op.path) for op in nova.operations}
assert ("GET", "/v2.1/servers") in methods
assert ("POST", "/v2.1/servers/{id}/action") in methods
assert ("GET", "/v2.1/extensions") in methods
assert ("GET", "/v2.0/address-groups") in {
(op.method, op.path) for op in packs["neutron"].operations
}
assert nova.max_microversion is not None
def test_major_mapping() -> None:
assert major_for_series("dalmatian") == 9
assert major_for_series("yoga") == 6
def test_api_json_files_present() -> None:
root = contracts_root() / "dalmatian"
services = [p for p in root.iterdir() if p.is_dir()]
assert len(services) == 28
for svc in services:
assert (svc / "api.json").is_file()
+109
View File
@@ -0,0 +1,109 @@
"""Per-path OpenStack contract registration (Proxmox-style)."""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.routing import APIRoute
from app.openstack.contract_loader import ensure_loaded, load_series_pack
from app.openstack.mount import build_openstack_handlers, mount_openstack_routes
from app.openstack.registry import (
HandlerRegistry,
normalize_path_template,
register_specialized_handlers,
)
from app.openstack.routes import nova
from app.openstack.schema_engine import remount_schema_services
def test_normalize_path_template_collapses_param_names() -> None:
assert normalize_path_template("/v2.1/servers/{id}") == normalize_path_template(
"/v2.1/servers/{server_id}"
)
assert normalize_path_template("/v1/{account}/{container}/{object}") == normalize_path_template(
"/v1/{account}/{container}/{object_name:path}"
)
def test_swift_object_handler_resolves_from_contract_path() -> None:
registry = build_openstack_handlers()
assert registry.get("swift", "/v1/{account}/{container}/{object}", "GET") is not None
assert registry.get("swift", "/v1/{account}/{container}/{object}", "PUT") is not None
def test_handler_registry_structural_lookup() -> None:
registry = HandlerRegistry()
async def handler(request): # noqa: ANN001
return request
registry.register("nova", "/v2.1/servers/{server_id}", "GET", handler)
found = registry.get("nova", "/v2.1/servers/{id}", "GET")
assert found is handler
def test_specialized_handlers_imported_from_nova_router() -> None:
registry = HandlerRegistry()
count = register_specialized_handlers(registry, "nova", nova.router)
assert count > 0
assert registry.get("nova", "/v2.1/servers", "GET") is not None
assert registry.get("nova", "/v2.1/servers/{id}", "GET") is not None
def test_mount_registers_one_route_per_unique_method_path() -> None:
app = FastAPI()
mount_openstack_routes(app, series="dalmatian")
packs = load_series_pack("dalmatian")
expected = 0
for pack in packs.values():
expected += len({(op.method, op.path) for op in pack.operations})
contract_routes = [
route
for route in app.router.routes
if isinstance(route, APIRoute)
and isinstance(route.name, str)
and route.name.startswith("os-contract:")
]
# Contract paths plus specialized-only aliases (trailing slash, PUT tags, …).
assert len(contract_routes) >= expected
assert app.state.openstack_schema_ops == len(contract_routes)
# name format: os-contract:{service}:{METHOD}:{path}
mounted_ops = set()
for route in contract_routes:
rest = route.name[len("os-contract:") :]
_service, _, remainder = rest.partition(":")
method, _, path = remainder.partition(":")
mounted_ops.add((method, path))
for pack in packs.values():
for op in pack.operations:
assert (op.method, op.path) in mounted_ops
# No legacy schema-* route names.
assert not any(
isinstance(getattr(r, "name", None), str) and str(r.name).startswith("schema-")
for r in app.router.routes
)
def test_remount_preserves_handlers_and_route_count() -> None:
app = FastAPI()
mount_openstack_routes(app, series="dalmatian")
handlers = app.state.openstack_handlers
assert isinstance(handlers, HandlerRegistry)
before = app.state.openstack_schema_ops
ensure_loaded("caracal")
summary = remount_schema_services(app, "caracal")
assert app.state.openstack_handlers is handlers
assert summary["routes_mounted"] == app.state.openstack_schema_ops
assert app.state.openstack_schema_ops > 0
# Switching series rebuilds routes; count may differ by series deltas.
assert isinstance(before, int)
def test_build_openstack_handlers_covers_core_services() -> None:
registry = build_openstack_handlers()
for service in ("keystone", "nova", "neutron", "glance", "cinder"):
keys = [k for k in registry.keys() if k[0] == service]
assert keys, f"expected handlers for {service}"
+66
View File
@@ -0,0 +1,66 @@
"""Integration tests for OpenStack demo cloud seed (requires PostgreSQL)."""
from __future__ import annotations
import os
import asyncpg
import pytest
from app.openstack.demo_cloud import (
DEMO_PROFILE,
DEMO_SERVER_COUNT,
clear_openstack_state,
openstack_demo_summary,
seed_openstack_demo,
)
from app.openstack.seed import seed_openstack
pytestmark = pytest.mark.integration
def _dsn() -> str:
return os.environ.get(
"TEST_DATABASE_URL",
os.environ.get(
"DATABASE_URL",
"postgresql://openstack:openstack@127.0.0.1:5433/openstack_simulator",
),
)
@pytest.fixture
async def conn():
try:
connection = await asyncpg.connect(_dsn())
except Exception as exc: # pragma: no cover
pytest.skip(f"postgres unavailable: {exc}")
try:
yield connection
finally:
await connection.close()
async def test_demo_seed_roundtrip(conn: asyncpg.Connection) -> None:
await seed_openstack_demo(conn)
summary = await openstack_demo_summary(conn)
assert summary["loaded"] is True
assert summary["servers"] == DEMO_SERVER_COUNT
assert summary["hypervisors"] == 16
assert summary["projects"] == 5
assert summary["volumes"] == 600
assert summary["profile"] == DEMO_PROFILE
await clear_openstack_state(conn)
result = await seed_openstack(conn)
assert result["profile"] == "minimal"
summary = await openstack_demo_summary(conn)
assert summary["loaded"] is False
assert summary["servers"] == 1
assert summary["profile"] == "minimal"
# Restore demo so a shared lab DB stays usable after the test.
await seed_openstack_demo(conn)
summary = await openstack_demo_summary(conn)
assert summary["loaded"] is True
assert summary["servers"] == DEMO_SERVER_COUNT
+57
View File
@@ -0,0 +1,57 @@
"""Path-based OpenStack service dispatch (WebUI on Keystone port)."""
from __future__ import annotations
from app.openstack.dispatch import resolve_service, resolve_service_from_path
def test_path_maps_core_services() -> None:
assert resolve_service_from_path("/v2.1/servers") == "nova"
assert resolve_service_from_path("/v2.0/networks") == "neutron"
assert resolve_service_from_path("/v2/images") == "glance"
assert resolve_service_from_path("/v3/volumes") == "cinder"
assert resolve_service_from_path("/v3/auth/tokens") == "keystone"
assert resolve_service_from_path("/v3/projects") == "keystone"
assert resolve_service_from_path("/v1/nodes") == "ironic"
assert resolve_service_from_path("/v2/lbaas/loadbalancers") == "octavia"
assert resolve_service_from_path("/resource_providers") == "placement"
def test_keystone_port_overrides_to_nova_path() -> None:
service = resolve_service(
{"x-openstack-service": "keystone", "x-forwarded-port": "5000"},
"/v2.1/servers/detail",
)
assert service == "nova"
def test_route_service_header_wins() -> None:
service = resolve_service(
{
"x-openstack-service": "keystone",
"x-openstack-route-service": "cinder",
"x-forwarded-port": "5000",
},
"/v3/limits",
)
assert service == "cinder"
def test_auth_path_ignores_stale_route_service() -> None:
service = resolve_service(
{
"x-openstack-service": "keystone",
"x-openstack-route-service": "cinder",
"x-forwarded-port": "5000",
},
"/v3/auth/tokens",
)
assert service == "keystone"
def test_dedicated_nova_port_keeps_nova() -> None:
service = resolve_service(
{"x-openstack-service": "nova", "x-forwarded-port": "8774"},
"/v2.1/servers",
)
assert service == "nova"
+22
View File
@@ -0,0 +1,22 @@
"""Pack-driven surface seed covers every contract resource_type."""
from __future__ import annotations
from app.openstack.pack_seed import iter_pack_resource_types
def test_iter_pack_resource_types_covers_schema_services() -> None:
types = iter_pack_resource_types()
assert len(types) >= 200
expected = {
("barbican", "secret"),
("barbican", "container"),
("manila", "share"),
("manila", "share_type"),
("watcher", "goal"),
("zun", "host"),
("cloudkitty", "dataframes"),
("designate", "zone"),
}
missing = expected - types
assert not missing, missing
+42
View File
@@ -0,0 +1,42 @@
"""Unit tests for OpenStack pagination helper."""
from __future__ import annotations
from starlette.requests import Request
from app.openstack.paging import paginate_rows, parse_limit
def _request(query: str = "") -> Request:
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/v2.1/servers",
"raw_path": b"/v2.1/servers",
"query_string": query.encode(),
"headers": [],
"client": ("127.0.0.1", 123),
"server": ("test", 80),
}
return Request(scope)
def test_parse_limit_clamps() -> None:
assert parse_limit(_request("")) == 0
assert parse_limit(_request("limit=25")) == 25
assert parse_limit(_request("limit=99999"), maximum=100) == 100
def test_paginate_rows_marker_and_next_link() -> None:
rows = [{"id": f"id-{i}"} for i in range(10)]
page, links = paginate_rows(
rows,
_request("limit=3&marker=id-2"),
id_attr=lambda r: r["id"],
)
assert [r["id"] for r in page] == ["id-3", "id-4", "id-5"]
assert links and links[0]["rel"] == "next"
assert "marker=id-5" in links[0]["href"]
+24
View File
@@ -0,0 +1,24 @@
"""Series packs must differ across Yoga → Dalmatian."""
from __future__ import annotations
from tools.os_api_inventory.catalog import build_all_operations
from tools.os_api_inventory.series_deltas import filter_ops_for_series, series_index
def test_series_operation_counts_increase() -> None:
all_ops = build_all_operations()
flat = [op for ops in all_ops.values() for op in ops]
counts = {
series: len(filter_ops_for_series(flat, series))
for series in ("yoga", "antelope", "caracal", "dalmatian")
}
assert counts["yoga"] < counts["antelope"] < counts["caracal"] < counts["dalmatian"]
def test_dalmatian_includes_yoga() -> None:
nova = build_all_operations()["nova"]
yoga_ids = {op["operation_id"] for op in filter_ops_for_series(nova, "yoga")}
dal_ids = {op["operation_id"] for op in filter_ops_for_series(nova, "dalmatian")}
assert yoga_ids <= dal_ids
assert series_index("yoga") < series_index("dalmatian")
+277
View File
@@ -0,0 +1,277 @@
"""TFA / OpenID / permissions access handlers."""
from __future__ import annotations
import json
import uuid
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from pydantic import SecretStr
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.db.pool import AsyncpgDatabase
from app.handlers.access_auth import register_access_auth_handlers
from app.security.auth import issue_ticket
class AuthPool:
def __init__(self) -> None:
self.principals = {
"root@pam": {
"id": uuid.uuid4(),
"tfa_locked_until": None,
"totp_locked": False,
}
}
self.tfa: dict[tuple[uuid.UUID, str], dict[str, Any]] = {}
self.realms = {
"sso": {
"kind": "openid",
"config": {
"issuer-url": "https://idp.example",
"client-id": "pve",
},
}
}
self.pending: dict[str, dict[str, str]] = {}
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
if "FROM principals p" in query and "LEFT JOIN tfa_entries" in query:
rows: list[dict[str, Any]] = []
for name, data in self.principals.items():
matches = [item for key, item in self.tfa.items() if key[0] == data["id"]]
if not matches:
rows.append(
{
"userid": name,
"tfa_locked_until": data["tfa_locked_until"],
"totp_locked": data["totp_locked"],
"entry_id": None,
"tfa_type": None,
"description": None,
"enable": None,
"created_at": 0,
}
)
for item in matches:
rows.append(
{
"userid": name,
"tfa_locked_until": data["tfa_locked_until"],
"totp_locked": data["totp_locked"],
**item,
}
)
return rows
if "FROM tfa_entries" in query and "DISTINCT" in query:
principal_id = arguments[0]
types = sorted(
{
item["tfa_type"]
for key, item in self.tfa.items()
if key[0] == principal_id and item["enable"]
}
)
return [{"tfa_type": value} for value in types]
if "FROM tfa_entries WHERE principal_id" in query or (
"FROM tfa_entries" in query and "principal_id=$1" in query and "DISTINCT" not in query
):
principal_id = arguments[0]
return [item for key, item in self.tfa.items() if key[0] == principal_id]
if "FROM acl_entries" in query:
return []
raise AssertionError(query)
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM principals WHERE name" in query:
userid = str(arguments[0])
data = self.principals.get(userid)
if data is None:
return None
return {"name": userid, **data}
if "FROM realms WHERE name" in query:
realm = str(arguments[0])
realm_data = self.realms.get(realm)
if realm_data is None:
return None
return {"name": realm, **realm_data}
if "FROM openid_pending WHERE state" in query:
return self.pending.get(str(arguments[0]))
if "FROM tfa_entries WHERE principal_id" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
item = self.tfa.get(key)
return item
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM principals" in query:
return str(arguments[0]) in self.principals
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "INSERT INTO openid_pending" in query:
self.pending[str(arguments[0])] = {
"realm": str(arguments[1]),
"redirect_url": str(arguments[2]),
}
return "INSERT 0 1"
if "DELETE FROM openid_pending" in query:
self.pending.pop(str(arguments[0]), None)
return "DELETE 1"
if "INSERT INTO principals" in query:
self.principals[str(arguments[0])] = {
"id": uuid.uuid4(),
"tfa_locked_until": None,
"totp_locked": False,
}
return "INSERT 0 1"
if "INSERT INTO tfa_entries" in query:
principal_id = cast(uuid.UUID, arguments[0])
entry_id = str(arguments[1])
self.tfa[(principal_id, entry_id)] = {
"entry_id": entry_id,
"tfa_type": str(arguments[2]),
"description": arguments[3],
"enable": True,
"created_at": 1_700_000_000,
"secret": arguments[4],
"metadata": json.loads(str(arguments[5])),
}
return "INSERT 0 1"
if "UPDATE tfa_entries SET enable" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
self.tfa[key]["enable"] = bool(arguments[2])
return "UPDATE 1"
if "UPDATE tfa_entries SET description" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
self.tfa[key]["description"] = arguments[2]
return "UPDATE 1"
if "DELETE FROM tfa_entries" in query:
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
if key not in self.tfa:
return "DELETE 0"
del self.tfa[key]
return "DELETE 1"
if "UPDATE principals" in query and "totp_locked" in query:
userid = str(arguments[0])
if userid not in self.principals:
return "UPDATE 0"
self.principals[userid]["tfa_locked_until"] = None
self.principals[userid]["totp_locked"] = False
return "UPDATE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: AuthPool) -> None:
self.pool = pool
def request(pool: AuthPool, principal: str = "root@pam") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
app.state.settings = Settings(ticket_signing_key=SecretStr("test-signing-key"))
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = principal
return result
def values(**items: object) -> dict[str, Any]:
return {"values": items, "provided": frozenset(items)}
async def test_tfa_lifecycle_and_unlock_persist() -> None:
registry = HandlerRegistry()
register_access_auth_handlers(registry)
pool = AuthPool()
http = request(pool)
create = registry.get("/access/tfa/{userid}", "POST")
listing = registry.get("/access/tfa/{userid}", "GET")
get = registry.get("/access/tfa/{userid}/{id}", "GET")
update = registry.get("/access/tfa/{userid}/{id}", "PUT")
delete = registry.get("/access/tfa/{userid}/{id}", "DELETE")
unlock = registry.get("/access/users/{userid}/unlock-tfa", "PUT")
types = registry.get("/access/users/{userid}/tfa", "GET")
assert create and listing and get and update and delete and unlock and types
created = await create(http, values(userid="root@pam", type="totp", description="phone"))
entry_id = created["id"]
assert await listing(http, values(userid="root@pam"))
fetched = await get(http, values(userid="root@pam", id=entry_id))
assert fetched["type"] == "totp"
await update(http, values(userid="root@pam", id=entry_id, enable=0))
assert (await get(http, values(userid="root@pam", id=entry_id)))["enable"] == 0
assert await unlock(http, values(userid="root@pam")) is True
assert (await types(http, values(userid="root@pam")))["types"] == []
await delete(http, values(userid="root@pam", id=entry_id))
with pytest.raises(ApiError):
await get(http, values(userid="root@pam", id=entry_id))
async def test_openid_auth_url_and_login_create_principal() -> None:
registry = HandlerRegistry()
register_access_auth_handlers(registry)
pool = AuthPool()
http = request(pool)
auth_url = registry.get("/access/openid/auth-url", "POST")
login = registry.get("/access/openid/login", "POST")
assert auth_url and login
url = await auth_url(
http,
values(realm="sso", **{"redirect-url": "https://pve.local/api2/json/access/openid/login"}),
)
assert "https://idp.example/authorize?" in url
assert pool.pending
state = next(iter(pool.pending))
result = await login(
http,
values(
code="abc1234567890",
state=state,
**{"redirect-url": "https://pve.local/api2/json/access/openid/login"},
),
)
assert result["ticket"].startswith("PVE:")
assert any(name.endswith("@sso") for name in pool.principals)
async def test_permissions_and_vncticket() -> None:
registry = HandlerRegistry()
register_access_auth_handlers(registry)
pool = AuthPool()
http = request(pool)
permissions = registry.get("/access/permissions", "GET")
vncticket = registry.get("/access/vncticket", "POST")
ticket_get = registry.get("/access/ticket", "GET")
assert permissions and vncticket and ticket_get
caps = await permissions(http, values())
assert "/" in caps
assert await ticket_get(http, values()) is None
ticket = issue_ticket("root@pam", b"test-signing-key")
await vncticket(
http,
values(
authid="root@pam",
path="/nodes/pve01/qemu/100/vncwebsocket",
privs="Sys.Console",
vncticket=ticket,
),
)
+247
View File
@@ -0,0 +1,247 @@
"""API-token lifecycle handler tests without external services."""
import json
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.access import register_access_handlers
class TokenPool:
def __init__(self) -> None:
self.token: dict[str, Any] | None = None
async def fetch(self, _query: str, _userid: str) -> list[dict[str, Any]]:
return [] if self.token is None else [{"token_id": "test", **self.token}]
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "INSERT INTO" in query:
self.token = {
"comment": arguments[3],
"privilege_separation": arguments[5],
"expire": arguments[4],
}
return self.token
if "UPDATE api_tokens" in query:
if self.token is None:
return None
self.token["comment"] = arguments[2]
self.token["privilege_separation"] = arguments[4]
return self.token
return self.token
async def fetchval(self, _query: str, _userid: str) -> bool:
return True
async def execute(self, _query: str, _userid: str, _tokenid: str) -> str:
if self.token is None:
return "DELETE 0"
self.token = None
return "DELETE 1"
class RealmPool:
def __init__(self) -> None:
self.realms: dict[str, dict[str, Any]] = {
"pam": {
"kind": "pam",
"config": {"comment": "Linux PAM standard authentication"},
},
"pve": {
"kind": "pve",
"config": {"comment": "Proxmox VE authentication server"},
},
}
self.principals: dict[str, str] = {"root@pam": "pam"}
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
del arguments
if "FROM realms ORDER BY name" in query:
return [
{"name": name, "kind": data["kind"], "config": dict(data["config"])}
for name, data in sorted(self.realms.items())
]
raise AssertionError(query)
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM realms WHERE name" in query:
realm = str(arguments[0])
data = self.realms.get(realm)
if data is None:
return None
return {"name": realm, "kind": data["kind"], "config": dict(data["config"])}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> bool:
realm = str(arguments[0])
if "EXISTS(SELECT 1 FROM realms" in query:
return realm in self.realms
if "EXISTS(SELECT 1 FROM principals" in query:
return any(value == realm for value in self.principals.values())
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "INSERT INTO realms" in query:
self.realms[str(arguments[0])] = {
"kind": str(arguments[1]),
"config": json.loads(str(arguments[2])),
}
return "INSERT 0 1"
if "UPDATE realms SET config=$2" in query:
realm = str(arguments[0])
self.realms[realm]["config"] = json.loads(str(arguments[1]))
return "UPDATE 1"
if "SET config = config - 'default'" in query:
skip = str(arguments[0]) if arguments else None
for name, data in self.realms.items():
if skip is not None and name == skip:
continue
data["config"].pop("default", None)
return "UPDATE 0"
if "DELETE FROM realms" in query:
realm = str(arguments[0])
if realm not in self.realms:
return "DELETE 0"
del self.realms[realm]
return "DELETE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: TokenPool | RealmPool) -> None:
self.pool = pool
def request(pool: TokenPool | RealmPool, principal: str = "root@pam") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = principal
return result
def values(**items: object) -> dict[str, Any]:
return {"values": items, "provided": frozenset(items)}
async def test_token_lifecycle_returns_secret_once_and_persists_metadata() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = TokenPool()
http_request = request(pool)
create = registry.get("/access/users/{userid}/token/{tokenid}", "POST")
get = registry.get("/access/users/{userid}/token/{tokenid}", "GET")
update = registry.get("/access/users/{userid}/token/{tokenid}", "PUT")
delete = registry.get("/access/users/{userid}/token/{tokenid}", "DELETE")
list_tokens = registry.get("/access/users/{userid}/token", "GET")
assert create and get and update and delete and list_tokens
created = await create(
http_request,
values(userid="root@pam", tokenid="test", comment="first", privsep=True),
)
assert created["full-tokenid"] == "root@pam!test"
assert created["value"]
assert "value" not in await get(http_request, values(userid="root@pam", tokenid="test"))
assert await list_tokens(http_request, values(userid="root@pam"))
updated = await update(
http_request,
values(userid="root@pam", tokenid="test", comment="second", privsep=False),
)
assert updated["comment"] == "second"
await delete(http_request, values(userid="root@pam", tokenid="test"))
with pytest.raises(ApiError) as missing:
await get(http_request, values(userid="root@pam", tokenid="test"))
assert missing.value.status_code == 404
async def test_token_lifecycle_rejects_non_owner() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
handler = registry.get("/access/users/{userid}/token", "GET")
assert handler
with pytest.raises(ApiError) as denied:
await handler(request(TokenPool(), "auditor@pve"), values(userid="other@pve"))
assert denied.value.status_code == 403
async def test_domain_lifecycle_persists_realm_config() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = RealmPool()
http_request = request(pool)
create = registry.get("/access/domains", "POST")
listing = registry.get("/access/domains", "GET")
get = registry.get("/access/domains/{realm}", "GET")
update = registry.get("/access/domains/{realm}", "PUT")
delete = registry.get("/access/domains/{realm}", "DELETE")
assert create and listing and get and update and delete
await create(
http_request,
values(
realm="corp",
type="ldap",
comment="Corporate LDAP",
server1="ldap.example.com",
password="secret", # noqa: S106 - fixture secret for unit test
default=1,
),
)
listed = await listing(http_request, values())
assert any(item["realm"] == "corp" and item["type"] == "ldap" for item in listed)
created = await get(http_request, values(realm="corp"))
assert created["comment"] == "Corporate LDAP"
assert created["server1"] == "ldap.example.com"
assert created["default"] == 1
assert "password" not in created
await update(
http_request,
values(realm="corp", comment="Updated LDAP", delete="default"),
)
updated = await get(http_request, values(realm="corp"))
assert updated["comment"] == "Updated LDAP"
assert "default" not in updated
await delete(http_request, values(realm="corp"))
with pytest.raises(ApiError) as missing:
await get(http_request, values(realm="corp"))
assert missing.value.status_code == 404
async def test_domain_delete_rejects_builtin_and_in_use_realms() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = RealmPool()
http_request = request(pool)
delete = registry.get("/access/domains/{realm}", "DELETE")
assert delete
with pytest.raises(ApiError) as builtin:
await delete(http_request, values(realm="pam"))
assert builtin.value.status_code == 400
pool.realms["corp"] = {"kind": "ldap", "config": {}}
pool.principals["alice@corp"] = "corp"
with pytest.raises(ApiError) as in_use:
await delete(http_request, values(realm="corp"))
assert in_use.value.status_code == 400
+61
View File
@@ -0,0 +1,61 @@
"""ACL propagation, token separation, and contract mapping tests."""
from app.contracts.model import Permissions
from app.security.acl import AclEntry, authorize, effective_privileges, requirement_from_contract
ENTRIES = (
AclEntry("alice@pve", "/vms", frozenset({"VM.Audit", "VM.PowerMgmt"})),
AclEntry("alice@pve", "/vms/200", frozenset({"VM.Config"}), propagate=False),
)
def test_acl_propagation_matrix() -> None:
assert effective_privileges("alice@pve", "/vms/100", ENTRIES) == frozenset(
{"VM.Audit", "VM.PowerMgmt"}
)
assert "VM.Config" in effective_privileges("alice@pve", "/vms/200", ENTRIES)
assert "VM.Config" not in effective_privileges("alice@pve", "/vms/200/snapshot", ENTRIES)
assert not effective_privileges("bob@pve", "/vms/100", ENTRIES)
def test_api_token_privileges_are_intersection_not_escalation() -> None:
assert authorize(
"alice@pve",
"/vms/100",
frozenset({"VM.Audit"}),
ENTRIES,
token_privileges=frozenset({"VM.Audit"}),
)
assert not authorize(
"alice@pve",
"/vms/100",
frozenset({"VM.PowerMgmt"}),
ENTRIES,
token_privileges=frozenset({"VM.Audit"}),
)
def test_contract_permission_maps_to_capability_requirement() -> None:
permissions = Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]})
requirement = requirement_from_contract(permissions, {"vmid": "100"})
assert requirement is not None
assert requirement.path == "/vms/100"
assert requirement.privileges == frozenset({"VM.PowerMgmt"})
any_permission = Permissions(
expression={
"check": ["perm", "/vms/{vmid}", ["VM.Config.CPU", "VM.Config.Memory"], "any", 1]
}
)
any_requirement = requirement_from_contract(any_permission, {"vmid": "100"})
assert any_requirement is not None
assert not any_requirement.require_all
assert authorize(
"alice@pve",
"/vms/100",
any_requirement.privileges,
(AclEntry("alice@pve", "/vms", frozenset({"VM.Config.CPU"})),),
require_all=any_requirement.require_all,
)
+106
View File
@@ -0,0 +1,106 @@
"""HTTP-boundary API-token and contract permission tests."""
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import _authenticate
from app.config import Settings
from app.contracts.model import Method, Permissions, Schema
from app.db.pool import AsyncpgDatabase
from app.security.auth import hash_secret
class FakePool:
def __init__(self, secret: str, token_privileges: list[str]) -> None:
self.secret_hash = hash_secret(secret, salt=b"boundary-token-v1")
self.token_privileges = token_privileges
async def fetchrow(self, _query: str, principal: str, token_id: str) -> dict[str, Any] | None:
if principal != "operator@pve" or token_id != "api":
return None
return {
"name": principal,
"secret_hash": self.secret_hash,
"privileges": self.token_privileges,
"privilege_separation": True,
}
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]:
return [
{
"path": "/vms",
"propagate": True,
"privileges": ["VM.Audit", "VM.PowerMgmt"],
"principal": principal,
}
]
class FakeDatabase:
def __init__(self, pool: FakePool) -> None:
self.pool = pool
def token_request(secret: str, token_privileges: list[str]) -> Request:
app = FastAPI()
app.state.settings = Settings()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(FakePool("valid", token_privileges)))
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/api2/json/nodes/pve1/qemu/101/status/start",
"headers": [(b"authorization", f"PVEAPIToken=operator@pve!api={secret}".encode())],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
def power_method() -> Method:
return Method(
verb="POST",
name="start",
returns=Schema(type="string"),
permissions=Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]}),
checksum="1" * 64,
)
async def test_api_token_skips_csrf_but_honors_separated_privileges() -> None:
allowed = token_request("valid", ["VM.PowerMgmt"])
await _authenticate(
allowed,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert allowed.state.principal == "operator@pve"
denied = token_request("valid", ["VM.Audit"])
with pytest.raises(ApiError) as error:
await _authenticate(
denied,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert error.value.status_code == 403
async def test_api_token_rejects_unknown_or_wrong_secret() -> None:
request = token_request("wrong", ["VM.PowerMgmt"])
with pytest.raises(ApiError) as error:
await _authenticate(
request,
"/nodes/{node}/qemu/{vmid}/status/start",
power_method(),
{"values": {"node": "pve1", "vmid": 101}},
)
assert error.value.status_code == 401
+21
View File
@@ -0,0 +1,21 @@
"""Offline checks for the researched API Viewer sample."""
import hashlib
import json
from pathlib import Path
from typing import Any, cast
FIXTURES = Path(__file__).parents[1] / "fixtures" / "api-viewer"
def test_version_fixture_matches_provenance() -> None:
fixture_path = FIXTURES / "pve-9.2.3-version.json"
provenance_path = FIXTURES / "pve-9.2.3-version.provenance.json"
fixture_bytes = fixture_path.read_bytes()
fixture = cast(dict[str, Any], json.loads(fixture_bytes))
provenance = cast(dict[str, Any], json.loads(provenance_path.read_bytes()))
assert fixture["path"] == "/version"
assert fixture["info"]["GET"]["method"] == "GET"
assert hashlib.sha256(fixture_bytes).hexdigest() == provenance["fixture_sha256"]
+68
View File
@@ -0,0 +1,68 @@
"""Authentication, CSRF, token, and redaction matrices."""
import pytest
from starlette.responses import Response
from app.security.auth import (
AuthenticationError,
csrf_token,
hash_secret,
issue_ticket,
parse_api_token,
redact_secrets,
set_ticket_cookie,
verify_csrf,
verify_secret,
verify_ticket,
)
KEY = b"test-signing-key-with-at-least-32-bytes"
def test_password_and_token_hashes_do_not_store_plaintext() -> None:
encoded = hash_secret("correct horse", salt=b"0123456789abcdef")
assert "correct horse" not in encoded
assert verify_secret("correct horse", encoded)
assert not verify_secret("wrong", encoded)
assert not verify_secret("correct horse", "unknown$format")
def test_signed_ticket_expiry_and_csrf() -> None:
ticket = issue_ticket("root@pam", KEY, now=100, ttl=60)
assert verify_ticket(ticket, KEY, now=120).principal == "root@pam"
token = csrf_token(ticket, KEY)
assert verify_csrf(ticket, token, KEY)
assert not verify_csrf(ticket, token + "x", KEY)
with pytest.raises(AuthenticationError, match="expired"):
verify_ticket(ticket, KEY, now=161)
with pytest.raises(AuthenticationError, match="invalid"):
verify_ticket(ticket + "x", KEY, now=120)
def test_ticket_cookie_is_http_only_and_secure() -> None:
response = Response()
set_ticket_cookie(response, "ticket")
header = response.headers["set-cookie"]
assert "PVEAuthCookie=ticket" in header
assert "HttpOnly" in header
assert "Secure" in header
assert "SameSite=strict" in header
def test_api_token_parsing_and_log_redaction() -> None:
token = parse_api_token("PVEAPIToken=user@pve!automation=supersecret")
assert token.principal == "user@pve"
assert token.token_id == "automation"
assert token.secret == "supersecret"
redacted = redact_secrets(
"PVEAPIToken=user@pve!automation=supersecret password=hunter2 token=abc"
)
assert "supersecret" not in redacted
assert "hunter2" not in redacted
assert "token=abc" not in redacted
with pytest.raises(AuthenticationError):
parse_api_token("Bearer secret")
+140
View File
@@ -0,0 +1,140 @@
"""Ceph pool/OSD mutation persistence tests."""
from __future__ import annotations
import json
from typing import Any, cast
from uuid import uuid4
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.ceph import register_ceph_handlers
from app.simulation.seed import CLUSTER_ID
class CephPool:
def __init__(self) -> None:
self.cluster_metadata: dict[str, Any] = {}
self.nodes = {"pve1": {"id": uuid4(), "metadata": {}}}
self.resources: dict[Any, dict[str, Any]] = {}
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
if "r.kind='ceph-osd'" in query and "ORDER BY" in query:
node = str(arguments[0])
node_id = self.nodes[node]["id"]
return [
{"external_id": item["external_id"], "state": item["state"]}
for item in self.resources.values()
if item["node_id"] == node_id
]
raise AssertionError(query)
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.cluster_metadata)}
if "SELECT metadata FROM nodes WHERE name" in query:
node = self.nodes.get(str(arguments[0]))
return None if node is None else {"metadata": json.dumps(node["metadata"])}
if "storage_type='ceph'" in query:
return {"capacity_bytes": 1000, "used_bytes": 100}
if "r.kind='ceph-osd'" in query:
node_name = str(arguments[0])
osdid = str(arguments[1])
node_id = self.nodes[node_name]["id"]
for item in self.resources.values():
if item["node_id"] == node_id and item["external_id"] in {
osdid,
f"osd.{osdid}",
arguments[2] if len(arguments) > 2 else "",
}:
return item
return None
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
if "SELECT id FROM nodes WHERE name" in query:
node = self.nodes.get(str(arguments[0]))
return None if node is None else node["id"]
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in query:
return len(self.resources)
if "COALESCE" in query and "ceph-osd" in query:
return len(self.resources)
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "jsonb_set" in query and "'{ceph}'" in query:
self.cluster_metadata["ceph"] = json.loads(str(arguments[1]))
return "UPDATE 1"
if "UPDATE nodes SET metadata" in query:
self.nodes[str(arguments[0])]["metadata"] = json.loads(str(arguments[1]))
return "UPDATE 1"
if "INSERT INTO resources" in query:
resource_id = uuid4()
self.resources[resource_id] = {
"id": resource_id,
"node_id": arguments[0],
"external_id": arguments[1],
"state": arguments[2],
}
return "INSERT 0 1"
if "UPDATE resources SET state" in query:
existing_id = arguments[0]
self.resources[existing_id]["state"] = arguments[1]
return "UPDATE 1"
if "DELETE FROM resources WHERE id" in query:
self.resources.pop(arguments[0], None)
return "DELETE 1"
raise AssertionError(query)
def request(pool: CephPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
async def test_ceph_pool_and_osd_mutations_persist() -> None:
registry = HandlerRegistry()
register_ceph_handlers(registry)
pool = CephPool()
http = request(pool)
create_pool = registry.get("/nodes/{node}/ceph/pool", "POST")
list_pool = registry.get("/nodes/{node}/ceph/pool", "GET")
create_osd = registry.get("/nodes/{node}/ceph/osd", "POST")
osd_out = registry.get("/nodes/{node}/ceph/osd/{osdid}/out", "POST")
assert create_pool and list_pool and create_osd and osd_out
await create_pool(http, {"values": {"node": "pve1", "name": "vms"}, "provided": frozenset()})
pools = await list_pool(http, {"values": {"node": "pve1"}, "provided": frozenset()})
assert any(item["pool"] == "vms" for item in pools)
assert "vms" in pool.cluster_metadata["ceph"]["pools"]
await create_osd(http, {"values": {"node": "pve1", "dev": "/dev/sdb"}, "provided": frozenset()})
assert len(pool.resources) == 1
resource_id = next(iter(pool.resources))
osdid = "0"
await osd_out(
http,
{"values": {"node": "pve1", "osdid": osdid}, "provided": frozenset()},
)
assert json.loads(pool.resources[resource_id]["state"])["in"] is False
assert CLUSTER_ID
+28
View File
@@ -0,0 +1,28 @@
"""Simulation clock behavior."""
import asyncio
from datetime import UTC, datetime
import pytest
from app.simulation.clock import AcceleratedClock, ManualClock
async def test_manual_clock_releases_sleep_only_after_advance() -> None:
clock = ManualClock(datetime(2026, 1, 1, tzinfo=UTC))
sleeper = asyncio.create_task(clock.sleep(10))
await asyncio.sleep(0)
assert not sleeper.done()
await clock.advance(9)
assert not sleeper.done()
await clock.advance(1)
await sleeper
assert await clock.now() == datetime(2026, 1, 1, 0, 0, 10, tzinfo=UTC)
def test_clocks_reject_invalid_configuration() -> None:
with pytest.raises(ValueError):
AcceleratedClock(0)
with pytest.raises(ValueError):
ManualClock(datetime(2026, 1, 1))
+138
View File
@@ -0,0 +1,138 @@
"""Mapping / ACME / cluster-config durable handlers."""
from __future__ import annotations
import json
from typing import Any, cast
from uuid import uuid4
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.acme import register_acme_handlers
from app.handlers.cluster_config import register_cluster_config_handlers
from app.handlers.mapping import register_mapping_handlers
class MetaPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
self.nodes = {"pve1": {"status": "online"}}
self.cluster_name = "pve-simulator"
async def fetch(self, query: str, *_arguments: object) -> list[dict[str, Any]]:
if "FROM nodes" in query:
return [{"name": name, "status": data["status"]} for name, data in self.nodes.items()]
raise AssertionError(query)
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
if "UPDATE clusters" in query and "SET name" in query:
self.cluster_name = str(arguments[0])
return "UPDATE 1"
if "INSERT INTO nodes" in query:
self.nodes[str(arguments[0])] = {"status": "online"}
return "INSERT 0 1"
if "UPDATE nodes SET status" in query:
self.nodes[str(arguments[0])]["status"] = "offline"
return "UPDATE 1"
raise AssertionError(query)
async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any:
handler = registry.get(path, verb)
assert handler is not None
return await handler(http, inputs)
def request(pool: MetaPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_mapping_acme_config_persist() -> None:
registry = HandlerRegistry()
register_mapping_handlers(registry)
register_acme_handlers(registry)
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
await call(
registry,
"/cluster/mapping/pci",
"POST",
http,
{"values": {"id": "gpu0", "map": "0000:01:00.0"}, "provided": frozenset()},
)
pci = await call(
registry,
"/cluster/mapping/pci/{id}",
"GET",
http,
{"values": {"id": "gpu0"}, "provided": frozenset()},
)
assert pci["map"] == "0000:01:00.0"
await call(
registry,
"/cluster/acme/account",
"POST",
http,
{
"values": {"name": "default", "contact": "admin@example.com", "eab-hmac-key": "x"},
"provided": frozenset(),
},
)
account = await call(
registry,
"/cluster/acme/account/{name}",
"GET",
http,
{"values": {"name": "default"}, "provided": frozenset()},
)
assert account["name"] == "default"
assert "eab-hmac-key" not in account
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab"}, "provided": frozenset()},
)
assert pool.metadata["cluster_config"]["clustername"] == "lab"
assert pool.cluster_name == "lab"
totem = await call(
registry, "/cluster/config/totem", "GET", http, {"values": {}, "provided": frozenset()}
)
assert totem["cluster_name"] == "lab"
assert uuid4()
+154
View File
@@ -0,0 +1,154 @@
"""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
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"]
+128
View File
@@ -0,0 +1,128 @@
"""Catalog-scoped compatibility payload tests."""
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
import pytest
from httpx import ASGITransport, AsyncClient
from app.compatibility import CompatibilityDimension, build_report
from app.config import Settings
from app.contracts.model import Method, PathContract, Schema, Snapshot
from app.main import create_app
from app.web.compatibility_catalog import compatibility_payload
from tests.unit.test_health import FakeDatabase
_BUNDLED = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_PVE7 = Path(
"contracts/2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f/snapshot.json"
)
def _snapshot(source_version: str, path: str) -> Snapshot:
method = Method(
verb="GET",
name="index",
returns=Schema(type="object"),
checksum="1" * 64,
)
return Snapshot(
source_version=source_version,
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=(PathContract(path=path, methods=(method,)),),
path_count=1,
method_count=1,
)
def test_catalog_compatibility_uses_selected_snapshot_version() -> None:
runtime_snapshot = _snapshot("9.2.3", "/version")
catalog_snapshot = _snapshot("7.4-16", "/nodes")
runtime_report = build_report(
runtime_snapshot,
implemented=frozenset({("/version", "GET")}),
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/version", "GET")})},
)
payload = compatibility_payload(
catalog_snapshot,
7,
implemented_methods=frozenset({("/nodes", "GET"), ("/version", "GET")}),
runtime_report=runtime_report,
runtime_version="9.2.3",
settings=None,
)
assert payload["catalog_version"] == "7.4-16"
assert payload["runtime_version"] == "9.2.3"
assert payload["evidence_scope"] == "catalog"
assert payload["total_declared"] == 1
levels = cast(dict[str, dict[str, object]], payload["levels"])
assert levels["implemented"]["count"] == 1
def test_catalog_compatibility_reuses_runtime_report_for_matching_version() -> None:
runtime_snapshot = _snapshot("9.2.3", "/version")
runtime_report = build_report(
runtime_snapshot,
implemented=frozenset({("/version", "GET")}),
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/version", "GET")})},
)
payload = compatibility_payload(
runtime_snapshot,
9,
implemented_methods=frozenset({("/version", "GET")}),
runtime_report=runtime_report,
runtime_version="9.2.3",
settings=None,
)
assert payload["catalog_version"] == "9.2.3"
assert payload["evidence_scope"] == "full"
async def test_ui_compatibility_endpoint_follows_selected_major() -> None:
if not _PVE7.is_file():
pytest.skip("PVE 7 bundled contract is unavailable")
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
major7 = await client.get("/ui/api/compatibility", params={"major": 7})
major9 = await client.get("/ui/api/compatibility", params={"major": 9})
assert major7.status_code == 200
assert major9.status_code == 200
body7 = major7.json()
body9 = major9.json()
pve7_snapshot = Snapshot.model_validate_json(_PVE7.read_bytes())
assert body7["catalog_version"] == pve7_snapshot.source_version
assert body9["catalog_version"] == "9.2.3"
assert body7["total_declared"] == pve7_snapshot.method_count
bundled = Snapshot.model_validate_json(_BUNDLED.read_bytes())
assert body9["total_declared"] == bundled.method_count
assert body7["major"] == 7
assert body9["major"] == 9
# Legacy aliases are kept in implemented_methods so older majors report full coverage.
assert body7["levels"]["implemented"]["count"] == body7["total_declared"]
assert body9["levels"]["implemented"]["count"] == body9["total_declared"]
async def test_ui_compatibility_covers_all_bundled_majors() -> None:
settings = Settings(contract_snapshot=_BUNDLED, compatibility_evidence=None)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
for major in (6, 7, 8, 9):
response = await client.get("/ui/api/compatibility", params={"major": major})
assert response.status_code == 200
body = response.json()
assert body["levels"]["implemented"]["count"] == body["total_declared"]
+110
View File
@@ -0,0 +1,110 @@
"""Golden HTTP input/output compatibility checks."""
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
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
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
+104
View File
@@ -0,0 +1,104 @@
"""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 == {"Yoga", "Antelope", "Caracal", "Dalmatian"}
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"])
dalmatian = next(item for item in majors_list if item["major"] == 9)
assert dalmatian["series"] == "Dalmatian"
assert dalmatian["artifact_url"] == "stub://openstack/dalmatian/api-contract"
assert dalmatian["bundled"] is True
def test_list_majors_honors_settings_overrides() -> None:
settings = Settings(catalog_artifact_url_9="https://example.test/dalmatian/apidoc.js")
payload = list_majors(runtime_version=None, settings=settings)
majors_list = cast(list[dict[str, Any]], payload["majors"])
dalmatian = next(item for item in majors_list if item["major"] == 9)
assert dalmatian["artifact_url"] == "https://example.test/dalmatian/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"] == "Dalmatian"
assert cast(str, payload["artifact_url"]).endswith("dalmatian/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"
+52
View File
@@ -0,0 +1,52 @@
"""Offline command workflows for contract management."""
import argparse
import json
from pathlib import Path
import pytest
from app.contracts.cli import parser, run
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
async def test_validate_command_reports_source_counts(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
arguments = argparse.Namespace(command="validate", store=tmp_path, file=FIXTURE)
assert await run(arguments) == 0
output = capsys.readouterr().out
assert json.loads(output) == {"nodes": 1, "warnings": 0}
async def test_local_import_list_and_show(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
import_arguments = argparse.Namespace(
command="import",
store=tmp_path,
file=FIXTURE,
url=None,
version="9.2.3",
)
assert await run(import_arguments) == 0
revision = Path(capsys.readouterr().out.strip()).name
assert await run(argparse.Namespace(command="list", store=tmp_path)) == 0
assert capsys.readouterr().out.strip() == revision
assert await run(argparse.Namespace(command="show", store=tmp_path, revision=revision)) == 0
manifest = json.loads(capsys.readouterr().out)
assert manifest["source_version"] == "9.2.3"
assert manifest["snapshot_sha256"] == revision
def test_cli_parser_accepts_local_import() -> None:
arguments = parser().parse_args(
["--store", "saved", "import", "--file", str(FIXTURE), "--version", "9.2.3"]
)
assert arguments.command == "import"
assert arguments.store == Path("saved")
+87
View File
@@ -0,0 +1,87 @@
"""Semantic contract diff classification and rendering tests."""
import json
from datetime import UTC, datetime
from app.contracts.diff import (
Severity,
compare_snapshots,
has_breaking_changes,
render_html,
render_json,
render_markdown,
render_text,
)
from app.contracts.model import Method, PathContract, Schema, Snapshot
def snapshot(paths: tuple[PathContract, ...]) -> Snapshot:
return Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=paths,
path_count=len(paths),
method_count=sum(len(path.methods) for path in paths),
)
def method(description: str = "old", returns: Schema | None = None) -> Method:
return Method(
verb="GET",
name="read",
description=description,
returns=returns or Schema(type="string"),
checksum="1" * 64,
)
def test_classifies_added_removed_and_changed_contracts() -> None:
before = snapshot(
(
PathContract(path="/removed", methods=(method(),)),
PathContract(path="/version", methods=(method(),)),
)
)
after = snapshot(
(
PathContract(path="/added", methods=(method(),)),
PathContract(
path="/version",
methods=(method("new", Schema(type="integer", minimum=1)),),
),
)
)
changes = compare_snapshots(before, after)
assert changes == tuple(sorted(changes))
assert {change.category for change in changes} >= {
"path",
"method",
"documentation",
"schema",
"constraint",
}
assert has_breaking_changes(changes)
assert any(change.severity is Severity.NON_BREAKING for change in changes)
def test_renderers_are_stable_and_escape_html() -> None:
before = snapshot((PathContract(path="/<old>", methods=(method(),)),))
after = snapshot(())
changes = compare_snapshots(before, after)
assert render_text(changes).startswith("breaking:")
assert "| breaking |" in render_markdown(changes)
assert "&lt;old&gt;" in render_html(changes)
decoded = json.loads(render_json(changes))
assert decoded[0]["severity"] == "breaking"
assert render_json(changes) == render_json(changes)
def test_no_changes_has_clean_ci_policy() -> None:
value = snapshot((PathContract(path="/version", methods=(method(),)),))
assert compare_snapshots(value, value) == ()
assert not has_breaking_changes(())
+105
View File
@@ -0,0 +1,105 @@
"""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
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
+92
View File
@@ -0,0 +1,92 @@
"""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
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)
+95
View File
@@ -0,0 +1,95 @@
"""Runtime contract hot-swap tests."""
from pathlib import Path
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
from tests.unit.test_health import FakeDatabase
_BUNDLED_9 = Path(
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
)
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
def _app() -> FastAPI:
settings = Settings(
contract_snapshot=_BUNDLED_9,
compatibility_evidence=_EVIDENCE_9,
)
return create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async def test_contract_apply_swaps_version_and_routes() -> None:
app = _app()
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
before = await client.get("/api2/json/version")
assert before.status_code == 200
assert before.json()["data"]["version"] == "9.2.3"
assert before.json()["data"]["release"] == "9.2"
versions = await client.get("/ui/api/versions")
assert versions.status_code == 200
assert versions.json()["runtime_version"] == "9.2.3"
applied = await client.post("/ui/api/contract/apply", params={"major": 7})
assert applied.status_code == 200
payload = applied.json()
assert payload["ok"] is True
assert payload["major"] == 7
assert payload["runtime_version"] == "7.4-16"
assert payload["path_count"] > 0
assert payload["method_count"] > 0
after = await client.get("/api2/json/version")
assert after.status_code == 200
assert after.json()["data"]["version"] == "7.4-16"
assert after.json()["data"]["release"] == "7.4"
versions_after = await client.get("/ui/api/versions")
assert versions_after.json()["runtime_version"] == "7.4-16"
# Still routed (handler or 501), not a missing route / 404.
nodes = await client.get("/api2/json/nodes")
assert nodes.status_code in {200, 401, 501}
restored = await client.post("/ui/api/contract/apply", params={"major": 9})
assert restored.status_code == 200
assert restored.json()["runtime_version"] == "9.2.3"
assert (await client.get("/api2/json/version")).json()["data"]["version"] == "9.2.3"
@pytest.mark.parametrize("major,version", [(6, "6.4-15"), (7, "7.4-16"), (8, "8.4.5")])
async def test_contract_apply_loads_per_major_verified_evidence(major: int, version: str) -> None:
app = _app()
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
applied = await client.post("/ui/api/contract/apply", params={"major": major})
assert applied.status_code == 200
assert applied.json()["runtime_version"] == version
report = await client.get("/admin/compatibility")
body = report.json()
assert body["source_version"] == version
assert body["levels"]["verified"]["count"] == body["total_declared"]
assert body["levels"]["verified"]["count"] > 0
async def test_contract_apply_requires_bootstrapped_contract() -> None:
app = create_app(
settings=Settings(contract_snapshot=None),
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/ui/api/contract/apply", params={"major": 7})
assert response.status_code == 503
+65
View File
@@ -0,0 +1,65 @@
"""Tests for safe API Viewer source parsing."""
import json
from pathlib import Path
import pytest
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceError
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"[]"
+202
View File
@@ -0,0 +1,202 @@
"""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
class FakePool:
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
if "principals" in sql and args[0] == "root@pam":
return {
"name": "root@pam",
"password_hash": hash_secret("secret", salt=b"pve-simulator-v1"),
}
if "FROM nodes" in sql and args[0] == "pve1":
return {"name": "pve1", "status": "online"}
if "FROM resources r" in sql and args == ("pve1", "100"):
if "SELECT r.id" in sql:
return {
"id": uuid.UUID("00000000-0000-0000-0000-000000000100"),
"state": '{"name":"demo","status":"stopped"}',
}
return {
"config": '{"name":"demo"}',
"state": '{"name":"demo","status":"stopped"}',
}
return None
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql:
return [{"node": "pve1", "status": "online"}]
if "r.kind='qemu'" in sql:
return [{"vmid": 100, "state": '{"name":"demo","status":"stopped"}'}]
return [
{
"type": "qemu",
"external_id": "100",
"state": '{"status":"stopped"}',
"node": "pve1",
}
]
async def fetchval(self, sql: str) -> int:
return 100 if "pg_backend_pid" in sql else 1_700_000_000
class FakeDatabase:
pool = FakePool()
async def connect(self) -> None:
pass
async def close(self) -> None:
pass
async def is_ready(self) -> bool:
return True
def method(verb: str, name: str, parameters: tuple[Parameter, ...] = ()) -> Method:
return Method(
verb=verb,
name=name,
parameters=parameters,
returns=Schema(type="object"),
checksum=(name[0] * 64),
)
def write_snapshot(path: Path) -> None:
string = Schema(type="string")
paths = (
PathContract(path="/version", methods=(method("GET", "version"),)),
PathContract(
path="/access/ticket",
methods=(
method(
"POST",
"ticket",
(
Parameter(name="username", definition=string),
Parameter(name="password", definition=string),
),
),
),
),
PathContract(path="/nodes", methods=(method("GET", "nodes"),)),
PathContract(
path="/nodes/{node}/status",
methods=(method("GET", "status", (Parameter(name="node", definition=string),)),),
),
PathContract(path="/cluster/resources", methods=(method("GET", "resources"),)),
PathContract(
path="/nodes/{node}/qemu",
methods=(method("GET", "qemu", (Parameter(name="node", definition=string),)),),
),
PathContract(
path="/nodes/{node}/qemu/{vmid}/config",
methods=(
method(
"GET",
"config",
(
Parameter(name="node", definition=string),
Parameter(name="vmid", definition=Schema(type="integer")),
),
),
),
),
PathContract(
path="/nodes/{node}/qemu/{vmid}/status/start",
methods=(
method(
"POST",
"start",
(
Parameter(name="node", definition=string),
Parameter(name="vmid", definition=Schema(type="integer")),
),
),
),
),
)
snapshot = Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=paths,
path_count=len(paths),
method_count=sum(len(item.methods) for item in paths),
)
path.write_bytes(snapshot.canonical_bytes())
async def test_core_login_and_read_endpoints(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: object) -> Task:
return Task(
uuid.uuid4(),
str(kwargs["upid"]),
str(kwargs["task_type"]),
"queued",
{},
0,
False,
0,
)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
snapshot_path = tmp_path / "snapshot.json"
write_snapshot(snapshot_path)
database = FakeDatabase()
app = create_app(
Settings(contract_snapshot=snapshot_path, compatibility_evidence=None),
lambda _settings: database,
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
login = await client.post(
"/api2/json/access/ticket",
content="username=root%40pam&password=secret",
headers={"content-type": "application/x-www-form-urlencoded"},
)
csrf = login.json()["data"]["CSRFPreventionToken"]
version = await client.get("/api2/json/version")
nodes = await client.get("/api2/json/nodes")
status = await client.get("/api2/json/nodes/pve1/status")
resources = await client.get("/api2/json/cluster/resources")
qemu = await client.get("/api2/json/nodes/pve1/qemu")
config = await client.get("/api2/json/nodes/pve1/qemu/100/config")
start = await client.post(
"/api2/json/nodes/pve1/qemu/100/status/start",
headers={"CSRFPreventionToken": csrf},
)
assert login.status_code == 200
assert login.json()["data"]["username"] == "root@pam"
assert "ticket" in login.json()["data"]
assert version.json()["data"]["version"] == "test"
assert version.json()["data"]["release"] == "test"
assert nodes.json()["data"][0]["node"] == "pve1"
assert status.json()["data"]["status"] == "online"
assert resources.json()["data"][0]["type"] == "qemu"
assert qemu.json()["data"][0]["vmid"] == 100
assert config.json()["data"]["name"] == "demo"
assert start.json()["data"].startswith("UPID:pve1:")
+59
View File
@@ -0,0 +1,59 @@
"""Database primitive behavior independent of PostgreSQL."""
import asyncpg # type: ignore[import-untyped]
import pytest
from app.db.primitives import (
ConflictError,
DatabaseOperationError,
ReferenceError,
RetryPolicy,
TransientDatabaseError,
map_database_error,
require_affected,
retry_transient,
)
def test_error_mapping_is_stable_and_safe() -> None:
assert isinstance(map_database_error(asyncpg.UniqueViolationError("secret")), ConflictError)
assert isinstance(
map_database_error(asyncpg.ForeignKeyViolationError("secret")), ReferenceError
)
assert isinstance(
map_database_error(asyncpg.SerializationError("secret")), TransientDatabaseError
)
assert "secret" not in str(map_database_error(asyncpg.PostgresError("secret")))
def test_affected_row_checks() -> None:
require_affected("UPDATE 1")
with pytest.raises(DatabaseOperationError, match="expected 1"):
require_affected("UPDATE 0")
with pytest.raises(DatabaseOperationError, match="unrecognized"):
require_affected("BROKEN")
async def test_transient_retry_is_bounded() -> None:
calls = 0
async def operation() -> str:
nonlocal calls
calls += 1
if calls < 3:
raise TransientDatabaseError("retry")
return "ok"
assert await retry_transient(operation, RetryPolicy(attempts=3, base_delay_seconds=0)) == "ok"
assert calls == 3
async def test_transient_retry_propagates_final_failure() -> None:
async def operation() -> None:
raise TransientDatabaseError("retry")
with pytest.raises(TransientDatabaseError):
await retry_transient(operation, RetryPolicy(attempts=2, base_delay_seconds=0))
with pytest.raises(ValueError, match="positive"):
await retry_transient(operation, RetryPolicy(attempts=0))
+99
View File
@@ -0,0 +1,99 @@
"""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
def contract_snapshot(*methods: Method) -> Snapshot:
paths = (PathContract(path="/version", methods=methods),)
return Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=paths,
path_count=1,
method_count=len(methods),
)
def get_method() -> Method:
return Method(
verb="GET",
name="version",
returns=Schema(type="object", properties={"version": Schema(type="string")}),
checksum="1" * 64,
)
async def request_app(
tmp_path: Path, fallback: str, handlers: HandlerRegistry | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
snapshot_path = tmp_path / "snapshot.json"
snapshot_path.write_bytes(contract_snapshot(get_method()).canonical_bytes())
settings = Settings(
contract_snapshot=snapshot_path,
contract_fallback=fallback,
compatibility_evidence=None,
)
database = FakeDatabase(True)
app = create_app(
settings,
lambda _settings: database,
handlers if handlers is not None else HandlerRegistry(),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
json_response = await client.get("/api2/json/version")
extjs_response = await client.get("/api2/extjs/version")
return json_response.json(), extjs_response.json()
async def test_registered_handler_serves_both_renderers(tmp_path: Path) -> None:
handlers = HandlerRegistry()
async def version(_request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
return {"version": "9.2.3"}
handlers.register("/version", "GET", version)
json_body, extjs_body = await request_app(tmp_path, "error", handlers)
assert json_body == {"data": {"version": "9.2.3"}}
assert extjs_body == {"data": {"version": "9.2.3"}, "success": True}
async def test_explicit_fallback_modes(tmp_path: Path) -> None:
error_body, _ = await request_app(tmp_path, "error")
default_body, _ = await request_app(tmp_path, "schema-default")
assert error_body["errors"] == "handler pending for this contract method"
assert default_body["data"]["version"] in {None, "example"}
def test_duplicate_snapshot_routes_are_rejected() -> None:
with pytest.raises(ValidationError, match="duplicate"):
contract_snapshot(get_method(), get_method())
def test_duplicate_semantic_handlers_are_rejected() -> None:
handlers = HandlerRegistry()
async def handler(_request: Request, _inputs: dict[str, Any]) -> None:
return None
handlers.register("/version", "GET", handler)
with pytest.raises(RouteCollisionError, match="duplicate"):
handlers.register("/version", "GET", handler)
+180
View File
@@ -0,0 +1,180 @@
"""Tests for cluster, storage, pool and ceph handlers."""
from __future__ import annotations
import uuid
from typing import Any
import pytest
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.ceph import register_ceph_handlers
from app.handlers.cluster import register_cluster_handlers
from app.handlers.pools import register_pool_handlers
from app.handlers.storage import register_storage_handlers
class HandlerPool:
def __init__(self) -> None:
self.node_exists = True
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql and "ORDER BY name" in sql:
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
if "FROM storages" in sql and "DISTINCT storage_id" in sql:
return [{"storage_id": "local-lvm-pve01"}]
if "FROM storages s" in sql:
return [
{
"storage_id": "local-lvm-pve01",
"storage_type": "lvmthin",
"shared": False,
"capacity_bytes": 1_000_000,
"used_bytes": 250_000,
"config": '{"content":["images"]}',
}
]
if "ceph-osd" in sql:
return [
{
"external_id": "osd.0",
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0}',
}
]
if "FROM pools" in sql:
return [
{
"id": uuid.uuid4(),
"pool_id": "production",
"comment": "prod",
"metadata": '{"members":["100"]}',
}
]
if "FROM pool_members" in sql:
return [{"external_id": "100"}]
if "FROM task_logs" in sql:
return [{"message": "seeded task", "sequence": 1}]
if "FROM tasks" in sql:
return [{"upid": "UPID:pve01:1:1:1:qmstart:100:root@pam:"}]
if "FROM storage_contents" in sql or "FROM backups" in sql:
return []
raise AssertionError(sql)
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
del args
if "FROM nodes WHERE name" in sql:
return {"name": "pve01", "status": "online"} if self.node_exists else None
if "FROM clusters" in sql:
return {"metadata": '{"options":{"keyboard":"de-ch"}}'}
if "FROM storages" in sql:
return {
"storage_id": "local-lvm-pve01",
"storage_type": "lvmthin",
"shared": False,
"capacity_bytes": 1_000_000,
"used_bytes": 250_000,
"config": '{"content":["images"]}',
"node_name": "pve01",
"resource_id": uuid.uuid4(),
}
if "ceph-osd" in sql:
return {
"external_id": "osd.0",
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0,"size_bytes":1000}',
}
if "storage_type='ceph'" in sql:
return {"capacity_bytes": 5_000_000, "used_bytes": 3_000_000}
raise AssertionError(sql)
async def fetchval(self, sql: str, *args: object) -> Any:
del args
if "EXISTS(SELECT 1 FROM nodes" in sql:
return self.node_exists
if "MAX(external_id::integer)" in sql:
return 150
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in sql:
return 300
if "SELECT resource_id FROM storages" in sql:
return uuid.uuid4()
return False
async def execute(self, sql: str, *args: object) -> str:
del sql, args
return "UPDATE 1"
def _request(pool: HandlerPool) -> Request:
app = type("App", (), {})()
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"query_string": b"",
"client": ("test", 1234),
"server": ("test", 80),
"scheme": "http",
"root_path": "",
"app": app,
}
request = Request(scope)
request.state.principal = "root@pam"
return request
async def _call(handler: Any, values: dict[str, Any], pool: HandlerPool | None = None) -> Any:
return await handler(_request(pool or HandlerPool()), {"values": values})
@pytest.mark.asyncio
async def test_cluster_status_and_nextid() -> None:
registry = HandlerRegistry()
register_cluster_handlers(registry)
status = await _call(registry.get("/cluster/status", "GET"), {})
assert status[0]["name"] == "pve01"
nextid = await _call(registry.get("/cluster/nextid", "GET"), {})
assert nextid == 151
@pytest.mark.asyncio
async def test_storage_and_ceph_handlers() -> None:
registry = HandlerRegistry()
register_storage_handlers(registry)
register_ceph_handlers(registry)
storage = await _call(
registry.get("/nodes/{node}/storage", "GET"),
{"node": "pve01"},
)
assert storage[0]["storage"] == "local-lvm-pve01"
osds = await _call(
registry.get("/nodes/{node}/ceph/osd", "GET"),
{"node": "pve01"},
)
assert osds[0]["status"] == "up"
ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {})
assert ceph_status["osdmap"]["num_osds"] == 300
@pytest.mark.asyncio
async def test_pools_list() -> None:
registry = HandlerRegistry()
register_pool_handlers(registry)
pools = await _call(registry.get("/pools", "GET"), {})
assert pools[0]["poolid"] == "production"
assert pools[0]["members"] == ["100"]
@pytest.mark.asyncio
async def test_missing_node_returns_404() -> None:
registry = HandlerRegistry()
register_storage_handlers(registry)
pool = HandlerPool()
pool.node_exists = False
handler = registry.get("/nodes/{node}/storage", "GET")
assert handler is not None
with pytest.raises(ApiError, match="node does not exist"):
await handler(_request(pool), {"values": {"node": "missing"}})
+83
View File
@@ -0,0 +1,83 @@
"""Firewall aliases/ipset/group persistence tests."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.firewall import register_firewall_handlers
from app.simulation.seed import CLUSTER_ID
class FirewallPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "jsonb_set" in query:
# args: CLUSTER_ID, firewall json
self.metadata["firewall"] = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
def request(pool: FirewallPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
async def test_firewall_alias_and_ipset_persist() -> None:
registry = HandlerRegistry()
register_firewall_handlers(registry)
pool = FirewallPool()
http = request(pool)
create_alias = registry.get("/cluster/firewall/aliases", "POST")
list_alias = registry.get("/cluster/firewall/aliases", "GET")
create_ipset = registry.get("/cluster/firewall/ipset", "POST")
add_ip = registry.get("/cluster/firewall/ipset/{name}", "POST")
get_ipset = registry.get("/cluster/firewall/ipset/{name}", "GET")
assert create_alias and list_alias and create_ipset and add_ip and get_ipset
await create_alias(
http, {"values": {"name": "lan", "cidr": "10.0.0.0/8"}, "provided": frozenset()}
)
aliases = await list_alias(http, {"values": {}, "provided": frozenset()})
assert aliases[0]["name"] == "lan"
await create_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
await add_ip(
http,
{"values": {"name": "blacklist", "cidr": "203.0.113.10"}, "provided": frozenset()},
)
entries = await get_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
assert entries[0]["cidr"] == "203.0.113.10"
assert "scopes" in pool.metadata["firewall"]
assert CLUSTER_ID
+288
View File
@@ -0,0 +1,288 @@
"""Tests for gap-plan handler implementations."""
from __future__ import annotations
import json
import uuid
from typing import Any
import pytest
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.access import register_access_handlers
from app.handlers.cluster import register_cluster_handlers
from app.handlers.ha import register_ha_handlers
from app.handlers.storage import register_storage_handlers
class GapPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {
"options": {"keyboard": "en-us"},
"replication": [],
"ha_groups": {},
}
self.node_metadata: dict[str, Any] = {}
self.node_exists = True
self.storage_resource_id = uuid.uuid4()
self.storage_contents: list[dict[str, object]] = []
self.principals = {"root@pam": {"enabled": True, "realm": "pam"}}
self.groups = {"operators": {"comment": "ops", "users": ["root@pam"]}}
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql and "ORDER BY name" in sql:
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
if "FROM tasks" in sql:
return []
if "FROM task_logs" in sql:
return []
if "FROM resources r JOIN nodes" in sql and "kind='ha'" in sql:
return []
if "FROM storage_contents" in sql and "ORDER BY" in sql:
return list(self.storage_contents)
if "FROM backups" in sql and "ORDER BY created_at DESC" in sql and "OFFSET" not in sql:
return []
if "FROM principals p" in sql and "ORDER BY p.name" in sql:
return [
{
"name": name,
"realm_name": data["realm"],
"enabled": data["enabled"],
"realm_kind": data["realm"],
}
for name, data in self.principals.items()
]
if "FROM identity_groups g" in sql and "GROUP BY" in sql:
return [
{
"group_id": group_id,
"comment": data["comment"],
"users": data["users"],
}
for group_id, data in self.groups.items()
]
raise AssertionError(sql)
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
if "FROM clusters WHERE id" in sql:
return {"metadata": json.dumps(self.metadata)}
if "FROM nodes WHERE name" in sql and "metadata" in sql:
name = str(args[0])
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
if "FROM nodes WHERE name" in sql:
return {"name": "pve01", "id": uuid.uuid4()} if self.node_exists else None
if "FROM storages WHERE storage_id" in sql and "resource_id" in sql:
return {"resource_id": self.storage_resource_id}
if "FROM storages s" in sql and "JOIN" in sql:
return {
"storage_id": "local-lvm",
"storage_type": "lvmthin",
"shared": False,
"capacity_bytes": 1_000_000,
"used_bytes": 250_000,
"config": '{"content":["images"]}',
"node_name": "pve01",
"resource_id": self.storage_resource_id,
}
if "FROM storage_contents" in sql and "volume_id=$2" in sql:
volume = str(args[1])
for item in self.storage_contents:
if item["volume_id"] == volume:
return item
return {
"volume_id": "local-lvm:100/vm-100-disk-0.raw",
"content_type": "images",
"size_bytes": 1024,
"metadata": '{"format":"raw"}',
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
}
if "FROM principals" in sql and "WHERE" in sql and "name" in sql:
userid = str(args[0])
if userid not in self.principals:
return None
data = self.principals[userid]
return {
"name": userid,
"realm_name": data["realm"],
"enabled": data["enabled"],
"realm_kind": data["realm"],
"id": uuid.uuid4(),
}
if "FROM identity_groups WHERE group_id" in sql:
groupid = str(args[0])
if groupid not in self.groups:
return None
return {"id": uuid.uuid4(), "group_id": groupid}
if "FROM identity_groups g" in sql and "WHERE g.group_id" in sql:
groupid = str(args[0])
if groupid not in self.groups:
return None
group_data = self.groups[groupid]
return {
"group_id": groupid,
"comment": group_data["comment"],
"users": group_data["users"],
}
if "count(*) FILTER" in sql and "kind='ha'" in sql:
return {"started": 0, "total": 0}
raise AssertionError(sql)
async def fetchval(self, sql: str, *args: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in sql:
return self.node_exists
if "MAX(external_id::integer)" in sql:
return 150
if "SELECT resource_id FROM storages" in sql:
return self.storage_resource_id
if "EXISTS(SELECT 1 FROM principals" in sql:
return False
if "EXISTS(SELECT 1 FROM realms" in sql:
return True
if "EXISTS(SELECT 1 FROM identity_groups" in sql:
return False
if "EXISTS(SELECT 1 FROM resources WHERE kind='ha'" in sql:
return False
if "SELECT metadata FROM nodes" in sql:
return json.dumps(self.node_metadata.get(str(args[0]), {}))
if "SELECT name FROM nodes WHERE status" in sql:
return "pve01"
return False
async def execute(self, sql: str, *args: object) -> str:
if "UPDATE clusters SET metadata" in sql:
self.metadata = json.loads(str(args[1]))
return "UPDATE 1"
if "UPDATE nodes SET metadata" in sql:
self.node_metadata[str(args[0])] = json.loads(str(args[1]))
return "UPDATE 1"
if "INSERT INTO storage_contents" in sql:
self.storage_contents.append(
{
"volume_id": str(args[1]),
"content_type": str(args[2]),
"size_bytes": int(str(args[3])),
"metadata": str(args[4]),
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
}
)
return "INSERT 0 1"
if "INSERT INTO resources" in sql and "kind='ha'" in sql:
return "INSERT 0 1"
if "DELETE FROM" in sql:
return "DELETE 1"
return "UPDATE 1"
def _request(pool: GapPool) -> Request:
app = type("App", (), {})()
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"query_string": b"",
"client": ("test", 1234),
"server": ("test", 80),
"scheme": "http",
"root_path": "",
"app": app,
}
request = Request(scope)
request.state.principal = "root@pam"
return request
async def _call(handler: Any, values: dict[str, Any], pool: GapPool | None = None) -> Any:
return await handler(
_request(pool or GapPool()),
{"values": values, "provided": tuple(values)},
)
@pytest.mark.asyncio
async def test_cluster_index_and_replication_crud() -> None:
registry = HandlerRegistry()
register_cluster_handlers(registry)
pool = GapPool()
index = await _call(registry.get("/cluster", "GET"), {}, pool)
assert any(item["subdir"] == "replication" for item in index)
created = await _call(
registry.get("/cluster/replication", "POST"),
{"guest": "100", "target": "pve02"},
pool,
)
assert created["id"] == "repl-100"
jobs = await _call(registry.get("/cluster/replication", "GET"), {}, pool)
assert jobs[0]["guest"] == "100"
fetched = await _call(
registry.get("/cluster/replication/{id}", "GET"), {"id": "repl-100"}, pool
)
assert fetched["target"] == "pve02"
@pytest.mark.asyncio
async def test_ha_group_create_and_index() -> None:
registry = HandlerRegistry()
register_ha_handlers(registry)
pool = GapPool()
index = await _call(registry.get("/cluster/ha", "GET"), {}, pool)
assert any(item["subdir"] == "groups" for item in index)
await _call(
registry.get("/cluster/ha/groups", "POST"),
{"group": "lab", "nodes": "pve01,pve02"},
pool,
)
assert "lab" in pool.metadata["ha_groups"]
groups = await _call(registry.get("/cluster/ha/groups", "GET"), {}, pool)
assert groups[0]["group"] == "lab"
@pytest.mark.asyncio
async def test_access_user_and_group_detail() -> None:
registry = HandlerRegistry()
register_access_handlers(registry)
pool = GapPool()
user = await _call(registry.get("/access/users/{userid}", "GET"), {"userid": "root@pam"}, pool)
assert user["userid"] == "root@pam"
group = await _call(
registry.get("/access/groups/{groupid}", "GET"),
{"groupid": "operators"},
pool,
)
assert group["users"] == ["root@pam"]
@pytest.mark.asyncio
async def test_storage_content_get_and_upload() -> None:
registry = HandlerRegistry()
register_storage_handlers(registry)
pool = GapPool()
item = await _call(
registry.get("/nodes/{node}/storage/{storage}/content/{volume}", "GET"),
{
"node": "pve01",
"storage": "local-lvm",
"volume": "local-lvm:100/vm-100-disk-0.raw",
},
pool,
)
assert item["content"] == "images"
upload = await _call(
registry.get("/nodes/{node}/storage/{storage}/upload", "POST"),
{"node": "pve01", "storage": "local-lvm", "filename": "image.iso"},
pool,
)
assert "uploadid" in upload
@pytest.mark.asyncio
async def test_replication_missing_returns_404() -> None:
registry = HandlerRegistry()
register_cluster_handlers(registry)
handler = registry.get("/cluster/replication/{id}", "GET")
with pytest.raises(ApiError, match="replication job does not exist"):
await _call(handler, {"id": "missing"}, GapPool())
+163
View File
@@ -0,0 +1,163 @@
"""Persistence tests for remaining gap handlers (nodes_extra / cluster_extra)."""
from __future__ import annotations
import json
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.cluster_extra import register_cluster_extra_handlers
from app.handlers.nodes_extra import register_nodes_extra_handlers
class GapRemainingPool:
def __init__(self) -> None:
self.cluster_metadata: dict[str, Any] = {}
self.node_metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "SELECT metadata FROM clusters" in query:
return {"metadata": json.dumps(self.cluster_metadata)}
if "SELECT metadata FROM nodes" in query:
name = str(arguments[0])
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.cluster_metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
if "UPDATE nodes SET metadata" in query:
self.node_metadata[str(arguments[0])] = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: GapRemainingPool) -> None:
self.pool = pool
def _request(pool: GapRemainingPool, *, method: str = "GET") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
{
"type": "http",
"app": app,
"method": method,
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
@pytest.mark.asyncio
async def test_disks_directory_create_persists() -> None:
registry = HandlerRegistry()
register_nodes_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/nodes/{node}/disks/directory", "POST")
assert create is not None
created = await create(
_request(pool, method="POST"),
{
"values": {"node": "pve01", "name": "tank", "device": "/dev/sdb"},
"provided": frozenset(),
},
)
assert created["name"] == "tank"
ops = pool.node_metadata["pve01"]["ops"]
assert any(item["name"] == "tank" for item in ops["disks"]["directory"])
@pytest.mark.asyncio
async def test_certificates_custom_create_does_not_echo_key() -> None:
registry = HandlerRegistry()
register_nodes_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/nodes/{node}/certificates/custom", "POST")
info = registry.get("/nodes/{node}/certificates/info", "GET")
assert create is not None and info is not None
await create(
_request(pool, method="POST"),
{
"values": {
"node": "pve01",
"certificates": "-----BEGIN CERTIFICATE-----\nSIM\n-----END CERTIFICATE-----",
"key": "-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----",
},
"provided": frozenset(),
},
)
stored = pool.node_metadata["pve01"]["ops"]["certificates"]["custom"]
assert stored["key"].startswith("-----BEGIN PRIVATE KEY-----")
listing = await info(
_request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
blob = json.dumps(listing)
assert "PRIVATE KEY" not in blob
assert "SECRET" not in blob
@pytest.mark.asyncio
async def test_realm_sync_job_create_persists() -> None:
registry = HandlerRegistry()
register_cluster_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/cluster/jobs/realm-sync/{id}", "POST")
listing = registry.get("/cluster/jobs/realm-sync", "GET")
assert create is not None and listing is not None
created = await create(
_request(pool, method="POST"),
{
"values": {"id": "pam-nightly", "realm": "pam", "schedule": "0 2 * * *"},
"provided": frozenset(),
},
)
assert created["id"] == "pam-nightly"
assert pool.cluster_metadata["jobs"]["realm_sync"]["pam-nightly"]["realm"] == "pam"
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
assert items[0]["id"] == "pam-nightly"
@pytest.mark.asyncio
async def test_metrics_server_create_persists() -> None:
registry = HandlerRegistry()
register_cluster_extra_handlers(registry)
pool = GapRemainingPool()
create = registry.get("/cluster/metrics/server/{id}", "POST")
listing = registry.get("/cluster/metrics/server", "GET")
assert create is not None and listing is not None
created = await create(
_request(pool, method="POST"),
{
"values": {
"id": "influx1",
"type": "influxdb",
"server": "10.0.0.20",
"port": 8089,
},
"provided": frozenset(),
},
)
assert created["id"] == "influx1"
assert pool.cluster_metadata["metrics"]["servers"]["influx1"]["server"] == "10.0.0.20"
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
assert items[0]["id"] == "influx1"
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import asyncio
from typing import Self
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db.pool import Database
from app.main import create_app
class FakeDatabase:
def __init__(self, ready: bool) -> None:
self.ready = ready
self.connected = False
self.closed = False
async def connect(self) -> None:
self.connected = True
async def close(self) -> None:
self.closed = True
async def is_ready(self) -> bool:
return self.ready
async def __aenter__(self) -> Self:
await self.connect()
return self
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
await self.close()
@pytest.mark.parametrize(("database_ready", "status_code"), [(True, 200), (False, 503)])
async def test_health_endpoints(database_ready: bool, status_code: int) -> None:
database = FakeDatabase(database_ready)
def factory(settings: Settings) -> Database:
del settings
return database
application = create_app(
Settings(contract_snapshot=None, compatibility_evidence=None),
factory,
worker_factories=(),
)
async with application.router.lifespan_context(application):
async with AsyncClient(
transport=ASGITransport(app=application, raise_app_exceptions=False),
base_url="http://test",
) as client:
live = await client.get("/health/live")
ready = await client.get("/health/ready", headers={"X-Request-ID": "test-request"})
assert live.status_code == 200
assert live.json() == {"status": "ok"}
assert ready.status_code == status_code
assert ready.headers["X-Request-ID"] == "test-request"
assert database.connected
assert database.closed
async def test_lifespan_starts_and_stops_injected_workers() -> None:
database = FakeDatabase(True)
started = asyncio.Event()
stopping = asyncio.Event()
class Worker:
async def run(self) -> None:
started.set()
await stopping.wait()
def stop(self) -> None:
stopping.set()
application = create_app(
Settings(contract_snapshot=None, compatibility_evidence=None),
lambda _settings: database,
worker_factories=(lambda _database: Worker(),),
)
async with application.router.lifespan_context(application):
await started.wait()
assert stopping.is_set()
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import json
import logging
from app.logging import JsonFormatter
def test_json_formatter_emits_structured_fields() -> None:
record = logging.LogRecord("test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
record.request_id = "request-1"
payload = json.loads(JsonFormatter().format(record))
assert payload["message"] == "hello world"
assert payload["request_id"] == "request-1"
assert payload["level"] == "INFO"
+155
View File
@@ -0,0 +1,155 @@
"""Persistent LXC semantic handler tests."""
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.lxc import register_lxc_handlers
class LxcPool:
def __init__(self) -> None:
self.resource_exists = False
self.missing = False
self.running = False
self.commands: list[str] = []
self.resource_id = uuid.uuid4()
async def fetchval(self, sql: str, *args: object) -> bool | int:
del args
if "pg_backend_pid" in sql:
return 123
if "extract(epoch" in sql:
return 1_700_000_000
if "FROM nodes" in sql:
return True
if "FROM resources" in sql:
return self.resource_exists
if "FROM snapshots" in sql:
return False
raise AssertionError(sql)
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM resources" in sql and "kind='lxc'" in sql:
return [{"vmid": 200, "state": '{"status":"stopped","name":"service"}'}]
assert "FROM snapshots" in sql
return [
{
"name": "baseline",
"parent_name": None,
"description": "stable",
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
}
]
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
del args
if self.missing:
return None
if "SELECT r.id, r.version" in sql:
return {
"id": self.resource_id,
"version": 1,
"state": '{"name":"old","status":"stopped"}',
"config": '{"name":"old"}',
}
if "SELECT r.id, r.state, c.config" in sql:
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
if "SELECT r.id, r.state FROM resources" in sql:
status = "running" if self.running else "stopped"
return {"id": self.resource_id, "state": f'{{"status":"{status}"}}'}
if "SELECT s.* FROM snapshots" in sql:
return {
"id": uuid.uuid4(),
"name": "baseline",
"parent_name": None,
"description": "stable",
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
"state": "{}",
}
raise AssertionError(sql)
async def execute(self, sql: str, *args: object) -> str:
del sql, args
self.commands.append("execute")
return "UPDATE 1"
class FakeDatabase:
def __init__(self, pool: LxcPool) -> None:
self.pool = pool
class FakeTaskRepository:
def __init__(self, pool: LxcPool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type(
"Task", (), {"upid": "UPID:pve1:00000001:00000001:1700000000:pctcreate:201:root@pam:"}
)()
def _request(pool: LxcPool) -> Request:
app = type("App", (), {"state": type("State", (), {"database": FakeDatabase(pool)})()})()
request = Request({"type": "http", "headers": [], "method": "POST", "path": "/"})
request.scope["app"] = app
request.state.principal = "root@pam"
return request
@pytest.fixture
def registry() -> HandlerRegistry:
handler_registry = HandlerRegistry()
register_lxc_handlers(handler_registry)
return handler_registry
async def test_lxc_list_returns_seeded_containers(registry: HandlerRegistry) -> None:
pool = LxcPool()
handler = registry.get("/nodes/{node}/lxc", "GET")
assert handler is not None
result = await handler(_request(pool), {"values": {"node": "pve1"}})
assert result == [{"vmid": 200, "status": "stopped", "name": "service"}]
async def test_lxc_create_rejects_duplicate_vmid(registry: HandlerRegistry) -> None:
pool = LxcPool()
pool.resource_exists = True
handler = registry.get("/nodes/{node}/lxc", "POST")
assert handler is not None
with pytest.raises(ApiError, match="VMID already exists"):
await handler(
_request(pool),
{"values": {"node": "pve1", "vmid": 201, "hostname": "app"}},
)
async def test_lxc_delete_requires_stopped_container(registry: HandlerRegistry) -> None:
pool = LxcPool()
pool.running = True
handler = registry.get("/nodes/{node}/lxc/{vmid}", "DELETE")
assert handler is not None
with pytest.raises(ApiError, match="cannot delete a running container"):
await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
async def test_lxc_start_creates_task(
monkeypatch: pytest.MonkeyPatch, registry: HandlerRegistry
) -> None:
pool = LxcPool()
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.lxc.TaskRepository", lambda _pool: repository)
handler = registry.get("/nodes/{node}/lxc/{vmid}/status/start", "POST")
assert handler is not None
upid = await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
assert upid.startswith("UPID:")
assert repository.created[0]["task_type"] == "lxc-start"
+56
View File
@@ -0,0 +1,56 @@
"""Migration discovery and checksum tests."""
from pathlib import Path
from app.db.migrations import load_migrations
def test_load_migrations_is_ordered_and_checksummed(tmp_path: Path) -> None:
(tmp_path / "002_second.sql").write_text("SELECT 2;")
(tmp_path / "001_first.sql").write_text("SELECT 1;")
migrations = load_migrations(tmp_path)
assert [migration.version for migration in migrations] == [1, 2]
assert migrations[0].name == "001_first"
assert len(migrations[0].checksum) == 64
def test_repository_migration_defines_required_planes() -> None:
migrations = load_migrations()
migration = migrations[0]
for table in (
"contract_snapshots",
"nodes",
"resources",
"principals",
"acl_entries",
"tasks",
"scenarios",
"audit_events",
):
assert f"CREATE TABLE {table}" in migration.sql
assert "CREATE TABLE realms" in migrations[1].sql
assert "CREATE TABLE api_tokens" in migrations[1].sql
domain = migrations[3].sql
for table in (
"clusters",
"virtual_machines",
"containers",
"storages",
"storage_contents",
"snapshots",
"backups",
"pools",
"identity_groups",
"contract_paths",
"observed_contracts",
"scenario_rules",
"fault_injections",
):
assert f"CREATE TABLE {table}" in domain
assert "CREATE TABLE group_acl_entries" in migrations[5].sql
assert "ADD COLUMN IF NOT EXISTS config jsonb" in migrations[6].sql
assert "CREATE TABLE tfa_entries" in migrations[7].sql
assert "CREATE TABLE openid_pending" in migrations[7].sql
+128
View File
@@ -0,0 +1,128 @@
"""Node ops handlers persist network/disks/services into nodes.metadata."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.nodes import register_node_ops_handlers
class NodePool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "SELECT metadata FROM nodes" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return True
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE nodes SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
class FakeDatabase:
def __init__(self, pool: NodePool) -> None:
self.pool = pool
def request(pool: NodePool, *, method: str = "GET", path: str = "/") -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
{
"type": "http",
"app": app,
"method": method,
"path": path,
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
async def test_network_and_service_mutations_persist() -> None:
registry = HandlerRegistry()
register_node_ops_handlers(registry)
pool = NodePool()
create = registry.get("/nodes/{node}/network", "POST")
listing = registry.get("/nodes/{node}/network", "GET")
delete = registry.get("/nodes/{node}/network/{iface}", "DELETE")
stop = registry.get("/nodes/{node}/services/{service}/stop", "POST")
state = registry.get("/nodes/{node}/services/{service}/state", "GET")
assert create and listing and delete and stop and state
await create(
request(pool, method="POST", path="/api2/json/nodes/pve01/network"),
{"values": {"node": "pve01", "iface": "vmbr9", "type": "bridge"}, "provided": frozenset()},
)
items = await listing(
request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
assert any(item["iface"] == "vmbr9" for item in items)
await delete(
request(pool, method="DELETE", path="/api2/json/nodes/pve01/network/vmbr9"),
{"values": {"node": "pve01", "iface": "vmbr9"}, "provided": frozenset()},
)
items = await listing(
request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
assert all(item["iface"] != "vmbr9" for item in items)
await stop(
request(pool, method="POST", path="/api2/json/nodes/pve01/services/pveproxy/stop"),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
)
service = await state(
request(pool),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
)
assert service["state"] == "stopped"
assert "ops" in pool.metadata
async def test_disk_init_and_wipe_persist() -> None:
registry = HandlerRegistry()
register_node_ops_handlers(registry)
pool = NodePool()
initgpt = registry.get("/nodes/{node}/disks/initgpt", "POST")
wipe = registry.get("/nodes/{node}/disks/wipedisk", "PUT")
listing = registry.get("/nodes/{node}/disks/list", "GET")
assert initgpt and wipe and listing
await initgpt(
request(pool, method="POST"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
)
await wipe(
request(pool, method="PUT"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
)
disks = await listing(
request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
target = next(item for item in disks if item["devpath"] == "/dev/sdb")
assert target["wiped"] == 1
assert target["gpt"] == 0
+87
View File
@@ -0,0 +1,87 @@
"""Notification endpoints/matchers persistence."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.notifications import register_notifications_handlers
from app.simulation.seed import CLUSTER_ID
class NotesPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
def request(pool: NotesPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_notification_endpoint_and_matcher_persist() -> None:
registry = HandlerRegistry()
register_notifications_handlers(registry)
pool = NotesPool()
http = request(pool)
create = registry.get("/cluster/notifications/endpoints/gotify", "POST")
get = registry.get("/cluster/notifications/endpoints/gotify/{name}", "GET")
matchers = registry.get("/cluster/notifications/matchers", "POST")
targets = registry.get("/cluster/notifications/targets", "GET")
test = registry.get("/cluster/notifications/targets/{name}/test", "POST")
assert create and get and matchers and targets and test
await create(
http,
{
"values": {
"name": "ops",
"server": "https://gotify.local",
"token": "secret-token",
},
"provided": frozenset(),
},
)
payload = await get(http, {"values": {"name": "ops"}, "provided": frozenset()})
assert payload["server"] == "https://gotify.local"
assert "token" not in payload
await matchers(
http,
{
"values": {"name": "all-mail", "target": "ops", "mode": "all"},
"provided": frozenset(),
},
)
listed = await targets(http, {"values": {}, "provided": frozenset()})
assert listed[0]["name"] == "ops"
await test(http, {"values": {"name": "ops"}, "provided": frozenset()})
assert pool.metadata["notifications"]["tests"]
assert CLUSTER_ID
+19
View File
@@ -0,0 +1,19 @@
"""OpenAPI tag categorization tests."""
from app.api.openapi import openapi_tag_metadata
def test_openapi_tag_metadata_is_openstack_only() -> None:
names = [entry["name"] for entry in openapi_tag_metadata()]
assert names == sorted(names)
assert "Simulator" in names
assert "Keystone" in names
assert "Nova" in names
assert "API2 JSON" not in names
assert "API2 ExtJS" not in names
assert "Core" not in names
assert "Access" not in names
assert "Nodes" not in names
assert "Pools" not in names
assert not any(name.startswith("Nodes ·") for name in names)
assert not any(name.startswith("Cluster ·") for name in names)
+15
View File
@@ -0,0 +1,15 @@
"""OpenStack catalog helper tests."""
from app.openstack.catalog import build_catalog, public_base
def test_public_base() -> None:
assert public_base("localhost", 5000) == "http://localhost:5000"
def test_build_catalog_includes_core_services() -> None:
catalog = build_catalog("127.0.0.1")
types = {item["type"] for item in catalog}
assert {"identity", "compute", "network", "image", "volumev3", "placement"} <= types
nova = next(item for item in catalog if item["type"] == "compute")
assert nova["endpoints"][0]["url"].endswith(":8774/v2.1")
+364
View File
@@ -0,0 +1,364 @@
"""Persistent QEMU CRUD semantic handler tests."""
import uuid
from datetime import UTC, datetime
from typing import Any, cast
import pytest
from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.handlers.qemu import register_qemu_handlers
from app.tasks.repository import Task
class QemuPool:
def __init__(self) -> None:
self.resource_exists = False
self.missing = False
self.running = False
self.commands: list[str] = []
self.resource_id = uuid.uuid4()
async def fetchval(self, sql: str, *args: object) -> bool | int:
del args
if "pg_backend_pid" in sql:
return 123
if "extract(epoch" in sql:
return 1_700_000_000
if "FROM nodes" in sql:
return True
if "FROM resources" in sql:
return self.resource_exists
if "FROM snapshots" in sql:
return False
raise AssertionError(sql)
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM resources" in sql:
return [{"vmid": 150, "state": '{"status":"stopped","name":"vm"}'}]
assert "FROM snapshots" in sql
return [
{
"name": "baseline",
"parent_name": None,
"description": "stable",
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
}
]
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
del args
if self.missing:
return None
if "SELECT r.id, r.version" in sql:
return {
"id": self.resource_id,
"version": 1,
"state": '{"name":"old","status":"stopped"}',
"config": '{"name":"old"}',
}
if "SELECT r.state, v.config" in sql:
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
if "SELECT r.id, r.state" in sql:
status = "running" if self.running else "stopped"
return {
"id": self.resource_id,
"state": f'{{"status":"{status}"}}',
"config": ('{"agent":1,"name":"vm","scsi0":"local-lvm:vm-150-disk-0,size=8G"}'),
}
if "SELECT r.id, r.state, v.config" in sql:
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
if "SELECT s.* FROM snapshots" in sql:
return {
"id": uuid.uuid4(),
"name": "baseline",
"parent_name": None,
"description": "stable",
"state": '{"config":{"name":"old"}}',
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
}
raise AssertionError(sql)
async def execute(self, sql: str, *args: object) -> str:
del args
self.commands.append(sql)
return "UPDATE 1"
class FakeDatabase:
def __init__(self, pool: QemuPool) -> None:
self.pool = pool
def request(pool: QemuPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
result = Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
result.state.principal = "root@pam"
return result
def inputs(**values: object) -> dict[str, Any]:
return {"values": values, "provided": tuple(values)}
async def test_qemu_create_sync_async_update_and_delete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
created_payloads: list[dict[str, Any]] = []
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: Any) -> Task:
created_payloads.append(kwargs)
return Task(
uuid.uuid4(),
str(kwargs["upid"]),
str(kwargs["task_type"]),
"queued",
dict(kwargs["payload"]),
0,
False,
0,
)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
create = registry.get("/nodes/{node}/qemu", "POST")
listing = registry.get("/nodes/{node}/qemu", "GET")
config = registry.get("/nodes/{node}/qemu/{vmid}/config", "GET")
current = registry.get("/nodes/{node}/qemu/{vmid}/status/current", "GET")
update_sync = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
update_async = registry.get("/nodes/{node}/qemu/{vmid}/config", "POST")
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
assert create and listing and config and current and update_sync and update_async and delete
assert (await listing(http_request, inputs(node="pve1")))[0]["name"] == "vm"
assert (await config(http_request, inputs(node="pve1", vmid=150)))["name"] == "vm"
assert (await current(http_request, inputs(node="pve1", vmid=150)))["status"] == "stopped"
create_upid = await create(
http_request,
inputs(node="pve1", vmid=150, name="new", cores=2),
)
assert create_upid.startswith("UPID:pve1:")
assert created_payloads[-1]["task_type"] == "qemu-create"
assert (
await update_sync(
http_request,
inputs(node="pve1", vmid=150, name="sync", delete="unused"),
)
is None
)
assert len(pool.commands) == 2
update_upid = await update_async(
http_request,
inputs(node="pve1", vmid=150, memory="2048"),
)
assert update_upid.startswith("UPID:pve1:")
assert created_payloads[-1]["task_type"] == "qemu-update"
delete_upid = await delete(http_request, inputs(node="pve1", vmid=150))
assert delete_upid.startswith("UPID:pve1:")
assert created_payloads[-1]["task_type"] == "qemu-delete"
async def test_qemu_crud_conflicts_and_missing_resources(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class ConflictingRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **_kwargs: object) -> Task:
raise ConflictError("resource is locked")
monkeypatch.setattr("app.handlers.qemu.TaskRepository", ConflictingRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
create = registry.get("/nodes/{node}/qemu", "POST")
update = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
assert create and update and delete
with pytest.raises(ApiError) as locked:
await create(http_request, inputs(node="pve1", vmid=150))
assert locked.value.status_code == 409
pool.resource_exists = True
with pytest.raises(ApiError) as duplicate:
await create(http_request, inputs(node="pve1", vmid=150))
assert duplicate.value.status_code == 409
pool.missing = True
with pytest.raises(ApiError) as missing:
await update(http_request, inputs(node="pve1", vmid=150, name="missing"))
assert missing.value.status_code == 404
pool.missing = False
pool.running = True
with pytest.raises(ApiError) as running:
await delete(http_request, inputs(node="pve1", vmid=150))
assert running.value.status_code == 409
async def test_qemu_snapshot_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
tasks: list[str] = []
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: Any) -> Task:
tasks.append(str(kwargs["task_type"]))
return Task(uuid.uuid4(), str(kwargs["upid"]), tasks[-1], "queued", {}, 0, False, 0)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
base = "/nodes/{node}/qemu/{vmid}/snapshot"
listing = registry.get(base, "GET")
create = registry.get(base, "POST")
get = registry.get(f"{base}/{{snapname}}", "GET")
delete = registry.get(f"{base}/{{snapname}}", "DELETE")
config_get = registry.get(f"{base}/{{snapname}}/config", "GET")
config_put = registry.get(f"{base}/{{snapname}}/config", "PUT")
rollback = registry.get(f"{base}/{{snapname}}/rollback", "POST")
assert listing and create and get and delete and config_get and config_put and rollback
common = inputs(node="pve1", vmid=150, snapname="baseline")
assert (await listing(http_request, inputs(node="pve1", vmid=150)))[0]["name"] == "baseline"
assert (await get(http_request, common))["description"] == "stable"
assert (await config_get(http_request, common))["config"] == {"name": "old"}
assert await config_put(http_request, inputs(**common["values"], description="updated")) is None
assert (
await create(http_request, inputs(**common["values"], description="stable"))
).startswith("UPID:pve1:")
assert (await rollback(http_request, common)).startswith("UPID:pve1:")
assert (await delete(http_request, common)).startswith("UPID:pve1:")
assert tasks == ["qemu-snapshot-create", "qemu-snapshot-rollback", "qemu-snapshot-delete"]
async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
tasks: list[dict[str, Any]] = []
class FakeTaskRepository:
def __init__(self, pool: object) -> None:
del pool
async def create(self, **kwargs: Any) -> Task:
tasks.append(kwargs)
return Task(
uuid.uuid4(),
str(kwargs["upid"]),
str(kwargs["task_type"]),
"queued",
{},
0,
False,
0,
)
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
http_request = request(pool)
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
resize = registry.get("/nodes/{node}/qemu/{vmid}/resize", "PUT")
move = registry.get("/nodes/{node}/qemu/{vmid}/move_disk", "POST")
assert clone and migrate_get and migrate and resize and move
clone_upid = await clone(
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
)
assert clone_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-clone"
assert (await migrate_get(http_request, inputs(node="pve1", vmid=150, target="pve2")))[
"local_disks"
] == []
migrate_upid = await migrate(
http_request, inputs(node="pve1", vmid=150, target="pve2", online=False)
)
assert migrate_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-migrate"
assert (
await resize(http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")) is None
)
move_upid = await move(
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
)
assert move_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-move-disk"
with pytest.raises(ApiError) as same_node:
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
assert same_node.value.status_code == 400
async def test_qemu_pending_and_agent_handlers() -> None:
registry = HandlerRegistry()
register_qemu_handlers(registry)
pool = QemuPool()
pool.running = True
http_request = request(pool)
values = inputs(node="pve1", vmid=150)
pending = registry.get("/nodes/{node}/qemu/{vmid}/pending", "GET")
routes = {
"info": "/nodes/{node}/qemu/{vmid}/agent/info",
"os": "/nodes/{node}/qemu/{vmid}/agent/get-osinfo",
"host": "/nodes/{node}/qemu/{vmid}/agent/get-host-name",
"network": "/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces",
"time": "/nodes/{node}/qemu/{vmid}/agent/get-time",
"ping": "/nodes/{node}/qemu/{vmid}/agent/ping",
}
handlers = {
name: registry.get(path, "POST" if name == "ping" else "GET")
for name, path in routes.items()
}
assert pending and all(handlers.values())
async def call(name: str) -> dict[str, Any]:
handler = handlers[name]
assert handler is not None
return cast(dict[str, Any], await handler(http_request, values))
assert await pending(http_request, values) == []
assert (await call("info"))["result"]["version"]
assert (await call("os"))["result"]["machine"] == "x86_64"
assert (await call("host"))["result"]["host-name"] == "vm"
assert (await call("network"))["result"][0]["name"] == "eth0"
assert (await call("time"))["result"]["seconds"] > 0
assert (await call("ping"))["result"] == {}
+284
View File
@@ -0,0 +1,284 @@
"""QEMU worker transition semantics."""
import uuid
from datetime import UTC, datetime
from typing import cast
from app.simulation.clock import Clock
from app.tasks.qemu import qemu_handler
from app.tasks.repository import Task, TaskRepository
class ImmediateClock:
async def now(self) -> datetime:
return datetime(2026, 1, 1, tzinfo=UTC)
async def sleep(self, seconds: float) -> None:
assert seconds == 1.0
class Connection:
def __init__(self) -> None:
self.states: list[str] = []
async def fetchrow(self, sql: str, resource_id: uuid.UUID) -> dict[str, object]:
del sql, resource_id
return {"state": '{"status":"stopped"}'}
async def execute(self, sql: str, resource_id: uuid.UUID, state: str) -> str:
del sql, resource_id
self.states.append(state)
return "UPDATE 1"
class Acquire:
def __init__(self, connection: Connection) -> None:
self.connection = connection
async def __aenter__(self) -> Connection:
return self.connection
async def __aexit__(self, *args: object) -> None:
return None
class Pool:
def __init__(self, connection: Connection) -> None:
self.connection = connection
def acquire(self) -> Acquire:
return Acquire(self.connection)
class Repository:
def __init__(self) -> None:
self.connection = Connection()
self.pool = Pool(self.connection)
self.logs: list[str] = []
async def append_log(self, task_id: uuid.UUID, message: str) -> None:
del task_id
self.logs.append(message)
class Transaction:
async def __aenter__(self) -> None:
return None
async def __aexit__(self, *args: object) -> None:
return None
class CrudConnection:
def __init__(self) -> None:
self.commands: list[str] = []
def transaction(self) -> Transaction:
return Transaction()
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
del args
if "FROM nodes" in sql:
return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()}
if "JOIN virtual_machines" in sql:
return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'}
if "SELECT state FROM resources" in sql:
return {"state": '{"status":"stopped","name":"old"}'}
if "SELECT config FROM virtual_machines" in sql:
return {"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=10G"}'}
if "FROM snapshots" in sql:
return {
"state": (
'{"resource_state":{"status":"stopped","name":"old"},"config":{"name":"old"}}'
)
}
return None
async def execute(self, sql: str, *args: object) -> str:
del args
self.commands.append(sql)
return "DELETE 1" if sql.startswith("DELETE") else "UPDATE 1"
class CrudRepository:
def __init__(self) -> None:
self.connection = CrudConnection()
self.pool = Pool(cast(Connection, self.connection))
self.logs: list[str] = []
async def append_log(self, _task_id: uuid.UUID, message: str) -> None:
self.logs.append(message)
async def test_qemu_worker_applies_intermediate_and_final_states() -> None:
repository = Repository()
task = Task(
uuid.uuid4(),
"UPID:test",
"qemu-start",
"running",
{"resource_id": str(uuid.uuid4())},
0,
False,
1,
)
result = await qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))(
task
)
assert result == {"status": "running"}
assert '"starting"' in repository.connection.states[0]
assert '"running"' in repository.connection.states[1]
assert repository.logs == ["VM start started", "VM start completed"]
async def test_qemu_worker_create_update_and_delete_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
created = await handler(
Task(
uuid.uuid4(),
"UPID:create",
"qemu-create",
"running",
{"node": "pve1", "vmid": 150, "config": {"name": "new"}},
0,
False,
1,
)
)
updated = await handler(
Task(
uuid.uuid4(),
"UPID:update",
"qemu-update",
"running",
{
"resource_id": str(resource_id),
"changes": {"name": "changed", "cores": 4},
"delete": "unused",
},
0,
False,
1,
)
)
deleted = await handler(
Task(
uuid.uuid4(),
"UPID:delete",
"qemu-delete",
"running",
{"resource_id": str(resource_id)},
0,
False,
1,
)
)
assert created == {"vmid": 150, "status": "stopped"}
assert updated == {"updated": ["cores", "name"], "deleted": ["unused"]}
assert deleted == {"deleted": True}
assert any("INSERT INTO resources" in command for command in repository.connection.commands)
assert any("UPDATE virtual_machines" in command for command in repository.connection.commands)
assert any("DELETE FROM resources" in command for command in repository.connection.commands)
async def test_qemu_worker_snapshot_create_rollback_and_delete_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
async def run(operation: str, **payload: object) -> dict[str, object]:
result = await handler(
Task(
uuid.uuid4(),
f"UPID:{operation}",
f"qemu-snapshot-{operation}",
"running",
{"resource_id": str(resource_id), "snapname": "baseline", **payload},
0,
False,
1,
)
)
assert result is not None
return cast(dict[str, object], result)
assert await run("create", description="stable") == {
"snapshot": "baseline",
"operation": "create",
}
assert await run("rollback", start=True) == {
"snapshot": "baseline",
"operation": "rollback",
}
assert await run("delete") == {"snapshot": "baseline", "operation": "delete"}
commands = repository.connection.commands
assert any("INSERT INTO snapshots" in command for command in commands)
assert any("UPDATE virtual_machines" in command for command in commands)
assert any("DELETE FROM snapshots" in command for command in commands)
async def test_qemu_worker_clone_and_migrate_are_persistent() -> None:
repository = CrudRepository()
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
resource_id = uuid.uuid4()
cloned = await handler(
Task(
uuid.uuid4(),
"UPID:clone",
"qemu-clone",
"running",
{
"source_resource_id": str(resource_id),
"node": "pve1",
"vmid": 151,
"name": "clone",
},
0,
False,
1,
)
)
migrated = await handler(
Task(
uuid.uuid4(),
"UPID:migrate",
"qemu-migrate",
"running",
{"resource_id": str(resource_id), "target": "pve2"},
0,
False,
1,
)
)
moved = await handler(
Task(
uuid.uuid4(),
"UPID:move",
"qemu-move-disk",
"running",
{
"resource_id": str(resource_id),
"disk": "scsi0",
"target_disk": "scsi0",
"storage": "local",
"delete": True,
},
0,
False,
1,
)
)
assert cloned == {"vmid": 151, "node": "pve1"}
assert migrated == {"node": "pve2", "status": "stopped"}
assert moved == {"disk": "scsi0", "storage": "local"}
commands = repository.connection.commands
assert any("INSERT INTO resources" in command for command in commands)
assert any("node_id=$2" in command for command in commands)
+25
View File
@@ -0,0 +1,25 @@
"""Tests for contract example generation."""
from app.contracts.examples import path_param_example, schema_example
from app.contracts.model import Schema
def test_path_param_examples_use_known_placeholders() -> None:
assert path_param_example("node") == "pve01"
assert path_param_example("vmid") == 100
def test_schema_example_prefers_default_and_enum() -> None:
assert schema_example(Schema(type="string", default="custom")) == "custom"
assert schema_example(Schema(type="string", enum=("a", "b"))) == "a"
def test_schema_example_builds_object_and_array() -> None:
schema = Schema(
type="object",
properties={
"count": Schema(type="integer", minimum=2),
"enabled": Schema(type="boolean", optional=True),
},
)
assert schema_example(schema) == {"count": 2}
+128
View File
@@ -0,0 +1,128 @@
"""SDN zone/vnet/subnet persistence tests."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.sdn import register_sdn_handlers
class SdnPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {}
self.nodes = {"pve1"}
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any:
handler = registry.get(path, verb)
assert handler is not None
return await handler(http, inputs)
def request(pool: SdnPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_sdn_zone_vnet_subnet_and_node_views() -> None:
registry = HandlerRegistry()
register_sdn_handlers(registry)
pool = SdnPool()
http = request(pool)
await call(
registry,
"/cluster/sdn/zones",
"POST",
http,
{"values": {"zone": "localzone", "type": "simple"}, "provided": frozenset()},
)
await call(
registry,
"/cluster/sdn/vnets",
"POST",
http,
{
"values": {"vnet": "vnet0", "zone": "localzone", "type": "vnet"},
"provided": frozenset(),
},
)
await call(
registry,
"/cluster/sdn/vnets/{vnet}/subnets",
"POST",
http,
{
"values": {
"vnet": "vnet0",
"subnet": "10.0.0.0/24",
"gateway": "10.0.0.1",
},
"provided": frozenset(),
},
)
zones = await call(
registry, "/cluster/sdn/zones", "GET", http, {"values": {}, "provided": frozenset()}
)
assert zones[0]["zone"] == "localzone"
subnets = await call(
registry,
"/cluster/sdn/vnets/{vnet}/subnets",
"GET",
http,
{"values": {"vnet": "vnet0"}, "provided": frozenset()},
)
assert subnets[0]["subnet"] == "10.0.0.0/24"
node_zones = await call(
registry,
"/nodes/{node}/sdn/zones",
"GET",
http,
{"values": {"node": "pve1"}, "provided": frozenset()},
)
assert node_zones[0]["zone"] == "localzone"
assert pool.metadata["sdn"]["pending"] is True
await call(
registry,
"/cluster/sdn",
"PUT",
http,
{"values": {"release-lock": 1}, "provided": frozenset()},
)
assert pool.metadata["sdn"]["pending"] is False
+127
View File
@@ -0,0 +1,127 @@
"""Deterministic seed profile tests."""
import pytest
from app.simulation.seed import (
build_profile,
clear_simulation_state,
large_profile,
small_profile,
stable_id,
)
def test_small_profile_matches_required_logical_shape() -> None:
first = small_profile()
second = small_profile()
assert first == second
state = first.logical_state()
assert state == second.logical_state()
assert state["nodes"] == [{"name": "pve01", "status": "online"}]
resources = state["resources"]
assert isinstance(resources, list)
assert [resource["kind"] for resource in resources].count("qemu") == 2
assert [resource["kind"] for resource in resources].count("lxc") == 1
assert [resource["kind"] for resource in resources].count("storage") == 2
tasks = state["tasks"]
assert isinstance(tasks, list)
assert len(tasks) == 2
def test_medium_and_fault_profiles_are_deterministic() -> None:
medium = build_profile("medium")
assert len(medium.nodes) == 3
assert sum(resource.kind == "qemu" for resource in medium.resources) == 50
assert sum(resource.kind == "lxc" for resource in medium.resources) == 20
assert build_profile("ha-demo") == build_profile("ha-demo")
broken = build_profile("broken-storage")
assert any(resource.state.get("status") == "offline" for resource in broken.resources)
def test_large_profile_is_configurable_and_stable() -> None:
first = large_profile(node_count=4, resource_count=1_000)
second = large_profile(node_count=4, resource_count=1_000)
assert first == second
assert len(first.nodes) == 4
assert len(first.resources) == 1_000
def test_profile_validation() -> None:
with pytest.raises(ValueError, match="unknown seed profile"):
build_profile("missing")
with pytest.raises(ValueError, match="positive"):
large_profile(node_count=0, resource_count=1)
def test_demo_cluster_profile_shape() -> None:
profile = build_profile("demo-cluster")
assert profile.name == "demo-cluster"
assert len(profile.nodes) == 20
assert sum(resource.kind == "qemu" for resource in profile.resources) == 850
assert sum(resource.kind == "lxc" for resource in profile.resources) == 150
assert sum(resource.kind == "ceph-osd" for resource in profile.resources) == 300
assert sum(resource.kind == "storage" for resource in profile.resources) >= 62
assert len(profile.tasks) == 250
external_ids = {
resource.external_id for resource in profile.resources if resource.kind in {"qemu", "lxc"}
}
assert len(external_ids) == 1000
def test_demo_cluster_spreads_guests_evenly_across_nodes() -> None:
profile = build_profile("demo-cluster")
names = {node.id: node.name for node in profile.nodes}
def counts(kind: str) -> list[int]:
counter: dict[str, int] = {name: 0 for name in names.values()}
for resource in profile.resources:
if resource.kind == kind:
counter[names[resource.node_id]] += 1
return list(counter.values())
for kind, expected_total in (("qemu", 850), ("lxc", 150), ("ceph-osd", 300)):
values = counts(kind)
assert sum(values) == expected_total
assert max(values) - min(values) <= 1
guest_counts = counts("qemu")
guest_counts = [a + b for a, b in zip(guest_counts, counts("lxc"), strict=True)]
assert max(guest_counts) - min(guest_counts) <= 2
def test_minimal_profile() -> None:
profile = build_profile("minimal")
assert len(profile.nodes) == 1
assert not any(resource.kind in {"qemu", "lxc"} for resource in profile.resources)
def test_stable_ids_are_namespaced_and_repeatable() -> None:
assert stable_id("qemu:100") == stable_id("qemu:100")
assert stable_id("qemu:100") != stable_id("qemu:101")
@pytest.mark.asyncio
async def test_clear_simulation_state_wipes_api_created_identity() -> None:
executed: list[str] = []
class FakeConnection:
async def execute(self, sql: str, *args: object) -> str:
del args
executed.append(" ".join(sql.split()))
return "DELETE 0"
await clear_simulation_state(FakeConnection())
joined = "\n".join(executed)
for table in (
"resources",
"nodes",
"principals",
"identity_groups",
"roles",
"storage_contents",
"api_tokens",
):
assert f"DELETE FROM {table}" in joined # noqa: S608 - asserting SQL text
assert "DELETE FROM realms WHERE name NOT IN" in joined
assert any(sql.startswith("UPDATE clusters") for sql in executed)
+100
View File
@@ -0,0 +1,100 @@
"""Bounded task worker outcome tests."""
import asyncio
import uuid
from typing import cast
from app.tasks.repository import Task, TaskRepository
from app.tasks.worker import TaskWorker
class FakeRepository:
def __init__(self, task: Task) -> None:
self.task = task
self.finishes: list[tuple[str, str | None]] = []
async def get(self, _task_id: uuid.UUID) -> Task:
return self.task
async def finish(
self,
_task_id: uuid.UUID,
_worker_id: str,
*,
status: str,
result: dict[str, object] | None = None,
error: str | None = None,
) -> None:
del result
self.finishes.append((status, error))
def make_task(*, task_type: str = "test", cancelled: bool = False) -> Task:
return Task(uuid.uuid4(), "UPID:test", task_type, "running", {}, 0, cancelled, 1)
async def test_worker_persists_success_error_and_unsupported() -> None:
task = make_task()
repository = FakeRepository(task)
async def success(_task: Task) -> dict[str, object]:
return {"ok": True}
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": success})
await worker._execute(task)
assert repository.finishes == [("success", None)]
unsupported = make_task(task_type="missing")
repository.task = unsupported
await worker._execute(unsupported)
assert repository.finishes[-1] == ("error", "unsupported task type")
async def failure(_task: Task) -> None:
raise RuntimeError("private detail")
failed = make_task()
repository.task = failed
worker.handlers["test"] = failure
await worker._execute(failed)
assert repository.finishes[-1] == ("error", "RuntimeError")
async def test_worker_honors_persisted_cancellation() -> None:
task = make_task(cancelled=True)
repository = FakeRepository(task)
called = False
async def handler(_task: Task) -> None:
nonlocal called
called = True
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": handler})
await worker._execute(task)
assert not called
assert repository.finishes == [("cancelled", None)]
async def test_worker_retries_after_claim_failure() -> None:
class RecoveringRepository:
attempts = 0
async def claim(self, _worker_id: str, _lease_seconds: float) -> None:
self.attempts += 1
if self.attempts == 1:
raise RuntimeError("database schema is not ready")
return None
repository = RecoveringRepository()
worker = TaskWorker(
cast(TaskRepository, repository),
"worker",
{},
poll_seconds=0.001,
)
running = asyncio.create_task(worker.run())
await asyncio.sleep(0.01)
worker.stop()
await running
assert repository.attempts > 1
+50
View File
@@ -0,0 +1,50 @@
"""VM state-machine and deterministic fault properties."""
import pytest
from hypothesis import given
from hypothesis import strategies as st
from app.simulation.scenarios import FaultContext, FaultRule, matches
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
@pytest.mark.parametrize(
("state", "operation", "final"),
[
(VmState.STOPPED, "start", VmState.RUNNING),
(VmState.RUNNING, "stop", VmState.STOPPED),
(VmState.RUNNING, "shutdown", VmState.STOPPED),
(VmState.RUNNING, "reboot", VmState.RUNNING),
(VmState.RUNNING, "reset", VmState.RUNNING),
(VmState.RUNNING, "suspend", VmState.PAUSED),
(VmState.RUNNING, "pause", VmState.PAUSED),
(VmState.PAUSED, "resume", VmState.RUNNING),
(VmState.RUNNING, "snapshot", VmState.RUNNING),
(VmState.STOPPED, "migrate", VmState.STOPPED),
],
)
def test_valid_transitions(state: VmState, operation: str, final: VmState) -> None:
transition = plan_transition(state, operation)
assert transition.before is state
assert transition.after is final
assert transition.intermediate is not state
@given(st.sampled_from(tuple(VmState)), st.text(min_size=1, max_size=12))
def test_transition_result_is_declared_or_rejected(state: VmState, operation: str) -> None:
try:
transition = plan_transition(state, operation)
except InvalidTransitionError:
return
assert transition.before is state
def test_fault_evaluation_is_seeded_and_filtered() -> None:
context = FaultContext("POST", "/nodes/pve1/qemu/100/status/start", node="pve1")
certain = FaultRule("task-failure", method="POST", node="pve1")
impossible = FaultRule("task-failure", probability=0)
assert matches(certain, context, seed=42)
assert not matches(impossible, context, seed=42)
probabilistic = FaultRule("task-failure", probability=0.5)
assert matches(probabilistic, context, 42) == matches(probabilistic, context, 42)
+71
View File
@@ -0,0 +1,71 @@
"""UPID examples and round-trip properties."""
import pytest
from hypothesis import given
from hypothesis import strategies as st
from app.tasks.upid import Upid
SAFE = st.from_regex(r"[a-z0-9][a-z0-9_-]{0,19}", fullmatch=True)
@given(
node=SAFE,
pid=st.integers(min_value=0, max_value=0xFFFFFFFF),
process_start=st.integers(min_value=0, max_value=0xFFFFFFFF),
start_time=st.integers(min_value=0, max_value=0xFFFFFFFF),
task_type=SAFE,
task_id=st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789_-", max_size=20),
user=SAFE,
)
def test_upid_round_trip(
node: str,
pid: int,
process_start: int,
start_time: int,
task_type: str,
task_id: str,
user: str,
) -> None:
upid = Upid(node, pid, process_start, start_time, task_type, task_id, user)
assert Upid.parse(str(upid)) == upid
def test_known_upid_shape() -> None:
value = "UPID:pve1:0000002A:00000010:65A1B2C3:qmstart:100:root@pam:"
parsed = Upid.parse(value)
assert parsed.pid == 42
assert parsed.task_id == "100"
assert str(parsed) == value
@pytest.mark.parametrize("value", ["", "UPID:broken", "UPID:pve:GGGGGGGG:00000000:00000000:x::u:"])
def test_invalid_upids_are_rejected(value: str) -> None:
with pytest.raises(ValueError):
Upid.parse(value)
@pytest.mark.parametrize(
"kwargs",
[
{"pid": -1},
{"node": "bad:node"},
{"task_id": "bad:id"},
],
)
def test_invalid_upid_components_are_rejected(kwargs: dict[str, object]) -> None:
values: dict[str, object] = {
"node": "pve1",
"pid": 1,
"process_start": 1,
"start_time": 1,
"task_type": "test",
"task_id": "100",
"user": "root@pam",
}
values.update(kwargs)
with pytest.raises(ValueError):
Upid(**values) # type: ignore[arg-type]
+36
View File
@@ -0,0 +1,36 @@
"""Web asset loading tests."""
from app.web.assets import console_html
def test_console_html_is_read_from_disk() -> None:
html = console_html()
assert "OpenStack API Emulator" in html
assert "workspace-brand-stack" in html
assert "#ED1C24" in html or "ED1C24" 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 "OPENSTACK_SERIES" 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
+124
View File
@@ -0,0 +1,124 @@
"""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 "OpenStack API Emulator" in response.text
assert "openstack" in response.text
assert 'id="catalog-drawer"' in response.text
assert "catalog-drawer" in response.text
assert 'id="help-drawer"' in response.text
assert 'id="help-badge"' in response.text
assert 'id="data-badge"' in response.text
assert 'id="data-drawer"' in response.text
assert "data-badge-btn" in response.text
assert 'id="endpoints-badge-count"' in response.text
assert 'id="endpoints-drawer-count"' in response.text
assert 'id="ui-modal"' in response.text
assert 'role="alertdialog"' in response.text
assert "Request body" in response.text
async def test_ui_method_nodes_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
method = await client.get(
"/ui/api/method",
params={"major": 7, "path": "/nodes", "verb": "GET"},
)
assert method.status_code == 200
assert method.json()["implemented"] is True
async def test_ui_method_read_group_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
method = await client.get(
"/ui/api/method",
params={"major": 9, "path": "/access/groups/{groupid}", "verb": "GET"},
)
assert method.status_code == 200
payload = method.json()
assert payload["name"] == "read_group"
assert payload["implemented"] is True
async def test_ui_catalog_read_group_is_implemented() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
catalog = await client.get("/ui/api/catalog", params={"major": 9})
assert catalog.status_code == 200
methods = {
(path["path"], method["name"]): method["implemented"]
for category in catalog.json()["categories"]
for path in category["paths"]
for method in path["methods"]
}
assert methods[("/access/groups/{groupid}", "read_group")] is True
async def test_demo_api_requires_database() -> None:
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
state = await client.get("/ui/api/demo/state")
load = await client.post("/ui/api/demo/load")
assert state.status_code == 503
assert load.status_code == 503
async def test_ui_versions_and_catalog_endpoints() -> None:
settings = Settings(contract_snapshot=_BUNDLED)
app = create_app(
settings=settings,
database_factory=lambda _settings: FakeDatabase(True),
worker_factories=(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
versions = await client.get("/ui/api/versions")
assert versions.status_code == 200
assert {item["major"] for item in versions.json()["majors"]} == {6, 7, 8, 9}
catalog = await client.get("/ui/api/catalog", params={"major": 9})
assert catalog.status_code == 200
assert catalog.json()["source_version"] == "9.2.3"
method = await client.get(
"/ui/api/method",
params={"major": 9, "path": "/version", "verb": "GET"},
)
assert method.status_code == 200
assert method.json()["path"] == "/version"