Initial commit: stateful OpenStack API laboratory simulator.
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
"""TFA / OpenID / permissions access handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.config import Settings
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.access_auth import register_access_auth_handlers
|
||||
from app.security.auth import issue_ticket
|
||||
|
||||
|
||||
class AuthPool:
|
||||
def __init__(self) -> None:
|
||||
self.principals = {
|
||||
"root@pam": {
|
||||
"id": uuid.uuid4(),
|
||||
"tfa_locked_until": None,
|
||||
"totp_locked": False,
|
||||
}
|
||||
}
|
||||
self.tfa: dict[tuple[uuid.UUID, str], dict[str, Any]] = {}
|
||||
self.realms = {
|
||||
"sso": {
|
||||
"kind": "openid",
|
||||
"config": {
|
||||
"issuer-url": "https://idp.example",
|
||||
"client-id": "pve",
|
||||
},
|
||||
}
|
||||
}
|
||||
self.pending: dict[str, dict[str, str]] = {}
|
||||
|
||||
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
|
||||
if "FROM principals p" in query and "LEFT JOIN tfa_entries" in query:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name, data in self.principals.items():
|
||||
matches = [item for key, item in self.tfa.items() if key[0] == data["id"]]
|
||||
if not matches:
|
||||
rows.append(
|
||||
{
|
||||
"userid": name,
|
||||
"tfa_locked_until": data["tfa_locked_until"],
|
||||
"totp_locked": data["totp_locked"],
|
||||
"entry_id": None,
|
||||
"tfa_type": None,
|
||||
"description": None,
|
||||
"enable": None,
|
||||
"created_at": 0,
|
||||
}
|
||||
)
|
||||
for item in matches:
|
||||
rows.append(
|
||||
{
|
||||
"userid": name,
|
||||
"tfa_locked_until": data["tfa_locked_until"],
|
||||
"totp_locked": data["totp_locked"],
|
||||
**item,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
if "FROM tfa_entries" in query and "DISTINCT" in query:
|
||||
principal_id = arguments[0]
|
||||
types = sorted(
|
||||
{
|
||||
item["tfa_type"]
|
||||
for key, item in self.tfa.items()
|
||||
if key[0] == principal_id and item["enable"]
|
||||
}
|
||||
)
|
||||
return [{"tfa_type": value} for value in types]
|
||||
if "FROM tfa_entries WHERE principal_id" in query or (
|
||||
"FROM tfa_entries" in query and "principal_id=$1" in query and "DISTINCT" not in query
|
||||
):
|
||||
principal_id = arguments[0]
|
||||
return [item for key, item in self.tfa.items() if key[0] == principal_id]
|
||||
if "FROM acl_entries" in query:
|
||||
return []
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM principals WHERE name" in query:
|
||||
userid = str(arguments[0])
|
||||
data = self.principals.get(userid)
|
||||
if data is None:
|
||||
return None
|
||||
return {"name": userid, **data}
|
||||
if "FROM realms WHERE name" in query:
|
||||
realm = str(arguments[0])
|
||||
realm_data = self.realms.get(realm)
|
||||
if realm_data is None:
|
||||
return None
|
||||
return {"name": realm, **realm_data}
|
||||
if "FROM openid_pending WHERE state" in query:
|
||||
return self.pending.get(str(arguments[0]))
|
||||
if "FROM tfa_entries WHERE principal_id" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
item = self.tfa.get(key)
|
||||
return item
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM principals" in query:
|
||||
return str(arguments[0]) in self.principals
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "INSERT INTO openid_pending" in query:
|
||||
self.pending[str(arguments[0])] = {
|
||||
"realm": str(arguments[1]),
|
||||
"redirect_url": str(arguments[2]),
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "DELETE FROM openid_pending" in query:
|
||||
self.pending.pop(str(arguments[0]), None)
|
||||
return "DELETE 1"
|
||||
if "INSERT INTO principals" in query:
|
||||
self.principals[str(arguments[0])] = {
|
||||
"id": uuid.uuid4(),
|
||||
"tfa_locked_until": None,
|
||||
"totp_locked": False,
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "INSERT INTO tfa_entries" in query:
|
||||
principal_id = cast(uuid.UUID, arguments[0])
|
||||
entry_id = str(arguments[1])
|
||||
self.tfa[(principal_id, entry_id)] = {
|
||||
"entry_id": entry_id,
|
||||
"tfa_type": str(arguments[2]),
|
||||
"description": arguments[3],
|
||||
"enable": True,
|
||||
"created_at": 1_700_000_000,
|
||||
"secret": arguments[4],
|
||||
"metadata": json.loads(str(arguments[5])),
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE tfa_entries SET enable" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
self.tfa[key]["enable"] = bool(arguments[2])
|
||||
return "UPDATE 1"
|
||||
if "UPDATE tfa_entries SET description" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
self.tfa[key]["description"] = arguments[2]
|
||||
return "UPDATE 1"
|
||||
if "DELETE FROM tfa_entries" in query:
|
||||
key = (cast(uuid.UUID, arguments[0]), str(arguments[1]))
|
||||
if key not in self.tfa:
|
||||
return "DELETE 0"
|
||||
del self.tfa[key]
|
||||
return "DELETE 1"
|
||||
if "UPDATE principals" in query and "totp_locked" in query:
|
||||
userid = str(arguments[0])
|
||||
if userid not in self.principals:
|
||||
return "UPDATE 0"
|
||||
self.principals[userid]["tfa_locked_until"] = None
|
||||
self.principals[userid]["totp_locked"] = False
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: AuthPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: AuthPool, principal: str = "root@pam") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
app.state.settings = Settings(ticket_signing_key=SecretStr("test-signing-key"))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = principal
|
||||
return result
|
||||
|
||||
|
||||
def values(**items: object) -> dict[str, Any]:
|
||||
return {"values": items, "provided": frozenset(items)}
|
||||
|
||||
|
||||
async def test_tfa_lifecycle_and_unlock_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_auth_handlers(registry)
|
||||
pool = AuthPool()
|
||||
http = request(pool)
|
||||
create = registry.get("/access/tfa/{userid}", "POST")
|
||||
listing = registry.get("/access/tfa/{userid}", "GET")
|
||||
get = registry.get("/access/tfa/{userid}/{id}", "GET")
|
||||
update = registry.get("/access/tfa/{userid}/{id}", "PUT")
|
||||
delete = registry.get("/access/tfa/{userid}/{id}", "DELETE")
|
||||
unlock = registry.get("/access/users/{userid}/unlock-tfa", "PUT")
|
||||
types = registry.get("/access/users/{userid}/tfa", "GET")
|
||||
assert create and listing and get and update and delete and unlock and types
|
||||
|
||||
created = await create(http, values(userid="root@pam", type="totp", description="phone"))
|
||||
entry_id = created["id"]
|
||||
assert await listing(http, values(userid="root@pam"))
|
||||
fetched = await get(http, values(userid="root@pam", id=entry_id))
|
||||
assert fetched["type"] == "totp"
|
||||
await update(http, values(userid="root@pam", id=entry_id, enable=0))
|
||||
assert (await get(http, values(userid="root@pam", id=entry_id)))["enable"] == 0
|
||||
assert await unlock(http, values(userid="root@pam")) is True
|
||||
assert (await types(http, values(userid="root@pam")))["types"] == []
|
||||
await delete(http, values(userid="root@pam", id=entry_id))
|
||||
with pytest.raises(ApiError):
|
||||
await get(http, values(userid="root@pam", id=entry_id))
|
||||
|
||||
|
||||
async def test_openid_auth_url_and_login_create_principal() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_auth_handlers(registry)
|
||||
pool = AuthPool()
|
||||
http = request(pool)
|
||||
auth_url = registry.get("/access/openid/auth-url", "POST")
|
||||
login = registry.get("/access/openid/login", "POST")
|
||||
assert auth_url and login
|
||||
|
||||
url = await auth_url(
|
||||
http,
|
||||
values(realm="sso", **{"redirect-url": "https://pve.local/api2/json/access/openid/login"}),
|
||||
)
|
||||
assert "https://idp.example/authorize?" in url
|
||||
assert pool.pending
|
||||
state = next(iter(pool.pending))
|
||||
result = await login(
|
||||
http,
|
||||
values(
|
||||
code="abc1234567890",
|
||||
state=state,
|
||||
**{"redirect-url": "https://pve.local/api2/json/access/openid/login"},
|
||||
),
|
||||
)
|
||||
assert result["ticket"].startswith("PVE:")
|
||||
assert any(name.endswith("@sso") for name in pool.principals)
|
||||
|
||||
|
||||
async def test_permissions_and_vncticket() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_auth_handlers(registry)
|
||||
pool = AuthPool()
|
||||
http = request(pool)
|
||||
permissions = registry.get("/access/permissions", "GET")
|
||||
vncticket = registry.get("/access/vncticket", "POST")
|
||||
ticket_get = registry.get("/access/ticket", "GET")
|
||||
assert permissions and vncticket and ticket_get
|
||||
|
||||
caps = await permissions(http, values())
|
||||
assert "/" in caps
|
||||
assert await ticket_get(http, values()) is None
|
||||
ticket = issue_ticket("root@pam", b"test-signing-key")
|
||||
await vncticket(
|
||||
http,
|
||||
values(
|
||||
authid="root@pam",
|
||||
path="/nodes/pve01/qemu/100/vncwebsocket",
|
||||
privs="Sys.Console",
|
||||
vncticket=ticket,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""API-token lifecycle handler tests without external services."""
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.access import register_access_handlers
|
||||
|
||||
|
||||
class TokenPool:
|
||||
def __init__(self) -> None:
|
||||
self.token: dict[str, Any] | None = None
|
||||
|
||||
async def fetch(self, _query: str, _userid: str) -> list[dict[str, Any]]:
|
||||
return [] if self.token is None else [{"token_id": "test", **self.token}]
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "INSERT INTO" in query:
|
||||
self.token = {
|
||||
"comment": arguments[3],
|
||||
"privilege_separation": arguments[5],
|
||||
"expire": arguments[4],
|
||||
}
|
||||
return self.token
|
||||
if "UPDATE api_tokens" in query:
|
||||
if self.token is None:
|
||||
return None
|
||||
self.token["comment"] = arguments[2]
|
||||
self.token["privilege_separation"] = arguments[4]
|
||||
return self.token
|
||||
return self.token
|
||||
|
||||
async def fetchval(self, _query: str, _userid: str) -> bool:
|
||||
return True
|
||||
|
||||
async def execute(self, _query: str, _userid: str, _tokenid: str) -> str:
|
||||
if self.token is None:
|
||||
return "DELETE 0"
|
||||
self.token = None
|
||||
return "DELETE 1"
|
||||
|
||||
|
||||
class RealmPool:
|
||||
def __init__(self) -> None:
|
||||
self.realms: dict[str, dict[str, Any]] = {
|
||||
"pam": {
|
||||
"kind": "pam",
|
||||
"config": {"comment": "Linux PAM standard authentication"},
|
||||
},
|
||||
"pve": {
|
||||
"kind": "pve",
|
||||
"config": {"comment": "Proxmox VE authentication server"},
|
||||
},
|
||||
}
|
||||
self.principals: dict[str, str] = {"root@pam": "pam"}
|
||||
|
||||
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
|
||||
del arguments
|
||||
if "FROM realms ORDER BY name" in query:
|
||||
return [
|
||||
{"name": name, "kind": data["kind"], "config": dict(data["config"])}
|
||||
for name, data in sorted(self.realms.items())
|
||||
]
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM realms WHERE name" in query:
|
||||
realm = str(arguments[0])
|
||||
data = self.realms.get(realm)
|
||||
if data is None:
|
||||
return None
|
||||
return {"name": realm, "kind": data["kind"], "config": dict(data["config"])}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> bool:
|
||||
realm = str(arguments[0])
|
||||
if "EXISTS(SELECT 1 FROM realms" in query:
|
||||
return realm in self.realms
|
||||
if "EXISTS(SELECT 1 FROM principals" in query:
|
||||
return any(value == realm for value in self.principals.values())
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "INSERT INTO realms" in query:
|
||||
self.realms[str(arguments[0])] = {
|
||||
"kind": str(arguments[1]),
|
||||
"config": json.loads(str(arguments[2])),
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE realms SET config=$2" in query:
|
||||
realm = str(arguments[0])
|
||||
self.realms[realm]["config"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "SET config = config - 'default'" in query:
|
||||
skip = str(arguments[0]) if arguments else None
|
||||
for name, data in self.realms.items():
|
||||
if skip is not None and name == skip:
|
||||
continue
|
||||
data["config"].pop("default", None)
|
||||
return "UPDATE 0"
|
||||
if "DELETE FROM realms" in query:
|
||||
realm = str(arguments[0])
|
||||
if realm not in self.realms:
|
||||
return "DELETE 0"
|
||||
del self.realms[realm]
|
||||
return "DELETE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: TokenPool | RealmPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: TokenPool | RealmPool, principal: str = "root@pam") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = principal
|
||||
return result
|
||||
|
||||
|
||||
def values(**items: object) -> dict[str, Any]:
|
||||
return {"values": items, "provided": frozenset(items)}
|
||||
|
||||
|
||||
async def test_token_lifecycle_returns_secret_once_and_persists_metadata() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = TokenPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/access/users/{userid}/token/{tokenid}", "POST")
|
||||
get = registry.get("/access/users/{userid}/token/{tokenid}", "GET")
|
||||
update = registry.get("/access/users/{userid}/token/{tokenid}", "PUT")
|
||||
delete = registry.get("/access/users/{userid}/token/{tokenid}", "DELETE")
|
||||
list_tokens = registry.get("/access/users/{userid}/token", "GET")
|
||||
assert create and get and update and delete and list_tokens
|
||||
|
||||
created = await create(
|
||||
http_request,
|
||||
values(userid="root@pam", tokenid="test", comment="first", privsep=True),
|
||||
)
|
||||
assert created["full-tokenid"] == "root@pam!test"
|
||||
assert created["value"]
|
||||
assert "value" not in await get(http_request, values(userid="root@pam", tokenid="test"))
|
||||
assert await list_tokens(http_request, values(userid="root@pam"))
|
||||
|
||||
updated = await update(
|
||||
http_request,
|
||||
values(userid="root@pam", tokenid="test", comment="second", privsep=False),
|
||||
)
|
||||
assert updated["comment"] == "second"
|
||||
await delete(http_request, values(userid="root@pam", tokenid="test"))
|
||||
with pytest.raises(ApiError) as missing:
|
||||
await get(http_request, values(userid="root@pam", tokenid="test"))
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
|
||||
async def test_token_lifecycle_rejects_non_owner() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
handler = registry.get("/access/users/{userid}/token", "GET")
|
||||
assert handler
|
||||
with pytest.raises(ApiError) as denied:
|
||||
await handler(request(TokenPool(), "auditor@pve"), values(userid="other@pve"))
|
||||
assert denied.value.status_code == 403
|
||||
|
||||
|
||||
async def test_domain_lifecycle_persists_realm_config() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = RealmPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/access/domains", "POST")
|
||||
listing = registry.get("/access/domains", "GET")
|
||||
get = registry.get("/access/domains/{realm}", "GET")
|
||||
update = registry.get("/access/domains/{realm}", "PUT")
|
||||
delete = registry.get("/access/domains/{realm}", "DELETE")
|
||||
assert create and listing and get and update and delete
|
||||
|
||||
await create(
|
||||
http_request,
|
||||
values(
|
||||
realm="corp",
|
||||
type="ldap",
|
||||
comment="Corporate LDAP",
|
||||
server1="ldap.example.com",
|
||||
password="secret", # noqa: S106 - fixture secret for unit test
|
||||
default=1,
|
||||
),
|
||||
)
|
||||
listed = await listing(http_request, values())
|
||||
assert any(item["realm"] == "corp" and item["type"] == "ldap" for item in listed)
|
||||
created = await get(http_request, values(realm="corp"))
|
||||
assert created["comment"] == "Corporate LDAP"
|
||||
assert created["server1"] == "ldap.example.com"
|
||||
assert created["default"] == 1
|
||||
assert "password" not in created
|
||||
|
||||
await update(
|
||||
http_request,
|
||||
values(realm="corp", comment="Updated LDAP", delete="default"),
|
||||
)
|
||||
updated = await get(http_request, values(realm="corp"))
|
||||
assert updated["comment"] == "Updated LDAP"
|
||||
assert "default" not in updated
|
||||
|
||||
await delete(http_request, values(realm="corp"))
|
||||
with pytest.raises(ApiError) as missing:
|
||||
await get(http_request, values(realm="corp"))
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
|
||||
async def test_domain_delete_rejects_builtin_and_in_use_realms() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = RealmPool()
|
||||
http_request = request(pool)
|
||||
delete = registry.get("/access/domains/{realm}", "DELETE")
|
||||
assert delete
|
||||
|
||||
with pytest.raises(ApiError) as builtin:
|
||||
await delete(http_request, values(realm="pam"))
|
||||
assert builtin.value.status_code == 400
|
||||
|
||||
pool.realms["corp"] = {"kind": "ldap", "config": {}}
|
||||
pool.principals["alice@corp"] = "corp"
|
||||
with pytest.raises(ApiError) as in_use:
|
||||
await delete(http_request, values(realm="corp"))
|
||||
assert in_use.value.status_code == 400
|
||||
@@ -0,0 +1,61 @@
|
||||
"""ACL propagation, token separation, and contract mapping tests."""
|
||||
|
||||
from app.contracts.model import Permissions
|
||||
from app.security.acl import AclEntry, authorize, effective_privileges, requirement_from_contract
|
||||
|
||||
ENTRIES = (
|
||||
AclEntry("alice@pve", "/vms", frozenset({"VM.Audit", "VM.PowerMgmt"})),
|
||||
AclEntry("alice@pve", "/vms/200", frozenset({"VM.Config"}), propagate=False),
|
||||
)
|
||||
|
||||
|
||||
def test_acl_propagation_matrix() -> None:
|
||||
assert effective_privileges("alice@pve", "/vms/100", ENTRIES) == frozenset(
|
||||
{"VM.Audit", "VM.PowerMgmt"}
|
||||
)
|
||||
assert "VM.Config" in effective_privileges("alice@pve", "/vms/200", ENTRIES)
|
||||
assert "VM.Config" not in effective_privileges("alice@pve", "/vms/200/snapshot", ENTRIES)
|
||||
assert not effective_privileges("bob@pve", "/vms/100", ENTRIES)
|
||||
|
||||
|
||||
def test_api_token_privileges_are_intersection_not_escalation() -> None:
|
||||
assert authorize(
|
||||
"alice@pve",
|
||||
"/vms/100",
|
||||
frozenset({"VM.Audit"}),
|
||||
ENTRIES,
|
||||
token_privileges=frozenset({"VM.Audit"}),
|
||||
)
|
||||
assert not authorize(
|
||||
"alice@pve",
|
||||
"/vms/100",
|
||||
frozenset({"VM.PowerMgmt"}),
|
||||
ENTRIES,
|
||||
token_privileges=frozenset({"VM.Audit"}),
|
||||
)
|
||||
|
||||
|
||||
def test_contract_permission_maps_to_capability_requirement() -> None:
|
||||
permissions = Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]})
|
||||
|
||||
requirement = requirement_from_contract(permissions, {"vmid": "100"})
|
||||
|
||||
assert requirement is not None
|
||||
assert requirement.path == "/vms/100"
|
||||
assert requirement.privileges == frozenset({"VM.PowerMgmt"})
|
||||
|
||||
any_permission = Permissions(
|
||||
expression={
|
||||
"check": ["perm", "/vms/{vmid}", ["VM.Config.CPU", "VM.Config.Memory"], "any", 1]
|
||||
}
|
||||
)
|
||||
any_requirement = requirement_from_contract(any_permission, {"vmid": "100"})
|
||||
assert any_requirement is not None
|
||||
assert not any_requirement.require_all
|
||||
assert authorize(
|
||||
"alice@pve",
|
||||
"/vms/100",
|
||||
any_requirement.privileges,
|
||||
(AclEntry("alice@pve", "/vms", frozenset({"VM.Config.CPU"})),),
|
||||
require_all=any_requirement.require_all,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""HTTP-boundary API-token and contract permission tests."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import _authenticate
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Permissions, Schema
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self, secret: str, token_privileges: list[str]) -> None:
|
||||
self.secret_hash = hash_secret(secret, salt=b"boundary-token-v1")
|
||||
self.token_privileges = token_privileges
|
||||
|
||||
async def fetchrow(self, _query: str, principal: str, token_id: str) -> dict[str, Any] | None:
|
||||
if principal != "operator@pve" or token_id != "api":
|
||||
return None
|
||||
return {
|
||||
"name": principal,
|
||||
"secret_hash": self.secret_hash,
|
||||
"privileges": self.token_privileges,
|
||||
"privilege_separation": True,
|
||||
}
|
||||
|
||||
async def fetch(self, _query: str, principal: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": "/vms",
|
||||
"propagate": True,
|
||||
"privileges": ["VM.Audit", "VM.PowerMgmt"],
|
||||
"principal": principal,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: FakePool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def token_request(secret: str, token_privileges: list[str]) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.settings = Settings()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(FakePool("valid", token_privileges)))
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/api2/json/nodes/pve1/qemu/101/status/start",
|
||||
"headers": [(b"authorization", f"PVEAPIToken=operator@pve!api={secret}".encode())],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def power_method() -> Method:
|
||||
return Method(
|
||||
verb="POST",
|
||||
name="start",
|
||||
returns=Schema(type="string"),
|
||||
permissions=Permissions(expression={"check": ["perm", "/vms/{vmid}", ["VM.PowerMgmt"]]}),
|
||||
checksum="1" * 64,
|
||||
)
|
||||
|
||||
|
||||
async def test_api_token_skips_csrf_but_honors_separated_privileges() -> None:
|
||||
allowed = token_request("valid", ["VM.PowerMgmt"])
|
||||
await _authenticate(
|
||||
allowed,
|
||||
"/nodes/{node}/qemu/{vmid}/status/start",
|
||||
power_method(),
|
||||
{"values": {"node": "pve1", "vmid": 101}},
|
||||
)
|
||||
assert allowed.state.principal == "operator@pve"
|
||||
|
||||
denied = token_request("valid", ["VM.Audit"])
|
||||
with pytest.raises(ApiError) as error:
|
||||
await _authenticate(
|
||||
denied,
|
||||
"/nodes/{node}/qemu/{vmid}/status/start",
|
||||
power_method(),
|
||||
{"values": {"node": "pve1", "vmid": 101}},
|
||||
)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
async def test_api_token_rejects_unknown_or_wrong_secret() -> None:
|
||||
request = token_request("wrong", ["VM.PowerMgmt"])
|
||||
with pytest.raises(ApiError) as error:
|
||||
await _authenticate(
|
||||
request,
|
||||
"/nodes/{node}/qemu/{vmid}/status/start",
|
||||
power_method(),
|
||||
{"values": {"node": "pve1", "vmid": 101}},
|
||||
)
|
||||
assert error.value.status_code == 401
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Offline checks for the researched API Viewer sample."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
FIXTURES = Path(__file__).parents[1] / "fixtures" / "api-viewer"
|
||||
|
||||
|
||||
def test_version_fixture_matches_provenance() -> None:
|
||||
fixture_path = FIXTURES / "pve-9.2.3-version.json"
|
||||
provenance_path = FIXTURES / "pve-9.2.3-version.provenance.json"
|
||||
|
||||
fixture_bytes = fixture_path.read_bytes()
|
||||
fixture = cast(dict[str, Any], json.loads(fixture_bytes))
|
||||
provenance = cast(dict[str, Any], json.loads(provenance_path.read_bytes()))
|
||||
|
||||
assert fixture["path"] == "/version"
|
||||
assert fixture["info"]["GET"]["method"] == "GET"
|
||||
assert hashlib.sha256(fixture_bytes).hexdigest() == provenance["fixture_sha256"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Authentication, CSRF, token, and redaction matrices."""
|
||||
|
||||
import pytest
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.security.auth import (
|
||||
AuthenticationError,
|
||||
csrf_token,
|
||||
hash_secret,
|
||||
issue_ticket,
|
||||
parse_api_token,
|
||||
redact_secrets,
|
||||
set_ticket_cookie,
|
||||
verify_csrf,
|
||||
verify_secret,
|
||||
verify_ticket,
|
||||
)
|
||||
|
||||
KEY = b"test-signing-key-with-at-least-32-bytes"
|
||||
|
||||
|
||||
def test_password_and_token_hashes_do_not_store_plaintext() -> None:
|
||||
encoded = hash_secret("correct horse", salt=b"0123456789abcdef")
|
||||
|
||||
assert "correct horse" not in encoded
|
||||
assert verify_secret("correct horse", encoded)
|
||||
assert not verify_secret("wrong", encoded)
|
||||
assert not verify_secret("correct horse", "unknown$format")
|
||||
|
||||
|
||||
def test_signed_ticket_expiry_and_csrf() -> None:
|
||||
ticket = issue_ticket("root@pam", KEY, now=100, ttl=60)
|
||||
|
||||
assert verify_ticket(ticket, KEY, now=120).principal == "root@pam"
|
||||
token = csrf_token(ticket, KEY)
|
||||
assert verify_csrf(ticket, token, KEY)
|
||||
assert not verify_csrf(ticket, token + "x", KEY)
|
||||
with pytest.raises(AuthenticationError, match="expired"):
|
||||
verify_ticket(ticket, KEY, now=161)
|
||||
with pytest.raises(AuthenticationError, match="invalid"):
|
||||
verify_ticket(ticket + "x", KEY, now=120)
|
||||
|
||||
|
||||
def test_ticket_cookie_is_http_only_and_secure() -> None:
|
||||
response = Response()
|
||||
set_ticket_cookie(response, "ticket")
|
||||
|
||||
header = response.headers["set-cookie"]
|
||||
assert "PVEAuthCookie=ticket" in header
|
||||
assert "HttpOnly" in header
|
||||
assert "Secure" in header
|
||||
assert "SameSite=strict" in header
|
||||
|
||||
|
||||
def test_api_token_parsing_and_log_redaction() -> None:
|
||||
token = parse_api_token("PVEAPIToken=user@pve!automation=supersecret")
|
||||
|
||||
assert token.principal == "user@pve"
|
||||
assert token.token_id == "automation"
|
||||
assert token.secret == "supersecret"
|
||||
redacted = redact_secrets(
|
||||
"PVEAPIToken=user@pve!automation=supersecret password=hunter2 token=abc"
|
||||
)
|
||||
assert "supersecret" not in redacted
|
||||
assert "hunter2" not in redacted
|
||||
assert "token=abc" not in redacted
|
||||
with pytest.raises(AuthenticationError):
|
||||
parse_api_token("Bearer secret")
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Ceph pool/OSD mutation persistence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.ceph import register_ceph_handlers
|
||||
from app.simulation.seed import CLUSTER_ID
|
||||
|
||||
|
||||
class CephPool:
|
||||
def __init__(self) -> None:
|
||||
self.cluster_metadata: dict[str, Any] = {}
|
||||
self.nodes = {"pve1": {"id": uuid4(), "metadata": {}}}
|
||||
self.resources: dict[Any, dict[str, Any]] = {}
|
||||
|
||||
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
|
||||
if "r.kind='ceph-osd'" in query and "ORDER BY" in query:
|
||||
node = str(arguments[0])
|
||||
node_id = self.nodes[node]["id"]
|
||||
return [
|
||||
{"external_id": item["external_id"], "state": item["state"]}
|
||||
for item in self.resources.values()
|
||||
if item["node_id"] == node_id
|
||||
]
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.cluster_metadata)}
|
||||
if "SELECT metadata FROM nodes WHERE name" in query:
|
||||
node = self.nodes.get(str(arguments[0]))
|
||||
return None if node is None else {"metadata": json.dumps(node["metadata"])}
|
||||
if "storage_type='ceph'" in query:
|
||||
return {"capacity_bytes": 1000, "used_bytes": 100}
|
||||
if "r.kind='ceph-osd'" in query:
|
||||
node_name = str(arguments[0])
|
||||
osdid = str(arguments[1])
|
||||
node_id = self.nodes[node_name]["id"]
|
||||
for item in self.resources.values():
|
||||
if item["node_id"] == node_id and item["external_id"] in {
|
||||
osdid,
|
||||
f"osd.{osdid}",
|
||||
arguments[2] if len(arguments) > 2 else "",
|
||||
}:
|
||||
return item
|
||||
return None
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return str(arguments[0]) in self.nodes
|
||||
if "SELECT id FROM nodes WHERE name" in query:
|
||||
node = self.nodes.get(str(arguments[0]))
|
||||
return None if node is None else node["id"]
|
||||
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in query:
|
||||
return len(self.resources)
|
||||
if "COALESCE" in query and "ceph-osd" in query:
|
||||
return len(self.resources)
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "jsonb_set" in query and "'{ceph}'" in query:
|
||||
self.cluster_metadata["ceph"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "UPDATE nodes SET metadata" in query:
|
||||
self.nodes[str(arguments[0])]["metadata"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "INSERT INTO resources" in query:
|
||||
resource_id = uuid4()
|
||||
self.resources[resource_id] = {
|
||||
"id": resource_id,
|
||||
"node_id": arguments[0],
|
||||
"external_id": arguments[1],
|
||||
"state": arguments[2],
|
||||
}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE resources SET state" in query:
|
||||
existing_id = arguments[0]
|
||||
self.resources[existing_id]["state"] = arguments[1]
|
||||
return "UPDATE 1"
|
||||
if "DELETE FROM resources WHERE id" in query:
|
||||
self.resources.pop(arguments[0], None)
|
||||
return "DELETE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
def request(pool: CephPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
async def test_ceph_pool_and_osd_mutations_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_ceph_handlers(registry)
|
||||
pool = CephPool()
|
||||
http = request(pool)
|
||||
|
||||
create_pool = registry.get("/nodes/{node}/ceph/pool", "POST")
|
||||
list_pool = registry.get("/nodes/{node}/ceph/pool", "GET")
|
||||
create_osd = registry.get("/nodes/{node}/ceph/osd", "POST")
|
||||
osd_out = registry.get("/nodes/{node}/ceph/osd/{osdid}/out", "POST")
|
||||
assert create_pool and list_pool and create_osd and osd_out
|
||||
|
||||
await create_pool(http, {"values": {"node": "pve1", "name": "vms"}, "provided": frozenset()})
|
||||
pools = await list_pool(http, {"values": {"node": "pve1"}, "provided": frozenset()})
|
||||
assert any(item["pool"] == "vms" for item in pools)
|
||||
assert "vms" in pool.cluster_metadata["ceph"]["pools"]
|
||||
|
||||
await create_osd(http, {"values": {"node": "pve1", "dev": "/dev/sdb"}, "provided": frozenset()})
|
||||
assert len(pool.resources) == 1
|
||||
resource_id = next(iter(pool.resources))
|
||||
osdid = "0"
|
||||
await osd_out(
|
||||
http,
|
||||
{"values": {"node": "pve1", "osdid": osdid}, "provided": frozenset()},
|
||||
)
|
||||
assert json.loads(pool.resources[resource_id]["state"])["in"] is False
|
||||
assert CLUSTER_ID
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Simulation clock behavior."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.simulation.clock import AcceleratedClock, ManualClock
|
||||
|
||||
|
||||
async def test_manual_clock_releases_sleep_only_after_advance() -> None:
|
||||
clock = ManualClock(datetime(2026, 1, 1, tzinfo=UTC))
|
||||
sleeper = asyncio.create_task(clock.sleep(10))
|
||||
await asyncio.sleep(0)
|
||||
assert not sleeper.done()
|
||||
|
||||
await clock.advance(9)
|
||||
assert not sleeper.done()
|
||||
await clock.advance(1)
|
||||
await sleeper
|
||||
assert await clock.now() == datetime(2026, 1, 1, 0, 0, 10, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_clocks_reject_invalid_configuration() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AcceleratedClock(0)
|
||||
with pytest.raises(ValueError):
|
||||
ManualClock(datetime(2026, 1, 1))
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Mapping / ACME / cluster-config durable handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.acme import register_acme_handlers
|
||||
from app.handlers.cluster_config import register_cluster_config_handlers
|
||||
from app.handlers.mapping import register_mapping_handlers
|
||||
|
||||
|
||||
class MetaPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
self.nodes = {"pve1": {"status": "online"}}
|
||||
self.cluster_name = "pve-simulator"
|
||||
|
||||
async def fetch(self, query: str, *_arguments: object) -> list[dict[str, Any]]:
|
||||
if "FROM nodes" in query:
|
||||
return [{"name": name, "status": data["status"]} for name, data in self.nodes.items()]
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return str(arguments[0]) in self.nodes
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in query:
|
||||
self.metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "UPDATE clusters" in query and "SET name" in query:
|
||||
self.cluster_name = str(arguments[0])
|
||||
return "UPDATE 1"
|
||||
if "INSERT INTO nodes" in query:
|
||||
self.nodes[str(arguments[0])] = {"status": "online"}
|
||||
return "INSERT 0 1"
|
||||
if "UPDATE nodes SET status" in query:
|
||||
self.nodes[str(arguments[0])]["status"] = "offline"
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
async def call(
|
||||
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
|
||||
) -> Any:
|
||||
handler = registry.get(path, verb)
|
||||
assert handler is not None
|
||||
return await handler(http, inputs)
|
||||
|
||||
|
||||
def request(pool: MetaPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_mapping_acme_config_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_mapping_handlers(registry)
|
||||
register_acme_handlers(registry)
|
||||
register_cluster_config_handlers(registry)
|
||||
pool = MetaPool()
|
||||
http = request(pool)
|
||||
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/mapping/pci",
|
||||
"POST",
|
||||
http,
|
||||
{"values": {"id": "gpu0", "map": "0000:01:00.0"}, "provided": frozenset()},
|
||||
)
|
||||
pci = await call(
|
||||
registry,
|
||||
"/cluster/mapping/pci/{id}",
|
||||
"GET",
|
||||
http,
|
||||
{"values": {"id": "gpu0"}, "provided": frozenset()},
|
||||
)
|
||||
assert pci["map"] == "0000:01:00.0"
|
||||
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/acme/account",
|
||||
"POST",
|
||||
http,
|
||||
{
|
||||
"values": {"name": "default", "contact": "admin@example.com", "eab-hmac-key": "x"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
account = await call(
|
||||
registry,
|
||||
"/cluster/acme/account/{name}",
|
||||
"GET",
|
||||
http,
|
||||
{"values": {"name": "default"}, "provided": frozenset()},
|
||||
)
|
||||
assert account["name"] == "default"
|
||||
assert "eab-hmac-key" not in account
|
||||
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/config",
|
||||
"POST",
|
||||
http,
|
||||
{"values": {"clustername": "lab"}, "provided": frozenset()},
|
||||
)
|
||||
assert pool.metadata["cluster_config"]["clustername"] == "lab"
|
||||
assert pool.cluster_name == "lab"
|
||||
totem = await call(
|
||||
registry, "/cluster/config/totem", "GET", http, {"values": {}, "provided": frozenset()}
|
||||
)
|
||||
assert totem["cluster_name"] == "lab"
|
||||
assert uuid4()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Compatibility accounting tests."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.compatibility import (
|
||||
CompatibilityDimension,
|
||||
EvidenceManifest,
|
||||
build_report,
|
||||
resolve_evidence_path,
|
||||
)
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
from app.contracts.runtime import build_compatibility_for_snapshot
|
||||
from app.handlers.core import build_core_handlers
|
||||
|
||||
|
||||
def snapshot() -> Snapshot:
|
||||
methods = (
|
||||
Method(
|
||||
verb="GET",
|
||||
name="version",
|
||||
returns=Schema(type="object"),
|
||||
checksum="1" * 64,
|
||||
),
|
||||
Method(
|
||||
verb="POST",
|
||||
name="update",
|
||||
returns=Schema(type="null"),
|
||||
checksum="2" * 64,
|
||||
),
|
||||
)
|
||||
return Snapshot(
|
||||
source_version="9.2.3",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="0" * 64,
|
||||
paths=(PathContract(path="/nodes/{node}", methods=methods),),
|
||||
path_count=1,
|
||||
method_count=2,
|
||||
)
|
||||
|
||||
|
||||
def test_report_scores_levels_and_groups_independently() -> None:
|
||||
report = build_report(
|
||||
snapshot(),
|
||||
implemented=frozenset({("/nodes/{node}", "GET")}),
|
||||
observed=frozenset({("/nodes/{node}", "GET"), ("/nodes/{node}", "POST")}),
|
||||
verified=frozenset({("/nodes/{node}", "GET")}),
|
||||
)
|
||||
data = report.as_json()
|
||||
|
||||
assert data["total_declared"] == 2
|
||||
levels = data["levels"]
|
||||
assert isinstance(levels, dict)
|
||||
assert levels["implemented"]["score"] == 0.5
|
||||
assert levels["observed"]["score"] == 1.0
|
||||
assert data["groups"] == {"nodes": {"declared": 2, "implemented": 1, "verified": 1}}
|
||||
assert "| implemented | 1 | 50.00% |" in report.as_markdown()
|
||||
|
||||
|
||||
def test_report_rejects_unbound_evidence() -> None:
|
||||
with pytest.raises(ValueError, match="undeclared"):
|
||||
build_report(snapshot(), verified=frozenset({("/missing", "GET")}))
|
||||
|
||||
|
||||
def test_all_thirteen_dimensions_have_independent_evidence_and_renderers() -> None:
|
||||
method = frozenset({("/nodes/{node}", "GET")})
|
||||
report = build_report(
|
||||
snapshot(),
|
||||
implemented=method,
|
||||
dimensions={dimension: method for dimension in CompatibilityDimension},
|
||||
)
|
||||
|
||||
payload = report.as_json()
|
||||
dimensions = cast(dict[str, dict[str, object]], payload["dimensions"])
|
||||
assert list(dimensions) == [dimension.value for dimension in CompatibilityDimension]
|
||||
assert len(dimensions) == 13
|
||||
assert all(item["count"] == 1 for item in dimensions.values())
|
||||
assert payload["dimension_groups"]
|
||||
classifications = cast(dict[str, list[str]], payload["classifications"])
|
||||
assert classifications["fully_compatible"] == ["GET /nodes/{node}"]
|
||||
assert not classifications["partially_compatible"]
|
||||
assert "| permissions | 1 |" in report.as_markdown()
|
||||
assert "<td>long_task_behavior</td><td>1</td>" in report.as_html()
|
||||
assert report.canonical_json() == report.canonical_json()
|
||||
|
||||
|
||||
def test_dimension_evidence_must_reference_declared_method() -> None:
|
||||
with pytest.raises(ValueError, match="permissions evidence"):
|
||||
build_report(
|
||||
snapshot(),
|
||||
dimensions={CompatibilityDimension.PERMISSIONS: frozenset({("/missing", "GET")})},
|
||||
)
|
||||
|
||||
|
||||
def test_evidence_manifest_requires_provenance_and_unique_methods() -> None:
|
||||
manifest = EvidenceManifest.model_validate(
|
||||
{
|
||||
"profile": "pve-9.2",
|
||||
"source_version": "9.2.3",
|
||||
"records": [
|
||||
{
|
||||
"path": "/nodes/{node}",
|
||||
"verb": "GET",
|
||||
"dimensions": ["http_status", "json_structure"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py"],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
evidence = manifest.dimension_map()
|
||||
assert evidence[CompatibilityDimension.HTTP_STATUS] == frozenset({("/nodes/{node}", "GET")})
|
||||
assert not evidence[CompatibilityDimension.PERMISSIONS]
|
||||
assert manifest.verified_methods() == frozenset({("/nodes/{node}", "GET")})
|
||||
assert manifest.observed_methods() == frozenset({("/nodes/{node}", "GET")})
|
||||
|
||||
duplicate = manifest.model_dump(mode="json")
|
||||
duplicate["records"].append(duplicate["records"][0])
|
||||
with pytest.raises(ValueError, match="duplicate methods"):
|
||||
EvidenceManifest.model_validate(duplicate)
|
||||
|
||||
|
||||
def test_resolve_evidence_path_prefers_per_version_ledger() -> None:
|
||||
settings = Settings(compatibility_evidence=Path("evidence/pve-9.2.3.json"))
|
||||
assert resolve_evidence_path("7.4-16", settings) == Path("evidence/pve-7.4-16.json").resolve()
|
||||
assert resolve_evidence_path("9.2.3", settings) == Path("evidence/pve-9.2.3.json").resolve()
|
||||
|
||||
|
||||
def test_build_compatibility_wires_verified_from_version_ledger() -> None:
|
||||
snapshot = Snapshot.model_validate_json(
|
||||
Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/"
|
||||
"snapshot.json"
|
||||
).read_bytes()
|
||||
)
|
||||
settings = Settings(
|
||||
contract_snapshot=Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/"
|
||||
"snapshot.json"
|
||||
),
|
||||
compatibility_evidence=Path("evidence/pve-9.2.3.json"),
|
||||
ticket_signing_key=SecretStr("x" * 32),
|
||||
)
|
||||
handlers = build_core_handlers(settings)
|
||||
report = build_compatibility_for_snapshot(snapshot, handlers, settings)
|
||||
data = report.as_json()
|
||||
levels = cast(dict[str, dict[str, object]], data["levels"])
|
||||
assert levels["verified"]["count"] == data["total_declared"]
|
||||
assert levels["observed"]["count"] == data["total_declared"]
|
||||
assert levels["implemented"]["count"] == data["total_declared"]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Catalog-scoped compatibility payload tests."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.compatibility import CompatibilityDimension, build_report
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
from app.main import create_app
|
||||
from app.web.compatibility_catalog import compatibility_payload
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
_BUNDLED = Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
|
||||
)
|
||||
_PVE7 = Path(
|
||||
"contracts/2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f/snapshot.json"
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(source_version: str, path: str) -> Snapshot:
|
||||
method = Method(
|
||||
verb="GET",
|
||||
name="index",
|
||||
returns=Schema(type="object"),
|
||||
checksum="1" * 64,
|
||||
)
|
||||
return Snapshot(
|
||||
source_version=source_version,
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="0" * 64,
|
||||
paths=(PathContract(path=path, methods=(method,)),),
|
||||
path_count=1,
|
||||
method_count=1,
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_compatibility_uses_selected_snapshot_version() -> None:
|
||||
runtime_snapshot = _snapshot("9.2.3", "/version")
|
||||
catalog_snapshot = _snapshot("7.4-16", "/nodes")
|
||||
runtime_report = build_report(
|
||||
runtime_snapshot,
|
||||
implemented=frozenset({("/version", "GET")}),
|
||||
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/version", "GET")})},
|
||||
)
|
||||
payload = compatibility_payload(
|
||||
catalog_snapshot,
|
||||
7,
|
||||
implemented_methods=frozenset({("/nodes", "GET"), ("/version", "GET")}),
|
||||
runtime_report=runtime_report,
|
||||
runtime_version="9.2.3",
|
||||
settings=None,
|
||||
)
|
||||
assert payload["catalog_version"] == "7.4-16"
|
||||
assert payload["runtime_version"] == "9.2.3"
|
||||
assert payload["evidence_scope"] == "catalog"
|
||||
assert payload["total_declared"] == 1
|
||||
levels = cast(dict[str, dict[str, object]], payload["levels"])
|
||||
assert levels["implemented"]["count"] == 1
|
||||
|
||||
|
||||
def test_catalog_compatibility_reuses_runtime_report_for_matching_version() -> None:
|
||||
runtime_snapshot = _snapshot("9.2.3", "/version")
|
||||
runtime_report = build_report(
|
||||
runtime_snapshot,
|
||||
implemented=frozenset({("/version", "GET")}),
|
||||
dimensions={CompatibilityDimension.ROUTE_METHOD: frozenset({("/version", "GET")})},
|
||||
)
|
||||
payload = compatibility_payload(
|
||||
runtime_snapshot,
|
||||
9,
|
||||
implemented_methods=frozenset({("/version", "GET")}),
|
||||
runtime_report=runtime_report,
|
||||
runtime_version="9.2.3",
|
||||
settings=None,
|
||||
)
|
||||
assert payload["catalog_version"] == "9.2.3"
|
||||
assert payload["evidence_scope"] == "full"
|
||||
|
||||
|
||||
async def test_ui_compatibility_endpoint_follows_selected_major() -> None:
|
||||
if not _PVE7.is_file():
|
||||
pytest.skip("PVE 7 bundled contract is unavailable")
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
major7 = await client.get("/ui/api/compatibility", params={"major": 7})
|
||||
major9 = await client.get("/ui/api/compatibility", params={"major": 9})
|
||||
assert major7.status_code == 200
|
||||
assert major9.status_code == 200
|
||||
body7 = major7.json()
|
||||
body9 = major9.json()
|
||||
pve7_snapshot = Snapshot.model_validate_json(_PVE7.read_bytes())
|
||||
assert body7["catalog_version"] == pve7_snapshot.source_version
|
||||
assert body9["catalog_version"] == "9.2.3"
|
||||
assert body7["total_declared"] == pve7_snapshot.method_count
|
||||
bundled = Snapshot.model_validate_json(_BUNDLED.read_bytes())
|
||||
assert body9["total_declared"] == bundled.method_count
|
||||
assert body7["major"] == 7
|
||||
assert body9["major"] == 9
|
||||
# Legacy aliases are kept in implemented_methods so older majors report full coverage.
|
||||
assert body7["levels"]["implemented"]["count"] == body7["total_declared"]
|
||||
assert body9["levels"]["implemented"]["count"] == body9["total_declared"]
|
||||
|
||||
|
||||
async def test_ui_compatibility_covers_all_bundled_majors() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED, compatibility_evidence=None)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
for major in (6, 7, 8, 9):
|
||||
response = await client.get("/ui/api/compatibility", params={"major": major})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["levels"]["implemented"]["count"] == body["total_declared"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Golden HTTP input/output compatibility checks."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
||||
from app.main import create_app
|
||||
from app.security.auth import csrf_token, issue_ticket
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
|
||||
async def client_for(tmp_path: Path) -> AsyncClient:
|
||||
method = Method(
|
||||
verb="POST",
|
||||
name="update",
|
||||
parameters=(
|
||||
Parameter(name="node", definition=Schema(type="string")),
|
||||
Parameter(name="count", definition=Schema(type="integer", minimum=1)),
|
||||
Parameter(name="force", definition=Schema(type="boolean", optional=True)),
|
||||
Parameter(name="scsi[n]", definition=Schema(type="string", optional=True)),
|
||||
),
|
||||
returns=Schema(type="null"),
|
||||
checksum="1" * 64,
|
||||
)
|
||||
snapshot = Snapshot(
|
||||
source_version="test",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="0" * 64,
|
||||
paths=(PathContract(path="/nodes/{node}/test", methods=(method,)),),
|
||||
path_count=1,
|
||||
method_count=1,
|
||||
)
|
||||
path = tmp_path / "snapshot.json"
|
||||
path.write_bytes(snapshot.canonical_bytes())
|
||||
handlers = HandlerRegistry()
|
||||
|
||||
async def handler(_request: Request, inputs: dict[str, Any]) -> None:
|
||||
assert inputs["values"]["count"] >= 1
|
||||
if "scsi0" in inputs["values"]:
|
||||
assert inputs["values"]["scsi0"] == "local:disk,size=8G"
|
||||
return None
|
||||
|
||||
handlers.register("/nodes/{node}/test", "POST", handler)
|
||||
app = create_app(
|
||||
Settings(contract_snapshot=path, compatibility_evidence=None),
|
||||
lambda _settings: FakeDatabase(True),
|
||||
handlers,
|
||||
worker_factories=(),
|
||||
)
|
||||
key = Settings().ticket_signing_key.get_secret_value().encode()
|
||||
ticket = issue_ticket("root@pam", key)
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
cookies={"PVEAuthCookie": ticket},
|
||||
headers={"CSRFPreventionToken": csrf_token(ticket, key)},
|
||||
)
|
||||
|
||||
|
||||
async def test_json_input_and_null_envelope(tmp_path: Path) -> None:
|
||||
async with await client_for(tmp_path) as client:
|
||||
response = await client.post("/api2/json/nodes/pve/test", json={"count": 2, "force": True})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"data": None}
|
||||
|
||||
|
||||
async def test_form_input_and_validation_error_shape(tmp_path: Path) -> None:
|
||||
async with await client_for(tmp_path) as client:
|
||||
valid = await client.post(
|
||||
"/api2/json/nodes/pve/test",
|
||||
content="count=1&force=yes",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
invalid = await client.post("/api2/json/nodes/pve/test", json={"count": 0, "unknown": "x"})
|
||||
|
||||
assert valid.status_code == 200
|
||||
assert invalid.status_code == 400
|
||||
assert invalid.json() == {
|
||||
"data": None,
|
||||
"message": "parameter verification failed",
|
||||
"errors": {
|
||||
"count": "value must be at least 1",
|
||||
"unknown": "property is not defined in schema",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_non_object_json_is_rejected_without_fastapi_body(tmp_path: Path) -> None:
|
||||
async with await client_for(tmp_path) as client:
|
||||
response = await client.post("/api2/json/nodes/pve/test", json=[1, 2])
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["errors"] == {"body": "expected an object"}
|
||||
assert "detail" not in response.json()
|
||||
|
||||
|
||||
async def test_indexed_contract_parameter_accepts_concrete_device(tmp_path: Path) -> None:
|
||||
async with await client_for(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api2/json/nodes/pve/test", json={"count": 1, "scsi0": "local:disk,size=8G"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Contract catalog helpers."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
||||
from app.web.contract_catalog import catalog_payload, list_majors, method_payload
|
||||
|
||||
|
||||
def _snapshot() -> Snapshot:
|
||||
method = Method(
|
||||
verb="POST",
|
||||
name="create",
|
||||
description="Create a VM.",
|
||||
parameters=(
|
||||
Parameter(name="node", definition=Schema(type="string")),
|
||||
Parameter(name="vmid", definition=Schema(type="integer", minimum=100)),
|
||||
Parameter(name="name", definition=Schema(type="string")),
|
||||
Parameter(name="memory", definition=Schema(type="integer", optional=True)),
|
||||
Parameter(name="scsi[n]", definition=Schema(type="string", optional=True)),
|
||||
),
|
||||
returns=Schema(type="string"),
|
||||
checksum="a" * 64,
|
||||
)
|
||||
return Snapshot(
|
||||
source_version="9.2.3",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="b" * 64,
|
||||
paths=(PathContract(path="/nodes/{node}/qemu", methods=(method,)),),
|
||||
path_count=1,
|
||||
method_count=1,
|
||||
)
|
||||
|
||||
|
||||
def test_list_majors_includes_latest_releases() -> None:
|
||||
payload = list_majors(runtime_version="9.2.3")
|
||||
majors_list = cast(list[dict[str, Any]], payload["majors"])
|
||||
majors = {item["major"] for item in majors_list}
|
||||
series = {item["series"] for item in majors_list}
|
||||
assert majors == {6, 7, 8, 9}
|
||||
assert series == {"Yoga", "Antelope", "Caracal", "Dalmatian"}
|
||||
assert payload["runtime_version"] == "9.2.3"
|
||||
|
||||
|
||||
def test_list_majors_includes_artifact_urls() -> None:
|
||||
payload = list_majors(runtime_version="9.2.3")
|
||||
majors_list = cast(list[dict[str, Any]], payload["majors"])
|
||||
dalmatian = next(item for item in majors_list if item["major"] == 9)
|
||||
assert dalmatian["series"] == "Dalmatian"
|
||||
assert dalmatian["artifact_url"] == "stub://openstack/dalmatian/api-contract"
|
||||
assert dalmatian["bundled"] is True
|
||||
|
||||
|
||||
def test_list_majors_honors_settings_overrides() -> None:
|
||||
settings = Settings(catalog_artifact_url_9="https://example.test/dalmatian/apidoc.js")
|
||||
payload = list_majors(runtime_version=None, settings=settings)
|
||||
majors_list = cast(list[dict[str, Any]], payload["majors"])
|
||||
dalmatian = next(item for item in majors_list if item["major"] == 9)
|
||||
assert dalmatian["artifact_url"] == "https://example.test/dalmatian/apidoc.js"
|
||||
|
||||
|
||||
def test_catalog_payload_groups_paths_by_tag() -> None:
|
||||
payload = catalog_payload(
|
||||
_snapshot(),
|
||||
9,
|
||||
implemented_methods=frozenset({("/nodes/{node}/qemu", "POST")}),
|
||||
)
|
||||
assert payload["source_version"] == "9.2.3"
|
||||
assert payload["series"] == "Dalmatian"
|
||||
assert cast(str, payload["artifact_url"]).endswith("dalmatian/api-contract")
|
||||
assert payload["latest_version"] == "9.2.3"
|
||||
assert payload["path_count"] == 1
|
||||
categories = cast(list[dict[str, Any]], payload["categories"])
|
||||
method = categories[0]["paths"][0]["methods"][0]
|
||||
assert method["verb"] == "POST"
|
||||
assert method["implemented"] is True
|
||||
|
||||
|
||||
def test_method_payload_builds_examples() -> None:
|
||||
payload = method_payload(
|
||||
_snapshot(),
|
||||
major=9,
|
||||
path="/nodes/{node}/qemu",
|
||||
verb="POST",
|
||||
runtime_version="9.2.3",
|
||||
implemented_methods=frozenset({("/nodes/{node}/qemu", "POST")}),
|
||||
)
|
||||
assert payload["resolved_path"] == "/nodes/pve01/qemu"
|
||||
assert payload["body_example"] == {"vmid": 100, "name": "example"}
|
||||
assert payload["implemented"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_snapshot_uses_bundled_revision() -> None:
|
||||
from app.web import contract_catalog
|
||||
|
||||
contract_catalog._SNAPSHOT_CACHE.clear()
|
||||
root = Path("contracts")
|
||||
snapshot = await contract_catalog.load_snapshot(9, root)
|
||||
assert snapshot.source_version == "9.2.3"
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Offline command workflows for contract management."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.contracts.cli import parser, run
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
|
||||
|
||||
|
||||
async def test_validate_command_reports_source_counts(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
arguments = argparse.Namespace(command="validate", store=tmp_path, file=FIXTURE)
|
||||
|
||||
assert await run(arguments) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert json.loads(output) == {"nodes": 1, "warnings": 0}
|
||||
|
||||
|
||||
async def test_local_import_list_and_show(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
import_arguments = argparse.Namespace(
|
||||
command="import",
|
||||
store=tmp_path,
|
||||
file=FIXTURE,
|
||||
url=None,
|
||||
version="9.2.3",
|
||||
)
|
||||
assert await run(import_arguments) == 0
|
||||
revision = Path(capsys.readouterr().out.strip()).name
|
||||
|
||||
assert await run(argparse.Namespace(command="list", store=tmp_path)) == 0
|
||||
assert capsys.readouterr().out.strip() == revision
|
||||
|
||||
assert await run(argparse.Namespace(command="show", store=tmp_path, revision=revision)) == 0
|
||||
manifest = json.loads(capsys.readouterr().out)
|
||||
assert manifest["source_version"] == "9.2.3"
|
||||
assert manifest["snapshot_sha256"] == revision
|
||||
|
||||
|
||||
def test_cli_parser_accepts_local_import() -> None:
|
||||
arguments = parser().parse_args(
|
||||
["--store", "saved", "import", "--file", str(FIXTURE), "--version", "9.2.3"]
|
||||
)
|
||||
|
||||
assert arguments.command == "import"
|
||||
assert arguments.store == Path("saved")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Semantic contract diff classification and rendering tests."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.contracts.diff import (
|
||||
Severity,
|
||||
compare_snapshots,
|
||||
has_breaking_changes,
|
||||
render_html,
|
||||
render_json,
|
||||
render_markdown,
|
||||
render_text,
|
||||
)
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
|
||||
|
||||
def snapshot(paths: tuple[PathContract, ...]) -> Snapshot:
|
||||
return Snapshot(
|
||||
source_version="test",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="0" * 64,
|
||||
paths=paths,
|
||||
path_count=len(paths),
|
||||
method_count=sum(len(path.methods) for path in paths),
|
||||
)
|
||||
|
||||
|
||||
def method(description: str = "old", returns: Schema | None = None) -> Method:
|
||||
return Method(
|
||||
verb="GET",
|
||||
name="read",
|
||||
description=description,
|
||||
returns=returns or Schema(type="string"),
|
||||
checksum="1" * 64,
|
||||
)
|
||||
|
||||
|
||||
def test_classifies_added_removed_and_changed_contracts() -> None:
|
||||
before = snapshot(
|
||||
(
|
||||
PathContract(path="/removed", methods=(method(),)),
|
||||
PathContract(path="/version", methods=(method(),)),
|
||||
)
|
||||
)
|
||||
after = snapshot(
|
||||
(
|
||||
PathContract(path="/added", methods=(method(),)),
|
||||
PathContract(
|
||||
path="/version",
|
||||
methods=(method("new", Schema(type="integer", minimum=1)),),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
changes = compare_snapshots(before, after)
|
||||
|
||||
assert changes == tuple(sorted(changes))
|
||||
assert {change.category for change in changes} >= {
|
||||
"path",
|
||||
"method",
|
||||
"documentation",
|
||||
"schema",
|
||||
"constraint",
|
||||
}
|
||||
assert has_breaking_changes(changes)
|
||||
assert any(change.severity is Severity.NON_BREAKING for change in changes)
|
||||
|
||||
|
||||
def test_renderers_are_stable_and_escape_html() -> None:
|
||||
before = snapshot((PathContract(path="/<old>", methods=(method(),)),))
|
||||
after = snapshot(())
|
||||
changes = compare_snapshots(before, after)
|
||||
|
||||
assert render_text(changes).startswith("breaking:")
|
||||
assert "| breaking |" in render_markdown(changes)
|
||||
assert "<old>" in render_html(changes)
|
||||
decoded = json.loads(render_json(changes))
|
||||
assert decoded[0]["severity"] == "breaking"
|
||||
assert render_json(changes) == render_json(changes)
|
||||
|
||||
|
||||
def test_no_changes_has_clean_ci_policy() -> None:
|
||||
value = snapshot((PathContract(path="/version", methods=(method(),)),))
|
||||
|
||||
assert compare_snapshots(value, value) == ()
|
||||
assert not has_breaking_changes(())
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Security and idempotency tests for contract imports."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.contracts.importer import (
|
||||
RemoteSourceImporter,
|
||||
validate_public_addresses,
|
||||
validate_remote_url,
|
||||
)
|
||||
from app.contracts.normalize import normalize_snapshot
|
||||
from app.contracts.source import ApiViewerParser, SourceError
|
||||
from app.contracts.store import RevisionStore
|
||||
|
||||
|
||||
async def public_resolver(_host: str) -> tuple[str, ...]:
|
||||
return ("93.184.216.34",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://pve.proxmox.com/apidoc.js",
|
||||
"https://evil.example/apidoc.js",
|
||||
"https://pve.proxmox.com.evil.example/apidoc.js",
|
||||
"https://user@pve.proxmox.com/apidoc.js",
|
||||
"https://pve.proxmox.com:444/apidoc.js",
|
||||
],
|
||||
)
|
||||
def test_remote_url_policy_rejects_unsafe_urls(url: str) -> None:
|
||||
with pytest.raises(SourceError):
|
||||
validate_remote_url(url, frozenset({"pve.proxmox.com"}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"198.18.0.42",
|
||||
"::ffff:198.18.0.42",
|
||||
],
|
||||
)
|
||||
def test_validate_public_addresses_allows_proxy_fake_ip(address: str) -> None:
|
||||
validate_public_addresses((address,))
|
||||
|
||||
|
||||
async def test_remote_import_rejects_private_resolution() -> None:
|
||||
async def private_resolver(_host: str) -> tuple[str, ...]:
|
||||
return ("127.0.0.1",)
|
||||
|
||||
importer = RemoteSourceImporter(
|
||||
"https://pve.proxmox.com/apidoc.js",
|
||||
resolver=private_resolver,
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"[]")),
|
||||
)
|
||||
|
||||
with pytest.raises(SourceError, match="non-public"):
|
||||
await importer.load()
|
||||
|
||||
|
||||
async def test_redirect_is_revalidated() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(302, headers={"location": "https://evil.example/private"})
|
||||
|
||||
importer = RemoteSourceImporter(
|
||||
"https://pve.proxmox.com/apidoc.js",
|
||||
resolver=public_resolver,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
with pytest.raises(SourceError, match="allowlist"):
|
||||
await importer.load()
|
||||
|
||||
|
||||
async def test_remote_import_enforces_size_limit() -> None:
|
||||
importer = RemoteSourceImporter(
|
||||
"https://pve.proxmox.com/apidoc.js",
|
||||
max_bytes=2,
|
||||
resolver=public_resolver,
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"[]\n")),
|
||||
)
|
||||
|
||||
with pytest.raises(SourceError, match="size"):
|
||||
await importer.load()
|
||||
|
||||
|
||||
def test_revision_store_is_idempotent(tmp_path: Path) -> None:
|
||||
raw = b'[{"path":"/version","info":{}}]'
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
snapshot, manifest = normalize_snapshot(
|
||||
parsed,
|
||||
raw=raw,
|
||||
source_version="test",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
store = RevisionStore(tmp_path)
|
||||
|
||||
first = store.save(raw, snapshot, manifest)
|
||||
second = store.save(raw, snapshot, manifest)
|
||||
|
||||
assert first == second
|
||||
assert store.list() == (manifest.snapshot_sha256,)
|
||||
assert store.manifest(manifest.snapshot_sha256) == manifest
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Determinism and validation checks for normalized contracts."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.contracts.model import Snapshot, canonical_json
|
||||
from app.contracts.normalize import normalize_snapshot
|
||||
from app.contracts.source import ApiViewerParser
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
|
||||
RETRIEVED_AT = datetime(2026, 7, 12, 20, 8, 59, tzinfo=UTC)
|
||||
|
||||
|
||||
def make_snapshot() -> Snapshot:
|
||||
raw = FIXTURE.read_bytes()
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
snapshot, _ = normalize_snapshot(
|
||||
parsed, raw=raw, source_version="9.2.3", retrieved_at=RETRIEVED_AT
|
||||
)
|
||||
return snapshot
|
||||
|
||||
|
||||
def test_normalization_is_deterministic_and_round_trips() -> None:
|
||||
first = make_snapshot()
|
||||
second = make_snapshot()
|
||||
|
||||
assert first.canonical_bytes() == second.canonical_bytes()
|
||||
assert first.checksum() == second.checksum()
|
||||
assert Snapshot.model_validate_json(first.canonical_bytes()) == first
|
||||
assert first.paths[0].methods[0].checksum == second.paths[0].methods[0].checksum
|
||||
|
||||
|
||||
def test_snapshot_validates_declared_counts() -> None:
|
||||
data = make_snapshot().model_dump(mode="json")
|
||||
data["method_count"] = 99
|
||||
|
||||
with pytest.raises(ValidationError, match="method_count"):
|
||||
Snapshot.model_validate(data)
|
||||
|
||||
|
||||
def test_unknown_schema_fields_are_retained() -> None:
|
||||
raw = json.dumps(
|
||||
[
|
||||
{
|
||||
"path": "/future",
|
||||
"info": {
|
||||
"GET": {
|
||||
"name": "future",
|
||||
"returns": {"type": "string", "futureKeyword": {"x": 1}},
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
).encode()
|
||||
snapshot, _ = normalize_snapshot(
|
||||
ApiViewerParser().parse(raw),
|
||||
raw=raw,
|
||||
source_version="test",
|
||||
retrieved_at=RETRIEVED_AT,
|
||||
)
|
||||
|
||||
assert snapshot.paths[0].methods[0].returns.extra["futureKeyword"] == {"x": 1}
|
||||
|
||||
|
||||
def test_nullable_source_collections_normalize_as_empty() -> None:
|
||||
raw = (
|
||||
b'[{"path":"/nullable","info":{"GET":{"parameters":{"properties":null},'
|
||||
b'"returns":{"type":"string","enum":null}}}}]'
|
||||
)
|
||||
snapshot, _ = normalize_snapshot(
|
||||
ApiViewerParser().parse(raw),
|
||||
raw=raw,
|
||||
source_version="test",
|
||||
retrieved_at=RETRIEVED_AT,
|
||||
)
|
||||
|
||||
method = snapshot.paths[0].methods[0]
|
||||
assert method.parameters == ()
|
||||
assert method.returns.enum == ()
|
||||
|
||||
|
||||
@given(st.dictionaries(st.text(min_size=1), st.integers(), max_size=10))
|
||||
def test_canonical_json_is_independent_of_mapping_order(values: dict[str, int]) -> None:
|
||||
reversed_values = dict(reversed(tuple(values.items())))
|
||||
|
||||
assert canonical_json(values) == canonical_json(reversed_values)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Runtime contract hot-swap tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
_BUNDLED_9 = Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
|
||||
)
|
||||
_EVIDENCE_9 = Path("evidence/pve-9.2.3.json")
|
||||
|
||||
|
||||
def _app() -> FastAPI:
|
||||
settings = Settings(
|
||||
contract_snapshot=_BUNDLED_9,
|
||||
compatibility_evidence=_EVIDENCE_9,
|
||||
)
|
||||
return create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
|
||||
|
||||
async def test_contract_apply_swaps_version_and_routes() -> None:
|
||||
app = _app()
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
before = await client.get("/api2/json/version")
|
||||
assert before.status_code == 200
|
||||
assert before.json()["data"]["version"] == "9.2.3"
|
||||
assert before.json()["data"]["release"] == "9.2"
|
||||
|
||||
versions = await client.get("/ui/api/versions")
|
||||
assert versions.status_code == 200
|
||||
assert versions.json()["runtime_version"] == "9.2.3"
|
||||
|
||||
applied = await client.post("/ui/api/contract/apply", params={"major": 7})
|
||||
assert applied.status_code == 200
|
||||
payload = applied.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["major"] == 7
|
||||
assert payload["runtime_version"] == "7.4-16"
|
||||
assert payload["path_count"] > 0
|
||||
assert payload["method_count"] > 0
|
||||
|
||||
after = await client.get("/api2/json/version")
|
||||
assert after.status_code == 200
|
||||
assert after.json()["data"]["version"] == "7.4-16"
|
||||
assert after.json()["data"]["release"] == "7.4"
|
||||
|
||||
versions_after = await client.get("/ui/api/versions")
|
||||
assert versions_after.json()["runtime_version"] == "7.4-16"
|
||||
|
||||
# Still routed (handler or 501), not a missing route / 404.
|
||||
nodes = await client.get("/api2/json/nodes")
|
||||
assert nodes.status_code in {200, 401, 501}
|
||||
|
||||
restored = await client.post("/ui/api/contract/apply", params={"major": 9})
|
||||
assert restored.status_code == 200
|
||||
assert restored.json()["runtime_version"] == "9.2.3"
|
||||
assert (await client.get("/api2/json/version")).json()["data"]["version"] == "9.2.3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("major,version", [(6, "6.4-15"), (7, "7.4-16"), (8, "8.4.5")])
|
||||
async def test_contract_apply_loads_per_major_verified_evidence(major: int, version: str) -> None:
|
||||
app = _app()
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
applied = await client.post("/ui/api/contract/apply", params={"major": major})
|
||||
assert applied.status_code == 200
|
||||
assert applied.json()["runtime_version"] == version
|
||||
report = await client.get("/admin/compatibility")
|
||||
body = report.json()
|
||||
assert body["source_version"] == version
|
||||
assert body["levels"]["verified"]["count"] == body["total_declared"]
|
||||
assert body["levels"]["verified"]["count"] > 0
|
||||
|
||||
|
||||
async def test_contract_apply_requires_bootstrapped_contract() -> None:
|
||||
app = create_app(
|
||||
settings=Settings(contract_snapshot=None),
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/ui/api/contract/apply", params={"major": 7})
|
||||
assert response.status_code == 503
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for safe API Viewer source parsing."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceError
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "api-viewer" / "pve-9.2.3-version.json"
|
||||
|
||||
|
||||
def test_parse_saved_json_fixture() -> None:
|
||||
parsed = ApiViewerParser().parse(FIXTURE.read_bytes())
|
||||
|
||||
assert parsed.nodes[0]["path"] == "/version"
|
||||
assert parsed.warnings == ()
|
||||
|
||||
|
||||
def test_extract_api_schema_without_executing_trailing_javascript() -> None:
|
||||
raw = b'const apiSchema = [{"path":"/x]y","leaf":1}]; throw new Error("no");'
|
||||
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
|
||||
assert parsed.nodes[0]["path"] == "/x]y"
|
||||
|
||||
|
||||
def test_extract_legacy_pveapi_declaration() -> None:
|
||||
raw = b'var pveapi = [{"path":"/version","leaf":1}];'
|
||||
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
|
||||
assert parsed.nodes[0]["path"] == "/version"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, message",
|
||||
[
|
||||
(b"", "empty"),
|
||||
(b"const other = [];", "not found"),
|
||||
(b"const apiSchema = [", "truncated"),
|
||||
(b"const apiSchema = [}];", "invalid"),
|
||||
(b"42", "not found"),
|
||||
],
|
||||
)
|
||||
def test_reject_malformed_sources(raw: bytes, message: str) -> None:
|
||||
with pytest.raises(SourceError, match=message):
|
||||
ApiViewerParser().parse(raw)
|
||||
|
||||
|
||||
def test_preserve_unknown_fields_and_warn() -> None:
|
||||
raw = json.dumps([{"path": "/version", "future": {"enabled": True}}]).encode()
|
||||
|
||||
parsed = ApiViewerParser().parse(raw)
|
||||
|
||||
assert parsed.nodes[0]["future"] == {"enabled": True}
|
||||
assert parsed.warnings[0].code == "unknown-node-field"
|
||||
assert parsed.warnings[0].path == "/0/future"
|
||||
|
||||
|
||||
async def test_local_file_importer(tmp_path: Path) -> None:
|
||||
artifact = tmp_path / "api.json"
|
||||
artifact.write_bytes(b"[]")
|
||||
|
||||
assert await LocalFileImporter(artifact).load() == b"[]"
|
||||
@@ -0,0 +1,202 @@
|
||||
"""First vertical read/login handler tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
||||
from app.main import create_app
|
||||
from app.security.auth import hash_secret
|
||||
from app.tasks.repository import Task
|
||||
|
||||
|
||||
class FakePool:
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
if "principals" in sql and args[0] == "root@pam":
|
||||
return {
|
||||
"name": "root@pam",
|
||||
"password_hash": hash_secret("secret", salt=b"pve-simulator-v1"),
|
||||
}
|
||||
if "FROM nodes" in sql and args[0] == "pve1":
|
||||
return {"name": "pve1", "status": "online"}
|
||||
if "FROM resources r" in sql and args == ("pve1", "100"):
|
||||
if "SELECT r.id" in sql:
|
||||
return {
|
||||
"id": uuid.UUID("00000000-0000-0000-0000-000000000100"),
|
||||
"state": '{"name":"demo","status":"stopped"}',
|
||||
}
|
||||
return {
|
||||
"config": '{"name":"demo"}',
|
||||
"state": '{"name":"demo","status":"stopped"}',
|
||||
}
|
||||
return None
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM nodes" in sql:
|
||||
return [{"node": "pve1", "status": "online"}]
|
||||
if "r.kind='qemu'" in sql:
|
||||
return [{"vmid": 100, "state": '{"name":"demo","status":"stopped"}'}]
|
||||
return [
|
||||
{
|
||||
"type": "qemu",
|
||||
"external_id": "100",
|
||||
"state": '{"status":"stopped"}',
|
||||
"node": "pve1",
|
||||
}
|
||||
]
|
||||
|
||||
async def fetchval(self, sql: str) -> int:
|
||||
return 100 if "pg_backend_pid" in sql else 1_700_000_000
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
pool = FakePool()
|
||||
|
||||
async def connect(self) -> None:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def method(verb: str, name: str, parameters: tuple[Parameter, ...] = ()) -> Method:
|
||||
return Method(
|
||||
verb=verb,
|
||||
name=name,
|
||||
parameters=parameters,
|
||||
returns=Schema(type="object"),
|
||||
checksum=(name[0] * 64),
|
||||
)
|
||||
|
||||
|
||||
def write_snapshot(path: Path) -> None:
|
||||
string = Schema(type="string")
|
||||
paths = (
|
||||
PathContract(path="/version", methods=(method("GET", "version"),)),
|
||||
PathContract(
|
||||
path="/access/ticket",
|
||||
methods=(
|
||||
method(
|
||||
"POST",
|
||||
"ticket",
|
||||
(
|
||||
Parameter(name="username", definition=string),
|
||||
Parameter(name="password", definition=string),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PathContract(path="/nodes", methods=(method("GET", "nodes"),)),
|
||||
PathContract(
|
||||
path="/nodes/{node}/status",
|
||||
methods=(method("GET", "status", (Parameter(name="node", definition=string),)),),
|
||||
),
|
||||
PathContract(path="/cluster/resources", methods=(method("GET", "resources"),)),
|
||||
PathContract(
|
||||
path="/nodes/{node}/qemu",
|
||||
methods=(method("GET", "qemu", (Parameter(name="node", definition=string),)),),
|
||||
),
|
||||
PathContract(
|
||||
path="/nodes/{node}/qemu/{vmid}/config",
|
||||
methods=(
|
||||
method(
|
||||
"GET",
|
||||
"config",
|
||||
(
|
||||
Parameter(name="node", definition=string),
|
||||
Parameter(name="vmid", definition=Schema(type="integer")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PathContract(
|
||||
path="/nodes/{node}/qemu/{vmid}/status/start",
|
||||
methods=(
|
||||
method(
|
||||
"POST",
|
||||
"start",
|
||||
(
|
||||
Parameter(name="node", definition=string),
|
||||
Parameter(name="vmid", definition=Schema(type="integer")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
snapshot = Snapshot(
|
||||
source_version="test",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="0" * 64,
|
||||
paths=paths,
|
||||
path_count=len(paths),
|
||||
method_count=sum(len(item.methods) for item in paths),
|
||||
)
|
||||
path.write_bytes(snapshot.canonical_bytes())
|
||||
|
||||
|
||||
async def test_core_login_and_read_endpoints(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: object) -> Task:
|
||||
return Task(
|
||||
uuid.uuid4(),
|
||||
str(kwargs["upid"]),
|
||||
str(kwargs["task_type"]),
|
||||
"queued",
|
||||
{},
|
||||
0,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
snapshot_path = tmp_path / "snapshot.json"
|
||||
write_snapshot(snapshot_path)
|
||||
database = FakeDatabase()
|
||||
app = create_app(
|
||||
Settings(contract_snapshot=snapshot_path, compatibility_evidence=None),
|
||||
lambda _settings: database,
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
login = await client.post(
|
||||
"/api2/json/access/ticket",
|
||||
content="username=root%40pam&password=secret",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
csrf = login.json()["data"]["CSRFPreventionToken"]
|
||||
version = await client.get("/api2/json/version")
|
||||
nodes = await client.get("/api2/json/nodes")
|
||||
status = await client.get("/api2/json/nodes/pve1/status")
|
||||
resources = await client.get("/api2/json/cluster/resources")
|
||||
qemu = await client.get("/api2/json/nodes/pve1/qemu")
|
||||
config = await client.get("/api2/json/nodes/pve1/qemu/100/config")
|
||||
start = await client.post(
|
||||
"/api2/json/nodes/pve1/qemu/100/status/start",
|
||||
headers={"CSRFPreventionToken": csrf},
|
||||
)
|
||||
|
||||
assert login.status_code == 200
|
||||
assert login.json()["data"]["username"] == "root@pam"
|
||||
assert "ticket" in login.json()["data"]
|
||||
assert version.json()["data"]["version"] == "test"
|
||||
assert version.json()["data"]["release"] == "test"
|
||||
assert nodes.json()["data"][0]["node"] == "pve1"
|
||||
assert status.json()["data"]["status"] == "online"
|
||||
assert resources.json()["data"][0]["type"] == "qemu"
|
||||
assert qemu.json()["data"][0]["vmid"] == 100
|
||||
assert config.json()["data"]["name"] == "demo"
|
||||
assert start.json()["data"].startswith("UPID:pve1:")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Database primitive behavior independent of PostgreSQL."""
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
import pytest
|
||||
|
||||
from app.db.primitives import (
|
||||
ConflictError,
|
||||
DatabaseOperationError,
|
||||
ReferenceError,
|
||||
RetryPolicy,
|
||||
TransientDatabaseError,
|
||||
map_database_error,
|
||||
require_affected,
|
||||
retry_transient,
|
||||
)
|
||||
|
||||
|
||||
def test_error_mapping_is_stable_and_safe() -> None:
|
||||
assert isinstance(map_database_error(asyncpg.UniqueViolationError("secret")), ConflictError)
|
||||
assert isinstance(
|
||||
map_database_error(asyncpg.ForeignKeyViolationError("secret")), ReferenceError
|
||||
)
|
||||
assert isinstance(
|
||||
map_database_error(asyncpg.SerializationError("secret")), TransientDatabaseError
|
||||
)
|
||||
assert "secret" not in str(map_database_error(asyncpg.PostgresError("secret")))
|
||||
|
||||
|
||||
def test_affected_row_checks() -> None:
|
||||
require_affected("UPDATE 1")
|
||||
with pytest.raises(DatabaseOperationError, match="expected 1"):
|
||||
require_affected("UPDATE 0")
|
||||
with pytest.raises(DatabaseOperationError, match="unrecognized"):
|
||||
require_affected("BROKEN")
|
||||
|
||||
|
||||
async def test_transient_retry_is_bounded() -> None:
|
||||
calls = 0
|
||||
|
||||
async def operation() -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls < 3:
|
||||
raise TransientDatabaseError("retry")
|
||||
return "ok"
|
||||
|
||||
assert await retry_transient(operation, RetryPolicy(attempts=3, base_delay_seconds=0)) == "ok"
|
||||
assert calls == 3
|
||||
|
||||
|
||||
async def test_transient_retry_propagates_final_failure() -> None:
|
||||
async def operation() -> None:
|
||||
raise TransientDatabaseError("retry")
|
||||
|
||||
with pytest.raises(TransientDatabaseError):
|
||||
await retry_transient(operation, RetryPolicy(attempts=2, base_delay_seconds=0))
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await retry_transient(operation, RetryPolicy(attempts=0))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Contract-driven route registry tests."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api.registry import HandlerRegistry, RouteCollisionError
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
from app.main import create_app
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
|
||||
def contract_snapshot(*methods: Method) -> Snapshot:
|
||||
paths = (PathContract(path="/version", methods=methods),)
|
||||
return Snapshot(
|
||||
source_version="test",
|
||||
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
raw_sha256="0" * 64,
|
||||
paths=paths,
|
||||
path_count=1,
|
||||
method_count=len(methods),
|
||||
)
|
||||
|
||||
|
||||
def get_method() -> Method:
|
||||
return Method(
|
||||
verb="GET",
|
||||
name="version",
|
||||
returns=Schema(type="object", properties={"version": Schema(type="string")}),
|
||||
checksum="1" * 64,
|
||||
)
|
||||
|
||||
|
||||
async def request_app(
|
||||
tmp_path: Path, fallback: str, handlers: HandlerRegistry | None = None
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
snapshot_path = tmp_path / "snapshot.json"
|
||||
snapshot_path.write_bytes(contract_snapshot(get_method()).canonical_bytes())
|
||||
settings = Settings(
|
||||
contract_snapshot=snapshot_path,
|
||||
contract_fallback=fallback,
|
||||
compatibility_evidence=None,
|
||||
)
|
||||
database = FakeDatabase(True)
|
||||
app = create_app(
|
||||
settings,
|
||||
lambda _settings: database,
|
||||
handlers if handlers is not None else HandlerRegistry(),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
json_response = await client.get("/api2/json/version")
|
||||
extjs_response = await client.get("/api2/extjs/version")
|
||||
return json_response.json(), extjs_response.json()
|
||||
|
||||
|
||||
async def test_registered_handler_serves_both_renderers(tmp_path: Path) -> None:
|
||||
handlers = HandlerRegistry()
|
||||
|
||||
async def version(_request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
|
||||
return {"version": "9.2.3"}
|
||||
|
||||
handlers.register("/version", "GET", version)
|
||||
|
||||
json_body, extjs_body = await request_app(tmp_path, "error", handlers)
|
||||
|
||||
assert json_body == {"data": {"version": "9.2.3"}}
|
||||
assert extjs_body == {"data": {"version": "9.2.3"}, "success": True}
|
||||
|
||||
|
||||
async def test_explicit_fallback_modes(tmp_path: Path) -> None:
|
||||
error_body, _ = await request_app(tmp_path, "error")
|
||||
default_body, _ = await request_app(tmp_path, "schema-default")
|
||||
|
||||
assert error_body["errors"] == "handler pending for this contract method"
|
||||
assert default_body["data"]["version"] in {None, "example"}
|
||||
|
||||
|
||||
def test_duplicate_snapshot_routes_are_rejected() -> None:
|
||||
with pytest.raises(ValidationError, match="duplicate"):
|
||||
contract_snapshot(get_method(), get_method())
|
||||
|
||||
|
||||
def test_duplicate_semantic_handlers_are_rejected() -> None:
|
||||
handlers = HandlerRegistry()
|
||||
|
||||
async def handler(_request: Request, _inputs: dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
handlers.register("/version", "GET", handler)
|
||||
with pytest.raises(RouteCollisionError, match="duplicate"):
|
||||
handlers.register("/version", "GET", handler)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for cluster, storage, pool and ceph handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.ceph import register_ceph_handlers
|
||||
from app.handlers.cluster import register_cluster_handlers
|
||||
from app.handlers.pools import register_pool_handlers
|
||||
from app.handlers.storage import register_storage_handlers
|
||||
|
||||
|
||||
class HandlerPool:
|
||||
def __init__(self) -> None:
|
||||
self.node_exists = True
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM nodes" in sql and "ORDER BY name" in sql:
|
||||
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
|
||||
if "FROM storages" in sql and "DISTINCT storage_id" in sql:
|
||||
return [{"storage_id": "local-lvm-pve01"}]
|
||||
if "FROM storages s" in sql:
|
||||
return [
|
||||
{
|
||||
"storage_id": "local-lvm-pve01",
|
||||
"storage_type": "lvmthin",
|
||||
"shared": False,
|
||||
"capacity_bytes": 1_000_000,
|
||||
"used_bytes": 250_000,
|
||||
"config": '{"content":["images"]}',
|
||||
}
|
||||
]
|
||||
if "ceph-osd" in sql:
|
||||
return [
|
||||
{
|
||||
"external_id": "osd.0",
|
||||
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0}',
|
||||
}
|
||||
]
|
||||
if "FROM pools" in sql:
|
||||
return [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"pool_id": "production",
|
||||
"comment": "prod",
|
||||
"metadata": '{"members":["100"]}',
|
||||
}
|
||||
]
|
||||
if "FROM pool_members" in sql:
|
||||
return [{"external_id": "100"}]
|
||||
if "FROM task_logs" in sql:
|
||||
return [{"message": "seeded task", "sequence": 1}]
|
||||
if "FROM tasks" in sql:
|
||||
return [{"upid": "UPID:pve01:1:1:1:qmstart:100:root@pam:"}]
|
||||
if "FROM storage_contents" in sql or "FROM backups" in sql:
|
||||
return []
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if "FROM nodes WHERE name" in sql:
|
||||
return {"name": "pve01", "status": "online"} if self.node_exists else None
|
||||
if "FROM clusters" in sql:
|
||||
return {"metadata": '{"options":{"keyboard":"de-ch"}}'}
|
||||
if "FROM storages" in sql:
|
||||
return {
|
||||
"storage_id": "local-lvm-pve01",
|
||||
"storage_type": "lvmthin",
|
||||
"shared": False,
|
||||
"capacity_bytes": 1_000_000,
|
||||
"used_bytes": 250_000,
|
||||
"config": '{"content":["images"]}',
|
||||
"node_name": "pve01",
|
||||
"resource_id": uuid.uuid4(),
|
||||
}
|
||||
if "ceph-osd" in sql:
|
||||
return {
|
||||
"external_id": "osd.0",
|
||||
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0,"size_bytes":1000}',
|
||||
}
|
||||
if "storage_type='ceph'" in sql:
|
||||
return {"capacity_bytes": 5_000_000, "used_bytes": 3_000_000}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> Any:
|
||||
del args
|
||||
if "EXISTS(SELECT 1 FROM nodes" in sql:
|
||||
return self.node_exists
|
||||
if "MAX(external_id::integer)" in sql:
|
||||
return 150
|
||||
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in sql:
|
||||
return 300
|
||||
if "SELECT resource_id FROM storages" in sql:
|
||||
return uuid.uuid4()
|
||||
return False
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del sql, args
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
def _request(pool: HandlerPool) -> Request:
|
||||
app = type("App", (), {})()
|
||||
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"client": ("test", 1234),
|
||||
"server": ("test", 80),
|
||||
"scheme": "http",
|
||||
"root_path": "",
|
||||
"app": app,
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state.principal = "root@pam"
|
||||
return request
|
||||
|
||||
|
||||
async def _call(handler: Any, values: dict[str, Any], pool: HandlerPool | None = None) -> Any:
|
||||
return await handler(_request(pool or HandlerPool()), {"values": values})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_status_and_nextid() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_handlers(registry)
|
||||
status = await _call(registry.get("/cluster/status", "GET"), {})
|
||||
assert status[0]["name"] == "pve01"
|
||||
nextid = await _call(registry.get("/cluster/nextid", "GET"), {})
|
||||
assert nextid == 151
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_and_ceph_handlers() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_storage_handlers(registry)
|
||||
register_ceph_handlers(registry)
|
||||
storage = await _call(
|
||||
registry.get("/nodes/{node}/storage", "GET"),
|
||||
{"node": "pve01"},
|
||||
)
|
||||
assert storage[0]["storage"] == "local-lvm-pve01"
|
||||
osds = await _call(
|
||||
registry.get("/nodes/{node}/ceph/osd", "GET"),
|
||||
{"node": "pve01"},
|
||||
)
|
||||
assert osds[0]["status"] == "up"
|
||||
ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {})
|
||||
assert ceph_status["osdmap"]["num_osds"] == 300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pools_list() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_pool_handlers(registry)
|
||||
pools = await _call(registry.get("/pools", "GET"), {})
|
||||
assert pools[0]["poolid"] == "production"
|
||||
assert pools[0]["members"] == ["100"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_node_returns_404() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_storage_handlers(registry)
|
||||
pool = HandlerPool()
|
||||
pool.node_exists = False
|
||||
handler = registry.get("/nodes/{node}/storage", "GET")
|
||||
assert handler is not None
|
||||
with pytest.raises(ApiError, match="node does not exist"):
|
||||
await handler(_request(pool), {"values": {"node": "missing"}})
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Firewall aliases/ipset/group persistence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.firewall import register_firewall_handlers
|
||||
from app.simulation.seed import CLUSTER_ID
|
||||
|
||||
|
||||
class FirewallPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return True
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "jsonb_set" in query:
|
||||
# args: CLUSTER_ID, firewall json
|
||||
self.metadata["firewall"] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
def request(pool: FirewallPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
async def test_firewall_alias_and_ipset_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_firewall_handlers(registry)
|
||||
pool = FirewallPool()
|
||||
http = request(pool)
|
||||
create_alias = registry.get("/cluster/firewall/aliases", "POST")
|
||||
list_alias = registry.get("/cluster/firewall/aliases", "GET")
|
||||
create_ipset = registry.get("/cluster/firewall/ipset", "POST")
|
||||
add_ip = registry.get("/cluster/firewall/ipset/{name}", "POST")
|
||||
get_ipset = registry.get("/cluster/firewall/ipset/{name}", "GET")
|
||||
assert create_alias and list_alias and create_ipset and add_ip and get_ipset
|
||||
|
||||
await create_alias(
|
||||
http, {"values": {"name": "lan", "cidr": "10.0.0.0/8"}, "provided": frozenset()}
|
||||
)
|
||||
aliases = await list_alias(http, {"values": {}, "provided": frozenset()})
|
||||
assert aliases[0]["name"] == "lan"
|
||||
await create_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
|
||||
await add_ip(
|
||||
http,
|
||||
{"values": {"name": "blacklist", "cidr": "203.0.113.10"}, "provided": frozenset()},
|
||||
)
|
||||
entries = await get_ipset(http, {"values": {"name": "blacklist"}, "provided": frozenset()})
|
||||
assert entries[0]["cidr"] == "203.0.113.10"
|
||||
assert "scopes" in pool.metadata["firewall"]
|
||||
assert CLUSTER_ID
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Tests for gap-plan handler implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.access import register_access_handlers
|
||||
from app.handlers.cluster import register_cluster_handlers
|
||||
from app.handlers.ha import register_ha_handlers
|
||||
from app.handlers.storage import register_storage_handlers
|
||||
|
||||
|
||||
class GapPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {
|
||||
"options": {"keyboard": "en-us"},
|
||||
"replication": [],
|
||||
"ha_groups": {},
|
||||
}
|
||||
self.node_metadata: dict[str, Any] = {}
|
||||
self.node_exists = True
|
||||
self.storage_resource_id = uuid.uuid4()
|
||||
self.storage_contents: list[dict[str, object]] = []
|
||||
self.principals = {"root@pam": {"enabled": True, "realm": "pam"}}
|
||||
self.groups = {"operators": {"comment": "ops", "users": ["root@pam"]}}
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM nodes" in sql and "ORDER BY name" in sql:
|
||||
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
|
||||
if "FROM tasks" in sql:
|
||||
return []
|
||||
if "FROM task_logs" in sql:
|
||||
return []
|
||||
if "FROM resources r JOIN nodes" in sql and "kind='ha'" in sql:
|
||||
return []
|
||||
if "FROM storage_contents" in sql and "ORDER BY" in sql:
|
||||
return list(self.storage_contents)
|
||||
if "FROM backups" in sql and "ORDER BY created_at DESC" in sql and "OFFSET" not in sql:
|
||||
return []
|
||||
if "FROM principals p" in sql and "ORDER BY p.name" in sql:
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"realm_name": data["realm"],
|
||||
"enabled": data["enabled"],
|
||||
"realm_kind": data["realm"],
|
||||
}
|
||||
for name, data in self.principals.items()
|
||||
]
|
||||
if "FROM identity_groups g" in sql and "GROUP BY" in sql:
|
||||
return [
|
||||
{
|
||||
"group_id": group_id,
|
||||
"comment": data["comment"],
|
||||
"users": data["users"],
|
||||
}
|
||||
for group_id, data in self.groups.items()
|
||||
]
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
if "FROM clusters WHERE id" in sql:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
if "FROM nodes WHERE name" in sql and "metadata" in sql:
|
||||
name = str(args[0])
|
||||
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
|
||||
if "FROM nodes WHERE name" in sql:
|
||||
return {"name": "pve01", "id": uuid.uuid4()} if self.node_exists else None
|
||||
if "FROM storages WHERE storage_id" in sql and "resource_id" in sql:
|
||||
return {"resource_id": self.storage_resource_id}
|
||||
if "FROM storages s" in sql and "JOIN" in sql:
|
||||
return {
|
||||
"storage_id": "local-lvm",
|
||||
"storage_type": "lvmthin",
|
||||
"shared": False,
|
||||
"capacity_bytes": 1_000_000,
|
||||
"used_bytes": 250_000,
|
||||
"config": '{"content":["images"]}',
|
||||
"node_name": "pve01",
|
||||
"resource_id": self.storage_resource_id,
|
||||
}
|
||||
if "FROM storage_contents" in sql and "volume_id=$2" in sql:
|
||||
volume = str(args[1])
|
||||
for item in self.storage_contents:
|
||||
if item["volume_id"] == volume:
|
||||
return item
|
||||
return {
|
||||
"volume_id": "local-lvm:100/vm-100-disk-0.raw",
|
||||
"content_type": "images",
|
||||
"size_bytes": 1024,
|
||||
"metadata": '{"format":"raw"}',
|
||||
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
|
||||
}
|
||||
if "FROM principals" in sql and "WHERE" in sql and "name" in sql:
|
||||
userid = str(args[0])
|
||||
if userid not in self.principals:
|
||||
return None
|
||||
data = self.principals[userid]
|
||||
return {
|
||||
"name": userid,
|
||||
"realm_name": data["realm"],
|
||||
"enabled": data["enabled"],
|
||||
"realm_kind": data["realm"],
|
||||
"id": uuid.uuid4(),
|
||||
}
|
||||
if "FROM identity_groups WHERE group_id" in sql:
|
||||
groupid = str(args[0])
|
||||
if groupid not in self.groups:
|
||||
return None
|
||||
return {"id": uuid.uuid4(), "group_id": groupid}
|
||||
if "FROM identity_groups g" in sql and "WHERE g.group_id" in sql:
|
||||
groupid = str(args[0])
|
||||
if groupid not in self.groups:
|
||||
return None
|
||||
group_data = self.groups[groupid]
|
||||
return {
|
||||
"group_id": groupid,
|
||||
"comment": group_data["comment"],
|
||||
"users": group_data["users"],
|
||||
}
|
||||
if "count(*) FILTER" in sql and "kind='ha'" in sql:
|
||||
return {"started": 0, "total": 0}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in sql:
|
||||
return self.node_exists
|
||||
if "MAX(external_id::integer)" in sql:
|
||||
return 150
|
||||
if "SELECT resource_id FROM storages" in sql:
|
||||
return self.storage_resource_id
|
||||
if "EXISTS(SELECT 1 FROM principals" in sql:
|
||||
return False
|
||||
if "EXISTS(SELECT 1 FROM realms" in sql:
|
||||
return True
|
||||
if "EXISTS(SELECT 1 FROM identity_groups" in sql:
|
||||
return False
|
||||
if "EXISTS(SELECT 1 FROM resources WHERE kind='ha'" in sql:
|
||||
return False
|
||||
if "SELECT metadata FROM nodes" in sql:
|
||||
return json.dumps(self.node_metadata.get(str(args[0]), {}))
|
||||
if "SELECT name FROM nodes WHERE status" in sql:
|
||||
return "pve01"
|
||||
return False
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in sql:
|
||||
self.metadata = json.loads(str(args[1]))
|
||||
return "UPDATE 1"
|
||||
if "UPDATE nodes SET metadata" in sql:
|
||||
self.node_metadata[str(args[0])] = json.loads(str(args[1]))
|
||||
return "UPDATE 1"
|
||||
if "INSERT INTO storage_contents" in sql:
|
||||
self.storage_contents.append(
|
||||
{
|
||||
"volume_id": str(args[1]),
|
||||
"content_type": str(args[2]),
|
||||
"size_bytes": int(str(args[3])),
|
||||
"metadata": str(args[4]),
|
||||
"created_at": type("TS", (), {"timestamp": lambda self: 1_700_000_000})(),
|
||||
}
|
||||
)
|
||||
return "INSERT 0 1"
|
||||
if "INSERT INTO resources" in sql and "kind='ha'" in sql:
|
||||
return "INSERT 0 1"
|
||||
if "DELETE FROM" in sql:
|
||||
return "DELETE 1"
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
def _request(pool: GapPool) -> Request:
|
||||
app = type("App", (), {})()
|
||||
app.state = type("State", (), {"database": type("DB", (), {"pool": pool})()})()
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"client": ("test", 1234),
|
||||
"server": ("test", 80),
|
||||
"scheme": "http",
|
||||
"root_path": "",
|
||||
"app": app,
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state.principal = "root@pam"
|
||||
return request
|
||||
|
||||
|
||||
async def _call(handler: Any, values: dict[str, Any], pool: GapPool | None = None) -> Any:
|
||||
return await handler(
|
||||
_request(pool or GapPool()),
|
||||
{"values": values, "provided": tuple(values)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_index_and_replication_crud() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_handlers(registry)
|
||||
pool = GapPool()
|
||||
index = await _call(registry.get("/cluster", "GET"), {}, pool)
|
||||
assert any(item["subdir"] == "replication" for item in index)
|
||||
created = await _call(
|
||||
registry.get("/cluster/replication", "POST"),
|
||||
{"guest": "100", "target": "pve02"},
|
||||
pool,
|
||||
)
|
||||
assert created["id"] == "repl-100"
|
||||
jobs = await _call(registry.get("/cluster/replication", "GET"), {}, pool)
|
||||
assert jobs[0]["guest"] == "100"
|
||||
fetched = await _call(
|
||||
registry.get("/cluster/replication/{id}", "GET"), {"id": "repl-100"}, pool
|
||||
)
|
||||
assert fetched["target"] == "pve02"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ha_group_create_and_index() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_ha_handlers(registry)
|
||||
pool = GapPool()
|
||||
index = await _call(registry.get("/cluster/ha", "GET"), {}, pool)
|
||||
assert any(item["subdir"] == "groups" for item in index)
|
||||
await _call(
|
||||
registry.get("/cluster/ha/groups", "POST"),
|
||||
{"group": "lab", "nodes": "pve01,pve02"},
|
||||
pool,
|
||||
)
|
||||
assert "lab" in pool.metadata["ha_groups"]
|
||||
groups = await _call(registry.get("/cluster/ha/groups", "GET"), {}, pool)
|
||||
assert groups[0]["group"] == "lab"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_user_and_group_detail() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_access_handlers(registry)
|
||||
pool = GapPool()
|
||||
user = await _call(registry.get("/access/users/{userid}", "GET"), {"userid": "root@pam"}, pool)
|
||||
assert user["userid"] == "root@pam"
|
||||
group = await _call(
|
||||
registry.get("/access/groups/{groupid}", "GET"),
|
||||
{"groupid": "operators"},
|
||||
pool,
|
||||
)
|
||||
assert group["users"] == ["root@pam"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_content_get_and_upload() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_storage_handlers(registry)
|
||||
pool = GapPool()
|
||||
item = await _call(
|
||||
registry.get("/nodes/{node}/storage/{storage}/content/{volume}", "GET"),
|
||||
{
|
||||
"node": "pve01",
|
||||
"storage": "local-lvm",
|
||||
"volume": "local-lvm:100/vm-100-disk-0.raw",
|
||||
},
|
||||
pool,
|
||||
)
|
||||
assert item["content"] == "images"
|
||||
upload = await _call(
|
||||
registry.get("/nodes/{node}/storage/{storage}/upload", "POST"),
|
||||
{"node": "pve01", "storage": "local-lvm", "filename": "image.iso"},
|
||||
pool,
|
||||
)
|
||||
assert "uploadid" in upload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replication_missing_returns_404() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_handlers(registry)
|
||||
handler = registry.get("/cluster/replication/{id}", "GET")
|
||||
with pytest.raises(ApiError, match="replication job does not exist"):
|
||||
await _call(handler, {"id": "missing"}, GapPool())
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Persistence tests for remaining gap handlers (nodes_extra / cluster_extra)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.cluster_extra import register_cluster_extra_handlers
|
||||
from app.handlers.nodes_extra import register_nodes_extra_handlers
|
||||
|
||||
|
||||
class GapRemainingPool:
|
||||
def __init__(self) -> None:
|
||||
self.cluster_metadata: dict[str, Any] = {}
|
||||
self.node_metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "SELECT metadata FROM clusters" in query:
|
||||
return {"metadata": json.dumps(self.cluster_metadata)}
|
||||
if "SELECT metadata FROM nodes" in query:
|
||||
name = str(arguments[0])
|
||||
return {"metadata": json.dumps(self.node_metadata.get(name, {}))}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return True
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in query:
|
||||
self.cluster_metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
if "UPDATE nodes SET metadata" in query:
|
||||
self.node_metadata[str(arguments[0])] = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: GapRemainingPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def _request(pool: GapRemainingPool, *, method: str = "GET") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": method,
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disks_directory_create_persists() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_nodes_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/nodes/{node}/disks/directory", "POST")
|
||||
assert create is not None
|
||||
created = await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {"node": "pve01", "name": "tank", "device": "/dev/sdb"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
assert created["name"] == "tank"
|
||||
ops = pool.node_metadata["pve01"]["ops"]
|
||||
assert any(item["name"] == "tank" for item in ops["disks"]["directory"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_certificates_custom_create_does_not_echo_key() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_nodes_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/nodes/{node}/certificates/custom", "POST")
|
||||
info = registry.get("/nodes/{node}/certificates/info", "GET")
|
||||
assert create is not None and info is not None
|
||||
await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {
|
||||
"node": "pve01",
|
||||
"certificates": "-----BEGIN CERTIFICATE-----\nSIM\n-----END CERTIFICATE-----",
|
||||
"key": "-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
stored = pool.node_metadata["pve01"]["ops"]["certificates"]["custom"]
|
||||
assert stored["key"].startswith("-----BEGIN PRIVATE KEY-----")
|
||||
listing = await info(
|
||||
_request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
blob = json.dumps(listing)
|
||||
assert "PRIVATE KEY" not in blob
|
||||
assert "SECRET" not in blob
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realm_sync_job_create_persists() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/cluster/jobs/realm-sync/{id}", "POST")
|
||||
listing = registry.get("/cluster/jobs/realm-sync", "GET")
|
||||
assert create is not None and listing is not None
|
||||
created = await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {"id": "pam-nightly", "realm": "pam", "schedule": "0 2 * * *"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
assert created["id"] == "pam-nightly"
|
||||
assert pool.cluster_metadata["jobs"]["realm_sync"]["pam-nightly"]["realm"] == "pam"
|
||||
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
|
||||
assert items[0]["id"] == "pam-nightly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_server_create_persists() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_cluster_extra_handlers(registry)
|
||||
pool = GapRemainingPool()
|
||||
create = registry.get("/cluster/metrics/server/{id}", "POST")
|
||||
listing = registry.get("/cluster/metrics/server", "GET")
|
||||
assert create is not None and listing is not None
|
||||
created = await create(
|
||||
_request(pool, method="POST"),
|
||||
{
|
||||
"values": {
|
||||
"id": "influx1",
|
||||
"type": "influxdb",
|
||||
"server": "10.0.0.20",
|
||||
"port": 8089,
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
assert created["id"] == "influx1"
|
||||
assert pool.cluster_metadata["metrics"]["servers"]["influx1"]["server"] == "10.0.0.20"
|
||||
items = await listing(_request(pool), {"values": {}, "provided": frozenset()})
|
||||
assert items[0]["id"] == "influx1"
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Self
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.pool import Database
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, ready: bool) -> None:
|
||||
self.ready = ready
|
||||
self.connected = False
|
||||
self.closed = False
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.connected = True
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
return self.ready
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("database_ready", "status_code"), [(True, 200), (False, 503)])
|
||||
async def test_health_endpoints(database_ready: bool, status_code: int) -> None:
|
||||
database = FakeDatabase(database_ready)
|
||||
|
||||
def factory(settings: Settings) -> Database:
|
||||
del settings
|
||||
return database
|
||||
|
||||
application = create_app(
|
||||
Settings(contract_snapshot=None, compatibility_evidence=None),
|
||||
factory,
|
||||
worker_factories=(),
|
||||
)
|
||||
async with application.router.lifespan_context(application):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
live = await client.get("/health/live")
|
||||
ready = await client.get("/health/ready", headers={"X-Request-ID": "test-request"})
|
||||
|
||||
assert live.status_code == 200
|
||||
assert live.json() == {"status": "ok"}
|
||||
assert ready.status_code == status_code
|
||||
assert ready.headers["X-Request-ID"] == "test-request"
|
||||
assert database.connected
|
||||
assert database.closed
|
||||
|
||||
|
||||
async def test_lifespan_starts_and_stops_injected_workers() -> None:
|
||||
database = FakeDatabase(True)
|
||||
started = asyncio.Event()
|
||||
stopping = asyncio.Event()
|
||||
|
||||
class Worker:
|
||||
async def run(self) -> None:
|
||||
started.set()
|
||||
await stopping.wait()
|
||||
|
||||
def stop(self) -> None:
|
||||
stopping.set()
|
||||
|
||||
application = create_app(
|
||||
Settings(contract_snapshot=None, compatibility_evidence=None),
|
||||
lambda _settings: database,
|
||||
worker_factories=(lambda _database: Worker(),),
|
||||
)
|
||||
async with application.router.lifespan_context(application):
|
||||
await started.wait()
|
||||
|
||||
assert stopping.is_set()
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.logging import JsonFormatter
|
||||
|
||||
|
||||
def test_json_formatter_emits_structured_fields() -> None:
|
||||
record = logging.LogRecord("test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
|
||||
record.request_id = "request-1"
|
||||
|
||||
payload = json.loads(JsonFormatter().format(record))
|
||||
|
||||
assert payload["message"] == "hello world"
|
||||
assert payload["request_id"] == "request-1"
|
||||
assert payload["level"] == "INFO"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Persistent LXC semantic handler tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.handlers.lxc import register_lxc_handlers
|
||||
|
||||
|
||||
class LxcPool:
|
||||
def __init__(self) -> None:
|
||||
self.resource_exists = False
|
||||
self.missing = False
|
||||
self.running = False
|
||||
self.commands: list[str] = []
|
||||
self.resource_id = uuid.uuid4()
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> bool | int:
|
||||
del args
|
||||
if "pg_backend_pid" in sql:
|
||||
return 123
|
||||
if "extract(epoch" in sql:
|
||||
return 1_700_000_000
|
||||
if "FROM nodes" in sql:
|
||||
return True
|
||||
if "FROM resources" in sql:
|
||||
return self.resource_exists
|
||||
if "FROM snapshots" in sql:
|
||||
return False
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM resources" in sql and "kind='lxc'" in sql:
|
||||
return [{"vmid": 200, "state": '{"status":"stopped","name":"service"}'}]
|
||||
assert "FROM snapshots" in sql
|
||||
return [
|
||||
{
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
]
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if self.missing:
|
||||
return None
|
||||
if "SELECT r.id, r.version" in sql:
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"version": 1,
|
||||
"state": '{"name":"old","status":"stopped"}',
|
||||
"config": '{"name":"old"}',
|
||||
}
|
||||
if "SELECT r.id, r.state, c.config" in sql:
|
||||
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
||||
if "SELECT r.id, r.state FROM resources" in sql:
|
||||
status = "running" if self.running else "stopped"
|
||||
return {"id": self.resource_id, "state": f'{{"status":"{status}"}}'}
|
||||
if "SELECT s.* FROM snapshots" in sql:
|
||||
return {
|
||||
"id": uuid.uuid4(),
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
"state": "{}",
|
||||
}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del sql, args
|
||||
self.commands.append("execute")
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: LxcPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: LxcPool) -> None:
|
||||
self.pool = pool
|
||||
self.created: list[dict[str, Any]] = []
|
||||
|
||||
async def create(self, **kwargs: Any) -> Any:
|
||||
self.created.append(kwargs)
|
||||
return type(
|
||||
"Task", (), {"upid": "UPID:pve1:00000001:00000001:1700000000:pctcreate:201:root@pam:"}
|
||||
)()
|
||||
|
||||
|
||||
def _request(pool: LxcPool) -> Request:
|
||||
app = type("App", (), {"state": type("State", (), {"database": FakeDatabase(pool)})()})()
|
||||
request = Request({"type": "http", "headers": [], "method": "POST", "path": "/"})
|
||||
request.scope["app"] = app
|
||||
request.state.principal = "root@pam"
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> HandlerRegistry:
|
||||
handler_registry = HandlerRegistry()
|
||||
register_lxc_handlers(handler_registry)
|
||||
return handler_registry
|
||||
|
||||
|
||||
async def test_lxc_list_returns_seeded_containers(registry: HandlerRegistry) -> None:
|
||||
pool = LxcPool()
|
||||
handler = registry.get("/nodes/{node}/lxc", "GET")
|
||||
assert handler is not None
|
||||
result = await handler(_request(pool), {"values": {"node": "pve1"}})
|
||||
assert result == [{"vmid": 200, "status": "stopped", "name": "service"}]
|
||||
|
||||
|
||||
async def test_lxc_create_rejects_duplicate_vmid(registry: HandlerRegistry) -> None:
|
||||
pool = LxcPool()
|
||||
pool.resource_exists = True
|
||||
handler = registry.get("/nodes/{node}/lxc", "POST")
|
||||
assert handler is not None
|
||||
with pytest.raises(ApiError, match="VMID already exists"):
|
||||
await handler(
|
||||
_request(pool),
|
||||
{"values": {"node": "pve1", "vmid": 201, "hostname": "app"}},
|
||||
)
|
||||
|
||||
|
||||
async def test_lxc_delete_requires_stopped_container(registry: HandlerRegistry) -> None:
|
||||
pool = LxcPool()
|
||||
pool.running = True
|
||||
handler = registry.get("/nodes/{node}/lxc/{vmid}", "DELETE")
|
||||
assert handler is not None
|
||||
with pytest.raises(ApiError, match="cannot delete a running container"):
|
||||
await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
|
||||
|
||||
|
||||
async def test_lxc_start_creates_task(
|
||||
monkeypatch: pytest.MonkeyPatch, registry: HandlerRegistry
|
||||
) -> None:
|
||||
pool = LxcPool()
|
||||
repository = FakeTaskRepository(pool)
|
||||
monkeypatch.setattr("app.handlers.lxc.TaskRepository", lambda _pool: repository)
|
||||
handler = registry.get("/nodes/{node}/lxc/{vmid}/status/start", "POST")
|
||||
assert handler is not None
|
||||
upid = await handler(_request(pool), {"values": {"node": "pve1", "vmid": "200"}})
|
||||
assert upid.startswith("UPID:")
|
||||
assert repository.created[0]["task_type"] == "lxc-start"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Migration discovery and checksum tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.db.migrations import load_migrations
|
||||
|
||||
|
||||
def test_load_migrations_is_ordered_and_checksummed(tmp_path: Path) -> None:
|
||||
(tmp_path / "002_second.sql").write_text("SELECT 2;")
|
||||
(tmp_path / "001_first.sql").write_text("SELECT 1;")
|
||||
|
||||
migrations = load_migrations(tmp_path)
|
||||
|
||||
assert [migration.version for migration in migrations] == [1, 2]
|
||||
assert migrations[0].name == "001_first"
|
||||
assert len(migrations[0].checksum) == 64
|
||||
|
||||
|
||||
def test_repository_migration_defines_required_planes() -> None:
|
||||
migrations = load_migrations()
|
||||
migration = migrations[0]
|
||||
|
||||
for table in (
|
||||
"contract_snapshots",
|
||||
"nodes",
|
||||
"resources",
|
||||
"principals",
|
||||
"acl_entries",
|
||||
"tasks",
|
||||
"scenarios",
|
||||
"audit_events",
|
||||
):
|
||||
assert f"CREATE TABLE {table}" in migration.sql
|
||||
assert "CREATE TABLE realms" in migrations[1].sql
|
||||
assert "CREATE TABLE api_tokens" in migrations[1].sql
|
||||
domain = migrations[3].sql
|
||||
for table in (
|
||||
"clusters",
|
||||
"virtual_machines",
|
||||
"containers",
|
||||
"storages",
|
||||
"storage_contents",
|
||||
"snapshots",
|
||||
"backups",
|
||||
"pools",
|
||||
"identity_groups",
|
||||
"contract_paths",
|
||||
"observed_contracts",
|
||||
"scenario_rules",
|
||||
"fault_injections",
|
||||
):
|
||||
assert f"CREATE TABLE {table}" in domain
|
||||
assert "CREATE TABLE group_acl_entries" in migrations[5].sql
|
||||
assert "ADD COLUMN IF NOT EXISTS config jsonb" in migrations[6].sql
|
||||
assert "CREATE TABLE tfa_entries" in migrations[7].sql
|
||||
assert "CREATE TABLE openid_pending" in migrations[7].sql
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Node ops handlers persist network/disks/services into nodes.metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.nodes import register_node_ops_handlers
|
||||
|
||||
|
||||
class NodePool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "SELECT metadata FROM nodes" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return True
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE nodes SET metadata" in query:
|
||||
self.metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: NodePool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: NodePool, *, method: str = "GET", path: str = "/") -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
async def test_network_and_service_mutations_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_node_ops_handlers(registry)
|
||||
pool = NodePool()
|
||||
|
||||
create = registry.get("/nodes/{node}/network", "POST")
|
||||
listing = registry.get("/nodes/{node}/network", "GET")
|
||||
delete = registry.get("/nodes/{node}/network/{iface}", "DELETE")
|
||||
stop = registry.get("/nodes/{node}/services/{service}/stop", "POST")
|
||||
state = registry.get("/nodes/{node}/services/{service}/state", "GET")
|
||||
assert create and listing and delete and stop and state
|
||||
|
||||
await create(
|
||||
request(pool, method="POST", path="/api2/json/nodes/pve01/network"),
|
||||
{"values": {"node": "pve01", "iface": "vmbr9", "type": "bridge"}, "provided": frozenset()},
|
||||
)
|
||||
items = await listing(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
assert any(item["iface"] == "vmbr9" for item in items)
|
||||
|
||||
await delete(
|
||||
request(pool, method="DELETE", path="/api2/json/nodes/pve01/network/vmbr9"),
|
||||
{"values": {"node": "pve01", "iface": "vmbr9"}, "provided": frozenset()},
|
||||
)
|
||||
items = await listing(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
assert all(item["iface"] != "vmbr9" for item in items)
|
||||
|
||||
await stop(
|
||||
request(pool, method="POST", path="/api2/json/nodes/pve01/services/pveproxy/stop"),
|
||||
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
|
||||
)
|
||||
service = await state(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
|
||||
)
|
||||
assert service["state"] == "stopped"
|
||||
assert "ops" in pool.metadata
|
||||
|
||||
|
||||
async def test_disk_init_and_wipe_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_node_ops_handlers(registry)
|
||||
pool = NodePool()
|
||||
initgpt = registry.get("/nodes/{node}/disks/initgpt", "POST")
|
||||
wipe = registry.get("/nodes/{node}/disks/wipedisk", "PUT")
|
||||
listing = registry.get("/nodes/{node}/disks/list", "GET")
|
||||
assert initgpt and wipe and listing
|
||||
|
||||
await initgpt(
|
||||
request(pool, method="POST"),
|
||||
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
|
||||
)
|
||||
await wipe(
|
||||
request(pool, method="PUT"),
|
||||
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
|
||||
)
|
||||
disks = await listing(
|
||||
request(pool),
|
||||
{"values": {"node": "pve01"}, "provided": frozenset()},
|
||||
)
|
||||
target = next(item for item in disks if item["devpath"] == "/dev/sdb")
|
||||
assert target["wiped"] == 1
|
||||
assert target["gpt"] == 0
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Notification endpoints/matchers persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.notifications import register_notifications_handlers
|
||||
from app.simulation.seed import CLUSTER_ID
|
||||
|
||||
|
||||
class NotesPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in query:
|
||||
self.metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
def request(pool: NotesPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_notification_endpoint_and_matcher_persist() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_notifications_handlers(registry)
|
||||
pool = NotesPool()
|
||||
http = request(pool)
|
||||
create = registry.get("/cluster/notifications/endpoints/gotify", "POST")
|
||||
get = registry.get("/cluster/notifications/endpoints/gotify/{name}", "GET")
|
||||
matchers = registry.get("/cluster/notifications/matchers", "POST")
|
||||
targets = registry.get("/cluster/notifications/targets", "GET")
|
||||
test = registry.get("/cluster/notifications/targets/{name}/test", "POST")
|
||||
assert create and get and matchers and targets and test
|
||||
|
||||
await create(
|
||||
http,
|
||||
{
|
||||
"values": {
|
||||
"name": "ops",
|
||||
"server": "https://gotify.local",
|
||||
"token": "secret-token",
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
payload = await get(http, {"values": {"name": "ops"}, "provided": frozenset()})
|
||||
assert payload["server"] == "https://gotify.local"
|
||||
assert "token" not in payload
|
||||
await matchers(
|
||||
http,
|
||||
{
|
||||
"values": {"name": "all-mail", "target": "ops", "mode": "all"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
listed = await targets(http, {"values": {}, "provided": frozenset()})
|
||||
assert listed[0]["name"] == "ops"
|
||||
await test(http, {"values": {"name": "ops"}, "provided": frozenset()})
|
||||
assert pool.metadata["notifications"]["tests"]
|
||||
assert CLUSTER_ID
|
||||
@@ -0,0 +1,19 @@
|
||||
"""OpenAPI tag categorization tests."""
|
||||
|
||||
from app.api.openapi import openapi_tag_metadata
|
||||
|
||||
|
||||
def test_openapi_tag_metadata_is_openstack_only() -> None:
|
||||
names = [entry["name"] for entry in openapi_tag_metadata()]
|
||||
assert names == sorted(names)
|
||||
assert "Simulator" in names
|
||||
assert "Keystone" in names
|
||||
assert "Nova" in names
|
||||
assert "API2 JSON" not in names
|
||||
assert "API2 ExtJS" not in names
|
||||
assert "Core" not in names
|
||||
assert "Access" not in names
|
||||
assert "Nodes" not in names
|
||||
assert "Pools" not in names
|
||||
assert not any(name.startswith("Nodes ·") for name in names)
|
||||
assert not any(name.startswith("Cluster ·") for name in names)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""OpenStack catalog helper tests."""
|
||||
|
||||
from app.openstack.catalog import build_catalog, public_base
|
||||
|
||||
|
||||
def test_public_base() -> None:
|
||||
assert public_base("localhost", 5000) == "http://localhost:5000"
|
||||
|
||||
|
||||
def test_build_catalog_includes_core_services() -> None:
|
||||
catalog = build_catalog("127.0.0.1")
|
||||
types = {item["type"] for item in catalog}
|
||||
assert {"identity", "compute", "network", "image", "volumev3", "placement"} <= types
|
||||
nova = next(item for item in catalog if item["type"] == "compute")
|
||||
assert nova["endpoints"][0]["url"].endswith(":8774/v2.1")
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Persistent QEMU CRUD semantic handler tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.db.primitives import ConflictError
|
||||
from app.handlers.qemu import register_qemu_handlers
|
||||
from app.tasks.repository import Task
|
||||
|
||||
|
||||
class QemuPool:
|
||||
def __init__(self) -> None:
|
||||
self.resource_exists = False
|
||||
self.missing = False
|
||||
self.running = False
|
||||
self.commands: list[str] = []
|
||||
self.resource_id = uuid.uuid4()
|
||||
|
||||
async def fetchval(self, sql: str, *args: object) -> bool | int:
|
||||
del args
|
||||
if "pg_backend_pid" in sql:
|
||||
return 123
|
||||
if "extract(epoch" in sql:
|
||||
return 1_700_000_000
|
||||
if "FROM nodes" in sql:
|
||||
return True
|
||||
if "FROM resources" in sql:
|
||||
return self.resource_exists
|
||||
if "FROM snapshots" in sql:
|
||||
return False
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||
del args
|
||||
if "FROM resources" in sql:
|
||||
return [{"vmid": 150, "state": '{"status":"stopped","name":"vm"}'}]
|
||||
assert "FROM snapshots" in sql
|
||||
return [
|
||||
{
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
]
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if self.missing:
|
||||
return None
|
||||
if "SELECT r.id, r.version" in sql:
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"version": 1,
|
||||
"state": '{"name":"old","status":"stopped"}',
|
||||
"config": '{"name":"old"}',
|
||||
}
|
||||
if "SELECT r.state, v.config" in sql:
|
||||
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
|
||||
if "SELECT r.id, r.state" in sql:
|
||||
status = "running" if self.running else "stopped"
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"state": f'{{"status":"{status}"}}',
|
||||
"config": ('{"agent":1,"name":"vm","scsi0":"local-lvm:vm-150-disk-0,size=8G"}'),
|
||||
}
|
||||
if "SELECT r.id, r.state, v.config" in sql:
|
||||
return {"id": self.resource_id, "state": '{"status":"stopped"}', "config": "{}"}
|
||||
if "SELECT s.* FROM snapshots" in sql:
|
||||
return {
|
||||
"id": uuid.uuid4(),
|
||||
"name": "baseline",
|
||||
"parent_name": None,
|
||||
"description": "stable",
|
||||
"state": '{"config":{"name":"old"}}',
|
||||
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
raise AssertionError(sql)
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
self.commands.append(sql)
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self, pool: QemuPool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
|
||||
def request(pool: QemuPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, FakeDatabase(pool))
|
||||
result = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
result.state.principal = "root@pam"
|
||||
return result
|
||||
|
||||
|
||||
def inputs(**values: object) -> dict[str, Any]:
|
||||
return {"values": values, "provided": tuple(values)}
|
||||
|
||||
|
||||
async def test_qemu_create_sync_async_update_and_delete(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created_payloads: list[dict[str, Any]] = []
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: Any) -> Task:
|
||||
created_payloads.append(kwargs)
|
||||
return Task(
|
||||
uuid.uuid4(),
|
||||
str(kwargs["upid"]),
|
||||
str(kwargs["task_type"]),
|
||||
"queued",
|
||||
dict(kwargs["payload"]),
|
||||
0,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/nodes/{node}/qemu", "POST")
|
||||
listing = registry.get("/nodes/{node}/qemu", "GET")
|
||||
config = registry.get("/nodes/{node}/qemu/{vmid}/config", "GET")
|
||||
current = registry.get("/nodes/{node}/qemu/{vmid}/status/current", "GET")
|
||||
update_sync = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
|
||||
update_async = registry.get("/nodes/{node}/qemu/{vmid}/config", "POST")
|
||||
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
|
||||
assert create and listing and config and current and update_sync and update_async and delete
|
||||
|
||||
assert (await listing(http_request, inputs(node="pve1")))[0]["name"] == "vm"
|
||||
assert (await config(http_request, inputs(node="pve1", vmid=150)))["name"] == "vm"
|
||||
assert (await current(http_request, inputs(node="pve1", vmid=150)))["status"] == "stopped"
|
||||
|
||||
create_upid = await create(
|
||||
http_request,
|
||||
inputs(node="pve1", vmid=150, name="new", cores=2),
|
||||
)
|
||||
assert create_upid.startswith("UPID:pve1:")
|
||||
assert created_payloads[-1]["task_type"] == "qemu-create"
|
||||
|
||||
assert (
|
||||
await update_sync(
|
||||
http_request,
|
||||
inputs(node="pve1", vmid=150, name="sync", delete="unused"),
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert len(pool.commands) == 2
|
||||
|
||||
update_upid = await update_async(
|
||||
http_request,
|
||||
inputs(node="pve1", vmid=150, memory="2048"),
|
||||
)
|
||||
assert update_upid.startswith("UPID:pve1:")
|
||||
assert created_payloads[-1]["task_type"] == "qemu-update"
|
||||
|
||||
delete_upid = await delete(http_request, inputs(node="pve1", vmid=150))
|
||||
assert delete_upid.startswith("UPID:pve1:")
|
||||
assert created_payloads[-1]["task_type"] == "qemu-delete"
|
||||
|
||||
|
||||
async def test_qemu_crud_conflicts_and_missing_resources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ConflictingRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **_kwargs: object) -> Task:
|
||||
raise ConflictError("resource is locked")
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", ConflictingRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
create = registry.get("/nodes/{node}/qemu", "POST")
|
||||
update = registry.get("/nodes/{node}/qemu/{vmid}/config", "PUT")
|
||||
delete = registry.get("/nodes/{node}/qemu/{vmid}", "DELETE")
|
||||
assert create and update and delete
|
||||
|
||||
with pytest.raises(ApiError) as locked:
|
||||
await create(http_request, inputs(node="pve1", vmid=150))
|
||||
assert locked.value.status_code == 409
|
||||
|
||||
pool.resource_exists = True
|
||||
with pytest.raises(ApiError) as duplicate:
|
||||
await create(http_request, inputs(node="pve1", vmid=150))
|
||||
assert duplicate.value.status_code == 409
|
||||
|
||||
pool.missing = True
|
||||
with pytest.raises(ApiError) as missing:
|
||||
await update(http_request, inputs(node="pve1", vmid=150, name="missing"))
|
||||
assert missing.value.status_code == 404
|
||||
|
||||
pool.missing = False
|
||||
pool.running = True
|
||||
with pytest.raises(ApiError) as running:
|
||||
await delete(http_request, inputs(node="pve1", vmid=150))
|
||||
assert running.value.status_code == 409
|
||||
|
||||
|
||||
async def test_qemu_snapshot_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
tasks: list[str] = []
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: Any) -> Task:
|
||||
tasks.append(str(kwargs["task_type"]))
|
||||
return Task(uuid.uuid4(), str(kwargs["upid"]), tasks[-1], "queued", {}, 0, False, 0)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
base = "/nodes/{node}/qemu/{vmid}/snapshot"
|
||||
|
||||
listing = registry.get(base, "GET")
|
||||
create = registry.get(base, "POST")
|
||||
get = registry.get(f"{base}/{{snapname}}", "GET")
|
||||
delete = registry.get(f"{base}/{{snapname}}", "DELETE")
|
||||
config_get = registry.get(f"{base}/{{snapname}}/config", "GET")
|
||||
config_put = registry.get(f"{base}/{{snapname}}/config", "PUT")
|
||||
rollback = registry.get(f"{base}/{{snapname}}/rollback", "POST")
|
||||
assert listing and create and get and delete and config_get and config_put and rollback
|
||||
|
||||
common = inputs(node="pve1", vmid=150, snapname="baseline")
|
||||
assert (await listing(http_request, inputs(node="pve1", vmid=150)))[0]["name"] == "baseline"
|
||||
assert (await get(http_request, common))["description"] == "stable"
|
||||
assert (await config_get(http_request, common))["config"] == {"name": "old"}
|
||||
assert await config_put(http_request, inputs(**common["values"], description="updated")) is None
|
||||
assert (
|
||||
await create(http_request, inputs(**common["values"], description="stable"))
|
||||
).startswith("UPID:pve1:")
|
||||
assert (await rollback(http_request, common)).startswith("UPID:pve1:")
|
||||
assert (await delete(http_request, common)).startswith("UPID:pve1:")
|
||||
assert tasks == ["qemu-snapshot-create", "qemu-snapshot-rollback", "qemu-snapshot-delete"]
|
||||
|
||||
|
||||
async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
tasks: list[dict[str, Any]] = []
|
||||
|
||||
class FakeTaskRepository:
|
||||
def __init__(self, pool: object) -> None:
|
||||
del pool
|
||||
|
||||
async def create(self, **kwargs: Any) -> Task:
|
||||
tasks.append(kwargs)
|
||||
return Task(
|
||||
uuid.uuid4(),
|
||||
str(kwargs["upid"]),
|
||||
str(kwargs["task_type"]),
|
||||
"queued",
|
||||
{},
|
||||
0,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.handlers.qemu.TaskRepository", FakeTaskRepository)
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
http_request = request(pool)
|
||||
clone = registry.get("/nodes/{node}/qemu/{vmid}/clone", "POST")
|
||||
migrate_get = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "GET")
|
||||
migrate = registry.get("/nodes/{node}/qemu/{vmid}/migrate", "POST")
|
||||
resize = registry.get("/nodes/{node}/qemu/{vmid}/resize", "PUT")
|
||||
move = registry.get("/nodes/{node}/qemu/{vmid}/move_disk", "POST")
|
||||
assert clone and migrate_get and migrate and resize and move
|
||||
|
||||
clone_upid = await clone(
|
||||
http_request, inputs(node="pve1", vmid=150, newid=151, name="clone", full=True)
|
||||
)
|
||||
assert clone_upid.startswith("UPID:pve1:")
|
||||
assert tasks[-1]["task_type"] == "qemu-clone"
|
||||
assert (await migrate_get(http_request, inputs(node="pve1", vmid=150, target="pve2")))[
|
||||
"local_disks"
|
||||
] == []
|
||||
migrate_upid = await migrate(
|
||||
http_request, inputs(node="pve1", vmid=150, target="pve2", online=False)
|
||||
)
|
||||
assert migrate_upid.startswith("UPID:pve1:")
|
||||
assert tasks[-1]["task_type"] == "qemu-migrate"
|
||||
assert (
|
||||
await resize(http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")) is None
|
||||
)
|
||||
move_upid = await move(
|
||||
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
|
||||
)
|
||||
assert move_upid.startswith("UPID:pve1:")
|
||||
assert tasks[-1]["task_type"] == "qemu-move-disk"
|
||||
|
||||
with pytest.raises(ApiError) as same_node:
|
||||
await migrate(http_request, inputs(node="pve1", vmid=150, target="pve1"))
|
||||
assert same_node.value.status_code == 400
|
||||
|
||||
|
||||
async def test_qemu_pending_and_agent_handlers() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_qemu_handlers(registry)
|
||||
pool = QemuPool()
|
||||
pool.running = True
|
||||
http_request = request(pool)
|
||||
values = inputs(node="pve1", vmid=150)
|
||||
|
||||
pending = registry.get("/nodes/{node}/qemu/{vmid}/pending", "GET")
|
||||
routes = {
|
||||
"info": "/nodes/{node}/qemu/{vmid}/agent/info",
|
||||
"os": "/nodes/{node}/qemu/{vmid}/agent/get-osinfo",
|
||||
"host": "/nodes/{node}/qemu/{vmid}/agent/get-host-name",
|
||||
"network": "/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces",
|
||||
"time": "/nodes/{node}/qemu/{vmid}/agent/get-time",
|
||||
"ping": "/nodes/{node}/qemu/{vmid}/agent/ping",
|
||||
}
|
||||
handlers = {
|
||||
name: registry.get(path, "POST" if name == "ping" else "GET")
|
||||
for name, path in routes.items()
|
||||
}
|
||||
assert pending and all(handlers.values())
|
||||
|
||||
async def call(name: str) -> dict[str, Any]:
|
||||
handler = handlers[name]
|
||||
assert handler is not None
|
||||
return cast(dict[str, Any], await handler(http_request, values))
|
||||
|
||||
assert await pending(http_request, values) == []
|
||||
assert (await call("info"))["result"]["version"]
|
||||
assert (await call("os"))["result"]["machine"] == "x86_64"
|
||||
assert (await call("host"))["result"]["host-name"] == "vm"
|
||||
assert (await call("network"))["result"][0]["name"] == "eth0"
|
||||
assert (await call("time"))["result"]["seconds"] > 0
|
||||
assert (await call("ping"))["result"] == {}
|
||||
@@ -0,0 +1,284 @@
|
||||
"""QEMU worker transition semantics."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
from app.simulation.clock import Clock
|
||||
from app.tasks.qemu import qemu_handler
|
||||
from app.tasks.repository import Task, TaskRepository
|
||||
|
||||
|
||||
class ImmediateClock:
|
||||
async def now(self) -> datetime:
|
||||
return datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
assert seconds == 1.0
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self) -> None:
|
||||
self.states: list[str] = []
|
||||
|
||||
async def fetchrow(self, sql: str, resource_id: uuid.UUID) -> dict[str, object]:
|
||||
del sql, resource_id
|
||||
return {"state": '{"status":"stopped"}'}
|
||||
|
||||
async def execute(self, sql: str, resource_id: uuid.UUID, state: str) -> str:
|
||||
del sql, resource_id
|
||||
self.states.append(state)
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class Acquire:
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
async def __aenter__(self) -> Connection:
|
||||
return self.connection
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Pool:
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
self.connection = connection
|
||||
|
||||
def acquire(self) -> Acquire:
|
||||
return Acquire(self.connection)
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.connection = Connection()
|
||||
self.pool = Pool(self.connection)
|
||||
self.logs: list[str] = []
|
||||
|
||||
async def append_log(self, task_id: uuid.UUID, message: str) -> None:
|
||||
del task_id
|
||||
self.logs.append(message)
|
||||
|
||||
|
||||
class Transaction:
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class CrudConnection:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[str] = []
|
||||
|
||||
def transaction(self) -> Transaction:
|
||||
return Transaction()
|
||||
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if "FROM nodes" in sql:
|
||||
return {"id": uuid.uuid4(), "cluster_id": uuid.uuid4()}
|
||||
if "JOIN virtual_machines" in sql:
|
||||
return {"state": '{"status":"stopped","name":"old"}', "config": '{"name":"old"}'}
|
||||
if "SELECT state FROM resources" in sql:
|
||||
return {"state": '{"status":"stopped","name":"old"}'}
|
||||
if "SELECT config FROM virtual_machines" in sql:
|
||||
return {"config": '{"scsi0":"local-lvm:vm-150-disk-0,size=10G"}'}
|
||||
if "FROM snapshots" in sql:
|
||||
return {
|
||||
"state": (
|
||||
'{"resource_state":{"status":"stopped","name":"old"},"config":{"name":"old"}}'
|
||||
)
|
||||
}
|
||||
return None
|
||||
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
self.commands.append(sql)
|
||||
return "DELETE 1" if sql.startswith("DELETE") else "UPDATE 1"
|
||||
|
||||
|
||||
class CrudRepository:
|
||||
def __init__(self) -> None:
|
||||
self.connection = CrudConnection()
|
||||
self.pool = Pool(cast(Connection, self.connection))
|
||||
self.logs: list[str] = []
|
||||
|
||||
async def append_log(self, _task_id: uuid.UUID, message: str) -> None:
|
||||
self.logs.append(message)
|
||||
|
||||
|
||||
async def test_qemu_worker_applies_intermediate_and_final_states() -> None:
|
||||
repository = Repository()
|
||||
task = Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:test",
|
||||
"qemu-start",
|
||||
"running",
|
||||
{"resource_id": str(uuid.uuid4())},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
|
||||
result = await qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))(
|
||||
task
|
||||
)
|
||||
|
||||
assert result == {"status": "running"}
|
||||
assert '"starting"' in repository.connection.states[0]
|
||||
assert '"running"' in repository.connection.states[1]
|
||||
assert repository.logs == ["VM start started", "VM start completed"]
|
||||
|
||||
|
||||
async def test_qemu_worker_create_update_and_delete_are_persistent() -> None:
|
||||
repository = CrudRepository()
|
||||
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
created = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:create",
|
||||
"qemu-create",
|
||||
"running",
|
||||
{"node": "pve1", "vmid": 150, "config": {"name": "new"}},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
updated = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:update",
|
||||
"qemu-update",
|
||||
"running",
|
||||
{
|
||||
"resource_id": str(resource_id),
|
||||
"changes": {"name": "changed", "cores": 4},
|
||||
"delete": "unused",
|
||||
},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
deleted = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:delete",
|
||||
"qemu-delete",
|
||||
"running",
|
||||
{"resource_id": str(resource_id)},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
assert created == {"vmid": 150, "status": "stopped"}
|
||||
assert updated == {"updated": ["cores", "name"], "deleted": ["unused"]}
|
||||
assert deleted == {"deleted": True}
|
||||
assert any("INSERT INTO resources" in command for command in repository.connection.commands)
|
||||
assert any("UPDATE virtual_machines" in command for command in repository.connection.commands)
|
||||
assert any("DELETE FROM resources" in command for command in repository.connection.commands)
|
||||
|
||||
|
||||
async def test_qemu_worker_snapshot_create_rollback_and_delete_are_persistent() -> None:
|
||||
repository = CrudRepository()
|
||||
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
async def run(operation: str, **payload: object) -> dict[str, object]:
|
||||
result = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
f"UPID:{operation}",
|
||||
f"qemu-snapshot-{operation}",
|
||||
"running",
|
||||
{"resource_id": str(resource_id), "snapname": "baseline", **payload},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
assert result is not None
|
||||
return cast(dict[str, object], result)
|
||||
|
||||
assert await run("create", description="stable") == {
|
||||
"snapshot": "baseline",
|
||||
"operation": "create",
|
||||
}
|
||||
assert await run("rollback", start=True) == {
|
||||
"snapshot": "baseline",
|
||||
"operation": "rollback",
|
||||
}
|
||||
assert await run("delete") == {"snapshot": "baseline", "operation": "delete"}
|
||||
commands = repository.connection.commands
|
||||
assert any("INSERT INTO snapshots" in command for command in commands)
|
||||
assert any("UPDATE virtual_machines" in command for command in commands)
|
||||
assert any("DELETE FROM snapshots" in command for command in commands)
|
||||
|
||||
|
||||
async def test_qemu_worker_clone_and_migrate_are_persistent() -> None:
|
||||
repository = CrudRepository()
|
||||
handler = qemu_handler(cast(TaskRepository, repository), cast(Clock, ImmediateClock()))
|
||||
resource_id = uuid.uuid4()
|
||||
|
||||
cloned = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:clone",
|
||||
"qemu-clone",
|
||||
"running",
|
||||
{
|
||||
"source_resource_id": str(resource_id),
|
||||
"node": "pve1",
|
||||
"vmid": 151,
|
||||
"name": "clone",
|
||||
},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
migrated = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:migrate",
|
||||
"qemu-migrate",
|
||||
"running",
|
||||
{"resource_id": str(resource_id), "target": "pve2"},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
moved = await handler(
|
||||
Task(
|
||||
uuid.uuid4(),
|
||||
"UPID:move",
|
||||
"qemu-move-disk",
|
||||
"running",
|
||||
{
|
||||
"resource_id": str(resource_id),
|
||||
"disk": "scsi0",
|
||||
"target_disk": "scsi0",
|
||||
"storage": "local",
|
||||
"delete": True,
|
||||
},
|
||||
0,
|
||||
False,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
assert cloned == {"vmid": 151, "node": "pve1"}
|
||||
assert migrated == {"node": "pve2", "status": "stopped"}
|
||||
assert moved == {"disk": "scsi0", "storage": "local"}
|
||||
commands = repository.connection.commands
|
||||
assert any("INSERT INTO resources" in command for command in commands)
|
||||
assert any("node_id=$2" in command for command in commands)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Tests for contract example generation."""
|
||||
|
||||
from app.contracts.examples import path_param_example, schema_example
|
||||
from app.contracts.model import Schema
|
||||
|
||||
|
||||
def test_path_param_examples_use_known_placeholders() -> None:
|
||||
assert path_param_example("node") == "pve01"
|
||||
assert path_param_example("vmid") == 100
|
||||
|
||||
|
||||
def test_schema_example_prefers_default_and_enum() -> None:
|
||||
assert schema_example(Schema(type="string", default="custom")) == "custom"
|
||||
assert schema_example(Schema(type="string", enum=("a", "b"))) == "a"
|
||||
|
||||
|
||||
def test_schema_example_builds_object_and_array() -> None:
|
||||
schema = Schema(
|
||||
type="object",
|
||||
properties={
|
||||
"count": Schema(type="integer", minimum=2),
|
||||
"enabled": Schema(type="boolean", optional=True),
|
||||
},
|
||||
)
|
||||
assert schema_example(schema) == {"count": 2}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""SDN zone/vnet/subnet persistence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.handlers.sdn import register_sdn_handlers
|
||||
|
||||
|
||||
class SdnPool:
|
||||
def __init__(self) -> None:
|
||||
self.metadata: dict[str, Any] = {}
|
||||
self.nodes = {"pve1"}
|
||||
|
||||
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
|
||||
if "FROM clusters WHERE id" in query:
|
||||
return {"metadata": json.dumps(self.metadata)}
|
||||
raise AssertionError(query)
|
||||
|
||||
async def fetchval(self, query: str, *arguments: object) -> Any:
|
||||
if "EXISTS(SELECT 1 FROM nodes" in query:
|
||||
return str(arguments[0]) in self.nodes
|
||||
raise AssertionError(query)
|
||||
|
||||
async def execute(self, query: str, *arguments: object) -> str:
|
||||
if "UPDATE clusters SET metadata" in query:
|
||||
self.metadata = json.loads(str(arguments[1]))
|
||||
return "UPDATE 1"
|
||||
raise AssertionError(query)
|
||||
|
||||
|
||||
async def call(
|
||||
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
|
||||
) -> Any:
|
||||
handler = registry.get(path, verb)
|
||||
assert handler is not None
|
||||
return await handler(http, inputs)
|
||||
|
||||
|
||||
def request(pool: SdnPool) -> Request:
|
||||
app = FastAPI()
|
||||
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"app": app,
|
||||
"method": "POST",
|
||||
"path": "/",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("test", 80),
|
||||
"client": ("test", 123),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_sdn_zone_vnet_subnet_and_node_views() -> None:
|
||||
registry = HandlerRegistry()
|
||||
register_sdn_handlers(registry)
|
||||
pool = SdnPool()
|
||||
http = request(pool)
|
||||
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn/zones",
|
||||
"POST",
|
||||
http,
|
||||
{"values": {"zone": "localzone", "type": "simple"}, "provided": frozenset()},
|
||||
)
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn/vnets",
|
||||
"POST",
|
||||
http,
|
||||
{
|
||||
"values": {"vnet": "vnet0", "zone": "localzone", "type": "vnet"},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn/vnets/{vnet}/subnets",
|
||||
"POST",
|
||||
http,
|
||||
{
|
||||
"values": {
|
||||
"vnet": "vnet0",
|
||||
"subnet": "10.0.0.0/24",
|
||||
"gateway": "10.0.0.1",
|
||||
},
|
||||
"provided": frozenset(),
|
||||
},
|
||||
)
|
||||
zones = await call(
|
||||
registry, "/cluster/sdn/zones", "GET", http, {"values": {}, "provided": frozenset()}
|
||||
)
|
||||
assert zones[0]["zone"] == "localzone"
|
||||
subnets = await call(
|
||||
registry,
|
||||
"/cluster/sdn/vnets/{vnet}/subnets",
|
||||
"GET",
|
||||
http,
|
||||
{"values": {"vnet": "vnet0"}, "provided": frozenset()},
|
||||
)
|
||||
assert subnets[0]["subnet"] == "10.0.0.0/24"
|
||||
node_zones = await call(
|
||||
registry,
|
||||
"/nodes/{node}/sdn/zones",
|
||||
"GET",
|
||||
http,
|
||||
{"values": {"node": "pve1"}, "provided": frozenset()},
|
||||
)
|
||||
assert node_zones[0]["zone"] == "localzone"
|
||||
assert pool.metadata["sdn"]["pending"] is True
|
||||
await call(
|
||||
registry,
|
||||
"/cluster/sdn",
|
||||
"PUT",
|
||||
http,
|
||||
{"values": {"release-lock": 1}, "provided": frozenset()},
|
||||
)
|
||||
assert pool.metadata["sdn"]["pending"] is False
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Deterministic seed profile tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.simulation.seed import (
|
||||
build_profile,
|
||||
clear_simulation_state,
|
||||
large_profile,
|
||||
small_profile,
|
||||
stable_id,
|
||||
)
|
||||
|
||||
|
||||
def test_small_profile_matches_required_logical_shape() -> None:
|
||||
first = small_profile()
|
||||
second = small_profile()
|
||||
|
||||
assert first == second
|
||||
state = first.logical_state()
|
||||
assert state == second.logical_state()
|
||||
assert state["nodes"] == [{"name": "pve01", "status": "online"}]
|
||||
resources = state["resources"]
|
||||
assert isinstance(resources, list)
|
||||
assert [resource["kind"] for resource in resources].count("qemu") == 2
|
||||
assert [resource["kind"] for resource in resources].count("lxc") == 1
|
||||
assert [resource["kind"] for resource in resources].count("storage") == 2
|
||||
tasks = state["tasks"]
|
||||
assert isinstance(tasks, list)
|
||||
assert len(tasks) == 2
|
||||
|
||||
|
||||
def test_medium_and_fault_profiles_are_deterministic() -> None:
|
||||
medium = build_profile("medium")
|
||||
assert len(medium.nodes) == 3
|
||||
assert sum(resource.kind == "qemu" for resource in medium.resources) == 50
|
||||
assert sum(resource.kind == "lxc" for resource in medium.resources) == 20
|
||||
assert build_profile("ha-demo") == build_profile("ha-demo")
|
||||
broken = build_profile("broken-storage")
|
||||
assert any(resource.state.get("status") == "offline" for resource in broken.resources)
|
||||
|
||||
|
||||
def test_large_profile_is_configurable_and_stable() -> None:
|
||||
first = large_profile(node_count=4, resource_count=1_000)
|
||||
second = large_profile(node_count=4, resource_count=1_000)
|
||||
assert first == second
|
||||
assert len(first.nodes) == 4
|
||||
assert len(first.resources) == 1_000
|
||||
|
||||
|
||||
def test_profile_validation() -> None:
|
||||
with pytest.raises(ValueError, match="unknown seed profile"):
|
||||
build_profile("missing")
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
large_profile(node_count=0, resource_count=1)
|
||||
|
||||
|
||||
def test_demo_cluster_profile_shape() -> None:
|
||||
profile = build_profile("demo-cluster")
|
||||
assert profile.name == "demo-cluster"
|
||||
assert len(profile.nodes) == 20
|
||||
assert sum(resource.kind == "qemu" for resource in profile.resources) == 850
|
||||
assert sum(resource.kind == "lxc" for resource in profile.resources) == 150
|
||||
assert sum(resource.kind == "ceph-osd" for resource in profile.resources) == 300
|
||||
assert sum(resource.kind == "storage" for resource in profile.resources) >= 62
|
||||
assert len(profile.tasks) == 250
|
||||
external_ids = {
|
||||
resource.external_id for resource in profile.resources if resource.kind in {"qemu", "lxc"}
|
||||
}
|
||||
assert len(external_ids) == 1000
|
||||
|
||||
|
||||
def test_demo_cluster_spreads_guests_evenly_across_nodes() -> None:
|
||||
profile = build_profile("demo-cluster")
|
||||
names = {node.id: node.name for node in profile.nodes}
|
||||
|
||||
def counts(kind: str) -> list[int]:
|
||||
counter: dict[str, int] = {name: 0 for name in names.values()}
|
||||
for resource in profile.resources:
|
||||
if resource.kind == kind:
|
||||
counter[names[resource.node_id]] += 1
|
||||
return list(counter.values())
|
||||
|
||||
for kind, expected_total in (("qemu", 850), ("lxc", 150), ("ceph-osd", 300)):
|
||||
values = counts(kind)
|
||||
assert sum(values) == expected_total
|
||||
assert max(values) - min(values) <= 1
|
||||
|
||||
guest_counts = counts("qemu")
|
||||
guest_counts = [a + b for a, b in zip(guest_counts, counts("lxc"), strict=True)]
|
||||
assert max(guest_counts) - min(guest_counts) <= 2
|
||||
|
||||
|
||||
def test_minimal_profile() -> None:
|
||||
profile = build_profile("minimal")
|
||||
assert len(profile.nodes) == 1
|
||||
assert not any(resource.kind in {"qemu", "lxc"} for resource in profile.resources)
|
||||
|
||||
|
||||
def test_stable_ids_are_namespaced_and_repeatable() -> None:
|
||||
assert stable_id("qemu:100") == stable_id("qemu:100")
|
||||
assert stable_id("qemu:100") != stable_id("qemu:101")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_simulation_state_wipes_api_created_identity() -> None:
|
||||
executed: list[str] = []
|
||||
|
||||
class FakeConnection:
|
||||
async def execute(self, sql: str, *args: object) -> str:
|
||||
del args
|
||||
executed.append(" ".join(sql.split()))
|
||||
return "DELETE 0"
|
||||
|
||||
await clear_simulation_state(FakeConnection())
|
||||
joined = "\n".join(executed)
|
||||
for table in (
|
||||
"resources",
|
||||
"nodes",
|
||||
"principals",
|
||||
"identity_groups",
|
||||
"roles",
|
||||
"storage_contents",
|
||||
"api_tokens",
|
||||
):
|
||||
assert f"DELETE FROM {table}" in joined # noqa: S608 - asserting SQL text
|
||||
assert "DELETE FROM realms WHERE name NOT IN" in joined
|
||||
assert any(sql.startswith("UPDATE clusters") for sql in executed)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Bounded task worker outcome tests."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
from app.tasks.repository import Task, TaskRepository
|
||||
from app.tasks.worker import TaskWorker
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, task: Task) -> None:
|
||||
self.task = task
|
||||
self.finishes: list[tuple[str, str | None]] = []
|
||||
|
||||
async def get(self, _task_id: uuid.UUID) -> Task:
|
||||
return self.task
|
||||
|
||||
async def finish(
|
||||
self,
|
||||
_task_id: uuid.UUID,
|
||||
_worker_id: str,
|
||||
*,
|
||||
status: str,
|
||||
result: dict[str, object] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
del result
|
||||
self.finishes.append((status, error))
|
||||
|
||||
|
||||
def make_task(*, task_type: str = "test", cancelled: bool = False) -> Task:
|
||||
return Task(uuid.uuid4(), "UPID:test", task_type, "running", {}, 0, cancelled, 1)
|
||||
|
||||
|
||||
async def test_worker_persists_success_error_and_unsupported() -> None:
|
||||
task = make_task()
|
||||
repository = FakeRepository(task)
|
||||
|
||||
async def success(_task: Task) -> dict[str, object]:
|
||||
return {"ok": True}
|
||||
|
||||
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": success})
|
||||
await worker._execute(task)
|
||||
assert repository.finishes == [("success", None)]
|
||||
|
||||
unsupported = make_task(task_type="missing")
|
||||
repository.task = unsupported
|
||||
await worker._execute(unsupported)
|
||||
assert repository.finishes[-1] == ("error", "unsupported task type")
|
||||
|
||||
async def failure(_task: Task) -> None:
|
||||
raise RuntimeError("private detail")
|
||||
|
||||
failed = make_task()
|
||||
repository.task = failed
|
||||
worker.handlers["test"] = failure
|
||||
await worker._execute(failed)
|
||||
assert repository.finishes[-1] == ("error", "RuntimeError")
|
||||
|
||||
|
||||
async def test_worker_honors_persisted_cancellation() -> None:
|
||||
task = make_task(cancelled=True)
|
||||
repository = FakeRepository(task)
|
||||
called = False
|
||||
|
||||
async def handler(_task: Task) -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
worker = TaskWorker(cast(TaskRepository, repository), "worker", {"test": handler})
|
||||
await worker._execute(task)
|
||||
|
||||
assert not called
|
||||
assert repository.finishes == [("cancelled", None)]
|
||||
|
||||
|
||||
async def test_worker_retries_after_claim_failure() -> None:
|
||||
class RecoveringRepository:
|
||||
attempts = 0
|
||||
|
||||
async def claim(self, _worker_id: str, _lease_seconds: float) -> None:
|
||||
self.attempts += 1
|
||||
if self.attempts == 1:
|
||||
raise RuntimeError("database schema is not ready")
|
||||
return None
|
||||
|
||||
repository = RecoveringRepository()
|
||||
worker = TaskWorker(
|
||||
cast(TaskRepository, repository),
|
||||
"worker",
|
||||
{},
|
||||
poll_seconds=0.001,
|
||||
)
|
||||
running = asyncio.create_task(worker.run())
|
||||
await asyncio.sleep(0.01)
|
||||
worker.stop()
|
||||
await running
|
||||
|
||||
assert repository.attempts > 1
|
||||
@@ -0,0 +1,50 @@
|
||||
"""VM state-machine and deterministic fault properties."""
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from app.simulation.scenarios import FaultContext, FaultRule, matches
|
||||
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "operation", "final"),
|
||||
[
|
||||
(VmState.STOPPED, "start", VmState.RUNNING),
|
||||
(VmState.RUNNING, "stop", VmState.STOPPED),
|
||||
(VmState.RUNNING, "shutdown", VmState.STOPPED),
|
||||
(VmState.RUNNING, "reboot", VmState.RUNNING),
|
||||
(VmState.RUNNING, "reset", VmState.RUNNING),
|
||||
(VmState.RUNNING, "suspend", VmState.PAUSED),
|
||||
(VmState.RUNNING, "pause", VmState.PAUSED),
|
||||
(VmState.PAUSED, "resume", VmState.RUNNING),
|
||||
(VmState.RUNNING, "snapshot", VmState.RUNNING),
|
||||
(VmState.STOPPED, "migrate", VmState.STOPPED),
|
||||
],
|
||||
)
|
||||
def test_valid_transitions(state: VmState, operation: str, final: VmState) -> None:
|
||||
transition = plan_transition(state, operation)
|
||||
assert transition.before is state
|
||||
assert transition.after is final
|
||||
assert transition.intermediate is not state
|
||||
|
||||
|
||||
@given(st.sampled_from(tuple(VmState)), st.text(min_size=1, max_size=12))
|
||||
def test_transition_result_is_declared_or_rejected(state: VmState, operation: str) -> None:
|
||||
try:
|
||||
transition = plan_transition(state, operation)
|
||||
except InvalidTransitionError:
|
||||
return
|
||||
assert transition.before is state
|
||||
|
||||
|
||||
def test_fault_evaluation_is_seeded_and_filtered() -> None:
|
||||
context = FaultContext("POST", "/nodes/pve1/qemu/100/status/start", node="pve1")
|
||||
certain = FaultRule("task-failure", method="POST", node="pve1")
|
||||
impossible = FaultRule("task-failure", probability=0)
|
||||
|
||||
assert matches(certain, context, seed=42)
|
||||
assert not matches(impossible, context, seed=42)
|
||||
probabilistic = FaultRule("task-failure", probability=0.5)
|
||||
assert matches(probabilistic, context, 42) == matches(probabilistic, context, 42)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""UPID examples and round-trip properties."""
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from app.tasks.upid import Upid
|
||||
|
||||
SAFE = st.from_regex(r"[a-z0-9][a-z0-9_-]{0,19}", fullmatch=True)
|
||||
|
||||
|
||||
@given(
|
||||
node=SAFE,
|
||||
pid=st.integers(min_value=0, max_value=0xFFFFFFFF),
|
||||
process_start=st.integers(min_value=0, max_value=0xFFFFFFFF),
|
||||
start_time=st.integers(min_value=0, max_value=0xFFFFFFFF),
|
||||
task_type=SAFE,
|
||||
task_id=st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789_-", max_size=20),
|
||||
user=SAFE,
|
||||
)
|
||||
def test_upid_round_trip(
|
||||
node: str,
|
||||
pid: int,
|
||||
process_start: int,
|
||||
start_time: int,
|
||||
task_type: str,
|
||||
task_id: str,
|
||||
user: str,
|
||||
) -> None:
|
||||
upid = Upid(node, pid, process_start, start_time, task_type, task_id, user)
|
||||
|
||||
assert Upid.parse(str(upid)) == upid
|
||||
|
||||
|
||||
def test_known_upid_shape() -> None:
|
||||
value = "UPID:pve1:0000002A:00000010:65A1B2C3:qmstart:100:root@pam:"
|
||||
|
||||
parsed = Upid.parse(value)
|
||||
|
||||
assert parsed.pid == 42
|
||||
assert parsed.task_id == "100"
|
||||
assert str(parsed) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "UPID:broken", "UPID:pve:GGGGGGGG:00000000:00000000:x::u:"])
|
||||
def test_invalid_upids_are_rejected(value: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
Upid.parse(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"pid": -1},
|
||||
{"node": "bad:node"},
|
||||
{"task_id": "bad:id"},
|
||||
],
|
||||
)
|
||||
def test_invalid_upid_components_are_rejected(kwargs: dict[str, object]) -> None:
|
||||
values: dict[str, object] = {
|
||||
"node": "pve1",
|
||||
"pid": 1,
|
||||
"process_start": 1,
|
||||
"start_time": 1,
|
||||
"task_type": "test",
|
||||
"task_id": "100",
|
||||
"user": "root@pam",
|
||||
}
|
||||
values.update(kwargs)
|
||||
with pytest.raises(ValueError):
|
||||
Upid(**values) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Web asset loading tests."""
|
||||
|
||||
from app.web.assets import console_html
|
||||
|
||||
|
||||
def test_console_html_is_read_from_disk() -> None:
|
||||
html = console_html()
|
||||
assert "OpenStack API Emulator" in html
|
||||
assert "workspace-brand-stack" in html
|
||||
assert "#ED1C24" in html or "ED1C24" in html
|
||||
assert 'id="catalog-drawer"' in html
|
||||
assert "catalog-drawer" in html
|
||||
assert 'id="catalog-coverage"' in html
|
||||
assert "Implementation coverage" in html
|
||||
for required_id in (
|
||||
"method-desc",
|
||||
"catalog-meta",
|
||||
"stat-runtime",
|
||||
"stat-catalog",
|
||||
"stat-cluster-name",
|
||||
"stat-nodes",
|
||||
"stat-qemu",
|
||||
"stat-lxc",
|
||||
"implemented-only",
|
||||
"btn-contract-apply",
|
||||
"btn-catalog-refresh",
|
||||
):
|
||||
assert f'id="{required_id}"' in html, required_id
|
||||
assert "Apply as runtime" in html
|
||||
assert "OPENSTACK_SERIES" in html
|
||||
assert 'id="help-drawer"' in html
|
||||
assert 'id="help-badge"' in html
|
||||
assert 'id="data-badge"' in html
|
||||
assert 'id="data-drawer"' in html
|
||||
assert 'id="data-panel"' in html
|
||||
assert 'id="ui-modal"' in html
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Web console route tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from tests.unit.test_health import FakeDatabase
|
||||
|
||||
_BUNDLED = Path(
|
||||
"contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json"
|
||||
)
|
||||
|
||||
|
||||
async def test_root_console_is_served() -> None:
|
||||
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "OpenStack API Emulator" in response.text
|
||||
assert "openstack" in response.text
|
||||
assert 'id="catalog-drawer"' in response.text
|
||||
assert "catalog-drawer" in response.text
|
||||
assert 'id="help-drawer"' in response.text
|
||||
assert 'id="help-badge"' in response.text
|
||||
assert 'id="data-badge"' in response.text
|
||||
assert 'id="data-drawer"' in response.text
|
||||
assert "data-badge-btn" in response.text
|
||||
assert 'id="endpoints-badge-count"' in response.text
|
||||
assert 'id="endpoints-drawer-count"' in response.text
|
||||
assert 'id="ui-modal"' in response.text
|
||||
assert 'role="alertdialog"' in response.text
|
||||
assert "Request body" in response.text
|
||||
|
||||
|
||||
async def test_ui_method_nodes_is_implemented() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
method = await client.get(
|
||||
"/ui/api/method",
|
||||
params={"major": 7, "path": "/nodes", "verb": "GET"},
|
||||
)
|
||||
assert method.status_code == 200
|
||||
assert method.json()["implemented"] is True
|
||||
|
||||
|
||||
async def test_ui_method_read_group_is_implemented() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
method = await client.get(
|
||||
"/ui/api/method",
|
||||
params={"major": 9, "path": "/access/groups/{groupid}", "verb": "GET"},
|
||||
)
|
||||
assert method.status_code == 200
|
||||
payload = method.json()
|
||||
assert payload["name"] == "read_group"
|
||||
assert payload["implemented"] is True
|
||||
|
||||
|
||||
async def test_ui_catalog_read_group_is_implemented() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
catalog = await client.get("/ui/api/catalog", params={"major": 9})
|
||||
assert catalog.status_code == 200
|
||||
methods = {
|
||||
(path["path"], method["name"]): method["implemented"]
|
||||
for category in catalog.json()["categories"]
|
||||
for path in category["paths"]
|
||||
for method in path["methods"]
|
||||
}
|
||||
assert methods[("/access/groups/{groupid}", "read_group")] is True
|
||||
|
||||
|
||||
async def test_demo_api_requires_database() -> None:
|
||||
app = create_app(database_factory=lambda _settings: FakeDatabase(True), worker_factories=())
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
state = await client.get("/ui/api/demo/state")
|
||||
load = await client.post("/ui/api/demo/load")
|
||||
assert state.status_code == 503
|
||||
assert load.status_code == 503
|
||||
|
||||
|
||||
async def test_ui_versions_and_catalog_endpoints() -> None:
|
||||
settings = Settings(contract_snapshot=_BUNDLED)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
database_factory=lambda _settings: FakeDatabase(True),
|
||||
worker_factories=(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
versions = await client.get("/ui/api/versions")
|
||||
assert versions.status_code == 200
|
||||
assert {item["major"] for item in versions.json()["majors"]} == {6, 7, 8, 9}
|
||||
catalog = await client.get("/ui/api/catalog", params={"major": 9})
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()["source_version"] == "9.2.3"
|
||||
method = await client.get(
|
||||
"/ui/api/method",
|
||||
params={"major": 9, "path": "/version", "verb": "GET"},
|
||||
)
|
||||
assert method.status_code == 200
|
||||
assert method.json()["path"] == "/version"
|
||||
Reference in New Issue
Block a user