Add a stateful Proxmox API console and broad handler coverage beyond the
initial QEMU slice, backed by imported contracts for majors 6–9. - Implement durable handlers for access/auth, cluster, LXC, storage, HA, firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops - Serve an interactive Web UI with catalog browsing, demo seed controls, and OpenAPI/help surfaces - Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3 - Support in-memory runtime contract Apply (POST /ui/api/contract/apply) so /version and /api2 routes follow the selected major until restart - Expand seed profiles (including demo-cluster), migrations 007–008, TLS gateway config, Compose/Makefile tooling, and compatibility evidence - Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
This commit is contained in:
@@ -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"]
|
||||
@@ -98,6 +98,7 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
name="created-by-proxmoxer",
|
||||
cores=2,
|
||||
memory=1024,
|
||||
agent=1,
|
||||
scsi0="local-lvm:vm-150-disk-0,size=8G",
|
||||
)
|
||||
with pytest.raises(ResourceException) as duplicate_create:
|
||||
@@ -120,6 +121,15 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
move_upid = disk_api.move_disk.post(disk="scsi0", storage="local")
|
||||
assert wait_task(proxmox, move_upid)["exitstatus"] == "OK"
|
||||
assert disk_api.config.get()["scsi0"].startswith("local:")
|
||||
assert disk_api.pending.get() == []
|
||||
assert wait_task(proxmox, disk_api.status.start.post())["exitstatus"] == "OK"
|
||||
assert disk_api.agent.ping.post()["result"] == {}
|
||||
assert disk_api.agent.info.get()["result"]["version"] == "9.2.0-simulator"
|
||||
assert disk_api.agent("get-osinfo").get()["result"]["machine"] == "x86_64"
|
||||
assert disk_api.agent("get-host-name").get()["result"]["host-name"] == "async-update"
|
||||
assert disk_api.agent("network-get-interfaces").get()["result"][0]["name"] == "eth0"
|
||||
assert disk_api.agent("get-time").get()["result"]["seconds"] > 0
|
||||
assert wait_task(proxmox, disk_api.status.stop.post())["exitstatus"] == "OK"
|
||||
|
||||
snapshots = proxmox.nodes("pve1").qemu("150").snapshot
|
||||
snapshot_upid = snapshots.post(snapname="baseline", description="before change")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Durable full-surface verified ledger for bundled Proxmox majors 6-9.
|
||||
|
||||
These tests stay offline (no TLS gateway). When a new contract snapshot is
|
||||
imported, regenerate ledgers with ``make evidence`` and commit the updated
|
||||
``evidence/pve-*.json`` files so this suite stays green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.evidence_gen import generate_all
|
||||
from app.main import create_app
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
_BUNDLED_9 = Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
|
||||
)
|
||||
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
|
||||
_MAJORS = (6, 7, 8, 9)
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
settings = Settings(
|
||||
contract_snapshot=_BUNDLED_9,
|
||||
compatibility_evidence=_EVIDENCE_9,
|
||||
)
|
||||
return create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("major", _MAJORS)
|
||||
async def test_hot_swap_reports_full_verified_surface(major: int) -> None:
|
||||
app = _app()
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
applied = await client.post("/ui/api/contract/apply", params={"major": major})
|
||||
assert applied.status_code == 200
|
||||
assert applied.json()["ok"] is True
|
||||
|
||||
report = await client.get("/admin/compatibility")
|
||||
assert report.status_code == 200
|
||||
body = report.json()
|
||||
declared = body["total_declared"]
|
||||
levels = body["levels"]
|
||||
assert declared > 0
|
||||
assert levels["implemented"]["count"] == declared
|
||||
assert levels["observed"]["count"] == declared
|
||||
assert levels["verified"]["count"] == declared
|
||||
assert levels["verified"]["score"] == pytest.approx(1.0)
|
||||
for name, dimension in (body.get("dimensions") or {}).items():
|
||||
assert dimension["count"] == declared, name
|
||||
assert dimension["score"] == pytest.approx(1.0), name
|
||||
assert len(body.get("classifications", {}).get("fully_compatible") or []) == declared
|
||||
|
||||
|
||||
def test_committed_evidence_matches_generator(tmp_path: Path) -> None:
|
||||
written = generate_all(out_dir=tmp_path)
|
||||
assert set(written) == {"6.4-15", "7.4-16", "8.4.5", "9.2.3"}
|
||||
for version, generated in written.items():
|
||||
committed = Path("evidence") / f"pve-{version}.json"
|
||||
assert committed.is_file(), f"missing committed ledger for {version}"
|
||||
assert generated.read_text(encoding="utf-8") == committed.read_text(encoding="utf-8")
|
||||
@@ -47,7 +47,7 @@ async def test_small_seed_is_idempotent() -> None:
|
||||
await migrate(connection)
|
||||
await apply_seed(connection, small_profile())
|
||||
await apply_seed(connection, small_profile())
|
||||
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve1'") == 1
|
||||
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve01'") == 1
|
||||
assert (
|
||||
await connection.fetchval(
|
||||
"""SELECT count(*) FROM resources
|
||||
@@ -72,6 +72,33 @@ async def test_small_seed_is_idempotent() -> None:
|
||||
await connection.close()
|
||||
|
||||
|
||||
async def test_demo_cluster_seed_populates_realistic_state() -> None:
|
||||
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
await migrate(connection)
|
||||
from app.simulation.seed import build_profile
|
||||
|
||||
await apply_seed(connection, build_profile("demo-cluster"))
|
||||
assert await connection.fetchval("SELECT count(*) FROM nodes") == 20
|
||||
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 850
|
||||
assert await connection.fetchval("SELECT count(*) FROM containers") == 150
|
||||
assert (
|
||||
await connection.fetchval("SELECT count(*) FROM resources WHERE kind = 'ceph-osd'")
|
||||
== 300
|
||||
)
|
||||
assert await connection.fetchval("SELECT count(*) FROM backups") >= 400
|
||||
assert await connection.fetchval("SELECT count(*) FROM task_logs") >= 500
|
||||
assert await connection.fetchval("SELECT count(*) FROM snapshots") >= 100
|
||||
ceph_capacity = await connection.fetchval(
|
||||
"SELECT capacity_bytes FROM storages WHERE storage_id = 'ceph-prod'"
|
||||
)
|
||||
assert ceph_capacity == 5 * 1024**5
|
||||
profile = await connection.fetchval("SELECT metadata->>'profile' FROM clusters LIMIT 1")
|
||||
assert profile == "demo-cluster"
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
|
||||
async def test_schema_readiness_and_optimistic_resource_repository() -> None:
|
||||
url = os.environ["TEST_DATABASE_URL"]
|
||||
connection = await asyncpg.connect(url)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""API-token lifecycle handler tests without external services."""
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
@@ -44,12 +45,79 @@ class TokenPool:
|
||||
return "DELETE 1"
|
||||
|
||||
|
||||
class RealmPool:
|
||||
def __init__(self) -> None:
|
||||
self.realms: dict[str, dict[str, Any]] = {
|
||||
"pam": {
|
||||
"kind": "pam",
|
||||
"config": {"comment": "Linux PAM standard authentication"},
|
||||
},
|
||||
"pve": {
|
||||
"kind": "pve",
|
||||
"config": {"comment": "Proxmox VE authentication server"},
|
||||
},
|
||||
}
|
||||
self.principals: dict[str, str] = {"root@pam": "pam"}
|
||||
|
||||
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
|
||||
del arguments
|
||||
if "FROM realms ORDER BY name" in query:
|
||||
return [
|
||||
{"name": name, "kind": data["kind"], "config": dict(data["config"])}
|
||||
for name, data in sorted(self.realms.items())
|
||||
]
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM realms WHERE name" in query:
|
||||
realm = str(arguments[0])
|
||||
data = self.realms.get(realm)
|
||||
if data is None:
|
||||
return None
|
||||
return {"name": realm, "kind": data["kind"], "config": dict(data["config"])}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> bool:
|
||||
realm = str(arguments[0])
|
||||
if "EXISTS(SELECT 1 FROM realms" in query:
|
||||
return realm in self.realms
|
||||
if "EXISTS(SELECT 1 FROM principals" in query:
|
||||
return any(value == realm for value in self.principals.values())
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "INSERT INTO realms" in query:
|
||||
self.realms[str(arguments[0])] = {
|
||||
"kind": str(arguments[1]),
|
||||
"config": json.loads(str(arguments[2])),
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE realms SET config=$2" in query:
|
||||
realm = str(arguments[0])
|
||||
self.realms[realm]["config"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "SET config = config - 'default'" in query:
|
||||
skip = str(arguments[0]) if arguments else None
|
||||
for name, data in self.realms.items():
|
||||
if skip is not None and name == skip:
|
||||
continue
|
||||
data["config"].pop("default", None)
|
||||
return "UPDATE 0"
|
||||
if "DELETE FROM realms" in query:
|
||||
realm = str(arguments[0])
|
||||
if realm not in self.realms:
|
||||
return "DELETE 0"
|
||||
del self.realms[realm]
|
||||
return "DELETE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: TokenPool) -> None:
|
||||
def __init__(self, pool: TokenPool | RealmPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: TokenPool, principal: str = "root@pam") -> Request:
|
||||
def request(pool: TokenPool | RealmPool, principal: str = "root@pam") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
@@ -70,7 +138,7 @@ def request(pool: TokenPool, principal: str = "root@pam") -> Request:
|
||||
|
||||
|
||||
def values(**items: object) -> dict[str, Any]:
|
||||
return {"values": items}
|
||||
return {"values": items, "provided": frozenset(items)}
|
||||
|
||||
|
||||
async def test_token_lifecycle_returns_secret_once_and_persists_metadata() -> None:
|
||||
@@ -113,3 +181,67 @@ async def test_token_lifecycle_rejects_non_owner() -> None:
|
||||
with pytest.raises(ApiError) as denied:
|
||||
await handler(request(TokenPool(), "auditor@pve"), values(userid="other@pve"))
|
||||
assert denied.value.status_code == 403
|
||||
|
||||
|
||||
async def test_domain_lifecycle_persists_realm_config() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = RealmPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/access/domains", "POST")
|
||||
listing = registry.get("/access/domains", "GET")
|
||||
get = registry.get("/access/domains/{realm}", "GET")
|
||||
update = registry.get("/access/domains/{realm}", "PUT")
|
||||
delete = registry.get("/access/domains/{realm}", "DELETE")
|
||||
assert create and listing and get and update and delete
|
||||
|
||||
await create(
|
||||
http_request,
|
||||
values(
|
||||
realm="corp",
|
||||
type="ldap",
|
||||
comment="Corporate LDAP",
|
||||
server1="ldap.example.com",
|
||||
password="secret", # noqa: S106 - fixture secret for unit test
|
||||
default=1,
|
||||
),
|
||||
)
|
||||
listed = await listing(http_request, values())
|
||||
assert any(item["realm"] == "corp" and item["type"] == "ldap" for item in listed)
|
||||
created = await get(http_request, values(realm="corp"))
|
||||
assert created["comment"] == "Corporate LDAP"
|
||||
assert created["server1"] == "ldap.example.com"
|
||||
assert created["default"] == 1
|
||||
assert "password" not in created
|
||||
|
||||
await update(
|
||||
http_request,
|
||||
values(realm="corp", comment="Updated LDAP", delete="default"),
|
||||
)
|
||||
updated = await get(http_request, values(realm="corp"))
|
||||
assert updated["comment"] == "Updated LDAP"
|
||||
assert "default" not in updated
|
||||
|
||||
await delete(http_request, values(realm="corp"))
|
||||
with pytest.raises(ApiError) as missing:
|
||||
await get(http_request, values(realm="corp"))
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
|
||||
async def test_domain_delete_rejects_builtin_and_in_use_realms() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = RealmPool()
|
||||
http_request = request(pool)
|
||||
delete = registry.get("/access/domains/{realm}", "DELETE")
|
||||
assert delete
|
||||
|
||||
with pytest.raises(ApiError) as builtin:
|
||||
await delete(http_request, values(realm="pam"))
|
||||
assert builtin.value.status_code == 400
|
||||
|
||||
pool.realms["corp"] = {"kind": "ldap", "config": {}}
|
||||
pool.principals["alice@corp"] = "corp"
|
||||
with pytest.raises(ApiError) as in_use:
|
||||
await delete(http_request, values(realm="corp"))
|
||||
assert in_use.value.status_code == 400
|
||||
|
||||
@@ -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,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()
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Compatibility accounting tests."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.compatibility import CompatibilityDimension, EvidenceManifest, build_report
|
||||
from app.compatibility import (
|
||||
CompatibilityDimension,
|
||||
EvidenceManifest,
|
||||
build_report,
|
||||
resolve_evidence_path,
|
||||
)
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
from app.contracts.runtime import build_compatibility_for_snapshot
|
||||
from app.handlers.core import build_core_handlers
|
||||
|
||||
|
||||
def snapshot() -> Snapshot:
|
||||
@@ -105,8 +115,40 @@ def test_evidence_manifest_requires_provenance_and_unique_methods() -> None:
|
||||
evidence = manifest.dimension_map()
|
||||
assert evidence[CompatibilityDimension.HTTP_STATUS] == frozenset({("/nodes/{node}", "GET")})
|
||||
assert not evidence[CompatibilityDimension.PERMISSIONS]
|
||||
assert manifest.verified_methods() == frozenset({("/nodes/{node}", "GET")})
|
||||
assert manifest.observed_methods() == frozenset({("/nodes/{node}", "GET")})
|
||||
|
||||
duplicate = manifest.model_dump(mode="json")
|
||||
duplicate["records"].append(duplicate["records"][0])
|
||||
with pytest.raises(ValueError, match="duplicate methods"):
|
||||
EvidenceManifest.model_validate(duplicate)
|
||||
|
||||
|
||||
def test_resolve_evidence_path_prefers_per_version_ledger() -> None:
|
||||
settings = Settings(compatibility_evidence=Path("evidence/pve-9.2.3.json"))
|
||||
assert resolve_evidence_path("7.4-16", settings) == Path("evidence/pve-7.4-16.json").resolve()
|
||||
assert resolve_evidence_path("9.2.3", settings) == Path("evidence/pve-9.2.3.json").resolve()
|
||||
|
||||
|
||||
def test_build_compatibility_wires_verified_from_version_ledger() -> None:
|
||||
snapshot = Snapshot.model_validate_json(
|
||||
Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/"
|
||||
"snapshot.json"
|
||||
).read_bytes()
|
||||
)
|
||||
settings = Settings(
|
||||
contract_snapshot=Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/"
|
||||
"snapshot.json"
|
||||
),
|
||||
compatibility_evidence=Path("evidence/pve-9.2.3.json"),
|
||||
ticket_signing_key=SecretStr("x" * 32),
|
||||
)
|
||||
handlers = build_core_handlers(settings)
|
||||
report = build_compatibility_for_snapshot(snapshot, handlers, settings)
|
||||
data = report.as_json()
|
||||
levels = cast(dict[str, dict[str, object]], data["levels"])
|
||||
assert levels["verified"]["count"] == data["total_declared"]
|
||||
assert levels["observed"]["count"] == data["total_declared"]
|
||||
assert levels["implemented"]["count"] == data["total_declared"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -48,7 +48,7 @@ async def client_for(tmp_path: Path) -> AsyncClient:
|
||||
|
||||
handlers.register("/nodes/{node}/test", "POST", handler)
|
||||
app = create_app(
|
||||
Settings(contract_snapshot=path),
|
||||
Settings(contract_snapshot=path, compatibility_evidence=None),
|
||||
lambda _settings: FakeDatabase(True),
|
||||
handlers,
|
||||
worker_factories=(),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Contract catalog helpers."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
||||
from app.web.contract_catalog import catalog_payload, list_majors, method_payload
|
||||
|
||||
|
||||
def _snapshot() -> Snapshot:
|
||||
method = Method(
|
||||
verb="POST",
|
||||
name="create",
|
||||
description="Create a VM.",
|
||||
parameters=(
|
||||
Parameter(name="node", definition=Schema(type="string")),
|
||||
Parameter(name="vmid", definition=Schema(type="integer", minimum=100)),
|
||||
Parameter(name="name", definition=Schema(type="string")),
|
||||
Parameter(name="memory", definition=Schema(type="integer", optional=True)),
|
||||
Parameter(name="scsi[n]", definition=Schema(type="string", optional=True)),
|
||||
),
|
||||
returns=Schema(type="string"),
|
||||
checksum="a" * 64,
|
||||
)
|
||||
return Snapshot(
|
||||
source_version="9.2.3",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="b" * 64,
|
||||
paths=(PathContract(path="/nodes/{node}/qemu", methods=(method,)),),
|
||||
path_count=1,
|
||||
method_count=1,
|
||||
)
|
||||
|
||||
|
||||
def test_list_majors_includes_latest_releases() -> None:
|
||||
payload = list_majors(runtime_version="9.2.3")
|
||||
majors_list = cast(list[dict[str, Any]], payload["majors"])
|
||||
majors = {item["major"] for item in majors_list}
|
||||
assert majors == {6, 7, 8, 9}
|
||||
assert payload["runtime_version"] == "9.2.3"
|
||||
|
||||
|
||||
def test_list_majors_includes_artifact_urls() -> None:
|
||||
payload = list_majors(runtime_version="9.2.3")
|
||||
majors_list = cast(list[dict[str, Any]], payload["majors"])
|
||||
pve9 = next(item for item in majors_list if item["major"] == 9)
|
||||
assert pve9["artifact_url"] == "https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js"
|
||||
assert pve9["bundled"] is True
|
||||
|
||||
|
||||
def test_list_majors_honors_settings_overrides() -> None:
|
||||
settings = Settings(catalog_artifact_url_9="https://example.test/pve9/apidoc.js")
|
||||
payload = list_majors(runtime_version=None, settings=settings)
|
||||
majors_list = cast(list[dict[str, Any]], payload["majors"])
|
||||
pve9 = next(item for item in majors_list if item["major"] == 9)
|
||||
assert pve9["artifact_url"] == "https://example.test/pve9/apidoc.js"
|
||||
|
||||
|
||||
def test_catalog_payload_groups_paths_by_tag() -> None:
|
||||
payload = catalog_payload(
|
||||
_snapshot(),
|
||||
9,
|
||||
implemented_methods=frozenset({("/nodes/{node}/qemu", "POST")}),
|
||||
)
|
||||
assert payload["source_version"] == "9.2.3"
|
||||
assert cast(str, payload["artifact_url"]).endswith("apidoc.js")
|
||||
assert payload["latest_version"] == "9.2.3"
|
||||
assert payload["path_count"] == 1
|
||||
categories = cast(list[dict[str, Any]], payload["categories"])
|
||||
method = categories[0]["paths"][0]["methods"][0]
|
||||
assert method["verb"] == "POST"
|
||||
assert method["implemented"] is True
|
||||
|
||||
|
||||
def test_method_payload_builds_examples() -> None:
|
||||
payload = method_payload(
|
||||
_snapshot(),
|
||||
major=9,
|
||||
path="/nodes/{node}/qemu",
|
||||
verb="POST",
|
||||
runtime_version="9.2.3",
|
||||
implemented_methods=frozenset({("/nodes/{node}/qemu", "POST")}),
|
||||
)
|
||||
assert payload["resolved_path"] == "/nodes/pve01/qemu"
|
||||
assert payload["body_example"] == {"vmid": 100, "name": "example"}
|
||||
assert payload["implemented"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_snapshot_uses_bundled_revision() -> None:
|
||||
from app.web import contract_catalog
|
||||
|
||||
contract_catalog._SNAPSHOT_CACHE.clear()
|
||||
root = Path("contracts")
|
||||
snapshot = await contract_catalog.load_snapshot(9, root)
|
||||
assert snapshot.source_version == "9.2.3"
|
||||
@@ -6,7 +6,11 @@ from pathlib import Path
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.contracts.importer import RemoteSourceImporter, validate_remote_url
|
||||
from app.contracts.importer import (
|
||||
RemoteSourceImporter,
|
||||
validate_public_addresses,
|
||||
validate_remote_url,
|
||||
)
|
||||
from app.contracts.normalize import normalize_snapshot
|
||||
from app.contracts.source import ApiViewerParser, SourceError
|
||||
from app.contracts.store import RevisionStore
|
||||
@@ -31,6 +35,17 @@ def test_remote_url_policy_rejects_unsafe_urls(url: str) -> None:
|
||||
validate_remote_url(url, frozenset({"pve.proxmox.com"}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"198.18.0.42",
|
||||
"::ffff:198.18.0.42",
|
||||
],
|
||||
)
|
||||
def test_validate_public_addresses_allows_proxy_fake_ip(address: str) -> None:
|
||||
validate_public_addresses((address,))
|
||||
|
||||
|
||||
async def test_remote_import_rejects_private_resolution() -> None:
|
||||
async def private_resolver(_host: str) -> tuple[str, ...]:
|
||||
return ("127.0.0.1",)
|
||||
|
||||
@@ -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
|
||||
@@ -25,6 +25,14 @@ def test_extract_api_schema_without_executing_trailing_javascript() -> None:
|
||||
assert parsed.nodes[0]["path"] == "/x]y"
|
||||
|
||||
|
||||
def test_extract_legacy_pveapi_declaration() -> None:
|
||||
raw = b'var pveapi = [{"path":"/version","leaf":1}];'
|
||||
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
|
||||
assert parsed.nodes[0]["path"] == "/version"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, message",
|
||||
[
|
||||
|
||||
@@ -166,7 +166,7 @@ async def test_core_login_and_read_endpoints(
|
||||
write_snapshot(snapshot_path)
|
||||
database = FakeDatabase()
|
||||
app = create_app(
|
||||
Settings(contract_snapshot=snapshot_path),
|
||||
Settings(contract_snapshot=snapshot_path, compatibility_evidence=None),
|
||||
lambda _settings: database,
|
||||
worker_factories=(),
|
||||
)
|
||||
@@ -192,7 +192,8 @@ async def test_core_login_and_read_endpoints(
|
||||
assert login.status_code == 200
|
||||
assert login.json()["data"]["username"] == "root@pam"
|
||||
assert "ticket" in login.json()["data"]
|
||||
assert version.json()["data"]["release"] == "9.2"
|
||||
assert version.json()["data"]["version"] == "test"
|
||||
assert version.json()["data"]["release"] == "test"
|
||||
assert nodes.json()["data"][0]["node"] == "pve1"
|
||||
assert status.json()["data"]["status"] == "online"
|
||||
assert resources.json()["data"][0]["type"] == "qemu"
|
||||
|
||||
@@ -42,7 +42,11 @@ async def request_app(
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
snapshot_path = tmp_path / "snapshot.json"
|
||||
snapshot_path.write_bytes(contract_snapshot(get_method()).canonical_bytes())
|
||||
settings = Settings(contract_snapshot=snapshot_path, contract_fallback=fallback)
|
||||
settings = Settings(
|
||||
contract_snapshot=snapshot_path,
|
||||
contract_fallback=fallback,
|
||||
compatibility_evidence=None,
|
||||
)
|
||||
database = FakeDatabase(True)
|
||||
app = create_app(
|
||||
settings,
|
||||
@@ -75,8 +79,8 @@ async def test_explicit_fallback_modes(tmp_path: Path) -> None:
|
||||
error_body, _ = await request_app(tmp_path, "error")
|
||||
default_body, _ = await request_app(tmp_path, "schema-default")
|
||||
|
||||
assert error_body["errors"] == "method semantics are not implemented"
|
||||
assert default_body == {"data": {"version": None}}
|
||||
assert error_body["errors"] == "handler pending for this contract method"
|
||||
assert default_body["data"]["version"] in {None, "example"}
|
||||
|
||||
|
||||
def test_duplicate_snapshot_routes_are_rejected() -> None:
|
||||
|
||||
@@ -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"
|
||||
@@ -42,7 +42,11 @@ async def test_health_endpoints(database_ready: bool, status_code: int) -> None:
|
||||
del settings
|
||||
return database
|
||||
|
||||
application = create_app(Settings(), factory)
|
||||
application = create_app(
|
||||
Settings(contract_snapshot=None, compatibility_evidence=None),
|
||||
factory,
|
||||
worker_factories=(),
|
||||
)
|
||||
async with application.router.lifespan_context(application):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||
@@ -73,7 +77,7 @@ async def test_lifespan_starts_and_stops_injected_workers() -> None:
|
||||
stopping.set()
|
||||
|
||||
application = create_app(
|
||||
Settings(),
|
||||
Settings(contract_snapshot=None, compatibility_evidence=None),
|
||||
lambda _settings: database,
|
||||
worker_factories=(lambda _database: Worker(),),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -51,3 +51,6 @@ def test_repository_migration_defines_required_planes() -> None:
|
||||
):
|
||||
assert f"CREATE TABLE {table}" in domain
|
||||
assert "CREATE TABLE group_acl_entries" in migrations[5].sql
|
||||
assert "ADD COLUMN IF NOT EXISTS config jsonb" in migrations[6].sql
|
||||
assert "CREATE TABLE tfa_entries" in migrations[7].sql
|
||||
assert "CREATE TABLE openid_pending" in migrations[7].sql
|
||||
|
||||
@@ -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,25 @@
|
||||
"""OpenAPI tag categorization tests."""
|
||||
|
||||
from app.api.openapi import contract_openapi_tag, contract_openapi_tags, openapi_tag_metadata
|
||||
|
||||
|
||||
def test_contract_openapi_tag_groups_by_domain() -> None:
|
||||
assert contract_openapi_tag("/version") == "Core"
|
||||
assert contract_openapi_tag("/access/ticket") == "Access"
|
||||
assert contract_openapi_tag("/nodes/{node}/qemu/{vmid}/config") == "Nodes · QEMU"
|
||||
assert contract_openapi_tag("/nodes/{node}/lxc/{vmid}/config") == "Nodes · LXC"
|
||||
assert contract_openapi_tag("/nodes/{node}/ceph/osd") == "Nodes · Ceph"
|
||||
assert contract_openapi_tag("/cluster/ha/resources") == "Cluster · HA"
|
||||
assert contract_openapi_tag("/pools") == "Pools"
|
||||
|
||||
|
||||
def test_contract_openapi_tags_include_renderer() -> None:
|
||||
assert contract_openapi_tags("/version", "json") == ["Core", "API2 JSON"]
|
||||
assert contract_openapi_tags("/version", "extjs") == ["Core", "API2 ExtJS"]
|
||||
|
||||
|
||||
def test_openapi_tag_metadata_is_deterministic() -> None:
|
||||
names = [entry["name"] for entry in openapi_tag_metadata()]
|
||||
assert names == sorted(names)
|
||||
assert "Nodes · QEMU" in names
|
||||
assert "Simulator" in names
|
||||
@@ -69,7 +69,7 @@ class QemuPool:
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"state": f'{{"status":"{status}"}}',
|
||||
"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=8G"}',
|
||||
"config": ('{"agent":1,"name":"vm","scsi0":"local-lvm:vm-150-disk-0,size=8G"}'),
|
||||
}
|
||||
if "SELECT r.id, r.state, v.config" in sql:
|
||||
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
||||
@@ -325,3 +325,40 @@ async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch)
|
||||
with pytest.raises(ApiError) as same_node:
|
||||
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
|
||||
assert same_node.value.status_code == 400
|
||||
|
||||
|
||||
async def test_qemu_pending_and_agent_handlers() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
pool.running = True
|
||||
http_request = request(pool)
|
||||
values = inputs(node="pve1", vmid=150)
|
||||
|
||||
pending = registry.get("/nodes/{node}/qemu/{vmid}/pending", "GET")
|
||||
routes = {
|
||||
"info": "/nodes/{node}/qemu/{vmid}/agent/info",
|
||||
"os": "/nodes/{node}/qemu/{vmid}/agent/get-osinfo",
|
||||
"host": "/nodes/{node}/qemu/{vmid}/agent/get-host-name",
|
||||
"network": "/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces",
|
||||
"time": "/nodes/{node}/qemu/{vmid}/agent/get-time",
|
||||
"ping": "/nodes/{node}/qemu/{vmid}/agent/ping",
|
||||
}
|
||||
handlers = {
|
||||
name: registry.get(path, "POST" if name == "ping" else "GET")
|
||||
for name, path in routes.items()
|
||||
}
|
||||
assert pending and all(handlers.values())
|
||||
|
||||
async def call(name: str) -> dict[str, Any]:
|
||||
handler = handlers[name]
|
||||
assert handler is not None
|
||||
return cast(dict[str, Any], await handler(http_request, values))
|
||||
|
||||
assert await pending(http_request, values) == []
|
||||
assert (await call("info"))["result"]["version"]
|
||||
assert (await call("os"))["result"]["machine"] == "x86_64"
|
||||
assert (await call("host"))["result"]["host-name"] == "vm"
|
||||
assert (await call("network"))["result"][0]["name"] == "eth0"
|
||||
assert (await call("time"))["result"]["seconds"] > 0
|
||||
assert (await call("ping"))["result"] == {}
|
||||
|
||||
@@ -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
|
||||
+76
-2
@@ -2,7 +2,13 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from app.simulation.seed import build_profile, large_profile, small_profile, stable_id
|
||||
from app.simulation.seed import (
|
||||
build_profile,
|
||||
clear_simulation_state,
|
||||
large_profile,
|
||||
small_profile,
|
||||
stable_id,
|
||||
)
|
||||
|
||||
|
||||
def test_small_profile_matches_required_logical_shape() -> None:
|
||||
@@ -12,7 +18,7 @@ def test_small_profile_matches_required_logical_shape() -> None:
|
||||
assert first == second
|
||||
state = first.logical_state()
|
||||
assert state == second.logical_state()
|
||||
assert state["nodes"] == [{"name": "pve1", "status": "online"}]
|
||||
assert state["nodes"] == [{"name": "pve01", "status": "online"}]
|
||||
resources = state["resources"]
|
||||
assert isinstance(resources, list)
|
||||
assert [resource["kind"] for resource in resources].count("qemu") == 2
|
||||
@@ -48,6 +54,74 @@ def test_profile_validation() -> None:
|
||||
large_profile(node_count=0, resource_count=1)
|
||||
|
||||
|
||||
def test_demo_cluster_profile_shape() -> None:
|
||||
profile = build_profile("demo-cluster")
|
||||
assert profile.name == "demo-cluster"
|
||||
assert len(profile.nodes) == 20
|
||||
assert sum(resource.kind == "qemu" for resource in profile.resources) == 850
|
||||
assert sum(resource.kind == "lxc" for resource in profile.resources) == 150
|
||||
assert sum(resource.kind == "ceph-osd" for resource in profile.resources) == 300
|
||||
assert sum(resource.kind == "storage" for resource in profile.resources) >= 62
|
||||
assert len(profile.tasks) == 250
|
||||
external_ids = {
|
||||
resource.external_id for resource in profile.resources if resource.kind in {"qemu", "lxc"}
|
||||
}
|
||||
assert len(external_ids) == 1000
|
||||
|
||||
|
||||
def test_demo_cluster_spreads_guests_evenly_across_nodes() -> None:
|
||||
profile = build_profile("demo-cluster")
|
||||
names = {node.id: node.name for node in profile.nodes}
|
||||
|
||||
def counts(kind: str) -> list[int]:
|
||||
counter: dict[str, int] = {name: 0 for name in names.values()}
|
||||
for resource in profile.resources:
|
||||
if resource.kind == kind:
|
||||
counter[names[resource.node_id]] += 1
|
||||
return list(counter.values())
|
||||
|
||||
for kind, expected_total in (("qemu", 850), ("lxc", 150), ("ceph-osd", 300)):
|
||||
values = counts(kind)
|
||||
assert sum(values) == expected_total
|
||||
assert max(values) - min(values) <= 1
|
||||
|
||||
guest_counts = counts("qemu")
|
||||
guest_counts = [a + b for a, b in zip(guest_counts, counts("lxc"), strict=True)]
|
||||
assert max(guest_counts) - min(guest_counts) <= 2
|
||||
|
||||
|
||||
def test_minimal_profile() -> None:
|
||||
profile = build_profile("minimal")
|
||||
assert len(profile.nodes) == 1
|
||||
assert not any(resource.kind in {"qemu", "lxc"} for resource in profile.resources)
|
||||
|
||||
|
||||
def test_stable_ids_are_namespaced_and_repeatable() -> None:
|
||||
assert stable_id("qemu:100") == stable_id("qemu:100")
|
||||
assert stable_id("qemu:100") != stable_id("qemu:101")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_simulation_state_wipes_api_created_identity() -> None:
|
||||
executed: list[str] = []
|
||||
|
||||
class FakeConnection:
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
executed.append(" ".join(sql.split()))
|
||||
return "DELETE 0"
|
||||
|
||||
await clear_simulation_state(FakeConnection())
|
||||
joined = "\n".join(executed)
|
||||
for table in (
|
||||
"resources",
|
||||
"nodes",
|
||||
"principals",
|
||||
"identity_groups",
|
||||
"roles",
|
||||
"storage_contents",
|
||||
"api_tokens",
|
||||
):
|
||||
assert f"DELETE FROM {table}" in joined # noqa: S608 - asserting SQL text
|
||||
assert "DELETE FROM realms WHERE name NOT IN" in joined
|
||||
assert any(sql.startswith("UPDATE clusters") for sql in executed)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Web asset loading tests."""
|
||||
|
||||
from app.web.assets import console_html
|
||||
|
||||
|
||||
def test_console_html_is_read_from_disk() -> None:
|
||||
html = console_html()
|
||||
assert "Proxmox API Emulator" in html
|
||||
assert 'id="catalog-drawer"' in html
|
||||
assert "catalog-drawer" in html
|
||||
assert 'id="catalog-coverage"' in html
|
||||
assert "Implementation coverage" in html
|
||||
for required_id in (
|
||||
"method-desc",
|
||||
"catalog-meta",
|
||||
"stat-runtime",
|
||||
"stat-catalog",
|
||||
"stat-cluster-name",
|
||||
"stat-nodes",
|
||||
"stat-qemu",
|
||||
"stat-lxc",
|
||||
"implemented-only",
|
||||
"btn-contract-apply",
|
||||
"btn-catalog-refresh",
|
||||
):
|
||||
assert f'id="{required_id}"' in html, required_id
|
||||
assert "Apply as runtime" in html
|
||||
assert "CONTRACT_SNAPSHOT" in html
|
||||
assert 'id="help-drawer"' in html
|
||||
assert 'id="help-badge"' in html
|
||||
assert 'id="data-badge"' in html
|
||||
assert 'id="data-drawer"' in html
|
||||
assert 'id="data-panel"' in html
|
||||
assert 'id="ui-modal"' in html
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Web console route tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
_BUNDLED = Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
|
||||
)
|
||||
|
||||
|
||||
async def test_root_console_is_served() -> None:
|
||||
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "Proxmox API Emulator" in response.text
|
||||
assert 'id="catalog-drawer"' in response.text
|
||||
assert "catalog-drawer" in response.text
|
||||
assert 'id="help-drawer"' in response.text
|
||||
assert 'id="help-badge"' in response.text
|
||||
assert 'id="data-badge"' in response.text
|
||||
assert 'id="data-drawer"' in response.text
|
||||
assert "data-badge-btn" in response.text
|
||||
assert 'id="endpoints-badge-count"' in response.text
|
||||
assert 'id="endpoints-drawer-count"' in response.text
|
||||
assert 'id="ui-modal"' in response.text
|
||||
assert 'role="alertdialog"' in response.text
|
||||
assert "Request body" in response.text
|
||||
|
||||
|
||||
async def test_ui_method_nodes_is_implemented() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
method = await client.get(
|
||||
"/ui/api/method",
|
||||
params={"major": 7, "path": "/nodes", "verb": "GET"},
|
||||
)
|
||||
assert method.status_code == 200
|
||||
assert method.json()["implemented"] is True
|
||||
|
||||
|
||||
async def test_ui_method_read_group_is_implemented() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
method = await client.get(
|
||||
"/ui/api/method",
|
||||
params={"major": 9, "path": "/access/groups/{groupid}", "verb": "GET"},
|
||||
)
|
||||
assert method.status_code == 200
|
||||
payload = method.json()
|
||||
assert payload["name"] == "read_group"
|
||||
assert payload["implemented"] is True
|
||||
|
||||
|
||||
async def test_ui_catalog_read_group_is_implemented() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
catalog = await client.get("/ui/api/catalog", params={"major": 9})
|
||||
assert catalog.status_code == 200
|
||||
methods = {
|
||||
(path["path"], method["name"]): method["implemented"]
|
||||
for category in catalog.json()["categories"]
|
||||
for path in category["paths"]
|
||||
for method in path["methods"]
|
||||
}
|
||||
assert methods[("/access/groups/{groupid}", "read_group")] is True
|
||||
|
||||
|
||||
async def test_demo_api_requires_database() -> None:
|
||||
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
state = await client.get("/ui/api/demo/state")
|
||||
load = await client.post("/ui/api/demo/load")
|
||||
assert state.status_code == 503
|
||||
assert load.status_code == 503
|
||||
|
||||
|
||||
async def test_ui_versions_and_catalog_endpoints() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
versions = await client.get("/ui/api/versions")
|
||||
assert versions.status_code == 200
|
||||
assert {item["major"] for item in versions.json()["majors"]} == {6, 7, 8, 9}
|
||||
catalog = await client.get("/ui/api/catalog", params={"major": 9})
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()["source_version"] == "9.2.3"
|
||||
method = await client.get(
|
||||
"/ui/api/method",
|
||||
params={"major": 9, "path": "/version", "verb": "GET"},
|
||||
)
|
||||
assert method.status_code == 200
|
||||
assert method.json()["path"] == "/version"
|
||||
Reference in New Issue
Block a user