Initial working version

This commit is contained in:
Philip Henning 2026-03-08 18:32:25 +01:00
parent 34a0627e76
commit b6886cb34a
61 changed files with 4475 additions and 6 deletions

16
tests/conftest.py Normal file
View file

@ -0,0 +1,16 @@
from __future__ import annotations
import pytest
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
markexpr = (config.option.markexpr or "").strip()
if markexpr:
return
skip_live = pytest.mark.skip(
reason="Live tests run only via `pytest -m live` or `pytest -m live_create`."
)
for item in items:
if "live" in item.keywords or "live_create" in item.keywords:
item.add_marker(skip_live)

View file

@ -0,0 +1,59 @@
from __future__ import annotations
import pytest
from pve_vm_setup.services.factory import ProxmoxServiceFactory
from pve_vm_setup.settings import AppSettings
def _load_live_settings_or_skip() -> AppSettings:
settings = AppSettings.from_env()
try:
settings.validate_live_requirements()
except Exception as exc: # pragma: no cover - only hit outside configured environments
pytest.skip(f"Live environment is not configured: {exc}")
return settings
@pytest.mark.live
def test_live_read_only_reference_loading() -> None:
settings = _load_live_settings_or_skip()
service = ProxmoxServiceFactory.create(settings)
assert service.mode == "live"
assert service.check_connectivity()
assert service.check_api_base()
realms = service.load_realms()
assert realms
service.login(
settings.proxmox_user or "",
settings.proxmox_password or "",
settings.proxmox_realm or "",
)
nodes = service.load_nodes()
assert nodes
pools = service.load_pools()
assert isinstance(pools, list)
tags = service.load_existing_tags()
assert isinstance(tags, list)
probe_node = settings.safety_policy.test_node or nodes[0].name
storages = service.load_storages(probe_node)
assert isinstance(storages, list)
iso_storages = [storage for storage in storages if "iso" in storage.content]
if iso_storages:
isos = service.load_isos(probe_node, iso_storages[0].storage)
assert isinstance(isos, list)
@pytest.mark.live_create
def test_live_create_path_requires_explicit_opt_in() -> None:
settings = _load_live_settings_or_skip()
if not settings.safety_policy.allow_create:
pytest.skip("Set PROXMOX_PREVENT_CREATE=false to enable live create tests.")
if settings.safety_policy.enable_test_mode:
assert settings.safety_policy.test_node

590
tests/test_app.py Normal file
View file

