"""Full vSphere REST + SOAP + major-matrix surface tests.""" from __future__ import annotations import os import re import pytest from httpx import ASGITransport, AsyncClient from app.config import Settings from app.main import create_app from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major from app.vsphere.rest.coverage import IMPLEMENTED, catalog_entries from app.vsphere.seed import seed_vsphere_inventory pytestmark = pytest.mark.integration _PATH_SUBS = { "{vm}": "vm-101", "{host}": "host-11", "{datastore}": "datastore-31", "{task}": "task-1", "{snapshot}": "snapshot-missing", "{category_id}": "cat-lab-1", "{tag_id}": "tag-lab-1", "{item_id}": "item-ubuntu", "{library_id}": "lib-local-1", "{session_id}": "session-lab-1", "{folder}": "group-v23", "{datacenter}": "datacenter-21", "{cluster}": "domain-c21", "{resource_pool}": "resgroup-22", "{permission_id}": "1", "{policy}": "policy-default", "{supervisor}": "supervisor-1", "{namespace}": "ns-lab-1", "{provider}": "vsphere.local", "{interface}": "nic0", "{network}": "network-41", "{cdrom}": "3000", "{disk}": "2000", "{nic}": "4000", "{adapter}": "1000", "{service}": "vsphere-ui", "{domain}": "lab.local", } def _concrete(path: str) -> str: out = path for key, value in _PATH_SUBS.items(): out = out.replace(key, value) return out @pytest.fixture async def client() -> AsyncClient: database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL") if not database_url: pytest.skip("TEST_DATABASE_URL / DATABASE_URL required") settings = Settings( database_url=database_url, # type: ignore[arg-type] contract_snapshot=None, enable_pve_stub=False, ) app = create_app(settings=settings, worker_factories=()) async with app.router.lifespan_context(app): await seed_vsphere_inventory(app.state.database, force=True, profile="small") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http: yield http async def _session( client: AsyncClient, user: str = "administrator@vsphere.local" ) -> dict[str, str]: login = await client.post("/api/session", auth=(user, "VMware1!")) assert login.status_code == 201, login.text sid = login.json() assert isinstance(sid, str) and sid return {"vmware-api-session-id": sid} @pytest.mark.parametrize("major", sorted(VERSIONS)) async def test_ui_catalog_and_method_fields_for_every_major( client: AsyncClient, major: int ) -> None: catalog = await client.get("/ui/api/catalog", params={"major": major}) assert catalog.status_code == 200 body = catalog.json() assert body["plane"] == "vsphere-rest" assert body["method_count"] == len(catalog_entries_for_major(major)) # Spot-check a path with params on majors that include VM get. method = await client.get( "/ui/api/method", params={"major": major, "path": "/api/vcenter/vm/{vm}", "verb": "GET"}, ) assert method.status_code == 200 payload = method.json() if payload.get("implemented"): assert any(f["name"] == "vm" for f in payload["path_fields"]) async def test_all_coverage_routes_no_server_error(client: AsyncClient) -> None: headers = await _session(client) failures: list[str] = [] for entry in catalog_entries(): verb = entry["verb"] path = entry["path"] if verb == "DELETE" and path == "/api/session": continue url = _concrete(path) kwargs: dict = {"headers": headers} if verb in {"POST", "PATCH", "PUT"}: kwargs["headers"] = {**headers, "Content-Type": "application/json"} if path.endswith("/power"): url = f"{url}?action=start" kwargs["json"] = {} elif "tag-association" in path: kwargs["json"] = { "action": "list-attached-tags", "tag_id": "x", "object_id": {"type": "VirtualMachine", "id": "vm-101"}, } else: kwargs["json"] = {} response = await client.request(verb, url, **kwargs) if response.status_code >= 500: failures.append(f"{verb} {path} -> {response.status_code} {response.text[:160]}") assert failures == [], "\n".join(failures) async def test_rest_inventory_returns_seed_data(client: AsyncClient) -> None: headers = await _session(client) for path, min_count in ( ("/api/vcenter/vm", 5), ("/api/vcenter/host", 3), ("/api/vcenter/datastore", 1), ("/api/vcenter/network", 1), ("/api/vcenter/datacenter", 1), ("/api/vcenter/cluster", 1), ("/api/vcenter/folder", 1), ): response = await client.get(path, headers=headers) assert response.status_code == 200, path assert len(response.json()) >= min_count, path async def test_legacy_rest_wrappers(client: AsyncClient) -> None: headers = await _session(client) for path in ( "/rest/vcenter/vm", "/rest/vcenter/host", "/rest/vcenter/datastore", "/rest/vcenter/network", "/rest/vcenter/datacenter", "/rest/vcenter/cluster", "/rest/appliance/system/version", ): response = await client.get(path, headers=headers) assert response.status_code == 200, path body = response.json() assert "value" in body async def test_session_contracts(client: AsyncClient) -> None: headers = await _session(client) get_session = await client.get("/api/session", headers=headers) assert get_session.status_code == 200 assert get_session.content in (b"", b"null") or not get_session.text.strip() assert "Administrator" in (get_session.headers.get("x-vmware-session-roles") or "") legacy = await client.post( "/rest/com/vmware/cis/session", auth=("administrator@vsphere.local", "VMware1!"), ) assert legacy.status_code in {200, 201} assert legacy.json()["value"] legacy_get = await client.get( "/rest/com/vmware/cis/session", headers={"vmware-api-session-id": legacy.json()["value"]}, ) assert legacy_get.status_code == 200 assert legacy_get.json()["value"] async def test_authz_readonly_forbidden_on_power(client: AsyncClient) -> None: headers = await _session(client, "readonly@vsphere.local") vms = await client.get("/api/vcenter/vm", headers=headers) assert vms.status_code == 200 vm = vms.json()[0]["vm"] power = await client.post( f"/api/vcenter/vm/{vm}/power", params={"action": "start"}, headers=headers, ) assert power.status_code == 403 async def test_appliance_version_public_and_health(client: AsyncClient) -> None: version = await client.get("/api/appliance/system/version") assert version.status_code == 200 assert version.json()["version"] headers = await _session(client) health = await client.get("/api/appliance/health/system", headers=headers) assert health.status_code == 200 assert health.json()["status"] == "green" networking = await client.get("/api/appliance/networking", headers=headers) assert networking.status_code == 200 assert networking.json()["hostname"] async def test_soap_login_service_content_and_inventory(client: AsyncClient) -> None: login = await client.post( "/sdk", content=""" <_this type="SessionManager">SessionManager administrator@vsphere.local VMware1! """, headers={"Content-Type": "text/xml"}, ) assert login.status_code == 200 assert "LoginResponse" in login.text cookie = (login.headers.get("set-cookie") or "").split(";")[0] assert "vmware_soap_session" in cookie content = await client.post( "/sdk", content=""" <_this type="ServiceInstance">ServiceInstance """, headers={"Content-Type": "text/xml", "Cookie": cookie}, ) assert content.status_code == 200 assert "propertyCollector" in content.text assert "eventManager" in content.text props = await client.post( "/sdk", content=""" <_this type="PropertyCollector">propertyCollector FolderchildEntityname group-d1 FolderchildEntity """, headers={"Content-Type": "text/xml", "Cookie": cookie}, ) assert props.status_code == 200 assert "datacenter-21" in props.text or "Datacenter" in props.text events = await client.post( "/sdk", content=""" <_this type="EventManager">EventManager """, headers={"Content-Type": "text/xml", "Cookie": cookie}, ) assert events.status_code == 200 assert "QueryEventsResponse" in events.text async def test_vm_lifecycle_and_power(client: AsyncClient) -> None: headers = await _session(client) created = await client.post( "/api/vcenter/vm", headers={**headers, "Content-Type": "application/json"}, json={ "name": "full-api-lifecycle", "placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"}, "cpu_count": 1, "memory_size_MiB": 512, }, ) assert created.status_code in {200, 201}, created.text vm = created.json() if isinstance(vm, dict): vm = vm.get("vm") or vm.get("value") or vm assert isinstance(vm, str) detail = await client.get(f"/api/vcenter/vm/{vm}", headers=headers) assert detail.status_code == 200 power = await client.post( f"/api/vcenter/vm/{vm}/power", params={"action": "start"}, headers=headers, ) assert power.status_code in {200, 204}, power.text # powered-on VMs cannot be deleted — stop first (vSphere semantics) stop = await client.post( f"/api/vcenter/vm/{vm}/power", params={"action": "stop"}, headers=headers, ) assert stop.status_code in {200, 204}, stop.text deleted = await client.delete(f"/api/vcenter/vm/{vm}", headers=headers) assert deleted.status_code in {200, 204}, deleted.text async def test_coverage_registry_matches_implemented_constant() -> None: assert len(catalog_entries()) == len(IMPLEMENTED) for verb, path in IMPLEMENTED: assert re.match(r"^/(api|rest)/", path), path assert verb in {"GET", "POST", "PUT", "PATCH", "DELETE"} @pytest.mark.parametrize("major", sorted(VERSIONS)) async def test_major_matrix_all_verbs_no_server_error(client: AsyncClient, major: int) -> None: """Apply each catalog major and exercise every registered GET/POST/PATCH/DELETE.""" headers = await _session(client) apply = await client.post("/ui/api/contract/apply", params={"major": major}) assert apply.status_code == 200, apply.text failures: list[str] = [] order = {"GET": 0, "PUT": 1, "PATCH": 2, "POST": 3, "DELETE": 4} entries = sorted( catalog_entries_for_major(major), key=lambda item: (order.get(item["verb"], 9), item["path"]), ) for entry in entries: verb = entry["verb"] path = entry["path"] if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}: continue url = _concrete(path) kwargs: dict = {"headers": {**headers}} if verb in {"POST", "PATCH", "PUT"}: kwargs["headers"] = {**headers, "Content-Type": "application/json"} if path.endswith("/power") and "/guest/" not in path: url = f"{url}?action=start" kwargs["json"] = {} elif path.endswith("/guest/power"): url = f"{url}?action=reboot" kwargs["json"] = {} elif path.endswith("/maintenance"): url = f"{url}?action=enter" kwargs["json"] = {} elif path == "/api/vcenter/folder/{folder}": url = f"{url}?action=rename" kwargs["json"] = {"name": "renamed-by-matrix"} elif path == "/api/content/local-library": kwargs["json"] = {"create_spec": {"name": f"lib-m{major}-{os.urandom(3).hex()}"}} elif path == "/api/cis/tagging/category": kwargs["json"] = { "create_spec": { "name": f"cat-m{major}-{os.urandom(3).hex()}", "cardinality": "MULTIPLE", "associable_types": [], } } elif path == "/api/cis/tagging/tag": kwargs["json"] = { "create_spec": { "name": f"tag-m{major}-{os.urandom(3).hex()}", "category_id": "missing-category", } } elif "tag-association" in path: kwargs["json"] = { "action": "list-attached-tags", "tag_id": "x", "object_id": {"type": "VirtualMachine", "id": "vm-101"}, } elif path == "/api/vcenter/network/dvpg": kwargs["json"] = { "name": f"dvpg-m{major}-{os.urandom(2).hex()}", "dvs": "dvs-51", "vlan_id": 20, } elif path == "/api/content/library/item": kwargs["json"] = { "create_spec": { "library_id": "lib-missing", "name": f"item-m{major}-{os.urandom(2).hex()}", "type": "ovf", } } elif path == "/api/vcenter/authorization/permissions": kwargs["json"] = { "principal": "readonly@vsphere.local", "role": "ReadOnly", "entity": "datacenter-21", } elif path == "/api/vcenter/datastore/{datastore}/files": kwargs["json"] = { "path": f"/probe-m{major}-{os.urandom(2).hex()}.txt", "size": 1, "type": "FILE", } elif path.endswith("/hardware/cpu"): kwargs["json"] = {"count": 2} elif path.endswith("/hardware/memory"): kwargs["json"] = {"size_MiB": 1024} elif path.endswith("/hardware/disk"): kwargs["json"] = {"type": "SCSI", "new_vmdk": {"capacity": 1024}} elif path.endswith("/hardware/ethernet"): kwargs["json"] = { "type": "VMXNET3", "backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"}, } elif path.endswith("/snapshots") and verb == "POST": kwargs["json"] = {"name": f"snap-m{major}-{os.urandom(2).hex()}"} elif "/snapshots/" in path and verb == "POST": kwargs["json"] = {"action": "revert"} elif path.endswith("/clone"): kwargs["json"] = { "name": f"clone-m{major}-{os.urandom(2).hex()}", "placement": {"folder": "group-v23", "host": "host-11"}, } elif path.endswith("/relocate"): kwargs["json"] = {"placement": {"host": "host-12"}} elif path.endswith("/console/tickets"): kwargs["json"] = {"type": "WEBMKS"} elif path.endswith("/guest/customization"): kwargs["json"] = {"name": {"name": f"guest-m{major}"}} elif path == "/api/vcenter/vm/{vm}" and verb == "POST": kwargs["json"] = {"action": "unregister"} else: kwargs["json"] = {"name": f"probe-{major}-{os.urandom(2).hex()}"} if verb == "DELETE" and path.endswith("{vm}"): url = "/api/vcenter/vm/vm-missing-matrix" response = await client.request(verb, url, **kwargs) if response.status_code >= 500: failures.append(f"{verb} {path} -> {response.status_code} {response.text[:160]}") assert failures == [], "\n".join(failures)