f8d3cbdd59
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""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())
|