@ -0,0 +1,590 @@
from __future__ import annotations
import asyncio
import time
from collections import Counter
import pytest
from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.widgets import Button, Checkbox, Input, Select, Static
from pve_vm_setup.app import PveVmSetupApp
from pve_vm_setup.models.workflow import WorkflowState
from pve_vm_setup.screens.login import LoginView
from pve_vm_setup.screens.wizard import NO_DISK_SELECTED, AutoStartConfirmModal, WizardView
from pve_vm_setup.services.fake import FakeProxmoxService
from pve_vm_setup.settings import AppSettings
class LoginHarnessApp(App[None]):
def compose(self) -> ComposeResult:
yield LoginView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
FakeProxmoxService(),
)
async def wait_for_wizard_ready(
pilot,
app: App[None],
*,
attempts: int = 12,
delay: float = 0.1,
) -> None:
for _ in range(attempts):
await pilot.pause(delay)
if (
app.query_one("#general-vmid", Input).value == "123"
and app.query_one("#general-node", Select).value == "fake-node-01"
and app.query_one("#os-storage", Select).value == "cephfs"
):
return
raise AssertionError("Timed out waiting for wizard reference data to load.")
@pytest.mark.asyncio
async def test_login_view_authenticates_with_pilot() -> None:
app = LoginHarnessApp()
async with app.run_test() as pilot:
await pilot.pause()
assert str(app.query_one("#title", Static).renderable) == "Proxmox Login"
assert app.focused is app.query_one("#username", Input)
app.query_one("#username", Input).value = "junior"
app.query_one("#password", Input).value = "secret"
app.query_one("#connect", Button).press()
await pilot.pause()
assert "Authenticated as junior@pam." == str(app.query_one("#status", Static).renderable)
@pytest.mark.asyncio
async def test_main_app_mounts_wizard_only_after_login() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
await pilot.pause()
assert app.query(LoginView)
assert not app.query(WizardView)
login = app.query_one(LoginView)
login.post_message(LoginView.Authenticated("junior@pam", "pam"))
await pilot.pause()
await pilot.pause()
await wait_for_wizard_ready(pilot, app)
assert not app.query(LoginView)
assert app.query(WizardView)
assert app.focused is app.query_one("#general-name", Input)
@pytest.mark.asyncio
async def test_wizard_activation_focuses_first_editable_field() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.focused is app.query_one("#general-name", Input)
@pytest.mark.asyncio
async def test_wizard_initial_activation_does_not_duplicate_live_reference_loads() -> None:
class CountingService(FakeProxmoxService):
def __init__(self) -> None:
super().__init__()
self.calls: list[str] = []
def load_nodes(self):
self.calls.append("load_nodes")
return super().load_nodes()
def load_pools(self):
self.calls.append("load_pools")
return super().load_pools()
def load_existing_tags(self):
self.calls.append("load_existing_tags")
return super().load_existing_tags()
def load_next_vmid(self):
self.calls.append("load_next_vmid")
return super().load_next_vmid()
def load_storages(self, node: str):
self.calls.append(f"load_storages:{node}")
return super().load_storages(node)
def load_bridges(self, node: str):
self.calls.append(f"load_bridges:{node}")
return super().load_bridges(node)
def load_isos(self, node: str, storage: str):
self.calls.append(f"load_isos:{node}:{storage}")
return super().load_isos(node, storage)
service = CountingService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
for _ in range(6):
await pilot.pause()
assert Counter(service.calls) == Counter(
[
"load_nodes",
"load_pools",
"load_existing_tags",
"load_next_vmid",
"load_storages:fake-node-01",
"load_bridges:fake-node-01",
"load_isos:fake-node-01:cephfs",
]
)
@pytest.mark.asyncio
async def test_wizard_initial_activation_loads_reference_data_concurrently() -> None:
class SlowService(FakeProxmoxService):
delay = 0.15
def load_nodes(self):
time.sleep(self.delay)
return super().load_nodes()
def load_pools(self):
time.sleep(self.delay)
return super().load_pools()
def load_existing_tags(self):
time.sleep(self.delay)
return super().load_existing_tags()
def load_next_vmid(self):
time.sleep(self.delay)
return super().load_next_vmid()
def load_storages(self, node: str):
time.sleep(self.delay)
return super().load_storages(node)
def load_bridges(self, node: str):
time.sleep(self.delay)
return super().load_bridges(node)
def load_isos(self, node: str, storage: str):
time.sleep(self.delay)
return super().load_isos(node, storage)
service = SlowService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
started_at = time.perf_counter()
wizard.activate()
await wait_for_wizard_ready(pilot, app)
elapsed = time.perf_counter() - started_at
assert elapsed < 1.0
@pytest.mark.asyncio
async def test_wizard_uses_scrollable_sections_with_border_titles() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
general_section = app.query_one("#general-section", ScrollableContainer)
os_section = app.query_one("#os-section", ScrollableContainer)
assert str(general_section.border_title).strip() == "General"
assert str(os_section.border_title).strip() == "Operating System"
@pytest.mark.asyncio
async def test_wizard_hides_os_fields_based_on_media_choice() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.query_one("#os-storage", Select).display is True
assert app.query_one("#os-iso", Select).display is True
assert app.query_one("#os-physical-drive", Input).display is False
app.query_one("#os-media-choice", Select).value = "physical"
await pilot.pause()
assert app.query_one("#os-storage", Select).display is False
assert app.query_one("#os-iso", Select).display is False
assert app.query_one("#os-physical-drive", Input).display is True
app.query_one("#os-media-choice", Select).value = "none"
await pilot.pause()
assert app.query_one("#os-storage", Select).display is False
assert app.query_one("#os-iso", Select).display is False
assert app.query_one("#os-physical-drive", Input).display is False
@pytest.mark.asyncio
async def test_wizard_hides_dependent_system_memory_and_network_fields() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.query_one("#system-efi-storage", Select).display is True
assert app.query_one("#system-pre-enroll", Checkbox).display is True
app.query_one("#system-add-efi", Checkbox).value = False
await pilot.pause()
assert app.query_one("#system-efi-storage", Select).display is False
assert app.query_one("#system-pre-enroll", Checkbox).display is False
app.query_one("#system-tpm", Checkbox).value = True
await pilot.pause()
assert app.query_one("#system-efi-storage", Select).display is True
assert app.query_one("#system-pre-enroll", Checkbox).display is False
assert app.query_one("#memory-min-size", Input).display is True
assert app.query_one("#memory-ksm", Checkbox).display is True
app.query_one("#memory-ballooning", Checkbox).value = False
await pilot.pause()
assert app.query_one("#memory-min-size", Input).display is False
assert app.query_one("#memory-ksm", Checkbox).display is False
assert app.query_one("#network-bridge", Select).display is True
assert app.query_one("#network-rate", Input).display is True
app.query_one("#network-none", Checkbox).value = True
await pilot.pause()
assert app.query_one("#network-bridge", Select).display is False
assert app.query_one("#network-rate", Input).display is False
@pytest.mark.asyncio
async def test_wizard_tag_rows_keep_input_and_button_visible() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
assert app.query_one("#general-tag-input", Input).display is True
assert app.query_one("#general-tag-add", Button).display is True
assert app.query_one("#general-tag-existing", Select).display is True
assert app.query_one("#general-tag-use", Button).display is True
@pytest.mark.asyncio
async def test_wizard_add_tag_button_updates_current_tags() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
app.query_one("#general-tag-input", Input).value = "alpha"
app.query_one("#general-tag-add", Button).press()
await pilot.pause()
assert wizard._workflow.config.general.tags == ["alpha"]
current_tags = app.query_one("#general-tag-current", Select)
assert current_tags.display is True
@pytest.mark.asyncio
async def test_wizard_hiding_select_collapses_open_overlay() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
storage = app.query_one("#os-storage", Select)
storage.expanded = True
await pilot.pause()
assert storage.expanded is True
app.query_one("#os-media-choice", Select).value = "physical"
await pilot.pause()
assert storage.display is False
assert storage.expanded is False
@pytest.mark.asyncio
async def test_disk_toolbar_buttons_render_left_of_disk_selector() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
wizard._workflow.current_step_index = 3
wizard._show_step()
await pilot.pause()
add_button = app.query_one("#disks-add", Button)
remove_button = app.query_one("#disks-remove", Button)
selector = app.query_one("#disks-select", Select)
assert add_button.region.x < remove_button.region.x
assert selector.region.x == add_button.region.x
assert selector.region.width > remove_button.region.width * 3
@pytest.mark.asyncio
async def test_disk_selector_switches_between_configured_disks_without_blank_option() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
wizard._workflow.current_step_index = 3
wizard._show_step()
await pilot.pause()
app.query_one("#disks-add", Button).press()
await pilot.pause()
selector = app.query_one("#disks-select", Select)
option_values = [value for _, value in selector._options]
assert NO_DISK_SELECTED not in option_values
assert selector.disabled is False
assert selector.value == "1"
await asyncio.wait_for(pilot.click("#disks-select"), timeout=2)
await pilot.pause()
assert selector.expanded is True
await asyncio.wait_for(pilot.press("up"), timeout=2)
await pilot.pause()
await asyncio.wait_for(pilot.press("enter"), timeout=2)
await pilot.pause()
assert selector.expanded is False
assert selector.value == "0"
assert wizard._selected_disk_index == 0
assert app.focused is selector
@pytest.mark.asyncio
async def test_confirm_step_replaces_create_with_exit_after_success() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
wizard._workflow.current_step_index = 7
wizard._workflow.submission.phase = "success"
wizard._workflow.submission.message = "VM 123 created."
wizard._show_step()
await pilot.pause()
create_button = app.query_one("#wizard-create", Button)
assert str(create_button.label) == "Exit"
assert app.focused is create_button
exited: list[bool] = []
app.exit = lambda *args, **kwargs: exited.append(True) # type: ignore[method-assign]
create_button.press()
await pilot.pause()
assert exited == [True]
@pytest.mark.asyncio
async def test_confirm_step_asks_whether_to_start_vm_before_submitting() -> None:
service = FakeProxmoxService()
app = PveVmSetupApp(
AppSettings.from_env({}, load_dotenv_file=False),
service=service,
)
async with app.run_test() as pilot:
app.query_one(LoginView).remove()
wizard = WizardView(
AppSettings.from_env({}, load_dotenv_file=False),
WorkflowState(),
service,
)
await app.query_one("#app-body").mount(wizard)
wizard.activate()
await wait_for_wizard_ready(pilot, app)
app.query_one("#general-name", Input).value = "demo"
wizard._workflow.current_step_index = 7
wizard._show_step()
await pilot.pause()
app.query_one("#wizard-create", Button).press()
await pilot.pause()
assert isinstance(app.screen_stack[-1], AutoStartConfirmModal)
assert service.created_vms == []
app.query_one("#auto-start-no", Button).press()
for _ in range(20):
await pilot.pause(0.05)
if service.created_vms:
break
assert len(service.created_vms) == 1
assert service.start_after_create_requests == [False]

