Initial working version
This commit is contained in:
parent
34a0627e76
commit
b6886cb34a
61 changed files with 4475 additions and 6 deletions
BIN
tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-313-pytest-8.4.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc
Normal file
BIN
tests/__pycache__/test_app.cpython-313-pytest-8.4.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_doctor.cpython-313-pytest-8.4.2.pyc
Normal file
BIN
tests/__pycache__/test_doctor.cpython-313-pytest-8.4.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc
Normal file
BIN
tests/__pycache__/test_domain.cpython-313-pytest-8.4.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc
Normal file
BIN
tests/__pycache__/test_factory.cpython-313-pytest-8.4.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc
Normal file
BIN
tests/__pycache__/test_settings.cpython-313-pytest-8.4.2.pyc
Normal file
Binary file not shown.
16
tests/conftest.py
Normal file
16
tests/conftest.py
Normal 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)
|
||||
Binary file not shown.
59
tests/integration/test_live_proxmox.py
Normal file
59
tests/integration/test_live_proxmox.py
Normal 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
590
tests/test_app.py
Normal 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
94
tests/test_doctor.py
Normal 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
107
tests/test_domain.py
Normal 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
30
tests/test_factory.py
Normal 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()
|
||||
193
tests/test_proxmox_client.py
Normal file
193
tests/test_proxmox_client.py
Normal 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
56
tests/test_settings.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue