Files
homelable/backend/tests/test_proxmox_router.py
T
Pouzor 9670d0a86a feat: proxmox import diagnostics, node style fix, and cluster edges
Scan History:
- Proxmox is now a first-class scan kind (badge, filter chip, Server icon,
  completion toast) instead of being mislabeled as an IP scan.
- A done run carrying a non-fatal advisory renders amber (info) with a
  warning toast, distinct from red failures.

Import diagnostics:
- test-connection probes /access/permissions and warns when the API token
  has no ACL (VMs/LXC would be invisible) — points at the PVEAuditor grant.
- import surfaces an advisory when hosts import but no guests are visible
  (privilege-separated token whose rights are the intersection with the user),
  rather than a silent "done".

Node style:
- Proxmox container mode is now opt-in (container_mode === true), matching the
  rest of the codebase (App.tsx nesting logic). Imported proxmox nodes leave
  the flag unset and render like a manually-created node instead of an empty
  group container.

Cluster edges:
- Hosts from one import are chained with 'cluster' edges via left/right handles,
  distinct from the vertical host->guest 'virtual' edges. Wired for both the
  direct "Add to Canvas" path and the pending -> approve path (host<->host
  proxmox_cluster links, resolved to cluster edges on approve; cluster hosts
  get left/right handles).

Tests added on both sides.

ha-relevant: maybe
2026-07-06 10:26:52 +02:00

279 lines
11 KiB
Python