94
tests/test_doctor.py Normal file
View file

@ -0,0 +1,94 @@
from __future__ import annotations
from io import StringIO
from pve_vm_setup.doctor import run_live_doctor
from pve_vm_setup.services.base import AuthenticatedSession, Node, Pool, Realm
from pve_vm_setup.settings import AppSettings
class StubDoctorService:
mode = "live"
def check_connectivity(self) -> str:
return "HTTP 200"
def check_api_base(self) -> str:
return "8.2"
def load_realms(self) -> list[Realm]:
return [Realm(name="pam", title="Linux PAM standard authentication", default=True)]
def login(self, username: str, password: str, realm: str) -> AuthenticatedSession:
return AuthenticatedSession(username=f"{username}@{realm}", ticket="ticket")
def load_nodes(self) -> list[Node]:
return [Node(name="pve-test-01")]
def load_pools(self) -> list[Pool]:
return [Pool(poolid="sandbox")]
def load_existing_tags(self) -> list[str]:
return []
def load_storages(self, node: str):
raise AssertionError("not used in doctor")
def load_isos(self, node: str, storage: str):
raise AssertionError("not used in doctor")
class StubFactory:
@staticmethod
def create(settings: AppSettings) -> StubDoctorService:
return StubDoctorService()
def test_doctor_succeeds_and_keeps_secrets_out_of_output() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "super-secret",
"PROXMOX_REALM": "pam",
},
load_dotenv_file=False,
)
stream = StringIO()
exit_code = run_live_doctor(settings, stream=stream, service_factory=StubFactory)
output = stream.getvalue()
assert exit_code == 0
assert "Doctor finished successfully." in output
assert "super-secret" not in output
assert "root@pam" in output
assert "host: proxmox.example.invalid:8006" in output
def test_doctor_validates_create_scope_when_enabled() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "super-secret",
"PROXMOX_REALM": "pam",
"PROXMOX_PREVENT_CREATE": "false",
"PROXMOX_ENABLE_TEST_MODE": "true",
"PROXMOX_TEST_NODE": "pve-test-01",
"PROXMOX_TEST_POOL": "sandbox",
},
load_dotenv_file=False,
)
stream = StringIO()
exit_code = run_live_doctor(settings, stream=stream, service_factory=StubFactory)
output = stream.getvalue()
assert exit_code == 0
assert "prevent_create: False" in output
assert "enable_test_mode: True" in output
assert "node=pve-test-01" in output
assert "pool=sandbox" in output
assert "tag=codex-e2e" in output
assert "name_prefix=codex-e2e-" in output

