501 lines
15 KiB
Python
501 lines
15 KiB
Python
import httpx
|
|
import pytest
|
|
|
|
from backend.app.connectors.unraid import UnraidConnector
|
|
from backend.app.core.config import settings
|
|
|
|
|
|
def test_unraid_request_preserves_api_key_for_same_host_https_redirect(monkeypatch):
|
|
calls = []
|
|
|
|
class FakeClient:
|
|
def __init__(self, **_kwargs):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def post(self, url, *, headers, json):
|
|
calls.append((url, headers.copy(), json.copy()))
|
|
request = httpx.Request('POST', url)
|
|
if len(calls) == 1:
|
|
return httpx.Response(
|
|
302,
|
|
headers={'location': 'https://example-host:5001/graphql'},
|
|
request=request,
|
|
)
|
|
return httpx.Response(200, json={'data': {'__typename': 'Query'}}, request=request)
|
|
|
|
monkeypatch.setitem(settings.apis, 'UNRAID_URL', 'http://example-host:5000')
|
|
monkeypatch.setitem(settings.apis, 'UNRAID_API_KEY', 'test-api-key')
|
|
monkeypatch.setattr('backend.app.connectors.unraid.httpx.Client', FakeClient)
|
|
|
|
result = UnraidConnector()._request('query { __typename }')
|
|
|
|
assert result == {'__typename': 'Query'}
|
|
assert [call[0] for call in calls] == [
|
|
'http://example-host:5000/graphql',
|
|
'https://example-host:5001/graphql',
|
|
]
|
|
assert calls[1][1]['x-api-key'] == 'test-api-key'
|
|
|
|
|
|
def test_unraid_request_refuses_cross_host_redirect(monkeypatch):
|
|
class FakeClient:
|
|
def __init__(self, **_kwargs):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def post(self, url, **_kwargs):
|
|
return httpx.Response(
|
|
302,
|
|
headers={'location': 'https://attacker.example/graphql'},
|
|
request=httpx.Request('POST', url),
|
|
)
|
|
|
|
monkeypatch.setitem(settings.apis, 'UNRAID_URL', 'http://example-host:5000')
|
|
monkeypatch.setitem(settings.apis, 'UNRAID_API_KEY', 'test-api-key')
|
|
monkeypatch.setattr('backend.app.connectors.unraid.httpx.Client', FakeClient)
|
|
|
|
with pytest.raises(RuntimeError, match='untrusted redirect'):
|
|
UnraidConnector()._request('query { __typename }')
|
|
|
|
|
|
def test_unraid_disk_warnings_maps_live_array_disk_schema(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(_queries):
|
|
return (
|
|
{
|
|
"array": {
|
|
"disks": [
|
|
{
|
|
"name": "disk1",
|
|
"status": "DISK_OK",
|
|
"warning": None,
|
|
"critical": None,
|
|
"fsUsed": 2_097_152,
|
|
"fsFree": 1_048_576,
|
|
"fsSize": 3_145_728,
|
|
},
|
|
{
|
|
"name": "disk2",
|
|
"status": "DISK_DSBL",
|
|
"warning": "SMART warning",
|
|
"critical": None,
|
|
},
|
|
]
|
|
}
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_disk_warnings()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["diskWarnings"] == 1
|
|
assert result["disks"][0] | {"type": None, "temperature": None} == {
|
|
"name": "disk1",
|
|
"status": "DISK_OK",
|
|
"healthy": True,
|
|
"type": None,
|
|
"usedGiB": 2,
|
|
"sizeGiB": 3,
|
|
"freeGiB": 1,
|
|
"temperature": None,
|
|
}
|
|
assert result["disks"][1]["name"] == "disk2"
|
|
assert result["disks"][1]["healthy"] is False
|
|
|
|
|
|
def test_unraid_share_summary_maps_live_share_schema(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(_queries):
|
|
return (
|
|
{
|
|
"shares": [
|
|
{
|
|
"name": "media",
|
|
"used": 2_097_152,
|
|
"free": 1_048_576,
|
|
"size": 0,
|
|
},
|
|
{
|
|
"name": "backups",
|
|
"used": 10,
|
|
"free": 5,
|
|
"size": 20,
|
|
},
|
|
]
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_share_summary()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["shares"] == [
|
|
{"name": "media", "usedGiB": 2, "sizeGiB": 3, "critical": False},
|
|
{"name": "backups", "usedGiB": 10, "sizeGiB": 20, "critical": False},
|
|
]
|
|
|
|
|
|
def test_unraid_docker_status_maps_live_docker_schema(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(_queries):
|
|
return (
|
|
{
|
|
"docker": {
|
|
"containers": [
|
|
{"id": "1", "names": ["plex"], "state": "running"},
|
|
{"id": "2", "names": ["radarr"], "state": "exited"},
|
|
],
|
|
"networks": [{"id": "n1", "name": "bridge"}],
|
|
}
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_docker_service_status()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["running"] is True
|
|
assert result["serviceStatus"] == "running"
|
|
assert result["containers"] == 2
|
|
|
|
|
|
def test_unraid_vm_summary_maps_live_vms_domains_schema(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(queries):
|
|
assert "vms { domains" in queries[0]
|
|
return ({"vms": {"domains": [{"id": "vm-1", "uuid": "uuid-1", "name": "Windows", "state": "RUNNING"}]}}, "ok")
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_vm_summary()
|
|
|
|
assert result == {"status": "ok", "vms": [{"id": "vm-1", "name": "Windows", "state": "RUNNING"}]}
|
|
|
|
|
|
def test_unraid_vm_summary_does_not_claim_vm_manager_is_disabled(monkeypatch):
|
|
connector = UnraidConnector()
|
|
monkeypatch.setattr(connector, "_query", lambda _queries: ({"error": "GraphQL error: Failed to retrieve VM domains: VMs are not available"}, "error"))
|
|
|
|
result = connector.get_vm_summary()
|
|
|
|
assert result["status"] == "api_unavailable"
|
|
assert "vm:read" in result["reason"]
|
|
assert "kan nog steeds actief zijn" in result["reason"]
|
|
assert result["vms"] == []
|
|
|
|
|
|
def test_unraid_vm_mutation_uses_escaped_graphql_id(monkeypatch):
|
|
connector = UnraidConnector()
|
|
monkeypatch.setattr(connector, "is_configured", lambda: True)
|
|
captured = {}
|
|
|
|
def fake_request(query):
|
|
captured["query"] = query
|
|
return {"vm": {"start": True}}
|
|
|
|
monkeypatch.setattr(connector, "_request", fake_request)
|
|
|
|
result = connector.mutate_vm('server:uuid-"quoted"', "start")
|
|
|
|
assert result["success"] is True
|
|
assert result["status"] == "ok"
|
|
assert 'start(id: "server:uuid-\\\"quoted\\\"")' in captured["query"]
|
|
|
|
|
|
def test_unraid_vm_mutation_blocks_unsafe_actions_without_request(monkeypatch):
|
|
connector = UnraidConnector()
|
|
monkeypatch.setattr(connector, "_request", lambda _query: (_ for _ in ()).throw(AssertionError("must not call Unraid")))
|
|
|
|
result = connector.mutate_vm("vm-1", "forceStop")
|
|
|
|
assert result == {
|
|
"success": False,
|
|
"status": "blocked",
|
|
"message": "unsupported_or_blocked_vm_action",
|
|
"action": "forcestop",
|
|
}
|
|
|
|
|
|
def test_unraid_query_stops_after_vm_api_reports_unavailable(monkeypatch):
|
|
connector = UnraidConnector()
|
|
calls = []
|
|
|
|
def fake_request(query):
|
|
calls.append(query)
|
|
raise RuntimeError("GraphQL error: Failed to retrieve VM domains: VMs are not available")
|
|
|
|
monkeypatch.setattr(connector, "_request", fake_request)
|
|
payload, status = connector._query(["vm-query", "legacy-fallback"])
|
|
|
|
assert status == "unavailable"
|
|
assert "VMs are not available" in payload["error"]
|
|
assert calls == ["vm-query"]
|
|
|
|
|
|
def test_unraid_cache_status_maps_live_array_cache_schema(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(_queries):
|
|
return (
|
|
{
|
|
"array": {
|
|
"caches": [
|
|
{"name": "cache", "fsUsed": 25, "fsSize": 100},
|
|
{"name": "cache2", "fsUsed": 10, "fsSize": 100},
|
|
]
|
|
}
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_cache_status()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["cacheUsage"] == 17
|
|
assert [pool["name"] for pool in result["pools"]] == ["cache", "cache2"]
|
|
|
|
|
|
def test_unraid_cache_status_returns_cache_pool_details(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(_queries):
|
|
return (
|
|
{
|
|
"array": {
|
|
"caches": [
|
|
{
|
|
"name": "cache",
|
|
"status": "DISK_OK",
|
|
"fsUsed": 25,
|
|
"fsFree": 75,
|
|
"fsSize": 100,
|
|
"type": "CACHE",
|
|
},
|
|
{
|
|
"name": "fastpool",
|
|
"status": "DISK_DSBL",
|
|
"fsUsed": 90,
|
|
"fsFree": 10,
|
|
"fsSize": 100,
|
|
"warning": "pool warning",
|
|
},
|
|
]
|
|
}
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_cache_status()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["cacheUsage"] == 57
|
|
assert result["pools"] == [
|
|
{
|
|
"name": "cache",
|
|
"status": "DISK_OK",
|
|
"healthy": True,
|
|
"type": "CACHE",
|
|
"usedGiB": 25,
|
|
"sizeGiB": 100,
|
|
"freeGiB": 75,
|
|
"usagePercent": 25,
|
|
},
|
|
{
|
|
"name": "fastpool",
|
|
"status": "DISK_DSBL",
|
|
"healthy": False,
|
|
"type": None,
|
|
"usedGiB": 90,
|
|
"sizeGiB": 100,
|
|
"freeGiB": 10,
|
|
"usagePercent": 90,
|
|
},
|
|
]
|
|
|
|
|
|
def test_unraid_parity_status_uses_live_parity_schema(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(queries):
|
|
assert "parityCheckStatus" in queries[0]
|
|
assert "parities" in queries[0]
|
|
return (
|
|
{
|
|
"array": {
|
|
"parityCheckStatus": {
|
|
"status": "IDLE",
|
|
"running": False,
|
|
"paused": False,
|
|
"correcting": False,
|
|
"progress": 100,
|
|
"speed": 0,
|
|
"errors": 3,
|
|
},
|
|
"parities": [{"name": "parity", "status": "DISK_OK", "warning": None, "critical": None}],
|
|
}
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_parity_status()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["parityStatus"] == "valid"
|
|
assert result["checkStatus"] == "idle"
|
|
assert result["errors"] == 3
|
|
|
|
|
|
def test_unraid_parity_status_reports_running_check(monkeypatch):
|
|
connector = UnraidConnector()
|
|
monkeypatch.setattr(
|
|
connector,
|
|
"_query",
|
|
lambda _queries: (
|
|
{
|
|
"array": {
|
|
"parityCheckStatus": {
|
|
"status": "RUNNING",
|
|
"running": None,
|
|
"paused": False,
|
|
"correcting": False,
|
|
"progress": 42,
|
|
"speed": 1234,
|
|
"errors": 0,
|
|
},
|
|
"parities": [{"name": "parity", "status": "DISK_OK"}],
|
|
}
|
|
},
|
|
"ok",
|
|
),
|
|
)
|
|
|
|
result = connector.get_parity_status()
|
|
|
|
assert result["parityStatus"] == "checking"
|
|
assert result["running"] is True
|
|
assert result["progress"] == 42
|
|
|
|
|
|
def test_unraid_parity_status_is_invalid_for_unhealthy_or_missing_parity(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
for parities in ([{"name": "parity", "status": "DISK_DSBL"}], []):
|
|
monkeypatch.setattr(
|
|
connector,
|
|
"_query",
|
|
lambda _queries, value=parities: ({"array": {"parities": value}}, "ok"),
|
|
)
|
|
result = connector.get_parity_status()
|
|
assert result["parityStatus"] == "invalid"
|
|
|
|
|
|
def test_unraid_system_metrics_maps_live_metrics_schema(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(_queries):
|
|
return (
|
|
{
|
|
"metrics": {
|
|
"cpu": {
|
|
"cpus": [
|
|
{"percentIdle": 80.0, "percentUser": 10.0, "percentSystem": 10.0},
|
|
{"percentIdle": 60.0, "percentUser": 25.0, "percentSystem": 15.0},
|
|
]
|
|
},
|
|
"memory": {
|
|
"total": 1000,
|
|
"used": 640,
|
|
"free": 360,
|
|
"available": 300,
|
|
},
|
|
}
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_system_metrics()
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["cpuUsage"] == 30
|
|
assert result["ramUsage"] == 70
|
|
assert result["sources"] == {
|
|
"cpu": "metrics.cpu.cpus.percentIdle",
|
|
"ram": "metrics.memory.available_total",
|
|
}
|
|
|
|
|
|
def test_unraid_system_metrics_reports_partial_instead_of_inventing_zero(monkeypatch):
|
|
connector = UnraidConnector()
|
|
|
|
def fake_query(_queries):
|
|
return (
|
|
{
|
|
"metrics": {
|
|
"cpu": {"cpus": []},
|
|
"memory": {"total": 1000, "used": 920},
|
|
}
|
|
},
|
|
"ok",
|
|
)
|
|
|
|
monkeypatch.setattr(connector, "_query", fake_query)
|
|
|
|
result = connector.get_system_metrics()
|
|
|
|
assert result["status"] == "partial"
|
|
assert result["cpuUsage"] is None
|
|
assert result["ramUsage"] == 92
|
|
assert "cpu_unavailable" in result["reason"]
|
|
|
|
|
|
def test_unraid_system_metrics_preserves_fractional_load(monkeypatch):
|
|
connector = UnraidConnector()
|
|
monkeypatch.setattr(connector, '_query', lambda queries: ({'metrics': {
|
|
'cpu': {'cpus': [{'percentIdle': 79.15}, {'percentIdle': 80.25}]},
|
|
'memory': {'total': 10000, 'available': 5432, 'used': 9000},
|
|
}}, 'ok'))
|
|
result = connector.get_system_metrics()
|
|
assert result['cpuUsage'] == 20.3
|
|
assert result['ramUsage'] == 45.68
|
|
|
|
|
|
def test_unraid_system_metrics_rejects_non_finite_load(monkeypatch):
|
|
connector = UnraidConnector()
|
|
monkeypatch.setattr(connector, '_query', lambda queries: ({'metrics': {
|
|
'cpu': {'cpus': [{'percentIdle': float('nan')}]},
|
|
'memory': {'total': 10000, 'used': float('inf')},
|
|
}}, 'ok'))
|
|
result = connector.get_system_metrics()
|
|
assert result['cpuUsage'] is None
|
|
assert result['ramUsage'] is None
|