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:
@@ -0,0 +1 @@
|
||||
"""Test package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""External client compatibility tests."""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""CI gate: every declared method on majors 6-9 is callable without critical failures.
|
||||
|
||||
Critical = HTTP 501, server 5xx, unhandled exceptions, or emulator-limitation
|
||||
strings. Synthetic 4xx (missing object / incomplete payload) are allowed.
|
||||
Requires PostgreSQL via ``TEST_DATABASE_URL``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.surface_probe import run_probe
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_majors_surface_has_zero_critical_failures() -> None:
|
||||
results = await run_probe()
|
||||
assert len(results) == 4
|
||||
for item in results:
|
||||
version = item["version"]
|
||||
declared = item["declared"]
|
||||
assert item["implemented"] == declared, version
|
||||
assert item["verified"] == declared, version
|
||||
assert item["dimensions_min"] == declared, version
|
||||
assert item["failure_count"] == 0, f"{version} critical failures: {item.get('failures')}"
|
||||
by_verb = item["by_verb"]
|
||||
for verb, buckets in by_verb.items():
|
||||
assert buckets.get("unimplemented_501", 0) == 0, (version, verb, buckets)
|
||||
assert buckets.get("unsupported_message", 0) == 0, (version, verb, buckets)
|
||||
assert buckets.get("server_5xx", 0) == 0, (version, verb, buckets)
|
||||
assert buckets.get("exception", 0) == 0, (version, verb, buckets)
|
||||
@@ -0,0 +1,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"]
|
||||
@@ -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")
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"info": {
|
||||
"GET": {
|
||||
"allowtoken": 1,
|
||||
"description": "API version details, including some parts of the global datacenter config.",
|
||||
"method": "GET",
|
||||
"name": "version",
|
||||
"parameters": {
|
||||
"additionalProperties": 0
|
||||
},
|
||||
"permissions": {
|
||||
"user": "all"
|
||||
},
|
||||
"returns": {
|
||||
"properties": {
|
||||
"console": {
|
||||
"description": "The default console viewer to use.",
|
||||
"enum": [
|
||||
"applet",
|
||||
"vv",
|
||||
"html5",
|
||||
"xtermjs"
|
||||
],
|
||||
"optional": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"release": {
|
||||
"description": "The current Proxmox VE point release in `x.y` format.",
|
||||
"type": "string"
|
||||
},
|
||||
"repoid": {
|
||||
"description": "The short git revision from which this version was build.",
|
||||
"pattern": "[0-9a-fA-F]{8,64}",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"description": "The full pve-manager package version of this node.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaf": 1,
|
||||
"path": "/version",
|
||||
"text": "version"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"artifact_etag": "\"4144c0-655b144140900\"",
|
||||
"artifact_last_modified": "Fri, 03 Jul 2026 09:08:20 GMT",
|
||||
"artifact_sha256": "f2b77b57c71f3781a0993cc5062940ef31e0843fd9a6bcfdb4de4dd2001d6d9e",
|
||||
"artifact_size": 4277440,
|
||||
"artifact_url": "https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js",
|
||||
"documentation_version": "9.2.3",
|
||||
"fixture_json_pointer": "/5",
|
||||
"fixture_sha256": "ad572969bbab259a10380ec11ac1c67f865e601be7c5aeec201fca368341c3fe",
|
||||
"retrieved_at": "2026-07-12T23:08:59+03:00",
|
||||
"viewer_url": "https://pve.proxmox.com/pve-docs/api-viewer/"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""PostgreSQL-backed integration tests."""
|
||||
@@ -0,0 +1,130 @@
|
||||
"""PostgreSQL migration acceptance checks."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.migrations import migrate
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.db.primitives import ConflictError
|
||||
from app.db.repositories.resources import ResourceRepository
|
||||
from app.simulation.seed import apply_seed, small_profile
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
|
||||
]
|
||||
|
||||
|
||||
async def test_migration_is_repeatable_and_constraints_hold() -> None:
|
||||
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
await migrate(connection)
|
||||
assert await migrate(connection) == 0
|
||||
node_id = uuid.uuid4()
|
||||
await connection.execute(
|
||||
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'online') ON CONFLICT DO NOTHING",
|
||||
node_id,
|
||||
f"test-{node_id}",
|
||||
)
|
||||
with pytest.raises(asyncpg.CheckViolationError):
|
||||
async with connection.transaction():
|
||||
await connection.execute(
|
||||
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'invalid')",
|
||||
uuid.uuid4(),
|
||||
f"invalid-{node_id}",
|
||||
)
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
|
||||
async def test_small_seed_is_idempotent() -> None:
|
||||
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
await migrate(connection)
|
||||
await apply_seed(connection, small_profile())
|
||||
await apply_seed(connection, small_profile())
|
||||
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve01'") == 1
|
||||
assert (
|
||||
await connection.fetchval(
|
||||
"""SELECT count(*) FROM resources
|
||||
WHERE external_id IN ('100', '101', '200', 'local', 'local-lvm')"""
|
||||
)
|
||||
== 5
|
||||
)
|
||||
assert await connection.fetchval("SELECT count(*) FROM tasks WHERE status = 'success'") == 2
|
||||
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 2
|
||||
assert await connection.fetchval("SELECT count(*) FROM containers") == 1
|
||||
assert await connection.fetchval("SELECT count(*) FROM storages") == 2
|
||||
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
|
||||
assert await connection.fetchval("SELECT count(*) FROM identity_groups") == 1
|
||||
assert await connection.fetchval("SELECT count(*) FROM identity_group_members") == 1
|
||||
assert await connection.fetchval("SELECT count(*) FROM group_acl_entries") == 1
|
||||
assert await connection.fetchval("SELECT count(*) FROM roles") == 3
|
||||
assert await connection.fetchval("SELECT count(*) FROM api_tokens") == 4
|
||||
secrets = await connection.fetch("SELECT secret_hash FROM api_tokens")
|
||||
assert all(str(row["secret_hash"]).startswith("scrypt$") for row in secrets)
|
||||
assert all("-secret" not in str(row["secret_hash"]) for row in secrets)
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
|
||||
async def test_demo_cluster_seed_populates_realistic_state() -> None:
|
||||
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
await migrate(connection)
|
||||
from app.simulation.seed import build_profile
|
||||
|
||||
await apply_seed(connection, build_profile("demo-cluster"))
|
||||
assert await connection.fetchval("SELECT count(*) FROM nodes") == 20
|
||||
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 850
|
||||
assert await connection.fetchval("SELECT count(*) FROM containers") == 150
|
||||
assert (
|
||||
await connection.fetchval("SELECT count(*) FROM resources WHERE kind = 'ceph-osd'")
|
||||
== 300
|
||||
)
|
||||
assert await connection.fetchval("SELECT count(*) FROM backups") >= 400
|
||||
assert await connection.fetchval("SELECT count(*) FROM task_logs") >= 500
|
||||
assert await connection.fetchval("SELECT count(*) FROM snapshots") >= 100
|
||||
ceph_capacity = await connection.fetchval(
|
||||
"SELECT capacity_bytes FROM storages WHERE storage_id = 'ceph-prod'"
|
||||
)
|
||||
assert ceph_capacity == 5 * 1024**5
|
||||
profile = await connection.fetchval("SELECT metadata->>'profile' FROM clusters LIMIT 1")
|
||||
assert profile == "demo-cluster"
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
|
||||
async def test_schema_readiness_and_optimistic_resource_repository() -> None:
|
||||
url = os.environ["TEST_DATABASE_URL"]
|
||||
connection = await asyncpg.connect(url)
|
||||
database = AsyncpgDatabase(Settings(database_url=url))
|
||||
try:
|
||||
await migrate(connection)
|
||||
await apply_seed(connection, small_profile())
|
||||
await database.connect()
|
||||
assert await database.is_ready()
|
||||
|
||||
repository = ResourceRepository(database.pool)
|
||||
resource = await repository.get(kind="qemu", external_id="101")
|
||||
assert resource is not None
|
||||
updated = await repository.update_state(
|
||||
resource.id,
|
||||
expected_version=resource.version,
|
||||
state={**resource.state, "status": "running"},
|
||||
)
|
||||
assert updated.version == resource.version + 1
|
||||
assert updated.state["status"] == "running"
|
||||
with pytest.raises(ConflictError):
|
||||
await repository.update_state(
|
||||
resource.id,
|
||||
expected_version=resource.version,
|
||||
state=resource.state,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
await connection.close()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Durable task concurrency and recovery tests."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
import pytest
|
||||
from asyncpg import Pool
|
||||
|
||||
from app.db.migrations import migrate
|
||||
from app.tasks.repository import TaskRepository
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
|
||||
]
|
||||
|
||||
|
||||
async def repository() -> tuple[Pool, TaskRepository]:
|
||||
pool = await asyncpg.create_pool(os.environ["TEST_DATABASE_URL"], min_size=1, max_size=4)
|
||||
async with pool.acquire() as connection:
|
||||
await migrate(connection)
|
||||
return pool, TaskRepository(pool)
|
||||
|
||||
|
||||
async def test_two_worker_exclusion_idempotency_and_logs() -> None:
|
||||
pool, tasks = await repository()
|
||||
key = uuid.uuid4().hex
|
||||
try:
|
||||
created = await tasks.create(
|
||||
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
|
||||
task_type="test",
|
||||
payload={"value": 1},
|
||||
resource_key=f"vm:{key}",
|
||||
idempotency_key=key,
|
||||
)
|
||||
repeated = await tasks.create(
|
||||
upid=f"ignored-{key}", task_type="test", payload={}, idempotency_key=key
|
||||
)
|
||||
assert repeated.id == created.id
|
||||
|
||||
first, second = await asyncio.gather(
|
||||
tasks.claim("worker-a", 30), tasks.claim("worker-b", 30)
|
||||
)
|
||||
claimed = first or second
|
||||
assert claimed is not None
|
||||
assert (first is None) != (second is None)
|
||||
worker = "worker-a" if first is not None else "worker-b"
|
||||
await tasks.append_log(claimed.id, "started")
|
||||
await tasks.progress(claimed.id, worker, 50)
|
||||
await tasks.finish(claimed.id, worker, status="success", result={"ok": True})
|
||||
assert await tasks.logs(claimed.id) == ("started",)
|
||||
finished = await tasks.get(claimed.id)
|
||||
assert finished is not None
|
||||
assert finished.status == "success"
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
async def test_expired_lease_is_reclaimed_after_restart() -> None:
|
||||
pool, tasks = await repository()
|
||||
key = uuid.uuid4().hex
|
||||
try:
|
||||
created = await tasks.create(
|
||||
upid=f"UPID:pve1:00000001:00000001:00000001:test:{key}:root@pam:",
|
||||
task_type="test",
|
||||
payload={},
|
||||
)
|
||||
assert await tasks.claim("dead-worker", 0) is not None
|
||||
recovered = await tasks.claim("new-worker", 30)
|
||||
assert recovered is not None
|
||||
assert recovered.id == created.id
|
||||
assert recovered.attempt == 2
|
||||
await tasks.request_cancel(recovered.id)
|
||||
cancelled = await tasks.get(recovered.id)
|
||||
assert cancelled is not None
|
||||
assert cancelled.cancel_requested
|
||||
await tasks.finish(recovered.id, "new-worker", status="cancelled")
|
||||
finally:
|
||||
await pool.close()
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -0,0 +1,277 @@
|
||||
"""TFA / OpenID / permissions access handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.config import Settings
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.access_auth import register_access_auth_handlers
|
||||
from app.security.auth import issue_ticket
|
||||
|
||||
|
||||
class AuthPool:
|
||||
def __init__(self) -> None:
|
||||
self.principals = {
|
||||
"root@pam": {
|
||||
"id": uuid.uuid4(),
|
||||
"tfa_locked_until": None,
|
||||
"totp_locked": False,
|
||||
}
|
||||
}
|
||||
self.tfa: dict[tuple[uuid.UUID, str], dict[str, Any]] = {}
|
||||
self.realms = {
|
||||
"sso": {
|
||||
"kind": "openid",
|
||||
"config": {
|
||||
"issuer-url": "https://idp.example",
|
||||
"client-id": "pve",
|
||||
},
|
||||
}
|
||||
}
|
||||
self.pending: dict[str, dict[str, str]] = {}
|
||||
|
||||
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
|
||||
if "FROM principals p" in query and "LEFT JOIN tfa_entries" in query:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name, data in self.principals.items():
|
||||
matches = [item for key, item in self.tfa.items() if key[0] == data["id"]]
|
||||
if not matches:
|
||||
rows.append(
|
||||
{
|
||||
"userid": name,
|
||||
"tfa_locked_until": data["tfa_locked_until"],
|
||||
"totp_locked": data["totp_locked"],
|
||||
"entry_id": None,
|
||||
"tfa_type": None,
|
||||
"description": None,
|
||||
"enable": None,
|
||||
"created_at": 0,
|
||||
}
|
||||
)
|
||||
for item in matches:
|
||||
rows.append(
|
||||
{
|
||||
"userid": name,
|
||||
"tfa_locked_until": data["tfa_locked_until"],
|
||||
"totp_locked": data["totp_locked"],
|
||||
**item,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
if "FROM tfa_entries" in query and "DISTINCT" in query:
|
||||
principal_id = arguments[0]
|
||||
types = sorted(
|
||||
{
|
||||
item["tfa_type"]
|
||||
for key, item in self.tfa.items()
|
||||
if key[0] == principal_id and item["enable"]
|
||||
}
|
||||
)
|
||||
return [{"tfa_type": value} for value in types]
|
||||
if "FROM tfa_entries WHERE principal_id" in query or (
|
||||
"FROM tfa_entries" in query and "principal_id=$1" in query and "DISTINCT" not in query
|
||||
):
|
||||
principal_id = arguments[0]
|
||||
return [item for key, item in self.tfa.items() if key[0] == principal_id]
|
||||
if "FROM acl_entries" in query:
|
||||
return []
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM principals WHERE name" in query:
|
||||
userid = str(arguments[0])
|
||||
data = self.principals.get(userid)
|
||||
if data is None:
|
||||
return None
|
||||
return {"name": userid, **data}
|
||||
if "FROM realms WHERE name" in query:
|
||||
realm = str(arguments[0])
|
||||
realm_data = self.realms.get(realm)
|
||||
if realm_data is None:
|
||||
return None
|
||||
return {"name": realm, **realm_data}
|
||||
if "FROM openid_pending WHERE state" in query:
|
||||
return self.pending.get(str(arguments[0]))
|
||||
if "FROM tfa_entries WHERE principal_id" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
item = self.tfa.get(key)
|
||||
return item
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM principals" in query:
|
||||
return str(arguments[0]) in self.principals
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "INSERT INTO openid_pending" in query:
|
||||
self.pending[str(arguments[0])] = {
|
||||
"realm": str(arguments[1]),
|
||||
"redirect_url": str(arguments[2]),
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "DELETE FROM openid_pending" in query:
|
||||
self.pending.pop(str(arguments[0]), None)
|
||||
return "DELETE 1"
|
||||
if "INSERT INTO principals" in query:
|
||||
self.principals[str(arguments[0])] = {
|
||||
"id": uuid.uuid4(),
|
||||
"tfa_locked_until": None,
|
||||
"totp_locked": False,
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "INSERT INTO tfa_entries" in query:
|
||||
principal_id = cast(uuid.UUID, arguments[0])
|
||||
entry_id = str(arguments[1])
|
||||
self.tfa[(principal_id, entry_id)] = {
|
||||
"entry_id": entry_id,
|
||||
"tfa_type": str(arguments[2]),
|
||||
"description": arguments[3],
|
||||
"enable": True,
|
||||
"created_at": 1_700_000_000,
|
||||
"secret": arguments[4],
|
||||
"metadata": json.loads(str(arguments[5])),
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE tfa_entries SET enable" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
self.tfa[key]["enable"] = bool(arguments[2])
|
||||
return "UPDATE 1"
|
||||
if "UPDATE tfa_entries SET description" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
self.tfa[key]["description"] = arguments[2]
|
||||
return "UPDATE 1"
|
||||
if "DELETE FROM tfa_entries" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
if key not in self.tfa:
|
||||
return "DELETE 0"
|
||||
del self.tfa[key]
|
||||
return "DELETE 1"
|
||||
if "UPDATE principals" in query and "totp_locked" in query:
|
||||
userid = str(arguments[0])
|
||||
if userid not in self.principals:
|
||||
return "UPDATE 0"
|
||||
self.principals[userid]["tfa_locked_until"] = None
|
||||
self.principals[userid]["totp_locked"] = False
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: AuthPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: AuthPool, principal: str = "root@pam") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
app.state.settings = Settings(ticket_signing_key=SecretStr("test-signing-key"))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = principal
|
||||
return result
|
||||
|
||||
|
||||
def values(**items: object) -> dict[str, Any]:
|
||||
return {"values": items, "provided": frozenset(items)}
|
||||
|
||||
|
||||
async def test_tfa_lifecycle_and_unlock_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_auth_handlers(registry)
|
||||
pool = AuthPool()
|
||||
http = request(pool)
|
||||
create = registry.get("/access/tfa/{userid}", "POST")
|
||||
listing = registry.get("/access/tfa/{userid}", "GET")
|
||||
get = registry.get("/access/tfa/{userid}/{id}", "GET")
|
||||
update = registry.get("/access/tfa/{userid}/{id}", "PUT")
|
||||
delete = registry.get("/access/tfa/{userid}/{id}", "DELETE")
|
||||
unlock = registry.get("/access/users/{userid}/unlock-tfa", "PUT")
|
||||
types = registry.get("/access/users/{userid}/tfa", "GET")
|
||||
assert create and listing and get and update and delete and unlock and types
|
||||
|
||||
created = await create(http, values(userid="root@pam", type="totp", description="phone"))
|
||||
entry_id = created["id"]
|
||||
assert await listing(http, values(userid="root@pam"))
|
||||
fetched = await get(http, values(userid="root@pam", id=entry_id))
|
||||
assert fetched["type"] == "totp"
|
||||
await update(http, values(userid="root@pam", id=entry_id, enable=0))
|
||||
assert (await get(http, values(userid="root@pam", id=entry_id)))["enable"] == 0
|
||||
assert await unlock(http, values(userid="root@pam")) is True
|
||||
assert (await types(http, values(userid="root@pam")))["types"] == []
|
||||
await delete(http, values(userid="root@pam", id=entry_id))
|
||||
with pytest.raises(ApiError):
|
||||
await get(http, values(userid="root@pam", id=entry_id))
|
||||
|
||||
|
||||
async def test_openid_auth_url_and_login_create_principal() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_auth_handlers(registry)
|
||||
pool = AuthPool()
|
||||
http = request(pool)
|
||||
auth_url = registry.get("/access/openid/auth-url", "POST")
|
||||
login = registry.get("/access/openid/login", "POST")
|
||||
assert auth_url and login
|
||||
|
||||
url = await auth_url(
|
||||
http,
|
||||
values(realm="sso", **{"redirect-url": "https://pve.local/api2/json/access/openid/login"}),
|
||||
)
|
||||
assert "https://idp.example/authorize?" in url
|
||||
assert pool.pending
|
||||
state = next(iter(pool.pending))
|
||||
result = await login(
|
||||
http,
|
||||
values(
|
||||
code="abc1234567890",
|
||||
state=state,
|
||||
**{"redirect-url": "https://pve.local/api2/json/access/openid/login"},
|
||||
),
|
||||
)
|
||||
assert result["ticket"].startswith("PVE:")
|
||||
assert any(name.endswith("@sso") for name in pool.principals)
|
||||
|
||||
|
||||
async def test_permissions_and_vncticket() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_auth_handlers(registry)
|
||||
pool = AuthPool()
|
||||
http = request(pool)
|
||||
permissions = registry.get("/access/permissions", "GET")
|
||||
vncticket = registry.get("/access/vncticket", "POST")
|
||||
ticket_get = registry.get("/access/ticket", "GET")
|
||||
assert permissions and vncticket and ticket_get
|
||||
|
||||
caps = await permissions(http, values())
|
||||
assert "/" in caps
|
||||
assert await ticket_get(http, values()) is None
|
||||
ticket = issue_ticket("root@pam", b"test-signing-key")
|
||||
await vncticket(
|
||||
http,
|
||||
values(
|
||||
authid="root@pam",
|
||||
path="/nodes/pve01/qemu/100/vncwebsocket",
|
||||
privs="Sys.Console",
|
||||
vncticket=ticket,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""API-token lifecycle handler tests without external services."""
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.access import register_access_handlers
|
||||
|
||||
|
||||
class TokenPool:
|
||||
def __init__(self) -> None:
|
||||
self.token: dict[str, Any] | None = None
|
||||
|
||||
async def fetch(self, _query: str, _userid: str) -> list[dict[str, Any]]:
|
||||
return [] if self.token is None else [{"token_id": "test", **self.token}]
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "INSERT INTO" in query:
|
||||
self.token = {
|
||||
"comment": arguments[3],
|
||||
"privilege_separation": arguments[5],
|
||||
"expire": arguments[4],
|
||||
}
|
||||
return self.token
|
||||
if "UPDATE api_tokens" in query:
|
||||
if self.token is None:
|
||||
return None
|
||||
self.token["comment"] = arguments[2]
|
||||
self.token["privilege_separation"] = arguments[4]
|
||||
return self.token
|
||||
return self.token
|
||||
|
||||
async def fetchval(self, _query: str, _userid: str) -> bool:
|
||||
return True
|
||||
|
||||
async def execute(self, _query: str, _userid: str, _tokenid: str) -> str:
|
||||
if self.token is None:
|
||||
return "DELETE 0"
|
||||
self.token = None
|
||||
return "DELETE 1"
|
||||
|
||||
|
||||
class RealmPool:
|
||||
def __init__(self) -> None:
|
||||
self.realms: dict[str, dict[str, Any]] = {
|
||||
"pam": {
|
||||
"kind": "pam",
|
||||
"config": {"comment": "Linux PAM standard authentication"},
|
||||
},
|
||||
"pve": {
|
||||
"kind": "pve",
|
||||
"config": {"comment": "Proxmox VE authentication server"},
|
||||
},
|
||||
}
|
||||
self.principals: dict[str, str] = {"root@pam": "pam"}
|
||||
|
||||
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
|
||||
del arguments
|
||||
if "FROM realms ORDER BY name" in query:
|
||||
return [
|
||||
{"name": name, "kind": data["kind"], "config": dict(data["config"])}
|
||||
for name, data in sorted(self.realms.items())
|
||||
]
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM realms WHERE name" in query:
|
||||
realm = str(arguments[0])
|
||||
data = self.realms.get(realm)
|
||||
if data is None:
|
||||
return None
|
||||
return {"name": realm, "kind": data["kind"], "config": dict(data["config"])}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> bool:
|
||||
realm = str(arguments[0])
|
||||
if "EXISTS(SELECT 1 FROM realms" in query:
|
||||
return realm in self.realms
|
||||
if "EXISTS(SELECT 1 FROM principals" in query:
|
||||
return any(value == realm for value in self.principals.values())
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "INSERT INTO realms" in query:
|
||||
self.realms[str(arguments[0])] = {
|
||||
"kind": str(arguments[1]),
|
||||
"config": json.loads(str(arguments[2])),
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE realms SET config=$2" in query:
|
||||
realm = str(arguments[0])
|
||||
self.realms[realm]["config"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "SET config = config - 'default'" in query:
|
||||
skip = str(arguments[0]) if arguments else None
|
||||
for name, data in self.realms.items():
|
||||
if skip is not None and name == skip:
|
||||
continue
|
||||
data["config"].pop("default", None)
|
||||
return "UPDATE 0"
|
||||
if "DELETE FROM realms" in query:
|
||||
realm = str(arguments[0])
|
||||
if realm not in self.realms:
|
||||
return "DELETE 0"
|
||||
del self.realms[realm]
|
||||
return "DELETE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: TokenPool | RealmPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: TokenPool | RealmPool, principal: str = "root@pam") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = principal
|
||||
return result
|
||||
|
||||
|
||||
def values(**items: object) -> dict[str, Any]:
|
||||
return {"values": items, "provided": frozenset(items)}
|
||||
|
||||
|
||||
async def test_token_lifecycle_returns_secret_once_and_persists_metadata() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = TokenPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/access/users/{userid}/token/{tokenid}", "POST")
|
||||
get = registry.get("/access/users/{userid}/token/{tokenid}", "GET")
|
||||
update = registry.get("/access/users/{userid}/token/{tokenid}", "PUT")
|
||||
delete = registry.get("/access/users/{userid}/token/{tokenid}", "DELETE")
|
||||
list_tokens = registry.get("/access/users/{userid}/token", "GET")
|
||||
assert create and get and update and delete and list_tokens
|
||||
|
||||
created = await create(
|
||||
http_request,
|
||||
values(userid="root@pam", tokenid="test", comment="first", privsep=True),
|
||||
)
|
||||
assert created["full-tokenid"] == "root@pam!test"
|
||||
assert created["value"]
|
||||
assert "value" not in await get(http_request, values(userid="root@pam", tokenid="test"))
|
||||
assert await list_tokens(http_request, values(userid="root@pam"))
|
||||
|
||||
updated = await update(
|
||||
http_request,
|
||||
values(userid="root@pam", tokenid="test", comment="second", privsep=False),
|
||||
)
|
||||
assert updated["comment"] == "second"
|
||||
await delete(http_request, values(userid="root@pam", tokenid="test"))
|
||||
with pytest.raises(ApiError) as missing:
|
||||
await get(http_request, values(userid="root@pam", tokenid="test"))
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
|
||||
async def test_token_lifecycle_rejects_non_owner() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
handler = registry.get("/access/users/{userid}/token", "GET")
|
||||
assert handler
|
||||
with pytest.raises(ApiError) as denied:
|
||||
await handler(request(TokenPool(), "auditor@pve"), values(userid="other@pve"))
|
||||
assert denied.value.status_code == 403
|
||||
|
||||
|
||||
async def test_domain_lifecycle_persists_realm_config() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = RealmPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/access/domains", "POST")
|
||||
listing = registry.get("/access/domains", "GET")
|
||||
get = registry.get("/access/domains/{realm}", "GET")
|
||||
update = registry.get("/access/domains/{realm}", "PUT")
|
||||
delete = registry.get("/access/domains/{realm}", "DELETE")
|
||||
assert create and listing and get and update and delete
|
||||
|
||||
await create(
|
||||
http_request,
|
||||
values(
|
||||
realm="corp",
|
||||
type="ldap",
|
||||
comment="Corporate LDAP",
|
||||
server1="ldap.example.com",
|
||||
password="secret", # 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
|
||||
@@ -0,0 +1,61 @@
|
||||
"""ACL propagation, token separation, and contract mapping tests."""
|
||||
|
||||
from app.contracts.model import Permissions
|
||||
from app.security.acl import AclEntry, authorize, effective_privileges, requirement_from_contract
|
||||
|
||||
ENTRIES = (
|
||||
AclEntry("alice@pve", "/vms", frozenset({"VM.Audit", "VM.PowerMgmt"})),
|
||||
AclEntry("alice@pve", "/vms/200", frozenset({"VM.Config"}), propagate=False),
|
||||
)
|
||||
|
||||
|
||||
def test_acl_propagation_matrix() -> None:
|
||||
assert effective_privileges("alice@pve", "/vms/100", ENTRIES) == frozenset(
|
||||
{"VM.Audit", "VM.PowerMgmt"}
|
||||
)
|
||||
assert "VM.Config" in effective_privileges("alice@pve", "/vms/200", ENTRIES)
|
||||
assert "VM.Config" not in effective_privileges("alice@pve", "/vms/200/snapshot", ENTRIES)
|
||||
assert not effective_privileges("bob@pve", "/vms/100", ENTRIES)
|
||||
|
||||
|
||||
def test_api_token_privileges_are_intersection_not_escalation() -> None:
|
||||
assert authorize(
|
||||
"alice@pve",
|
||||
"/vms/100",
|
||||
frozenset({"VM.Audit"}),
|
||||
ENTRIES,
|
||||
token_privileges=frozenset({"VM.Audit"}),
|
||||
)
|
||||
assert not authorize(
|
||||
"alice@pve",
|
||||
"/vms/100",
|
||||
frozenset({"VM.PowerMgmt"}),
|
||||
ENTRIES,
|
||||
token_privileges=frozenset({"VM.Audit"}),
|
||||
)
|
||||
|
||||
|
||||
def test_contract_permission_maps_to_capability_requirement() -> None:
|
||||
permissions = Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]})
|
||||
|
||||
requirement = requirement_from_contract(permissions, {"vmid": "100"})
|
||||
|
||||
assert requirement is not None
|
||||
assert requirement.path == "/vms/100"
|
||||
assert requirement.privileges == frozenset({"VM.PowerMgmt"})
|
||||
|
||||
any_permission = Permissions(
|
||||
expression={
|
||||
"check": ["perm", "/vms/{vmid}", ["VM.Config.CPU", "VM.Config.Memory"], "any", 1]
|
||||
}
|
||||
)
|
||||
any_requirement = requirement_from_contract(any_permission, {"vmid": "100"})
|
||||
assert any_requirement is not None
|
||||
assert not any_requirement.require_all
|
||||
assert authorize(
|
||||
"alice@pve",
|
||||
"/vms/100",
|
||||
any_requirement.privileges,
|
||||
(AclEntry("alice@pve", "/vms", frozenset({"VM.Config.CPU"})),),
|
||||
require_all=any_requirement.require_all,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""HTTP-boundary API-token and contract permission tests."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import _authenticate
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Permissions, Schema
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self, secret: str, token_privileges: list[str]) -> None:
|
||||
self.secret_hash = hash_secret(secret, salt=b"boundary-token-v1")
|
||||
self.token_privileges = token_privileges
|
||||
|
||||
async def fetchrow(self, _query: str, principal: str, token_id: str) -> dict[str, Any] | None:
|
||||
if principal != "operator@pve" or token_id != "api":
|
||||
return None
|
||||
return {
|
||||
"name": principal,
|
||||
"secret_hash": self.secret_hash,
|
||||
"privileges": self.token_privileges,
|
||||
"privilege_separation": True,
|
||||
}
|
||||
|
||||
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": "/vms",
|
||||
"propagate": True,
|
||||
"privileges": ["VM.Audit", "VM.PowerMgmt"],
|
||||
"principal": principal,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: FakePool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def token_request(secret: str, token_privileges: list[str]) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.settings = Settings()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(FakePool("valid", token_privileges)))
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/api2/json/nodes/pve1/qemu/101/status/start",
|
||||
"headers": [(b"authorization", f"PVEAPIToken=operator@pve!api={secret}".encode())],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def power_method() -> Method:
|
||||
return Method(
|
||||
verb="POST",
|
||||
name="start",
|
||||
returns=Schema(type="string"),
|
||||
permissions=Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]}),
|
||||
checksum="1" * 64,
|
||||
)
|
||||
|
||||
|
||||
async def test_api_token_skips_csrf_but_honors_separated_privileges() -> None:
|
||||
allowed = token_request("valid", ["VM.PowerMgmt"])
|
||||
await _authenticate(
|
||||
allowed,
|
||||
"/nodes/{node}/qemu/{vmid}/status/start",
|
||||
power_method(),
|
||||
{"values": {"node": "pve1", "vmid": 101}},
|
||||
)
|
||||
assert allowed.state.principal == "operator@pve"
|
||||
|
||||
denied = token_request("valid", ["VM.Audit"])
|
||||
with pytest.raises(ApiError) as error:
|
||||
await _authenticate(
|
||||
denied,
|
||||
"/nodes/{node}/qemu/{vmid}/status/start",
|
||||
power_method(),
|
||||
{"values": {"node": "pve1", "vmid": 101}},
|
||||
)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
async def test_api_token_rejects_unknown_or_wrong_secret() -> None:
|
||||
request = token_request("wrong", ["VM.PowerMgmt"])
|
||||
with pytest.raises(ApiError) as error:
|
||||
await _authenticate(
|
||||
request,
|
||||
"/nodes/{node}/qemu/{vmid}/status/start",
|
||||
power_method(),
|
||||
{"values": {"node": "pve1", "vmid": 101}},
|
||||
)
|
||||
assert error.value.status_code == 401
|
||||
@@ -0,0 +1,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"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Authentication, CSRF, token, and redaction matrices."""
|
||||
|
||||
import pytest
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.security.auth import (
|
||||
AuthenticationError,
|
||||
csrf_token,
|
||||
hash_secret,
|
||||
issue_ticket,
|
||||
parse_api_token,
|
||||
redact_secrets,
|
||||
set_ticket_cookie,
|
||||
verify_csrf,
|
||||
verify_secret,
|
||||
verify_ticket,
|
||||
)
|
||||
|
||||
KEY = b"test-signing-key-with-at-least-32-bytes"
|
||||
|
||||
|
||||
def test_password_and_token_hashes_do_not_store_plaintext() -> None:
|
||||
encoded = hash_secret("correct horse", salt=b"0123456789abcdef")
|
||||
|
||||
assert "correct horse" not in encoded
|
||||
assert verify_secret("correct horse", encoded)
|
||||
assert not verify_secret("wrong", encoded)
|
||||
assert not verify_secret("correct horse", "unknown$format")
|
||||
|
||||
|
||||
def test_signed_ticket_expiry_and_csrf() -> None:
|
||||
ticket = issue_ticket("root@pam", KEY, now=100, ttl=60)
|
||||
|
||||
assert verify_ticket(ticket, KEY, now=120).principal == "root@pam"
|
||||
token = csrf_token(ticket, KEY)
|
||||
assert verify_csrf(ticket, token, KEY)
|
||||
assert not verify_csrf(ticket, token + "x", KEY)
|
||||
with pytest.raises(AuthenticationError, match="expired"):
|
||||
verify_ticket(ticket, KEY, now=161)
|
||||
with pytest.raises(AuthenticationError, match="invalid"):
|
||||
verify_ticket(ticket + "x", KEY, now=120)
|
||||
|
||||
|
||||
def test_ticket_cookie_is_http_only_and_secure() -> None:
|
||||
response = Response()
|
||||
set_ticket_cookie(response, "ticket")
|
||||
|
||||
header = response.headers["set-cookie"]
|
||||
assert "PVEAuthCookie=ticket" in header
|
||||
assert "HttpOnly" in header
|
||||
assert "Secure" in header
|
||||
assert "SameSite=strict" in header
|
||||
|
||||
|
||||
def test_api_token_parsing_and_log_redaction() -> None:
|
||||
token = parse_api_token("PVEAPIToken=user@pve!automation=supersecret")
|
||||
|
||||
assert token.principal == "user@pve"
|
||||
assert token.token_id == "automation"
|
||||
assert token.secret == "supersecret"
|
||||
redacted = redact_secrets(
|
||||
"PVEAPIToken=user@pve!automation=supersecret password=hunter2 token=abc"
|
||||
)
|
||||
assert "supersecret" not in redacted
|
||||
assert "hunter2" not in redacted
|
||||
assert "token=abc" not in redacted
|
||||
with pytest.raises(AuthenticationError):
|
||||
parse_api_token("Bearer secret")
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Ceph pool/OSD mutation persistence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.ceph import register_ceph_handlers
|
||||
from app.simulation.seed import CLUSTER_ID
|
||||
|
||||
|
||||
class CephPool:
|
||||
def __init__(self) -> None:
|
||||
self.cluster_metadata: dict[str, Any] = {}
|
||||
self.nodes = {"pve1": {"id": uuid4(), "metadata": {}}}
|
||||
self.resources: dict[Any, dict[str, Any]] = {}
|
||||
|
||||
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
|
||||
if "r.kind='ceph-osd'" in query and "ORDER BY" in query:
|
||||
node = str(arguments[0])
|
||||
node_id = self.nodes[node]["id"]
|
||||
return [
|
||||
{"external_id": item["external_id"], "state": item["state"]}
|
||||
for item in self.resources.values()
|
||||
if item["node_id"] == node_id
|
||||
]
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.cluster_metadata)}
|
||||
if "SELECT metadata FROM nodes WHERE name" in query:
|
||||
node = self.nodes.get(str(arguments[0]))
|
||||
return None if node is None else {"metadata": json.dumps(node["metadata"])}
|
||||
if "storage_type='ceph'" in query:
|
||||
return {"capacity_bytes": 1000, "used_bytes": 100}
|
||||
if "r.kind='ceph-osd'" in query:
|
||||
node_name = str(arguments[0])
|
||||
osdid = str(arguments[1])
|
||||
node_id = self.nodes[node_name]["id"]
|
||||
for item in self.resources.values():
|
||||
if item["node_id"] == node_id and item["external_id"] in {
|
||||
osdid,
|
||||
f"osd.{osdid}",
|
||||
arguments[2] if len(arguments) > 2 else "",
|
||||
}:
|
||||
return item
|
||||
return None
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return str(arguments[0]) in self.nodes
|
||||
if "SELECT id FROM nodes WHERE name" in query:
|
||||
node = self.nodes.get(str(arguments[0]))
|
||||
return None if node is None else node["id"]
|
||||
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in query:
|
||||
return len(self.resources)
|
||||
if "COALESCE" in query and "ceph-osd" in query:
|
||||
return len(self.resources)
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "jsonb_set" in query and "'{ceph}'" in query:
|
||||
self.cluster_metadata["ceph"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "UPDATE nodes SET metadata" in query:
|
||||
self.nodes[str(arguments[0])]["metadata"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "INSERT INTO resources" in query:
|
||||
resource_id = uuid4()
|
||||
self.resources[resource_id] = {
|
||||
"id": resource_id,
|
||||
"node_id": arguments[0],
|
||||
"external_id": arguments[1],
|
||||
"state": arguments[2],
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE resources SET state" in query:
|
||||
existing_id = arguments[0]
|
||||
self.resources[existing_id]["state"] = arguments[1]
|
||||
return "UPDATE 1"
|
||||
if "DELETE FROM resources WHERE id" in query:
|
||||
self.resources.pop(arguments[0], None)
|
||||
return "DELETE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
def request(pool: CephPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
async def test_ceph_pool_and_osd_mutations_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_ceph_handlers(registry)
|
||||
pool = CephPool()
|
||||
http = request(pool)
|
||||
|
||||
create_pool = registry.get("/nodes/{node}/ceph/pool", "POST")
|
||||
list_pool = registry.get("/nodes/{node}/ceph/pool", "GET")
|
||||
create_osd = registry.get("/nodes/{node}/ceph/osd", "POST")
|
||||
osd_out = registry.get("/nodes/{node}/ceph/osd/{osdid}/out", "POST")
|
||||
assert create_pool and list_pool and create_osd and osd_out
|
||||
|
||||
await create_pool(http, {"values": {"node": "pve1", "name": "vms"}, "provided": frozenset()})
|
||||
pools = await list_pool(http, {"values": {"node": "pve1"}, "provided": frozenset()})
|
||||
assert any(item["pool"] == "vms" for item in pools)
|
||||
assert "vms" in pool.cluster_metadata["ceph"]["pools"]
|
||||
|
||||
await create_osd(http, {"values": {"node": "pve1", "dev": "/dev/sdb"}, "provided": frozenset()})
|
||||
assert len(pool.resources) == 1
|
||||
resource_id = next(iter(pool.resources))
|
||||
osdid = "0"
|
||||
await osd_out(
|
||||
http,
|
||||
{"values": {"node": "pve1", "osdid": osdid}, "provided": frozenset()},
|
||||
)
|
||||
assert json.loads(pool.resources[resource_id]["state"])["in"] is False
|
||||
assert CLUSTER_ID
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Simulation clock behavior."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.simulation.clock import AcceleratedClock, ManualClock
|
||||
|
||||
|
||||
async def test_manual_clock_releases_sleep_only_after_advance() -> None:
|
||||
clock = ManualClock(datetime(2026, 1, 1, tzinfo=UTC))
|
||||
sleeper = asyncio.create_task(clock.sleep(10))
|
||||
await asyncio.sleep(0)
|
||||
assert not sleeper.done()
|
||||
|
||||
await clock.advance(9)
|
||||
assert not sleeper.done()
|
||||
await clock.advance(1)
|
||||
await sleeper
|
||||
assert await clock.now() == datetime(2026, 1, 1, 0, 0, 10, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_clocks_reject_invalid_configuration() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AcceleratedClock(0)
|
||||
with pytest.raises(ValueError):
|
||||
ManualClock(datetime(2026, 1, 1))
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Semantic contract diff classification and rendering tests."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.contracts.diff import (
|
||||
Severity,
|
||||
compare_snapshots,
|
||||
has_breaking_changes,
|
||||
render_html,
|
||||
render_json,
|
||||
render_markdown,
|
||||
render_text,
|
||||
)
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
|
||||
|
||||
def snapshot(paths: tuple[PathContract, ...]) -> Snapshot:
|
||||
return Snapshot(
|
||||
source_version="test",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="0" * 64,
|
||||
paths=paths,
|
||||
path_count=len(paths),
|
||||
method_count=sum(len(path.methods) for path in paths),
|
||||
)
|
||||
|
||||
|
||||
def method(description: str = "old", returns: Schema | None = None) -> Method:
|
||||
return Method(
|
||||
verb="GET",
|
||||
name="read",
|
||||
description=description,
|
||||
returns=returns or Schema(type="string"),
|
||||
checksum="1" * 64,
|
||||
)
|
||||
|
||||
|
||||
def test_classifies_added_removed_and_changed_contracts() -> None:
|
||||
before = snapshot(
|
||||
(
|
||||
PathContract(path="/removed", methods=(method(),)),
|
||||
PathContract(path="/version", methods=(method(),)),
|
||||
)
|
||||
)
|
||||
after = snapshot(
|
||||
(
|
||||
PathContract(path="/added", methods=(method(),)),
|
||||
PathContract(
|
||||
path="/version",
|
||||
methods=(method("new", Schema(type="integer", minimum=1)),),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
changes = compare_snapshots(before, after)
|
||||
|
||||
assert changes == tuple(sorted(changes))
|
||||
assert {change.category for change in changes} >= {
|
||||
"path",
|
||||
"method",
|
||||
"documentation",
|
||||
"schema",
|
||||
"constraint",
|
||||
}
|
||||
assert has_breaking_changes(changes)
|
||||
assert any(change.severity is Severity.NON_BREAKING for change in changes)
|
||||
|
||||
|
||||
def test_renderers_are_stable_and_escape_html() -> None:
|
||||
before = snapshot((PathContract(path="/<old>", methods=(method(),)),))
|
||||
after = snapshot(())
|
||||
changes = compare_snapshots(before, after)
|
||||
|
||||
assert render_text(changes).startswith("breaking:")
|
||||
assert "| breaking |" in render_markdown(changes)
|
||||
assert "<old>" in render_html(changes)
|
||||
decoded = json.loads(render_json(changes))
|
||||
assert decoded[0]["severity"] == "breaking"
|
||||
assert render_json(changes) == render_json(changes)
|
||||
|
||||
|
||||
def test_no_changes_has_clean_ci_policy() -> None:
|
||||
value = snapshot((PathContract(path="/version", methods=(method(),)),))
|
||||
|
||||
assert compare_snapshots(value, value) == ()
|
||||
assert not has_breaking_changes(())
|
||||
@@ -0,0 +1,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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"[]"
|
||||
@@ -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:")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Database primitive behavior independent of PostgreSQL."""
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
import pytest
|
||||
|
||||
from app.db.primitives import (
|
||||
ConflictError,
|
||||
DatabaseOperationError,
|
||||
ReferenceError,
|
||||
RetryPolicy,
|
||||
TransientDatabaseError,
|
||||
map_database_error,
|
||||
require_affected,
|
||||
retry_transient,
|
||||
)
|
||||
|
||||
|
||||
def test_error_mapping_is_stable_and_safe() -> None:
|
||||
assert isinstance(map_database_error(asyncpg.UniqueViolationError("secret")), ConflictError)
|
||||
assert isinstance(
|
||||
map_database_error(asyncpg.ForeignKeyViolationError("secret")), ReferenceError
|
||||
)
|
||||
assert isinstance(
|
||||
map_database_error(asyncpg.SerializationError("secret")), TransientDatabaseError
|
||||
)
|
||||
assert "secret" not in str(map_database_error(asyncpg.PostgresError("secret")))
|
||||
|
||||
|
||||
def test_affected_row_checks() -> None:
|
||||
require_affected("UPDATE 1")
|
||||
with pytest.raises(DatabaseOperationError, match="expected 1"):
|
||||
require_affected("UPDATE 0")
|
||||
with pytest.raises(DatabaseOperationError, match="unrecognized"):
|
||||
require_affected("BROKEN")
|
||||
|
||||
|
||||
async def test_transient_retry_is_bounded() -> None:
|
||||
calls = 0
|
||||
|
||||
async def operation() -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls < 3:
|
||||
raise TransientDatabaseError("retry")
|
||||
return "ok"
|
||||
|
||||
assert await retry_transient(operation, RetryPolicy(attempts=3, base_delay_seconds=0)) == "ok"
|
||||
assert calls == 3
|
||||
|
||||
|
||||
async def test_transient_retry_propagates_final_failure() -> None:
|
||||
async def operation() -> None:
|
||||
raise TransientDatabaseError("retry")
|
||||
|
||||
with pytest.raises(TransientDatabaseError):
|
||||
await retry_transient(operation, RetryPolicy(attempts=2, base_delay_seconds=0))
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await retry_transient(operation, RetryPolicy(attempts=0))
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for cluster, storage, pool and ceph handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.ceph import register_ceph_handlers
|
||||
from app.handlers.cluster import register_cluster_handlers
|
||||
from app.handlers.pools import register_pool_handlers
|
||||
from app.handlers.storage import register_storage_handlers
|
||||
|
||||
|
||||
class HandlerPool:
|
||||
def __init__(self) -> None:
|
||||
self.node_exists = True
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM nodes" in sql and "ORDER BY name" in sql:
|
||||
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
|
||||
if "FROM storages" in sql and "DISTINCT storage_id" in sql:
|
||||
return [{"storage_id": "local-lvm-pve01"}]
|
||||
if "FROM storages s" in sql:
|
||||
return [
|
||||
{
|
||||
"storage_id": "local-lvm-pve01",
|
||||
"storage_type": "lvmthin",
|
||||
"shared": False,
|
||||
"capacity_bytes": 1_000_000,
|
||||
"used_bytes": 250_000,
|
||||
"config": '{"content":["images"]}',
|
||||
}
|
||||
]
|
||||
if "ceph-osd" in sql:
|
||||
return [
|
||||
{
|
||||
"external_id": "osd.0",
|
||||
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0}',
|
||||
}
|
||||
]
|
||||
if "FROM pools" in sql:
|
||||
return [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"pool_id": "production",
|
||||
"comment": "prod",
|
||||
"metadata": '{"members":["100"]}',
|
||||
}
|
||||
]
|
||||
if "FROM pool_members" in sql:
|
||||
return [{"external_id": "100"}]
|
||||
if "FROM task_logs" in sql:
|
||||
return [{"message": "seeded task", "sequence": 1}]
|
||||
if "FROM tasks" in sql:
|
||||
return [{"upid": "UPID:pve01:1:1:1:qmstart:100:root@pam:"}]
|
||||
if "FROM storage_contents" in sql or "FROM backups" in sql:
|
||||
return []
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if "FROM nodes WHERE name" in sql:
|
||||
return {"name": "pve01", "status": "online"} if self.node_exists else None
|
||||
if "FROM clusters" in sql:
|
||||
return {"metadata": '{"options":{"keyboard":"de-ch"}}'}
|
||||
if "FROM storages" in sql:
|
||||
return {
|
||||
"storage_id": "local-lvm-pve01",
|
||||
"storage_type": "lvmthin",
|
||||
"shared": False,
|
||||
"capacity_bytes": 1_000_000,
|
||||
"used_bytes": 250_000,
|
||||
"config": '{"content":["images"]}',
|
||||
"node_name": "pve01",
|
||||
"resource_id": uuid.uuid4(),
|
||||
}
|
||||
if "ceph-osd" in sql:
|
||||
return {
|
||||
"external_id": "osd.0",
|
||||
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0,"size_bytes":1000}',
|
||||
}
|
||||
if "storage_type='ceph'" in sql:
|
||||
return {"capacity_bytes": 5_000_000, "used_bytes": 3_000_000}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> Any:
|
||||
del args
|
||||
if "EXISTS(SELECT 1 FROM nodes" in sql:
|
||||
return self.node_exists
|
||||
if "MAX(external_id::integer)" in sql:
|
||||
return 150
|
||||
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in sql:
|
||||
return 300
|
||||
if "SELECT resource_id FROM storages" in sql:
|
||||
return uuid.uuid4()
|
||||
return False
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del sql, args
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
def _request(pool: HandlerPool) -> Request:
|
||||
app = type("App", (), {})()
|
||||
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"client": ("test", 1234),
|
||||
"server": ("test", 80),
|
||||
"scheme": "http",
|
||||
"root_path": "",
|
||||
"app": app,
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state.principal = "root@pam"
|
||||
return request
|
||||
|
||||
|
||||
async def _call(handler: Any, values: dict[str, Any], pool: HandlerPool | None = None) -> Any:
|
||||
return await handler(_request(pool or HandlerPool()), {"values": values})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_status_and_nextid() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_handlers(registry)
|
||||
status = await _call(registry.get("/cluster/status", "GET"), {})
|
||||
assert status[0]["name"] == "pve01"
|
||||
nextid = await _call(registry.get("/cluster/nextid", "GET"), {})
|
||||
assert nextid == 151
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_and_ceph_handlers() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_storage_handlers(registry)
|
||||
register_ceph_handlers(registry)
|
||||
storage = await _call(
|
||||
registry.get("/nodes/{node}/storage", "GET"),
|
||||
{"node": "pve01"},
|
||||
)
|
||||
assert storage[0]["storage"] == "local-lvm-pve01"
|
||||
osds = await _call(
|
||||
registry.get("/nodes/{node}/ceph/osd", "GET"),
|
||||
{"node": "pve01"},
|
||||
)
|
||||
assert osds[0]["status"] == "up"
|
||||
ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {})
|
||||
assert ceph_status["osdmap"]["num_osds"] == 300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pools_list() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_pool_handlers(registry)
|
||||
pools = await _call(registry.get("/pools", "GET"), {})
|
||||
assert pools[0]["poolid"] == "production"
|
||||
assert pools[0]["members"] == ["100"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_node_returns_404() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_storage_handlers(registry)
|
||||
pool = HandlerPool()
|
||||
pool.node_exists = False
|
||||
handler = registry.get("/nodes/{node}/storage", "GET")
|
||||
assert handler is not None
|
||||
with pytest.raises(ApiError, match="node does not exist"):
|
||||
await handler(_request(pool), {"values": {"node": "missing"}})
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Firewall aliases/ipset/group persistence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.firewall import register_firewall_handlers
|
||||
from app.simulation.seed import CLUSTER_ID
|
||||
|
||||
|
||||
class FirewallPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return True
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "jsonb_set" in query:
|
||||
# args: CLUSTER_ID, firewall json
|
||||
self.metadata["firewall"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
def request(pool: FirewallPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
async def test_firewall_alias_and_ipset_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_firewall_handlers(registry)
|
||||
pool = FirewallPool()
|
||||
http = request(pool)
|
||||
create_alias = registry.get("/cluster/firewall/aliases", "POST")
|
||||
list_alias = registry.get("/cluster/firewall/aliases", "GET")
|
||||
create_ipset = registry.get("/cluster/firewall/ipset", "POST")
|
||||
add_ip = registry.get("/cluster/firewall/ipset/{name}", "POST")
|
||||
get_ipset = registry.get("/cluster/firewall/ipset/{name}", "GET")
|
||||
assert create_alias and list_alias and create_ipset and add_ip and get_ipset
|
||||
|
||||
await create_alias(
|
||||
http, {"values": {"name": "lan", "cidr": "10.0.0.0/8"}, "provided": frozenset()}
|
||||
)
|
||||
aliases = await list_alias(http, {"values": {}, "provided": frozenset()})
|
||||
assert aliases[0]["name"] == "lan"
|
||||
await create_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
|
||||
await add_ip(
|
||||
http,
|
||||
{"values": {"name": "blacklist", "cidr": "203.0.113.10"}, "provided": frozenset()},
|
||||
)
|
||||
entries = await get_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
|
||||
assert entries[0]["cidr"] == "203.0.113.10"
|
||||
assert "scopes" in pool.metadata["firewall"]
|
||||
assert CLUSTER_ID
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Tests for gap-plan handler implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.access import register_access_handlers
|
||||
from app.handlers.cluster import register_cluster_handlers
|
||||
from app.handlers.ha import register_ha_handlers
|
||||
from app.handlers.storage import register_storage_handlers
|
||||
|
||||
|
||||
class GapPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {
|
||||
"options": {"keyboard": "en-us"},
|
||||
"replication": [],
|
||||
"ha_groups": {},
|
||||
}
|
||||
self.node_metadata: dict[str, Any] = {}
|
||||
self.node_exists = True
|
||||
self.storage_resource_id = uuid.uuid4()
|
||||
self.storage_contents: list[dict[str, object]] = []
|
||||
self.principals = {"root@pam": {"enabled": True, "realm": "pam"}}
|
||||
self.groups = {"operators": {"comment": "ops", "users": ["root@pam"]}}
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM nodes" in sql and "ORDER BY name" in sql:
|
||||
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
|
||||
if "FROM tasks" in sql:
|
||||
return []
|
||||
if "FROM task_logs" in sql:
|
||||
return []
|
||||
if "FROM resources r JOIN nodes" in sql and "kind='ha'" in sql:
|
||||
return []
|
||||
if "FROM storage_contents" in sql and "ORDER BY" in sql:
|
||||
return list(self.storage_contents)
|
||||
if "FROM backups" in sql and "ORDER BY created_at DESC" in sql and "OFFSET" not in sql:
|
||||
return []
|
||||
if "FROM principals p" in sql and "ORDER BY p.name" in sql:
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"realm_name": data["realm"],
|
||||
"enabled": data["enabled"],
|
||||
"realm_kind": data["realm"],
|
||||
}
|
||||
for name, data in self.principals.items()
|
||||
]
|
||||
if "FROM identity_groups g" in sql and "GROUP BY" in sql:
|
||||
return [
|
||||
{
|
||||
"group_id": group_id,
|
||||
"comment": data["comment"],
|
||||
"users": data["users"],
|
||||
}
|
||||
for group_id, data in self.groups.items()
|
||||
]
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
if "FROM clusters WHERE id" in sql:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
if "FROM nodes WHERE name" in sql and "metadata" in sql:
|
||||
name = str(args[0])
|
||||
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
|
||||
if "FROM nodes WHERE name" in sql:
|
||||
return {"name": "pve01", "id": uuid.uuid4()} if self.node_exists else None
|
||||
if "FROM storages WHERE storage_id" in sql and "resource_id" in sql:
|
||||
return {"resource_id": self.storage_resource_id}
|
||||
if "FROM storages s" in sql and "JOIN" in sql:
|
||||
return {
|
||||
"storage_id": "local-lvm",
|
||||
"storage_type": "lvmthin",
|
||||
"shared": False,
|
||||
"capacity_bytes": 1_000_000,
|
||||
"used_bytes": 250_000,
|
||||
"config": '{"content":["images"]}',
|
||||
"node_name": "pve01",
|
||||
"resource_id": self.storage_resource_id,
|
||||
}
|
||||
if "FROM storage_contents" in sql and "volume_id=$2" in sql:
|
||||
volume = str(args[1])
|
||||
for item in self.storage_contents:
|
||||
if item["volume_id"] == volume:
|
||||
return item
|
||||
return {
|
||||
"volume_id": "local-lvm:100/vm-100-disk-0.raw",
|
||||
"content_type": "images",
|
||||
"size_bytes": 1024,
|
||||
"metadata": '{"format":"raw"}',
|
||||
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
|
||||
}
|
||||
if "FROM principals" in sql and "WHERE" in sql and "name" in sql:
|
||||
userid = str(args[0])
|
||||
if userid not in self.principals:
|
||||
return None
|
||||
data = self.principals[userid]
|
||||
return {
|
||||
"name": userid,
|
||||
"realm_name": data["realm"],
|
||||
"enabled": data["enabled"],
|
||||
"realm_kind": data["realm"],
|
||||
"id": uuid.uuid4(),
|
||||
}
|
||||
if "FROM identity_groups WHERE group_id" in sql:
|
||||
groupid = str(args[0])
|
||||
if groupid not in self.groups:
|
||||
return None
|
||||
return {"id": uuid.uuid4(), "group_id": groupid}
|
||||
if "FROM identity_groups g" in sql and "WHERE g.group_id" in sql:
|
||||
groupid = str(args[0])
|
||||
if groupid not in self.groups:
|
||||
return None
|
||||
group_data = self.groups[groupid]
|
||||
return {
|
||||
"group_id": groupid,
|
||||
"comment": group_data["comment"],
|
||||
"users": group_data["users"],
|
||||
}
|
||||
if "count(*) FILTER" in sql and "kind='ha'" in sql:
|
||||
return {"started": 0, "total": 0}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in sql:
|
||||
return self.node_exists
|
||||
if "MAX(external_id::integer)" in sql:
|
||||
return 150
|
||||
if "SELECT resource_id FROM storages" in sql:
|
||||
return self.storage_resource_id
|
||||
if "EXISTS(SELECT 1 FROM principals" in sql:
|
||||
return False
|
||||
if "EXISTS(SELECT 1 FROM realms" in sql:
|
||||
return True
|
||||
if "EXISTS(SELECT 1 FROM identity_groups" in sql:
|
||||
return False
|
||||
if "EXISTS(SELECT 1 FROM resources WHERE kind='ha'" in sql:
|
||||
return False
|
||||
if "SELECT metadata FROM nodes" in sql:
|
||||
return json.dumps(self.node_metadata.get(str(args[0]), {}))
|
||||
if "SELECT name FROM nodes WHERE status" in sql:
|
||||
return "pve01"
|
||||
return False
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in sql:
|
||||
self.metadata = json.loads(str(args[1]))
|
||||
return "UPDATE 1"
|
||||
if "UPDATE nodes SET metadata" in sql:
|
||||
self.node_metadata[str(args[0])] = json.loads(str(args[1]))
|
||||
return "UPDATE 1"
|
||||
if "INSERT INTO storage_contents" in sql:
|
||||
self.storage_contents.append(
|
||||
{
|
||||
"volume_id": str(args[1]),
|
||||
"content_type": str(args[2]),
|
||||
"size_bytes": int(str(args[3])),
|
||||
"metadata": str(args[4]),
|
||||
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
|
||||
}
|
||||
)
|
||||
return "INSERT 0 1"
|
||||
if "INSERT INTO resources" in sql and "kind='ha'" in sql:
|
||||
return "INSERT 0 1"
|
||||
if "DELETE FROM" in sql:
|
||||
return "DELETE 1"
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
def _request(pool: GapPool) -> Request:
|
||||
app = type("App", (), {})()
|
||||
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"client": ("test", 1234),
|
||||
"server": ("test", 80),
|
||||
"scheme": "http",
|
||||
"root_path": "",
|
||||
"app": app,
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state.principal = "root@pam"
|
||||
return request
|
||||
|
||||
|
||||
async def _call(handler: Any, values: dict[str, Any], pool: GapPool | None = None) -> Any:
|
||||
return await handler(
|
||||
_request(pool or GapPool()),
|
||||
{"values": values, "provided": tuple(values)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_index_and_replication_crud() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_handlers(registry)
|
||||
pool = GapPool()
|
||||
index = await _call(registry.get("/cluster", "GET"), {}, pool)
|
||||
assert any(item["subdir"] == "replication" for item in index)
|
||||
created = await _call(
|
||||
registry.get("/cluster/replication", "POST"),
|
||||
{"guest": "100", "target": "pve02"},
|
||||
pool,
|
||||
)
|
||||
assert created["id"] == "repl-100"
|
||||
jobs = await _call(registry.get("/cluster/replication", "GET"), {}, pool)
|
||||
assert jobs[0]["guest"] == "100"
|
||||
fetched = await _call(
|
||||
registry.get("/cluster/replication/{id}", "GET"), {"id": "repl-100"}, pool
|
||||
)
|
||||
assert fetched["target"] == "pve02"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ha_group_create_and_index() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_ha_handlers(registry)
|
||||
pool = GapPool()
|
||||
index = await _call(registry.get("/cluster/ha", "GET"), {}, pool)
|
||||
assert any(item["subdir"] == "groups" for item in index)
|
||||
await _call(
|
||||
registry.get("/cluster/ha/groups", "POST"),
|
||||
{"group": "lab", "nodes": "pve01,pve02"},
|
||||
pool,
|
||||
)
|
||||
assert "lab" in pool.metadata["ha_groups"]
|
||||
groups = await _call(registry.get("/cluster/ha/groups", "GET"), {}, pool)
|
||||
assert groups[0]["group"] == "lab"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_user_and_group_detail() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = GapPool()
|
||||
user = await _call(registry.get("/access/users/{userid}", "GET"), {"userid": "root@pam"}, pool)
|
||||
assert user["userid"] == "root@pam"
|
||||
group = await _call(
|
||||
registry.get("/access/groups/{groupid}", "GET"),
|
||||
{"groupid": "operators"},
|
||||
pool,
|
||||
)
|
||||
assert group["users"] == ["root@pam"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_content_get_and_upload() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_storage_handlers(registry)
|
||||
pool = GapPool()
|
||||
item = await _call(
|
||||
registry.get("/nodes/{node}/storage/{storage}/content/{volume}", "GET"),
|
||||
{
|
||||
"node": "pve01",
|
||||
"storage": "local-lvm",
|
||||
"volume": "local-lvm:100/vm-100-disk-0.raw",
|
||||
},
|
||||
pool,
|
||||
)
|
||||
assert item["content"] == "images"
|
||||
upload = await _call(
|
||||
registry.get("/nodes/{node}/storage/{storage}/upload", "POST"),
|
||||
{"node": "pve01", "storage": "local-lvm", "filename": "image.iso"},
|
||||
pool,
|
||||
)
|
||||
assert "uploadid" in upload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replication_missing_returns_404() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_handlers(registry)
|
||||
handler = registry.get("/cluster/replication/{id}", "GET")
|
||||
with pytest.raises(ApiError, match="replication job does not exist"):
|
||||
await _call(handler, {"id": "missing"}, GapPool())
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Persistence tests for remaining gap handlers (nodes_extra / cluster_extra)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.cluster_extra import register_cluster_extra_handlers
|
||||
from app.handlers.nodes_extra import register_nodes_extra_handlers
|
||||
|
||||
|
||||
class GapRemainingPool:
|
||||
def __init__(self) -> None:
|
||||
self.cluster_metadata: dict[str, Any] = {}
|
||||
self.node_metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "SELECT metadata FROM clusters" in query:
|
||||
return {"metadata": json.dumps(self.cluster_metadata)}
|
||||
if "SELECT metadata FROM nodes" in query:
|
||||
name = str(arguments[0])
|
||||
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return True
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in query:
|
||||
self.cluster_metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "UPDATE nodes SET metadata" in query:
|
||||
self.node_metadata[str(arguments[0])] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: GapRemainingPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def _request(pool: GapRemainingPool, *, method: str = "GET") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": method,
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disks_directory_create_persists() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_nodes_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/nodes/{node}/disks/directory", "POST")
|
||||
assert create is not None
|
||||
created = await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {"node": "pve01", "name": "tank", "device": "/dev/sdb"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
assert created["name"] == "tank"
|
||||
ops = pool.node_metadata["pve01"]["ops"]
|
||||
assert any(item["name"] == "tank" for item in ops["disks"]["directory"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_certificates_custom_create_does_not_echo_key() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_nodes_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/nodes/{node}/certificates/custom", "POST")
|
||||
info = registry.get("/nodes/{node}/certificates/info", "GET")
|
||||
assert create is not None and info is not None
|
||||
await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {
|
||||
"node": "pve01",
|
||||
"certificates": "-----BEGIN CERTIFICATE-----\nSIM\n-----END CERTIFICATE-----",
|
||||
"key": "-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
stored = pool.node_metadata["pve01"]["ops"]["certificates"]["custom"]
|
||||
assert stored["key"].startswith("-----BEGIN PRIVATE KEY-----")
|
||||
listing = await info(
|
||||
_request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
blob = json.dumps(listing)
|
||||
assert "PRIVATE KEY" not in blob
|
||||
assert "SECRET" not in blob
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realm_sync_job_create_persists() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/cluster/jobs/realm-sync/{id}", "POST")
|
||||
listing = registry.get("/cluster/jobs/realm-sync", "GET")
|
||||
assert create is not None and listing is not None
|
||||
created = await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {"id": "pam-nightly", "realm": "pam", "schedule": "0 2 * * *"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
assert created["id"] == "pam-nightly"
|
||||
assert pool.cluster_metadata["jobs"]["realm_sync"]["pam-nightly"]["realm"] == "pam"
|
||||
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
|
||||
assert items[0]["id"] == "pam-nightly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_server_create_persists() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/cluster/metrics/server/{id}", "POST")
|
||||
listing = registry.get("/cluster/metrics/server", "GET")
|
||||
assert create is not None and listing is not None
|
||||
created = await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {
|
||||
"id": "influx1",
|
||||
"type": "influxdb",
|
||||
"server": "10.0.0.20",
|
||||
"port": 8089,
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
assert created["id"] == "influx1"
|
||||
assert pool.cluster_metadata["metrics"]["servers"]["influx1"]["server"] == "10.0.0.20"
|
||||
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
|
||||
assert items[0]["id"] == "influx1"
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Self
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.pool import Database
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, ready: bool) -> None:
|
||||
self.ready = ready
|
||||
self.connected = False
|
||||
self.closed = False
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.connected = True
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
return self.ready
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("database_ready", "status_code"), [(True, 200), (False, 503)])
|
||||
async def test_health_endpoints(database_ready: bool, status_code: int) -> None:
|
||||
database = FakeDatabase(database_ready)
|
||||
|
||||
def factory(settings: Settings) -> Database:
|
||||
del settings
|
||||
return database
|
||||
|
||||
application = create_app(
|
||||
Settings(contract_snapshot=None, compatibility_evidence=None),
|
||||
factory,
|
||||
worker_factories=(),
|
||||
)
|
||||
async with application.router.lifespan_context(application):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
live = await client.get("/health/live")
|
||||
ready = await client.get("/health/ready", headers={"X-Request-ID": "test-request"})
|
||||
|
||||
assert live.status_code == 200
|
||||
assert live.json() == {"status": "ok"}
|
||||
assert ready.status_code == status_code
|
||||
assert ready.headers["X-Request-ID"] == "test-request"
|
||||
assert database.connected
|
||||
assert database.closed
|
||||
|
||||
|
||||
async def test_lifespan_starts_and_stops_injected_workers() -> None:
|
||||
database = FakeDatabase(True)
|
||||
started = asyncio.Event()
|
||||
stopping = asyncio.Event()
|
||||
|
||||
class Worker:
|
||||
async def run(self) -> None:
|
||||
started.set()
|
||||
await stopping.wait()
|
||||
|
||||
def stop(self) -> None:
|
||||
stopping.set()
|
||||
|
||||
application = create_app(
|
||||
Settings(contract_snapshot=None, compatibility_evidence=None),
|
||||
lambda _settings: database,
|
||||
worker_factories=(lambda _database: Worker(),),
|
||||
)
|
||||
async with application.router.lifespan_context(application):
|
||||
await started.wait()
|
||||
|
||||
assert stopping.is_set()
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.logging import JsonFormatter
|
||||
|
||||
|
||||
def test_json_formatter_emits_structured_fields() -> None:
|
||||
record = logging.LogRecord("test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
|
||||
record.request_id = "request-1"
|
||||
|
||||
payload = json.loads(JsonFormatter().format(record))
|
||||
|
||||
assert payload["message"] == "hello world"
|
||||
assert payload["request_id"] == "request-1"
|
||||
assert payload["level"] == "INFO"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Persistent LXC semantic handler tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.lxc import register_lxc_handlers
|
||||
|
||||
|
||||
class LxcPool:
|
||||
def __init__(self) -> None:
|
||||
self.resource_exists = False
|
||||
self.missing = False
|
||||
self.running = False
|
||||
self.commands: list[str] = []
|
||||
self.resource_id = uuid.uuid4()
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> bool | int:
|
||||
del args
|
||||
if "pg_backend_pid" in sql:
|
||||
return 123
|
||||
if "extract(epoch" in sql:
|
||||
return 1_700_000_000
|
||||
if "FROM nodes" in sql:
|
||||
return True
|
||||
if "FROM resources" in sql:
|
||||
return self.resource_exists
|
||||
if "FROM snapshots" in sql:
|
||||
return False
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM resources" in sql and "kind='lxc'" in sql:
|
||||
return [{"vmid": 200, "state": '{"status":"stopped","name":"service"}'}]
|
||||
assert "FROM snapshots" in sql
|
||||
return [
|
||||
{
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
]
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if self.missing:
|
||||
return None
|
||||
if "SELECT r.id, r.version" in sql:
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"version": 1,
|
||||
"state": '{"name":"old","status":"stopped"}',
|
||||
"config": '{"name":"old"}',
|
||||
}
|
||||
if "SELECT r.id, r.state, c.config" in sql:
|
||||
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
||||
if "SELECT r.id, r.state FROM resources" in sql:
|
||||
status = "running" if self.running else "stopped"
|
||||
return {"id": self.resource_id, "state": f'{{"status":"{status}"}}'}
|
||||
if "SELECT s.* FROM snapshots" in sql:
|
||||
return {
|
||||
"id": uuid.uuid4(),
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
"state": "{}",
|
||||
}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del sql, args
|
||||
self.commands.append("execute")
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: LxcPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: LxcPool) -> None:
|
||||
self.pool = pool
|
||||
self.created: list[dict[str, Any]] = []
|
||||
|
||||
async def create(self, **kwargs: Any) -> Any:
|
||||
self.created.append(kwargs)
|
||||
return type(
|
||||
"Task", (), {"upid": "UPID:pve1:00000001:00000001:1700000000:pctcreate:201:root@pam:"}
|
||||
)()
|
||||
|
||||
|
||||
def _request(pool: LxcPool) -> Request:
|
||||
app = type("App", (), {"state": type("State", (), {"database": FakeDatabase(pool)})()})()
|
||||
request = Request({"type": "http", "headers": [], "method": "POST", "path": "/"})
|
||||
request.scope["app"] = app
|
||||
request.state.principal = "root@pam"
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> HandlerRegistry:
|
||||
handler_registry = HandlerRegistry()
|
||||
register_lxc_handlers(handler_registry)
|
||||
return handler_registry
|
||||
|
||||
|
||||
async def test_lxc_list_returns_seeded_containers(registry: HandlerRegistry) -> None:
|
||||
pool = LxcPool()
|
||||
handler = registry.get("/nodes/{node}/lxc", "GET")
|
||||
assert handler is not None
|
||||
result = await handler(_request(pool), {"values": {"node": "pve1"}})
|
||||
assert result == [{"vmid": 200, "status": "stopped", "name": "service"}]
|
||||
|
||||
|
||||
async def test_lxc_create_rejects_duplicate_vmid(registry: HandlerRegistry) -> None:
|
||||
pool = LxcPool()
|
||||
pool.resource_exists = True
|
||||
handler = registry.get("/nodes/{node}/lxc", "POST")
|
||||
assert handler is not None
|
||||
with pytest.raises(ApiError, match="VMID already exists"):
|
||||
await handler(
|
||||
_request(pool),
|
||||
{"values": {"node": "pve1", "vmid": 201, "hostname": "app"}},
|
||||
)
|
||||
|
||||
|
||||
async def test_lxc_delete_requires_stopped_container(registry: HandlerRegistry) -> None:
|
||||
pool = LxcPool()
|
||||
pool.running = True
|
||||
handler = registry.get("/nodes/{node}/lxc/{vmid}", "DELETE")
|
||||
assert handler is not None
|
||||
with pytest.raises(ApiError, match="cannot delete a running container"):
|
||||
await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
|
||||
|
||||
|
||||
async def test_lxc_start_creates_task(
|
||||
monkeypatch: pytest.MonkeyPatch, registry: HandlerRegistry
|
||||
) -> None:
|
||||
pool = LxcPool()
|
||||
repository = FakeTaskRepository(pool)
|
||||
monkeypatch.setattr("app.handlers.lxc.TaskRepository", lambda _pool: repository)
|
||||
handler = registry.get("/nodes/{node}/lxc/{vmid}/status/start", "POST")
|
||||
assert handler is not None
|
||||
upid = await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
|
||||
assert upid.startswith("UPID:")
|
||||
assert repository.created[0]["task_type"] == "lxc-start"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Migration discovery and checksum tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.db.migrations import load_migrations
|
||||
|
||||
|
||||
def test_load_migrations_is_ordered_and_checksummed(tmp_path: Path) -> None:
|
||||
(tmp_path / "002_second.sql").write_text("SELECT 2;")
|
||||
(tmp_path / "001_first.sql").write_text("SELECT 1;")
|
||||
|
||||
migrations = load_migrations(tmp_path)
|
||||
|
||||
assert [migration.version for migration in migrations] == [1, 2]
|
||||
assert migrations[0].name == "001_first"
|
||||
assert len(migrations[0].checksum) == 64
|
||||
|
||||
|
||||
def test_repository_migration_defines_required_planes() -> None:
|
||||
migrations = load_migrations()
|
||||
migration = migrations[0]
|
||||
|
||||
for table in (
|
||||
"contract_snapshots",
|
||||
"nodes",
|
||||
"resources",
|
||||
"principals",
|
||||
"acl_entries",
|
||||
"tasks",
|
||||
"scenarios",
|
||||
"audit_events",
|
||||
):
|
||||
assert f"CREATE TABLE {table}" in migration.sql
|
||||
assert "CREATE TABLE realms" in migrations[1].sql
|
||||
assert "CREATE TABLE api_tokens" in migrations[1].sql
|
||||
domain = migrations[3].sql
|
||||
for table in (
|
||||
"clusters",
|
||||
"virtual_machines",
|
||||
"containers",
|
||||
"storages",
|
||||
"storage_contents",
|
||||
"snapshots",
|
||||
"backups",
|
||||
"pools",
|
||||
"identity_groups",
|
||||
"contract_paths",
|
||||
"observed_contracts",
|
||||
"scenario_rules",
|
||||
"fault_injections",
|
||||
):
|
||||
assert f"CREATE TABLE {table}" in domain
|
||||
assert "CREATE TABLE group_acl_entries" in migrations[5].sql
|
||||
assert "ADD COLUMN IF NOT EXISTS config jsonb" in migrations[6].sql
|
||||
assert "CREATE TABLE tfa_entries" in migrations[7].sql
|
||||
assert "CREATE TABLE openid_pending" in migrations[7].sql
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Node ops handlers persist network/disks/services into nodes.metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.nodes import register_node_ops_handlers
|
||||
|
||||
|
||||
class NodePool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "SELECT metadata FROM nodes" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return True
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE nodes SET metadata" in query:
|
||||
self.metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: NodePool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: NodePool, *, method: str = "GET", path: str = "/") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
async def test_network_and_service_mutations_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_node_ops_handlers(registry)
|
||||
pool = NodePool()
|
||||
|
||||
create = registry.get("/nodes/{node}/network", "POST")
|
||||
listing = registry.get("/nodes/{node}/network", "GET")
|
||||
delete = registry.get("/nodes/{node}/network/{iface}", "DELETE")
|
||||
stop = registry.get("/nodes/{node}/services/{service}/stop", "POST")
|
||||
state = registry.get("/nodes/{node}/services/{service}/state", "GET")
|
||||
assert create and listing and delete and stop and state
|
||||
|
||||
await create(
|
||||
request(pool, method="POST", path="/api2/json/nodes/pve01/network"),
|
||||
{"values": {"node": "pve01", "iface": "vmbr9", "type": "bridge"}, "provided": frozenset()},
|
||||
)
|
||||
items = await listing(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
assert any(item["iface"] == "vmbr9" for item in items)
|
||||
|
||||
await delete(
|
||||
request(pool, method="DELETE", path="/api2/json/nodes/pve01/network/vmbr9"),
|
||||
{"values": {"node": "pve01", "iface": "vmbr9"}, "provided": frozenset()},
|
||||
)
|
||||
items = await listing(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
assert all(item["iface"] != "vmbr9" for item in items)
|
||||
|
||||
await stop(
|
||||
request(pool, method="POST", path="/api2/json/nodes/pve01/services/pveproxy/stop"),
|
||||
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
|
||||
)
|
||||
service = await state(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
|
||||
)
|
||||
assert service["state"] == "stopped"
|
||||
assert "ops" in pool.metadata
|
||||
|
||||
|
||||
async def test_disk_init_and_wipe_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_node_ops_handlers(registry)
|
||||
pool = NodePool()
|
||||
initgpt = registry.get("/nodes/{node}/disks/initgpt", "POST")
|
||||
wipe = registry.get("/nodes/{node}/disks/wipedisk", "PUT")
|
||||
listing = registry.get("/nodes/{node}/disks/list", "GET")
|
||||
assert initgpt and wipe and listing
|
||||
|
||||
await initgpt(
|
||||
request(pool, method="POST"),
|
||||
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
|
||||
)
|
||||
await wipe(
|
||||
request(pool, method="PUT"),
|
||||
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
|
||||
)
|
||||
disks = await listing(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
target = next(item for item in disks if item["devpath"] == "/dev/sdb")
|
||||
assert target["wiped"] == 1
|
||||
assert target["gpt"] == 0
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Notification endpoints/matchers persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.notifications import register_notifications_handlers
|
||||
from app.simulation.seed import CLUSTER_ID
|
||||
|
||||
|
||||
class NotesPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in query:
|
||||
self.metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
def request(pool: NotesPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_notification_endpoint_and_matcher_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_notifications_handlers(registry)
|
||||
pool = NotesPool()
|
||||
http = request(pool)
|
||||
create = registry.get("/cluster/notifications/endpoints/gotify", "POST")
|
||||
get = registry.get("/cluster/notifications/endpoints/gotify/{name}", "GET")
|
||||
matchers = registry.get("/cluster/notifications/matchers", "POST")
|
||||
targets = registry.get("/cluster/notifications/targets", "GET")
|
||||
test = registry.get("/cluster/notifications/targets/{name}/test", "POST")
|
||||
assert create and get and matchers and targets and test
|
||||
|
||||
await create(
|
||||
http,
|
||||
{
|
||||
"values": {
|
||||
"name": "ops",
|
||||
"server": "https://gotify.local",
|
||||
"token": "secret-token",
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
payload = await get(http, {"values": {"name": "ops"}, "provided": frozenset()})
|
||||
assert payload["server"] == "https://gotify.local"
|
||||
assert "token" not in payload
|
||||
await matchers(
|
||||
http,
|
||||
{
|
||||
"values": {"name": "all-mail", "target": "ops", "mode": "all"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
listed = await targets(http, {"values": {}, "provided": frozenset()})
|
||||
assert listed[0]["name"] == "ops"
|
||||
await test(http, {"values": {"name": "ops"}, "provided": frozenset()})
|
||||
assert pool.metadata["notifications"]["tests"]
|
||||
assert CLUSTER_ID
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Persistent QEMU CRUD semantic handler tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.db.primitives import ConflictError
|
||||
from app.handlers.qemu import register_qemu_handlers
|
||||
from app.tasks.repository import Task
|
||||
|
||||
|
||||
class QemuPool:
|
||||
def __init__(self) -> None:
|
||||
self.resource_exists = False
|
||||
self.missing = False
|
||||
self.running = False
|
||||
self.commands: list[str] = []
|
||||
self.resource_id = uuid.uuid4()
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> bool | int:
|
||||
del args
|
||||
if "pg_backend_pid" in sql:
|
||||
return 123
|
||||
if "extract(epoch" in sql:
|
||||
return 1_700_000_000
|
||||
if "FROM nodes" in sql:
|
||||
return True
|
||||
if "FROM resources" in sql:
|
||||
return self.resource_exists
|
||||
if "FROM snapshots" in sql:
|
||||
return False
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM resources" in sql:
|
||||
return [{"vmid": 150, "state": '{"status":"stopped","name":"vm"}'}]
|
||||
assert "FROM snapshots" in sql
|
||||
return [
|
||||
{
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
]
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if self.missing:
|
||||
return None
|
||||
if "SELECT r.id, r.version" in sql:
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"version": 1,
|
||||
"state": '{"name":"old","status":"stopped"}',
|
||||
"config": '{"name":"old"}',
|
||||
}
|
||||
if "SELECT r.state, v.config" in sql:
|
||||
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
|
||||
if "SELECT r.id, r.state" in sql:
|
||||
status = "running" if self.running else "stopped"
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"state": f'{{"status":"{status}"}}',
|
||||
"config": ('{"agent":1,"name":"vm","scsi0":"local-lvm:vm-150-disk-0,size=8G"}'),
|
||||
}
|
||||
if "SELECT r.id, r.state, v.config" in sql:
|
||||
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
||||
if "SELECT s.* FROM snapshots" in sql:
|
||||
return {
|
||||
"id": uuid.uuid4(),
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"state": '{"config":{"name":"old"}}',
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
self.commands.append(sql)
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: QemuPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: QemuPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
def inputs(**values: object) -> dict[str, Any]:
|
||||
return {"values": values, "provided": tuple(values)}
|
||||
|
||||
|
||||
async def test_qemu_create_sync_async_update_and_delete(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created_payloads: list[dict[str, Any]] = []
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: Any) -> Task:
|
||||
created_payloads.append(kwargs)
|
||||
return Task(
|
||||
uuid.uuid4(),
|
||||
str(kwargs["upid"]),
|
||||
str(kwargs["task_type"]),
|
||||
"queued",
|
||||
dict(kwargs["payload"]),
|
||||
0,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/nodes/{node}/qemu", "POST")
|
||||
listing = registry.get("/nodes/{node}/qemu", "GET")
|
||||
config = registry.get("/nodes/{node}/qemu/{vmid}/config", "GET")
|
||||
current = registry.get("/nodes/{node}/qemu/{vmid}/status/current", "GET")
|
||||
update_sync = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
|
||||
update_async = registry.get("/nodes/{node}/qemu/{vmid}/config", "POST")
|
||||
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
|
||||
assert create and listing and config and current and update_sync and update_async and delete
|
||||
|
||||
assert (await listing(http_request, inputs(node="pve1")))[0]["name"] == "vm"
|
||||
assert (await config(http_request, inputs(node="pve1", vmid=150)))["name"] == "vm"
|
||||
assert (await current(http_request, inputs(node="pve1", vmid=150)))["status"] == "stopped"
|
||||
|
||||
create_upid = await create(
|
||||
http_request,
|
||||
inputs(node="pve1", vmid=150, name="new", cores=2),
|
||||
)
|
||||
assert create_upid.startswith("UPID:pve1:")
|
||||
assert created_payloads[-1]["task_type"] == "qemu-create"
|
||||
|
||||
assert (
|
||||
await update_sync(
|
||||
http_request,
|
||||
inputs(node="pve1", vmid=150, name="sync", delete="unused"),
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert len(pool.commands) == 2
|
||||
|
||||
update_upid = await update_async(
|
||||
http_request,
|
||||
inputs(node="pve1", vmid=150, memory="2048"),
|
||||
)
|
||||
assert update_upid.startswith("UPID:pve1:")
|
||||
assert created_payloads[-1]["task_type"] == "qemu-update"
|
||||
|
||||
delete_upid = await delete(http_request, inputs(node="pve1", vmid=150))
|
||||
assert delete_upid.startswith("UPID:pve1:")
|
||||
assert created_payloads[-1]["task_type"] == "qemu-delete"
|
||||
|
||||
|
||||
async def test_qemu_crud_conflicts_and_missing_resources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ConflictingRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **_kwargs: object) -> Task:
|
||||
raise ConflictError("resource is locked")
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", ConflictingRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/nodes/{node}/qemu", "POST")
|
||||
update = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
|
||||
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
|
||||
assert create and update and delete
|
||||
|
||||
with pytest.raises(ApiError) as locked:
|
||||
await create(http_request, inputs(node="pve1", vmid=150))
|
||||
assert locked.value.status_code == 409
|
||||
|
||||
pool.resource_exists = True
|
||||
with pytest.raises(ApiError) as duplicate:
|
||||
await create(http_request, inputs(node="pve1", vmid=150))
|
||||
assert duplicate.value.status_code == 409
|
||||
|
||||
pool.missing = True
|
||||
with pytest.raises(ApiError) as missing:
|
||||
await update(http_request, inputs(node="pve1", vmid=150, name="missing"))
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
pool.missing = False
|
||||
pool.running = True
|
||||
with pytest.raises(ApiError) as running:
|
||||
await delete(http_request, inputs(node="pve1", vmid=150))
|
||||
assert running.value.status_code == 409
|
||||
|
||||
|
||||
async def test_qemu_snapshot_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
tasks: list[str] = []
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: Any) -> Task:
|
||||
tasks.append(str(kwargs["task_type"]))
|
||||
return Task(uuid.uuid4(), str(kwargs["upid"]), tasks[-1], "queued", {}, 0, False, 0)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
base = "/nodes/{node}/qemu/{vmid}/snapshot"
|
||||
|
||||
listing = registry.get(base, "GET")
|
||||
create = registry.get(base, "POST")
|
||||
get = registry.get(f"{base}/{{snapname}}", "GET")
|
||||
delete = registry.get(f"{base}/{{snapname}}", "DELETE")
|
||||
config_get = registry.get(f"{base}/{{snapname}}/config", "GET")
|
||||
config_put = registry.get(f"{base}/{{snapname}}/config", "PUT")
|
||||
rollback = registry.get(f"{base}/{{snapname}}/rollback", "POST")
|
||||
assert listing and create and get and delete and config_get and config_put and rollback
|
||||
|
||||
common = inputs(node="pve1", vmid=150, snapname="baseline")
|
||||
assert (await listing(http_request, inputs(node="pve1", vmid=150)))[0]["name"] == "baseline"
|
||||
assert (await get(http_request, common))["description"] == "stable"
|
||||
assert (await config_get(http_request, common))["config"] == {"name": "old"}
|
||||
assert await config_put(http_request, inputs(**common["values"], description="updated")) is None
|
||||
assert (
|
||||
await create(http_request, inputs(**common["values"], description="stable"))
|
||||
).startswith("UPID:pve1:")
|
||||
assert (await rollback(http_request, common)).startswith("UPID:pve1:")
|
||||
assert (await delete(http_request, common)).startswith("UPID:pve1:")
|
||||
assert tasks == ["qemu-snapshot-create", "qemu-snapshot-rollback", "qemu-snapshot-delete"]
|
||||
|
||||
|
||||
async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
tasks: list[dict[str, Any]] = []
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: Any) -> Task:
|
||||
tasks.append(kwargs)
|
||||
return Task(
|
||||
uuid.uuid4(),
|
||||
str(kwargs["upid"]),
|
||||
str(kwargs["task_type"]),
|
||||
"queued",
|
||||
{},
|
||||
0,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
|
||||
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
|
||||
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
|
||||
resize = registry.get("/nodes/{node}/qemu/{vmid}/resize", "PUT")
|
||||
move = registry.get("/nodes/{node}/qemu/{vmid}/move_disk", "POST")
|
||||
assert clone and migrate_get and migrate and resize and move
|
||||
|
||||
clone_upid = await clone(
|
||||
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
|
||||
)
|
||||
assert clone_upid.startswith("UPID:pve1:")
|
||||
assert tasks[-1]["task_type"] == "qemu-clone"
|
||||
assert (await migrate_get(http_request, inputs(node="pve1", vmid=150, target="pve2")))[
|
||||
"local_disks"
|
||||
] == []
|
||||
migrate_upid = await migrate(
|
||||
http_request, inputs(node="pve1", vmid=150, target="pve2", online=False)
|
||||
)
|
||||
assert migrate_upid.startswith("UPID:pve1:")
|
||||
assert tasks[-1]["task_type"] == "qemu-migrate"
|
||||
assert (
|
||||
await resize(http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")) is None
|
||||
)
|
||||
move_upid = await move(
|
||||
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
|
||||
)
|
||||
assert move_upid.startswith("UPID:pve1:")
|
||||
assert tasks[-1]["task_type"] == "qemu-move-disk"
|
||||
|
||||
with pytest.raises(ApiError) as same_node:
|
||||
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
|
||||
assert same_node.value.status_code == 400
|
||||
|
||||
|
||||
async def test_qemu_pending_and_agent_handlers() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
pool.running = True
|
||||
http_request = request(pool)
|
||||
values = inputs(node="pve1", vmid=150)
|
||||
|
||||
pending = registry.get("/nodes/{node}/qemu/{vmid}/pending", "GET")
|
||||
routes = {
|
||||
"info": "/nodes/{node}/qemu/{vmid}/agent/info",
|
||||
"os": "/nodes/{node}/qemu/{vmid}/agent/get-osinfo",
|
||||
"host": "/nodes/{node}/qemu/{vmid}/agent/get-host-name",
|
||||
"network": "/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces",
|
||||
"time": "/nodes/{node}/qemu/{vmid}/agent/get-time",
|
||||
"ping": "/nodes/{node}/qemu/{vmid}/agent/ping",
|
||||
}
|
||||
handlers = {
|
||||
name: registry.get(path, "POST" if name == "ping" else "GET")
|
||||
for name, path in routes.items()
|
||||
}
|
||||
assert pending and all(handlers.values())
|
||||
|
||||
async def call(name: str) -> dict[str, Any]:
|
||||
handler = handlers[name]
|
||||
assert handler is not None
|
||||
return cast(dict[str, Any], await handler(http_request, values))
|
||||
|
||||
assert await pending(http_request, values) == []
|
||||
assert (await call("info"))["result"]["version"]
|
||||
assert (await call("os"))["result"]["machine"] == "x86_64"
|
||||
assert (await call("host"))["result"]["host-name"] == "vm"
|
||||
assert (await call("network"))["result"][0]["name"] == "eth0"
|
||||
assert (await call("time"))["result"]["seconds"] > 0
|
||||
assert (await call("ping"))["result"] == {}
|
||||
@@ -0,0 +1,284 @@
|
||||
"""QEMU worker transition semantics."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
from app.simulation.clock import Clock
|
||||
from app.tasks.qemu import qemu_handler
|
||||
from app.tasks.repository import Task, TaskRepository
|
||||
|
||||
|
||||
class ImmediateClock:
|
||||
async def now(self) -> datetime:
|
||||
return datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
assert seconds == 1.0
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self) -> None:
|
||||
self.states: list[str] = []
|
||||
|
||||
async def fetchrow(self, sql: str, resource_id: uuid.UUID) -> dict[str, object]:
|
||||
del sql, resource_id
|
||||
return {"state": '{"status":"stopped"}'}
|
||||
|
||||
async def execute(self, sql: str, resource_id: uuid.UUID, state: str) -> str:
|
||||
del sql, resource_id
|
||||
self.states.append(state)
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class Acquire:
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
async def __aenter__(self) -> Connection:
|
||||
return self.connection
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Pool:
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
def acquire(self) -> Acquire:
|
||||
return Acquire(self.connection)
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.connection = Connection()
|
||||
self.pool = Pool(self.connection)
|
||||
self.logs: list[str] = []
|
||||
|
||||
async def append_log(self, task_id: uuid.UUID, message: str) -> None:
|
||||
del task_id
|
||||
self.logs.append(message)
|
||||
|
||||
|
||||
class Transaction:
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class CrudConnection:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[str] = []
|
||||
|
||||
def transaction(self) -> Transaction:
|
||||
return Transaction()
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if "FROM nodes" in sql:
|
||||
return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()}
|
||||
if "JOIN virtual_machines" in sql:
|
||||
return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'}
|
||||
if "SELECT state FROM resources" in sql:
|
||||
return {"state": '{"status":"stopped","name":"old"}'}
|
||||
if "SELECT config FROM virtual_machines" in sql:
|
||||
return {"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=10G"}'}
|
||||
if "FROM snapshots" in sql:
|
||||
return {
|
||||
"state": (
|
||||
'{"resource_state":{"status":"stopped","name":"old"},"config":{"name":"old"}}'
|
||||
)
|
||||
}
|
||||
return None
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
self.commands.append(sql)
|
||||
return "DELETE 1" if sql.startswith("DELETE") else "UPDATE 1"
|
||||
|
||||
|
||||
class CrudRepository:
|
||||
def __init__(self) -> None:
|
||||
self.connection = CrudConnection()
|
||||
self.pool = Pool(cast(Connection, self.connection))
|
||||
self.logs: list[str] = []
|
||||
|
||||
async def append_log(self, _task_id: uuid.UUID, message: str) -> None:
|
||||
self.logs.append(message)
|
||||
|
||||
|
||||
async def test_qemu_worker_applies_intermediate_and_final_states() -> None:
|
||||
repository = Repository()
|
||||
task = Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:test",
|
||||
"qemu-start",
|
||||
"running",
|
||||
{"resource_id": str(uuid.uuid4())},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
|
||||
result = await qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))(
|
||||
task
|
||||
)
|
||||
|
||||
assert result == {"status": "running"}
|
||||
assert '"starting"' in repository.connection.states[0]
|
||||
assert '"running"' in repository.connection.states[1]
|
||||
assert repository.logs == ["VM start started", "VM start completed"]
|
||||
|
||||
|
||||
async def test_qemu_worker_create_update_and_delete_are_persistent() -> None:
|
||||
repository = CrudRepository()
|
||||
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
created = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:create",
|
||||
"qemu-create",
|
||||
"running",
|
||||
{"node": "pve1", "vmid": 150, "config": {"name": "new"}},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
updated = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:update",
|
||||
"qemu-update",
|
||||
"running",
|
||||
{
|
||||
"resource_id": str(resource_id),
|
||||
"changes": {"name": "changed", "cores": 4},
|
||||
"delete": "unused",
|
||||
},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
deleted = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:delete",
|
||||
"qemu-delete",
|
||||
"running",
|
||||
{"resource_id": str(resource_id)},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
assert created == {"vmid": 150, "status": "stopped"}
|
||||
assert updated == {"updated": ["cores", "name"], "deleted": ["unused"]}
|
||||
assert deleted == {"deleted": True}
|
||||
assert any("INSERT INTO resources" in command for command in repository.connection.commands)
|
||||
assert any("UPDATE virtual_machines" in command for command in repository.connection.commands)
|
||||
assert any("DELETE FROM resources" in command for command in repository.connection.commands)
|
||||
|
||||
|
||||
async def test_qemu_worker_snapshot_create_rollback_and_delete_are_persistent() -> None:
|
||||
repository = CrudRepository()
|
||||
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
async def run(operation: str, **payload: object) -> dict[str, object]:
|
||||
result = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
f"UPID:{operation}",
|
||||
f"qemu-snapshot-{operation}",
|
||||
"running",
|
||||
{"resource_id": str(resource_id), "snapname": "baseline", **payload},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
assert result is not None
|
||||
return cast(dict[str, object], result)
|
||||
|
||||
assert await run("create", description="stable") == {
|
||||
"snapshot": "baseline",
|
||||
"operation": "create",
|
||||
}
|
||||
assert await run("rollback", start=True) == {
|
||||
"snapshot": "baseline",
|
||||
"operation": "rollback",
|
||||
}
|
||||
assert await run("delete") == {"snapshot": "baseline", "operation": "delete"}
|
||||
commands = repository.connection.commands
|
||||
assert any("INSERT INTO snapshots" in command for command in commands)
|
||||
assert any("UPDATE virtual_machines" in command for command in commands)
|
||||
assert any("DELETE FROM snapshots" in command for command in commands)
|
||||
|
||||
|
||||
async def test_qemu_worker_clone_and_migrate_are_persistent() -> None:
|
||||
repository = CrudRepository()
|
||||
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
cloned = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:clone",
|
||||
"qemu-clone",
|
||||
"running",
|
||||
{
|
||||
"source_resource_id": str(resource_id),
|
||||
"node": "pve1",
|
||||
"vmid": 151,
|
||||
"name": "clone",
|
||||
},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
migrated = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:migrate",
|
||||
"qemu-migrate",
|
||||
"running",
|
||||
{"resource_id": str(resource_id), "target": "pve2"},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
moved = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:move",
|
||||
"qemu-move-disk",
|
||||
"running",
|
||||
{
|
||||
"resource_id": str(resource_id),
|
||||
"disk": "scsi0",
|
||||
"target_disk": "scsi0",
|
||||
"storage": "local",
|
||||
"delete": True,
|
||||
},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
assert cloned == {"vmid": 151, "node": "pve1"}
|
||||
assert migrated == {"node": "pve2", "status": "stopped"}
|
||||
assert moved == {"disk": "scsi0", "storage": "local"}
|
||||
commands = repository.connection.commands
|
||||
assert any("INSERT INTO resources" in command for command in commands)
|
||||
assert any("node_id=$2" in command for command in commands)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Tests for contract example generation."""
|
||||
|
||||
from app.contracts.examples import path_param_example, schema_example
|
||||
from app.contracts.model import Schema
|
||||
|
||||
|
||||
def test_path_param_examples_use_known_placeholders() -> None:
|
||||
assert path_param_example("node") == "pve01"
|
||||
assert path_param_example("vmid") == 100
|
||||
|
||||
|
||||
def test_schema_example_prefers_default_and_enum() -> None:
|
||||
assert schema_example(Schema(type="string", default="custom")) == "custom"
|
||||
assert schema_example(Schema(type="string", enum=("a", "b"))) == "a"
|
||||
|
||||
|
||||
def test_schema_example_builds_object_and_array() -> None:
|
||||
schema = Schema(
|
||||
type="object",
|
||||
properties={
|
||||
"count": Schema(type="integer", minimum=2),
|
||||
"enabled": Schema(type="boolean", optional=True),
|
||||
},
|
||||
)
|
||||
assert schema_example(schema) == {"count": 2}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""SDN zone/vnet/subnet persistence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.sdn import register_sdn_handlers
|
||||
|
||||
|
||||
class SdnPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
self.nodes = {"pve1"}
|
||||
|
||||
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return str(arguments[0]) in self.nodes
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in query:
|
||||
self.metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
async def call(
|
||||
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
|
||||
) -> Any:
|
||||
handler = registry.get(path, verb)
|
||||
assert handler is not None
|
||||
return await handler(http, inputs)
|
||||
|
||||
|
||||
def request(pool: SdnPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_sdn_zone_vnet_subnet_and_node_views() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_sdn_handlers(registry)
|
||||
pool = SdnPool()
|
||||
http = request(pool)
|
||||
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn/zones",
|
||||
"POST",
|
||||
http,
|
||||
{"values": {"zone": "localzone", "type": "simple"}, "provided": frozenset()},
|
||||
)
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn/vnets",
|
||||
"POST",
|
||||
http,
|
||||
{
|
||||
"values": {"vnet": "vnet0", "zone": "localzone", "type": "vnet"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn/vnets/{vnet}/subnets",
|
||||
"POST",
|
||||
http,
|
||||
{
|
||||
"values": {
|
||||
"vnet": "vnet0",
|
||||
"subnet": "10.0.0.0/24",
|
||||
"gateway": "10.0.0.1",
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
zones = await call(
|
||||
registry, "/cluster/sdn/zones", "GET", http, {"values": {}, "provided": frozenset()}
|
||||
)
|
||||
assert zones[0]["zone"] == "localzone"
|
||||
subnets = await call(
|
||||
registry,
|
||||
"/cluster/sdn/vnets/{vnet}/subnets",
|
||||
"GET",
|
||||
http,
|
||||
{"values": {"vnet": "vnet0"}, "provided": frozenset()},
|
||||
)
|
||||
assert subnets[0]["subnet"] == "10.0.0.0/24"
|
||||
node_zones = await call(
|
||||
registry,
|
||||
"/nodes/{node}/sdn/zones",
|
||||
"GET",
|
||||
http,
|
||||
{"values": {"node": "pve1"}, "provided": frozenset()},
|
||||
)
|
||||
assert node_zones[0]["zone"] == "localzone"
|
||||
assert pool.metadata["sdn"]["pending"] is True
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn",
|
||||
"PUT",
|
||||
http,
|
||||
{"values": {"release-lock": 1}, "provided": frozenset()},
|
||||
)
|
||||
assert pool.metadata["sdn"]["pending"] is False
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Deterministic seed profile tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.simulation.seed import (
|
||||
build_profile,
|
||||
clear_simulation_state,
|
||||
large_profile,
|
||||
small_profile,
|
||||
stable_id,
|
||||
)
|
||||
|
||||
|
||||
def test_small_profile_matches_required_logical_shape() -> None:
|
||||
first = small_profile()
|
||||
second = small_profile()
|
||||
|
||||
assert first == second
|
||||
state = first.logical_state()
|
||||
assert state == second.logical_state()
|
||||
assert state["nodes"] == [{"name": "pve01", "status": "online"}]
|
||||
resources = state["resources"]
|
||||
assert isinstance(resources, list)
|
||||
assert [resource["kind"] for resource in resources].count("qemu") == 2
|
||||
assert [resource["kind"] for resource in resources].count("lxc") == 1
|
||||
assert [resource["kind"] for resource in resources].count("storage") == 2
|
||||
tasks = state["tasks"]
|
||||
assert isinstance(tasks, list)
|
||||
assert len(tasks) == 2
|
||||
|
||||
|
||||
def test_medium_and_fault_profiles_are_deterministic() -> None:
|
||||
medium = build_profile("medium")
|
||||
assert len(medium.nodes) == 3
|
||||
assert sum(resource.kind == "qemu" for resource in medium.resources) == 50
|
||||
assert sum(resource.kind == "lxc" for resource in medium.resources) == 20
|
||||
assert build_profile("ha-demo") == build_profile("ha-demo")
|
||||
broken = build_profile("broken-storage")
|
||||
assert any(resource.state.get("status") == "offline" for resource in broken.resources)
|
||||
|
||||
|
||||
def test_large_profile_is_configurable_and_stable() -> None:
|
||||
first = large_profile(node_count=4, resource_count=1_000)
|
||||
second = large_profile(node_count=4, resource_count=1_000)
|
||||
assert first == second
|
||||
assert len(first.nodes) == 4
|
||||
assert len(first.resources) == 1_000
|
||||
|
||||
|
||||
def test_profile_validation() -> None:
|
||||
with pytest.raises(ValueError, match="unknown seed profile"):
|
||||
build_profile("missing")
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
large_profile(node_count=0, resource_count=1)
|
||||
|
||||
|
||||
def test_demo_cluster_profile_shape() -> None:
|
||||
profile = build_profile("demo-cluster")
|
||||
assert profile.name == "demo-cluster"
|
||||
assert len(profile.nodes) == 20
|
||||
assert sum(resource.kind == "qemu" for resource in profile.resources) == 850
|
||||
assert sum(resource.kind == "lxc" for resource in profile.resources) == 150
|
||||
assert sum(resource.kind == "ceph-osd" for resource in profile.resources) == 300
|
||||
assert sum(resource.kind == "storage" for resource in profile.resources) >= 62
|
||||
assert len(profile.tasks) == 250
|
||||
external_ids = {
|
||||
resource.external_id for resource in profile.resources if resource.kind in {"qemu", "lxc"}
|
||||
}
|
||||
assert len(external_ids) == 1000
|
||||
|
||||
|
||||
def test_demo_cluster_spreads_guests_evenly_across_nodes() -> None:
|
||||
profile = build_profile("demo-cluster")
|
||||
names = {node.id: node.name for node in profile.nodes}
|
||||
|
||||
def counts(kind: str) -> list[int]:
|
||||
counter: dict[str, int] = {name: 0 for name in names.values()}
|
||||
for resource in profile.resources:
|
||||
if resource.kind == kind:
|
||||
counter[names[resource.node_id]] += 1
|
||||
return list(counter.values())
|
||||
|
||||
for kind, expected_total in (("qemu", 850), ("lxc", 150), ("ceph-osd", 300)):
|
||||
values = counts(kind)
|
||||
assert sum(values) == expected_total
|
||||
assert max(values) - min(values) <= 1
|
||||
|
||||
guest_counts = counts("qemu")
|
||||
guest_counts = [a + b for a, b in zip(guest_counts, counts("lxc"), strict=True)]
|
||||
assert max(guest_counts) - min(guest_counts) <= 2
|
||||
|
||||
|
||||
def test_minimal_profile() -> None:
|
||||
profile = build_profile("minimal")
|
||||
assert len(profile.nodes) == 1
|
||||
assert not any(resource.kind in {"qemu", "lxc"} for resource in profile.resources)
|
||||
|
||||
|
||||
def test_stable_ids_are_namespaced_and_repeatable() -> None:
|
||||
assert stable_id("qemu:100") == stable_id("qemu:100")
|
||||
assert stable_id("qemu:100") != stable_id("qemu:101")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_simulation_state_wipes_api_created_identity() -> None:
|
||||
executed: list[str] = []
|
||||
|
||||
class FakeConnection:
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
executed.append(" ".join(sql.split()))
|
||||
return "DELETE 0"
|
||||
|
||||
await clear_simulation_state(FakeConnection())
|
||||
joined = "\n".join(executed)
|
||||
for table in (
|
||||
"resources",
|
||||
"nodes",
|
||||
"principals",
|
||||
"identity_groups",
|
||||
"roles",
|
||||
"storage_contents",
|
||||
"api_tokens",
|
||||
):
|
||||
assert f"DELETE FROM {table}" in joined # noqa: S608 - asserting SQL text
|
||||
assert "DELETE FROM realms WHERE name NOT IN" in joined
|
||||
assert any(sql.startswith("UPDATE clusters") for sql in executed)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Bounded task worker outcome tests."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
from app.tasks.repository import Task, TaskRepository
|
||||
from app.tasks.worker import TaskWorker
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, task: Task) -> None:
|
||||
self.task = task
|
||||
self.finishes: list[tuple[str, str | None]] = []
|
||||
|
||||
async def get(self, _task_id: uuid.UUID) -> Task:
|
||||
return self.task
|
||||
|
||||
async def finish(
|
||||
self,
|
||||
_task_id: uuid.UUID,
|
||||
_worker_id: str,
|
||||
*,
|
||||
status: str,
|
||||
result: dict[str, object] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
del result
|
||||
self.finishes.append((status, error))
|
||||
|
||||
|
||||
def make_task(*, task_type: str = "test", cancelled: bool = False) -> Task:
|
||||
return Task(uuid.uuid4(), "UPID:test", task_type, "running", {}, 0, cancelled, 1)
|
||||
|
||||
|
||||
async def test_worker_persists_success_error_and_unsupported() -> None:
|
||||
task = make_task()
|
||||
repository = FakeRepository(task)
|
||||
|
||||
async def success(_task: Task) -> dict[str, object]:
|
||||
return {"ok": True}
|
||||
|
||||
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": success})
|
||||
await worker._execute(task)
|
||||
assert repository.finishes == [("success", None)]
|
||||
|
||||
unsupported = make_task(task_type="missing")
|
||||
repository.task = unsupported
|
||||
await worker._execute(unsupported)
|
||||
assert repository.finishes[-1] == ("error", "unsupported task type")
|
||||
|
||||
async def failure(_task: Task) -> None:
|
||||
raise RuntimeError("private detail")
|
||||
|
||||
failed = make_task()
|
||||
repository.task = failed
|
||||
worker.handlers["test"] = failure
|
||||
await worker._execute(failed)
|
||||
assert repository.finishes[-1] == ("error", "RuntimeError")
|
||||
|
||||
|
||||
async def test_worker_honors_persisted_cancellation() -> None:
|
||||
task = make_task(cancelled=True)
|
||||
repository = FakeRepository(task)
|
||||
called = False
|
||||
|
||||
async def handler(_task: Task) -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": handler})
|
||||
await worker._execute(task)
|
||||
|
||||
assert not called
|
||||
assert repository.finishes == [("cancelled", None)]
|
||||
|
||||
|
||||
async def test_worker_retries_after_claim_failure() -> None:
|
||||
class RecoveringRepository:
|
||||
attempts = 0
|
||||
|
||||
async def claim(self, _worker_id: str, _lease_seconds: float) -> None:
|
||||
self.attempts += 1
|
||||
if self.attempts == 1:
|
||||
raise RuntimeError("database schema is not ready")
|
||||
return None
|
||||
|
||||
repository = RecoveringRepository()
|
||||
worker = TaskWorker(
|
||||
cast(TaskRepository, repository),
|
||||
"worker",
|
||||
{},
|
||||
poll_seconds=0.001,
|
||||
)
|
||||
running = asyncio.create_task(worker.run())
|
||||
await asyncio.sleep(0.01)
|
||||
worker.stop()
|
||||
await running
|
||||
|
||||
assert repository.attempts > 1
|
||||
@@ -0,0 +1,50 @@
|
||||
"""VM state-machine and deterministic fault properties."""
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from app.simulation.scenarios import FaultContext, FaultRule, matches
|
||||
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "operation", "final"),
|
||||
[
|
||||
(VmState.STOPPED, "start", VmState.RUNNING),
|
||||
(VmState.RUNNING, "stop", VmState.STOPPED),
|
||||
(VmState.RUNNING, "shutdown", VmState.STOPPED),
|
||||
(VmState.RUNNING, "reboot", VmState.RUNNING),
|
||||
(VmState.RUNNING, "reset", VmState.RUNNING),
|
||||
(VmState.RUNNING, "suspend", VmState.PAUSED),
|
||||
(VmState.RUNNING, "pause", VmState.PAUSED),
|
||||
(VmState.PAUSED, "resume", VmState.RUNNING),
|
||||
(VmState.RUNNING, "snapshot", VmState.RUNNING),
|
||||
(VmState.STOPPED, "migrate", VmState.STOPPED),
|
||||
],
|
||||
)
|
||||
def test_valid_transitions(state: VmState, operation: str, final: VmState) -> None:
|
||||
transition = plan_transition(state, operation)
|
||||
assert transition.before is state
|
||||
assert transition.after is final
|
||||
assert transition.intermediate is not state
|
||||
|
||||
|
||||
@given(st.sampled_from(tuple(VmState)), st.text(min_size=1, max_size=12))
|
||||
def test_transition_result_is_declared_or_rejected(state: VmState, operation: str) -> None:
|
||||
try:
|
||||
transition = plan_transition(state, operation)
|
||||
except InvalidTransitionError:
|
||||
return
|
||||
assert transition.before is state
|
||||
|
||||
|
||||
def test_fault_evaluation_is_seeded_and_filtered() -> None:
|
||||
context = FaultContext("POST", "/nodes/pve1/qemu/100/status/start", node="pve1")
|
||||
certain = FaultRule("task-failure", method="POST", node="pve1")
|
||||
impossible = FaultRule("task-failure", probability=0)
|
||||
|
||||
assert matches(certain, context, seed=42)
|
||||
assert not matches(impossible, context, seed=42)
|
||||
probabilistic = FaultRule("task-failure", probability=0.5)
|
||||
assert matches(probabilistic, context, 42) == matches(probabilistic, context, 42)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""UPID examples and round-trip properties."""
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from app.tasks.upid import Upid
|
||||
|
||||
SAFE = st.from_regex(r"[a-z0-9][a-z0-9_-]{0,19}", fullmatch=True)
|
||||
|
||||
|
||||
@given(
|
||||
node=SAFE,
|
||||
pid=st.integers(min_value=0, max_value=0xFFFFFFFF),
|
||||
process_start=st.integers(min_value=0, max_value=0xFFFFFFFF),
|
||||
start_time=st.integers(min_value=0, max_value=0xFFFFFFFF),
|
||||
task_type=SAFE,
|
||||
task_id=st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789_-", max_size=20),
|
||||
user=SAFE,
|
||||
)
|
||||
def test_upid_round_trip(
|
||||
node: str,
|
||||
pid: int,
|
||||
process_start: int,
|
||||
start_time: int,
|
||||
task_type: str,
|
||||
task_id: str,
|
||||
user: str,
|
||||
) -> None:
|
||||
upid = Upid(node, pid, process_start, start_time, task_type, task_id, user)
|
||||
|
||||
assert Upid.parse(str(upid)) == upid
|
||||
|
||||
|
||||
def test_known_upid_shape() -> None:
|
||||
value = "UPID:pve1:0000002A:00000010:65A1B2C3:qmstart:100:root@pam:"
|
||||
|
||||
parsed = Upid.parse(value)
|
||||
|
||||
assert parsed.pid == 42
|
||||
assert parsed.task_id == "100"
|
||||
assert str(parsed) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "UPID:broken", "UPID:pve:GGGGGGGG:00000000:00000000:x::u:"])
|
||||
def test_invalid_upids_are_rejected(value: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
Upid.parse(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"pid": -1},
|
||||
{"node": "bad:node"},
|
||||
{"task_id": "bad:id"},
|
||||
],
|
||||
)
|
||||
def test_invalid_upid_components_are_rejected(kwargs: dict[str, object]) -> None:
|
||||
values: dict[str, object] = {
|
||||
"node": "pve1",
|
||||
"pid": 1,
|
||||
"process_start": 1,
|
||||
"start_time": 1,
|
||||
"task_type": "test",
|
||||
"task_id": "100",
|
||||
"user": "root@pam",
|
||||
}
|
||||
values.update(kwargs)
|
||||
with pytest.raises(ValueError):
|
||||
Upid(**values) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user