107
tests/test_domain.py Normal file
View file

@ -0,0 +1,107 @@
from pve_vm_setup.domain import build_create_payload, select_latest_nixos_iso, validate_all_steps
from pve_vm_setup.models.workflow import VmConfig
from pve_vm_setup.settings import AppSettings
def test_select_latest_nixos_iso_prefers_latest_year_month() -> None:
choice = select_latest_nixos_iso(
[
"cephfs:iso/nixos-minimal-24.11.1234abcd-x86_64-linux.iso",
"cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso",
"cephfs:iso/debian-12.iso",
]
)
assert choice == "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
def test_build_create_payload_applies_safety_name_tag_and_key_settings() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
"PROXMOX_ENABLE_TEST_MODE": "true",
"PROXMOX_TEST_NODE": "fake-node-01",
"PROXMOX_TEST_POOL": "lab",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.tags = ["linux"]
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
payload = build_create_payload(config, settings)
assert payload["name"] == "codex-e2e-demo"
assert payload["tags"] == "codex-e2e;linux"
assert payload["bios"] == "ovmf"
assert payload["scsihw"] == "virtio-scsi-single"
assert payload["allow-ksm"] == 1
assert payload["net0"] == "model=virtio,bridge=vmbr9,firewall=1,link_down=0"
assert payload["scsi0"] == (
"ceph-pool:32,format=raw,cache=none,discard=ignore,"
"iothread=1,ssd=1,backup=1,replicate=1,aio=io_uring"
)
def test_validate_all_steps_requires_live_create_opt_in() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "true",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
errors = validate_all_steps(config, settings, references=type("Refs", (), {})())
assert "Set PROXMOX_PREVENT_CREATE=false to enable VM creation." in errors
def test_build_create_payload_leaves_name_and_tags_untouched_outside_test_mode() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.tags = ["linux"]
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
payload = build_create_payload(config, settings)
assert payload["name"] == "demo"
assert payload["tags"] == "linux"
def test_build_create_payload_can_disable_allow_ksm() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
},
load_dotenv_file=False,
)
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos-minimal-25.05.ffffeeee-x86_64-linux.iso"
config.memory.allow_ksm = False
payload = build_create_payload(config, settings)
assert payload["allow-ksm"] == 0

