from __future__ import annotations import argparse import importlib.util import sys import tempfile import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import patch from uvm.cli import build_parser, main from uvm.config import Settings from uvm.domain import PortRule, VmRecord, VmSpec from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError from uvm.lifecycle import ListedVm from uvm.server import _http_status_for, create_api, run_server def fake_application(*, api_token: str | None = None): settings = SimpleNamespace( app_name="uvm", default_vcpu=1, default_ram_mib=512, default_ssh_user="root", api_token=api_token, api_tls_cert=None, api_tls_key=None, ) return SimpleNamespace(settings=settings) class ServerCliTests(unittest.TestCase): def test_parser_accepts_the_requested_serve_invocation(self) -> None: parser = build_parser(fake_application()) args = parser.parse_args(["--serve", "--port", "8123", "--host", "127.0.0.1"]) self.assertTrue(args.serve) self.assertEqual(args.port, 8123) self.assertEqual(args.host, "127.0.0.1") self.assertIsNone(args.command) def test_parser_rejects_an_invalid_server_port(self) -> None: parser = build_parser(fake_application()) with self.assertRaises(SystemExit): parser.parse_args(["--serve", "--port", "70000"]) def test_create_parser_defaults_guest_credentials_to_root(self) -> None: args = build_parser(fake_application()).parse_args(["create"]) self.assertEqual(args.username, "root") self.assertEqual(args.password, "root") def test_main_delegates_serve_mode_to_the_server_launcher(self) -> None: application = fake_application() with ( patch("uvm.cli.build_application", return_value=application), patch("uvm.server.run_server") as run_server_mock, ): result = main(["--serve", "--host", "127.0.0.1", "--port", "8123"]) self.assertEqual(result, 0) run_server_mock.assert_called_once_with(application, host="127.0.0.1", port=8123) def test_server_requires_an_api_token_even_on_loopback(self) -> None: with self.assertRaises(UvmError): run_server(fake_application(api_token=None), host="127.0.0.1", port=8000) def test_non_loopback_server_requires_tls(self) -> None: with self.assertRaises(UvmError): run_server(fake_application(api_token="test-token"), host="0.0.0.0", port=8000) def test_factory_requires_an_explicit_host(self) -> None: with self.assertRaises(UvmError): create_api(fake_application(api_token="test-token")) def test_invalid_tls_material_is_rejected_before_uvicorn_starts(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: certificate = Path(temporary_directory) / "cert.pem" key = Path(temporary_directory) / "key.pem" certificate.touch() key.touch() application = fake_application(api_token="test-token") application.settings.api_tls_cert = certificate application.settings.api_tls_key = key with self.assertRaises(UvmError): run_server(application, host="127.0.0.1", port=8000) def test_non_loopback_server_uses_configured_tls(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: certificate = Path(temporary_directory) / "cert.pem" key = Path(temporary_directory) / "key.pem" certificate.touch() key.touch() application = fake_application(api_token="test-token") application.settings.api_tls_cert = certificate application.settings.api_tls_key = key fake_uvicorn = SimpleNamespace(run=lambda *_args, **_kwargs: None) with ( patch("uvm.server._validate_server_settings"), patch("uvm.server.create_api", return_value=object()), patch.dict(sys.modules, {"uvicorn": fake_uvicorn}), patch.object(fake_uvicorn, "run") as run_mock, ): run_server(application, host="0.0.0.0", port=8443) run_mock.assert_called_once() self.assertEqual(run_mock.call_args.kwargs["ssl_certfile"], str(certificate)) self.assertEqual(run_mock.call_args.kwargs["ssl_keyfile"], str(key)) def test_api_token_rejects_non_ascii_or_whitespace(self) -> None: for token in ("s\u00e9cret", "contains space", ""): with self.subTest(token=token): with self.assertRaises(ConfigurationError): Settings(api_token=token) class ServerErrorTests(unittest.TestCase): def test_domain_errors_map_to_http_status_codes(self) -> None: for error, expected in ( (NotFoundError("unknown guest"), 404), (ConflictError("ambiguous name"), 409), (ValidationError("invalid name"), 422), (ConfigurationError("invalid settings"), 422), (UvmError("this operation needs root"), 403), (UvmError("host operation failed"), 500), ): with self.subTest(error=error): self.assertEqual(_http_status_for(error), expected) FASTAPI_AVAILABLE = ( importlib.util.find_spec("fastapi") is not None and importlib.util.find_spec("httpx") is not None ) if FASTAPI_AVAILABLE: from fastapi.testclient import TestClient class FakeLifecycle: def __init__(self) -> None: self.last_spec: VmSpec | None = None self.observed_status = "running" self.policy_status = "enforced" self.vm = VmRecord( id="vm-test", name="test-vm", cpu=1, ram_mib=512, guest_ip="10.42.0.2", gateway="10.42.0.1", tap="uvm-test", mac="02:fc:00:00:00:01", socket="/tmp/firecracker.sock", config="/tmp/config.json", log="/tmp/firecracker.log", password="guest-secret", status="running", ports=(PortRule(22),), operation=None, disk_ready=True, boot_id="test-boot", ) def list_vms(self) -> list[ListedVm]: return [ListedVm( vm=self.vm, observed_status=self.observed_status, policy_status=self.policy_status, )] def inspect(self, identifier: str) -> ListedVm: if identifier not in {self.vm.id, self.vm.guest_ip, self.vm.name}: raise NotFoundError(f"VM not found: {identifier}") return ListedVm( vm=self.vm, observed_status=self.observed_status, policy_status=self.policy_status, ) def create(self, spec: VmSpec) -> VmRecord: self.last_spec = spec self.vm.cpu = spec.cpu self.vm.ram_mib = spec.ram_mib if spec.guest_ip is not None: self.vm.guest_ip = str(spec.guest_ip) self.vm.username = spec.username self.vm.password = spec.password self.vm.name = spec.name self.vm.ports = spec.ports self.vm.status = "running" self.vm.operation = None self.observed_status = "running" self.policy_status = "enforced" return self.vm def stop(self, identifier: str) -> VmRecord: self.inspect(identifier) self.vm.status = "stopped" self.vm.pid = None self.vm.operation = None self.observed_status = "stopped" self.policy_status = "inactive" return self.vm def destroy(self, identifier: str) -> VmRecord: self.inspect(identifier) return self.vm @unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for API tests") class ApiRouterTests(unittest.TestCase): def setUp(self) -> None: application = fake_application(api_token="test-token") self.lifecycle = FakeLifecycle() application.lifecycle = self.lifecycle application.installer = SimpleNamespace( install=lambda **_kwargs: ( Path("/tmp/firecracker"), SimpleNamespace(kernel=Path("/tmp/vmlinux"), rootfs=Path("/tmp/rootfs")), ) ) application.state_store = SimpleNamespace(initialize=lambda: None) self.headers = {"X-UVM-Token": "test-token"} self.client = TestClient(create_api(application, host="127.0.0.1")) self.addCleanup(self.client.close) def test_health_is_available_without_credentials(self) -> None: response = self.client.get("/health") self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"status": "ok"}) def test_vm_routes_require_and_accept_the_api_token(self) -> None: for method, path in ( ("GET", "/vms"), ("POST", "/vms"), ("GET", "/vms/test-vm"), ("POST", "/vms/test-vm/stop"), ("DELETE", "/vms/test-vm"), ("POST", "/install"), ): for headers in ({}, {"X-UVM-Token": "wrong-token"}): with self.subTest(method=method, path=path, headers=headers): response = self.client.request(method, path, headers=headers, json={}) self.assertEqual(response.status_code, 401) self.assertIsNone(self.lifecycle.last_spec) self.assertEqual(self.lifecycle.vm.status, "running") authorized = self.client.get("/vms", headers=self.headers) self.assertEqual(authorized.status_code, 200) self.assertEqual(authorized.json()[0]["id"], "vm-test") self.assertNotIn("password", authorized.json()[0]) def test_vm_lifecycle_routes_and_extra_field_validation(self) -> None: created = self.client.post( "/vms", headers=self.headers, json={ "cpu": 1, "ram": "512", "username": "root", "password": "api-secret", }, ) detail = self.client.get("/vms/vm-test", headers=self.headers) stopped = self.client.post("/vms/vm-test/stop", headers=self.headers) destroyed = self.client.delete("/vms/vm-test", headers=self.headers) invalid = self.client.post("/vms", headers=self.headers, json={"forse": True}) self.assertEqual(created.status_code, 201) self.assertEqual(created.json()["username"], "root") self.assertEqual(self.lifecycle.last_spec.password, "api-secret") for response in (created, detail, stopped, destroyed): self.assertNotIn("password", response.json()) self.assertNotIn("api-secret", response.text) self.assertEqual(detail.status_code, 200) self.assertEqual(stopped.json()["status"], "stopped") self.assertEqual(stopped.json()["policy_status"], "inactive") self.assertEqual(destroyed.json(), {"id": "vm-test", "status": "terminated"}) self.assertEqual(invalid.status_code, 422) def test_vm_create_defaults_guest_credentials_to_root(self) -> None: response = self.client.post( "/vms", headers=self.headers, json={"cpu": 1, "ram": "512"}, ) self.assertEqual(response.status_code, 201) self.assertEqual(self.lifecycle.last_spec.username, "root") self.assertEqual(self.lifecycle.last_spec.password, "root") self.assertIsNone(self.lifecycle.last_spec.name) self.assertEqual(self.lifecycle.last_spec.ports, (PortRule(22),)) def test_create_accepts_valid_names(self) -> None: for name in (None, "a", "web-1", "a" * 63): with self.subTest(name=name): response = self.client.post("/vms", headers=self.headers, json={"name": name}) self.assertEqual(response.status_code, 201) self.assertEqual(self.lifecycle.last_spec.name, name) self.assertEqual(response.json()["name"], name) def test_create_rejects_invalid_names_before_lifecycle_calls(self) -> None: for name in ("", "-web", "9web", "Web", "web_name", "web.name", "two words", "a" * 64, 1, True): with self.subTest(name=name): response = self.client.post("/vms", headers=self.headers, json={"name": name}) self.assertEqual(response.status_code, 422) self.assertIsNone(self.lifecycle.last_spec) def test_create_normalizes_private_ports_and_default_ssh(self) -> None: cases = ( ({}, (PortRule(22),)), ({"ports": None}, (PortRule(22),)), ({"ports": []}, (PortRule(22),)), ( {"ports": [{"port": 443, "protocol": "HTTPS"}, {"port": 80, "protocol": "HTTP"}]}, (PortRule(22), PortRule(80, "HTTP"), PortRule(443, "HTTPS")), ), ( {"ports": [{"port": 80}, {"port": 22}, {"port": 80, "protocol": "TCP"}]}, (PortRule(22), PortRule(80)), ), ({"ports": [{"port": 22, "protocol": "HTTP"}]}, (PortRule(22, "HTTP"),)), ({"ports": [{"port": 65535}, {"port": 1}]}, (PortRule(1), PortRule(22), PortRule(65535))), ({"no_default_ports": True}, ()), ({"no_default_ports": True, "ports": None}, ()), ({"no_default_ports": True, "ports": []}, ()), ( {"no_default_ports": True, "ports": [{"port": 443, "protocol": "HTTPS"}]}, (PortRule(443, "HTTPS"),), ), ({"no_default_ports": True, "ports": [{"port": 22}]}, (PortRule(22),)), ) for payload, expected in cases: with self.subTest(payload=payload): response = self.client.post("/vms", headers=self.headers, json=payload) self.assertEqual(response.status_code, 201, response.text) self.assertEqual(self.lifecycle.last_spec.ports, expected) self.assertEqual(response.json()["ports"], [rule.to_dict() for rule in expected]) self.assertEqual(response.json()["policy_status"], "enforced") def test_create_rejects_invalid_ports_before_lifecycle_calls(self) -> None: invalid_rules = [ {"port": value} for value in (0, -1, 65536, True, False, 22.0, 22.5, "22", None, {}, []) ] + [ {"port": 80, "protocol": value} for value in ("UDP", "http", "HTTP ", "", None, True, 1) ] + [ {}, {"protocol": "TCP"}, {"port": 80, "public_port": 8080}, {"port": 80, "host_port": 8080}, ] payloads = [{"ports": [rule]} for rule in invalid_rules] + [ {"ports": value} for value in ("22:TCP", {}, [22], ["22:TCP"], [None]) ] + [ {"ports": [{"port": 80}, {"port": 80, "protocol": "HTTP"}]}, {"ports": [{"port": 443, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]}, {"public_ports": [80]}, ] + [ {"no_default_ports": value} for value in (None, 0, 1, "true", "false") ] for payload in payloads: with self.subTest(payload=payload): response = self.client.post("/vms", headers=self.headers, json=payload) self.assertEqual(response.status_code, 422, response.text) self.assertIsNone(self.lifecycle.last_spec) def test_list_and_get_whitelist_fields_and_preserve_observed_policy(self) -> None: expected_fields = { "id", "name", "cpu", "ram_mib", "guest_ip", "gateway", "mac", "username", "status", "observed_status", "ports", "policy_status", "operation", "pid", "created_at", "updated_at", "last_error", } self.lifecycle.vm.operation = "ports" self.lifecycle.observed_status = "dead" for ports, policy_status in ( (None, "unmanaged"), ((), "enforced"), ((PortRule(443, "HTTPS"),), "missing"), ((PortRule(22),), "unknown"), ((PortRule(22),), "drift"), ): with self.subTest(ports=ports, policy_status=policy_status): self.lifecycle.vm.ports = ports self.lifecycle.policy_status = policy_status listed = self.client.get("/vms", headers=self.headers) detail = self.client.get("/vms/test-vm", headers=self.headers) self.assertEqual(listed.status_code, 200) self.assertEqual(detail.status_code, 200) self.assertIsInstance(listed.json(), list) self.assertEqual(listed.json(), [detail.json()]) body = detail.json() self.assertEqual(set(body), expected_fields) self.assertEqual(body["name"], "test-vm") self.assertEqual(body["status"], "running") self.assertEqual(body["observed_status"], "dead") self.assertEqual(body["policy_status"], policy_status) self.assertEqual(body["operation"], "ports") self.assertEqual(body["ports"], None if ports is None else [p.to_dict() for p in ports]) self.assertNotIn("guest-secret", detail.text) def test_get_delegates_id_ip_and_name_resolution_to_inspect(self) -> None: with ( patch.object(self.lifecycle, "inspect", wraps=self.lifecycle.inspect) as inspect, patch.object(self.lifecycle, "list_vms", side_effect=AssertionError("must use inspect")), ): for identifier in ("vm-test", "10.42.0.2", "test-vm"): with self.subTest(identifier=identifier): response = self.client.get(f"/vms/{identifier}", headers=self.headers) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["id"], "vm-test") inspect.assert_called_with(identifier) def test_stop_and_destroy_pass_identifiers_to_lifecycle(self) -> None: with ( patch.object(self.lifecycle, "stop", wraps=self.lifecycle.stop) as stop, patch.object(self.lifecycle, "destroy", wraps=self.lifecycle.destroy) as destroy, ): for identifier in ("vm-test", "10.42.0.2", "test-vm"): with self.subTest(identifier=identifier): stopped = self.client.post(f"/vms/{identifier}/stop", headers=self.headers) self.assertEqual(stopped.status_code, 200) self.assertEqual(stopped.json()["id"], "vm-test") self.assertEqual(stopped.json()["policy_status"], "inactive") self.assertEqual(stopped.json()["ports"], [{"port": 22, "protocol": "TCP"}]) stop.assert_called_with(identifier) destroyed = self.client.delete(f"/vms/{identifier}", headers=self.headers) self.assertEqual(destroyed.status_code, 200) self.assertEqual(destroyed.json()["id"], "vm-test") destroy.assert_called_with(identifier) def test_create_claims_enforcement_only_for_successful_managed_vms(self) -> None: for ports, status, operation, policy_status in ( (None, "running", None, "unmanaged"), ((), "running", None, "enforced"), ((PortRule(22),), "failed", None, "unknown"), ((PortRule(22),), "starting", "create", "unknown"), ((PortRule(22),), "running", "ports", "unknown"), ): with self.subTest(ports=ports, status=status, operation=operation): self.lifecycle.vm.ports = ports self.lifecycle.vm.status = status self.lifecycle.vm.operation = operation with patch.object(self.lifecycle, "create", return_value=self.lifecycle.vm): response = self.client.post("/vms", headers=self.headers, json={}) self.assertEqual(response.status_code, 201) self.assertEqual(response.json()["policy_status"], policy_status) def test_lifecycle_errors_use_typed_status_codes(self) -> None: for method, path, operation in ( ("GET", "/vms", "list_vms"), ("GET", "/vms/test-vm", "inspect"), ("POST", "/vms", "create"), ("POST", "/vms/test-vm/stop", "stop"), ("DELETE", "/vms/test-vm", "destroy"), ): for error, expected in ( (NotFoundError("unknown guest"), 404), (ConflictError("ambiguous name"), 409), (ValidationError("invalid request"), 422), (ConfigurationError("invalid settings"), 422), (UvmError("this operation needs root"), 403), (UvmError("host operation failed"), 500), ): with self.subTest(operation=operation, error=error): with patch.object(self.lifecycle, operation, side_effect=error): response = self.client.request(method, path, headers=self.headers, json={}) self.assertEqual(response.status_code, expected) self.assertEqual(response.json(), {"error": {"message": str(error)}}) def test_missing_vm_returns_not_found(self) -> None: response = self.client.get("/vms/missing", headers=self.headers) self.assertEqual(response.status_code, 404) self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}}) def test_api_schema_keeps_only_existing_operations(self) -> None: schema = self.client.get("/openapi.json").json() self.assertEqual( {(path, method) for path, methods in schema["paths"].items() for method in methods}, { ("/health", "get"), ("/install", "post"), ("/vms", "get"), ("/vms", "post"), ("/vms/{identifier}", "get"), ("/vms/{vm_id}/stop", "post"), ("/vms/{vm_id}", "delete"), }, ) self.assertEqual( set(schema["components"]["schemas"]["PortRuleModel"]["properties"]), {"port", "protocol"}, ) def test_install_route_uses_the_existing_installer(self) -> None: response = self.client.post("/install", headers=self.headers, json={"force": True}) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["firecracker"], "/tmp/firecracker")