"""API + persistence tests for /api/v1/proxmox/*."""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.api.routes.proxmox import _guest_visibility_advisory, _persist_pending_import
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
from app.core.config import settings
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture(autouse=True)
def _clear_env_token():
"""Ensure a clean token state per test; restore afterwards."""
tid, sec = settings.proxmox_token_id, settings.proxmox_token_secret
settings.proxmox_token_id = ""
settings.proxmox_token_secret = ""
yield
settings.proxmox_token_id, settings.proxmox_token_secret = tid, sec
def _host_node() -> dict:
return {
"id": "pve-node-pve1", "label": "pve1", "type": "proxmox",
"ieee_address": "pve-node-pve1", "hostname": "pve1", "ip": None,
"status": "online", "cpu_count": 8, "ram_gb": 16.0, "disk_gb": 500.0,
"vendor": "Proxmox VE", "model": None, "parent_ieee": None,
}
def _guest_node(vmid: int, ip: str | None, status: str = "online") -> dict:
return {
"id": f"pve-pve1-{vmid}", "label": f"vm{vmid}", "type": "vm",
"ieee_address": f"pve-pve1-{vmid}", "hostname": f"vm{vmid}", "ip": ip,
"status": status, "cpu_count": 2, "ram_gb": 4.0, "disk_gb": 32.0,
"vendor": "Proxmox VE", "model": "QEMU", "vmid": vmid,
"parent_ieee": "pve-node-pve1",
}
# --- endpoints -------------------------------------------------------------
@pytest.mark.asyncio
async def test_test_connection_uses_body_token(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.proxmox.test_proxmox_connection", new=AsyncMock(return_value=(True, "ok"))):
res = await client.post(
"/api/v1/proxmox/test-connection",
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
headers=headers,
)
assert res.status_code == 200
assert res.json()["connected"] is True
@pytest.mark.asyncio
async def test_missing_token_is_rejected(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/proxmox/test-connection",
json={"host": "pve", "port": 8006},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_import_pending_creates_scan_run(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.proxmox._background_proxmox_import", new_callable=AsyncMock):
res = await client.post(
"/api/v1/proxmox/import-pending",
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "proxmox"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/proxmox/import-pending", json={"host": "pve"})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_config_omits_token(client: AsyncClient, headers: dict) -> None:
settings.proxmox_token_id = "u@pam!t"
settings.proxmox_token_secret = "supersecret"
res = await client.get("/api/v1/proxmox/config", headers=headers)
assert res.status_code == 200
body = res.text
assert "supersecret" not in body
assert res.json()["token_configured"] is True
@pytest.mark.asyncio
async def test_enable_sync_without_token_rejected(client: AsyncClient, headers: dict) -> None:
res = await client.post(
"/api/v1/proxmox/config",
json={"host": "pve", "port": 8006, "verify_tls": True, "sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
# --- guest-visibility advisory ---------------------------------------------
def test_advisory_when_hosts_only() -> None:
msg = _guest_visibility_advisory([_host_node()])
assert msg is not None
assert "PVEAuditor" in msg
def test_no_advisory_when_guests_present() -> None:
assert _guest_visibility_advisory([_host_node(), _guest_node(101, "10.0.0.5")]) is None
def test_no_advisory_when_nothing_imported() -> None:
assert _guest_visibility_advisory([]) is None
# --- persistence / dedupe --------------------------------------------------
@pytest.mark.asyncio
async def test_persist_creates_pending(db_session) -> None:
nodes = [_host_node(), _guest_node(101, "10.0.0.5")]
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
result = await _persist_pending_import(db_session, nodes, edges)
assert result.pending_created == 2
assert result.links_recorded == 1
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
assert {r.suggested_type for r in rows} == {"proxmox", "vm"}
# Specs carried as properties.
vm = next(r for r in rows if r.suggested_type == "vm")
assert any(p["key"] == "CPU Cores" for p in vm.properties)
@pytest.mark.asyncio
async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
# A device previously found by an IP scan (no ieee, no specs).
scanned = Node(
id=str(uuid.uuid4()), type="generic", label="10.0.0.5",
ip="10.0.0.5", status="online", pos_x=0, pos_y=0,
)
db_session.add(scanned)
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
# No duplicate node; identity + specs merged onto the existing one.
nodes = (await db_session.execute(select(Node).where(Node.ip == "10.0.0.5"))).scalars().all()
assert len(nodes) == 1
merged = nodes[0]
assert merged.ieee_address == "pve-pve1-101"
assert merged.cpu_count == 2
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
# Inventory row exists as approved (already on canvas).
inv = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert inv.status == "approved"
@pytest.mark.asyncio
async def test_persist_resync_updates_in_place(db_session) -> None:
nodes = [_guest_node(101, "10.0.0.5")]
await _persist_pending_import(db_session, nodes, [])
# Second sync: same device, new IP. Should update, not duplicate.
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.9")], [])
rows = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalars().all()
assert len(rows) == 1
assert rows[0].ip == "10.0.0.9"
@pytest.mark.asyncio
async def test_persist_keeps_hidden_hidden(db_session) -> None:
db_session.add(PendingDevice(
id=str(uuid.uuid4()), ieee_address="pve-pve1-101", ip="10.0.0.5",
suggested_type="vm", status="hidden", discovery_source="proxmox",
))
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
row = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert row.status == "hidden"
@pytest.mark.asyncio
async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncClient, headers: dict, db_session) -> None:
# Legacy row: properties column NULL (added by migration on older DBs).
dev = PendingDevice(
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
status="pending", discovery_source="arp",
)
dev.properties = None
db_session.add(dev)
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
assert res.json()[0]["properties"] == []
# --- cluster links ---------------------------------------------------------
@pytest.mark.asyncio
async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
# Two hosts + a guest → one host↔host cluster link, one host→guest link.
nodes = [
{**_host_node(), "id": "pve-node-a", "ieee_address": "pve-node-a", "hostname": "a", "label": "a"},
{**_host_node(), "id": "pve-node-b", "ieee_address": "pve-node-b", "hostname": "b", "label": "b"},
_guest_node(101, "10.0.0.5"),
]
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
await _persist_pending_import(db_session, nodes, edges)
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
assert len(cluster) == 1
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
# Membership helper sees both endpoints.
assert await _is_proxmox_cluster_member(db_session, "pve-node-a") is True
assert await _is_proxmox_cluster_member(db_session, "pve-node-b") is True
assert await _is_proxmox_cluster_member(db_session, "pve-pve1-101") is False
@pytest.mark.asyncio
async def test_single_host_records_no_cluster_link(db_session) -> None:
await _persist_pending_import(db_session, [_host_node()], [])
links = (await db_session.execute(
select(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
)).scalars().all()
assert links == []
@pytest.mark.asyncio
async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
# Two host nodes already on a canvas + a pending cluster link between them.
design = Design(id=str(uuid.uuid4()), name="d")
db_session.add(design)
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", ieee_address="pve-node-a",
status="online", pos_x=0, pos_y=0, design_id=design.id,
left_handles=1, right_handles=1)
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", ieee_address="pve-node-b",
status="online", pos_x=0, pos_y=0, design_id=design.id,
left_handles=1, right_handles=1)
db_session.add_all([a, b])
db_session.add(PendingDeviceLink(
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
discovery_source="proxmox_cluster",
))
await db_session.commit()
await _resolve_pending_links_for_ieee(db_session, "pve-node-a")
edge = (await db_session.execute(select(Edge))).scalars().one()
assert edge.type == "cluster"
assert edge.source_handle == "right"
assert edge.target_handle == "left-t"
@pytest.mark.asyncio
async def test_persist_never_deletes(db_session) -> None:
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
# A later sync that no longer includes vm101 must not remove it.
await _persist_pending_import(db_session, [_guest_node(202, "10.0.0.6")], [])
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
ieees = {r.ieee_address for r in rows}
assert "pve-pve1-101" in ieees and "pve-pve1-202" in ieees