30
tests/test_factory.py Normal file
View file

@ -0,0 +1,30 @@
from pve_vm_setup.services.factory import ProxmoxServiceFactory
from pve_vm_setup.services.fake import FakeProxmoxService
from pve_vm_setup.services.proxmox import LiveProxmoxService
from pve_vm_setup.settings import AppSettings
def test_factory_returns_fake_service_when_live_env_is_missing() -> None:
settings = AppSettings.from_env({}, load_dotenv_file=False)
service = ProxmoxServiceFactory.create(settings)
assert isinstance(service, FakeProxmoxService)
def test_factory_returns_live_service_when_live_env_is_present() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "secret",
"PROXMOX_REALM": "pam",
},
load_dotenv_file=False,
)
service = ProxmoxServiceFactory.create(settings)
try:
assert isinstance(service, LiveProxmoxService)
finally:
service.close()

View file

@ -0,0 +1,193 @@
from __future__ import annotations
from urllib.parse import parse_qs
import httpx
import pytest
from pve_vm_setup.errors import ProxmoxConnectError
from pve_vm_setup.models.workflow import VmConfig
from pve_vm_setup.services.proxmox import ProxmoxApiClient
from pve_vm_setup.settings import AppSettings
def build_settings() -> AppSettings:
return AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "secret",
"PROXMOX_REALM": "pam",
},
load_dotenv_file=False,
)
def test_client_uses_api_base_when_loading_realms() -> None:
recorded_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded_urls.append(str(request.url))
return httpx.Response(200, json={"data": [{"realm": "pam", "comment": "Linux PAM"}]})
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
try:
realms = client.load_realms()
finally:
client.close()
assert realms[0].name == "pam"
assert recorded_urls == ["https://proxmox.example.invalid:8006/api2/json/access/domains"]
def test_client_maps_connect_errors() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
try:
with pytest.raises(ProxmoxConnectError):
client.load_realms()
finally:
client.close()
def test_client_attaches_serial_device_without_switching_display_to_serial() -> None:
requests: list[tuple[str, str, bytes]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, request.content))
path = request.url.path
if path.endswith("/nodes/fake-node-01/qemu") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:create"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:create/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/config") and request.method == "PUT":
return httpx.Response(200, json={"data": "UPID:serial"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:serial/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
raise AssertionError(f"Unexpected request: {request.method} {request.url}")
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
client._ticket = "ticket"
client._csrf_token = "csrf"
client._client.cookies.set("PVEAuthCookie", "ticket")
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.ha_enabled = False
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos.iso"
try:
client.create_vm(config)
finally:
client.close()
serial_request = next(
content
for method, path, content in requests
if method == "PUT" and path.endswith("/nodes/fake-node-01/qemu/123/config")
)
payload = parse_qs(serial_request.decode())
assert payload["serial0"] == ["socket"]
assert "vga" not in payload
def test_client_starts_vm_after_create_when_requested() -> None:
requests: list[tuple[str, str, bytes]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, request.content))
path = request.url.path
if path.endswith("/nodes/fake-node-01/qemu") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:create"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:create/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/config") and request.method == "PUT":
return httpx.Response(200, json={"data": "UPID:serial"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:serial/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/status/start") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:start"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:start/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
raise AssertionError(f"Unexpected request: {request.method} {request.url}")
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
client._ticket = "ticket"
client._csrf_token = "csrf"
client._client.cookies.set("PVEAuthCookie", "ticket")
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.ha_enabled = False
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos.iso"
try:
client.create_vm(config, start_after_create=True)
finally:
client.close()
assert any(
method == "POST" and path.endswith("/nodes/fake-node-01/qemu/123/status/start")
for method, path, _ in requests
)
def test_client_registers_ha_without_start_when_auto_start_disabled() -> None:
requests: list[tuple[str, str, bytes]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path, request.content))
path = request.url.path
if path.endswith("/nodes/fake-node-01/qemu") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:create"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:create/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/nodes/fake-node-01/qemu/123/config") and request.method == "PUT":
return httpx.Response(200, json={"data": "UPID:serial"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:serial/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
if path.endswith("/cluster/ha/resources") and request.method == "POST":
return httpx.Response(200, json={"data": "UPID:ha"})
if path.endswith("/nodes/fake-node-01/tasks/UPID:ha/status"):
return httpx.Response(200, json={"data": {"status": "stopped", "exitstatus": "OK"}})
raise AssertionError(f"Unexpected request: {request.method} {request.url}")
client = ProxmoxApiClient(build_settings(), transport=httpx.MockTransport(handler))
client._ticket = "ticket"
client._csrf_token = "csrf"
client._client.cookies.set("PVEAuthCookie", "ticket")
config = VmConfig()
config.general.node = "fake-node-01"
config.general.vmid = 123
config.general.name = "demo"
config.general.ha_enabled = True
config.os.storage = "cephfs"
config.os.iso = "cephfs:iso/nixos.iso"
try:
client.create_vm(config, start_after_create=False)
finally:
client.close()
ha_request = next(
content
for method, path, content in requests
if method == "POST" and path.endswith("/cluster/ha/resources")
)
payload = parse_qs(ha_request.decode())
assert payload["state"] == ["stopped"]
assert not any(
method == "POST" and path.endswith("/nodes/fake-node-01/qemu/123/status/start")
for method, path, _ in requests
)

56
tests/test_settings.py Normal file
View file

@ -0,0 +1,56 @@
import pytest
from pve_vm_setup.errors import SettingsError
from pve_vm_setup.settings import AppSettings
def test_settings_load_defaults_and_normalize_api_base() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_URL": "https://proxmox.example.invalid:8006/",
"PROXMOX_USER": "root",
"PROXMOX_PASSWORD": "secret",
"PROXMOX_REALM": "pam",
"PROXMOX_API_BASE": "api2/json",
},
load_dotenv_file=False,
)
assert settings.proxmox_url == "https://proxmox.example.invalid:8006"
assert settings.proxmox_api_base == "/api2/json"
assert settings.proxmox_verify_tls is False
assert settings.request_timeout_seconds == 15
assert settings.effective_username == "root@pam"
assert settings.safety_policy.prevent_create is False
assert settings.safety_policy.enable_test_mode is False
assert settings.safety_policy.test_tag == "codex-e2e"
assert settings.safety_policy.test_vm_name_prefix == "codex-e2e-"
def test_settings_reject_test_mode_without_required_scope() -> None:
with pytest.raises(SettingsError):
AppSettings.from_env(
{
"PROXMOX_ENABLE_TEST_MODE": "true",
},
load_dotenv_file=False,
)
def test_settings_allow_create_without_test_scope_when_test_mode_disabled() -> None:
settings = AppSettings.from_env(
{
"PROXMOX_PREVENT_CREATE": "false",
},
load_dotenv_file=False,
)
assert settings.safety_policy.allow_create is True
assert settings.safety_policy.enable_test_mode is False
def test_settings_allow_create_by_default_when_prevent_flag_is_unset() -> None:
settings = AppSettings.from_env({}, load_dotenv_file=False)
assert settings.safety_policy.prevent_create is False
assert settings.safety_policy.allow_create is True