2025-12-11 18:32:55 -07:00
|
|
|
"""
|
|
|
|
|
Integration tests for Controller API endpoints.
|
|
|
|
|
"""
|
2026-05-15 21:10:30 -06:00
|
|
|
import re
|
2025-12-11 18:32:55 -07:00
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from controller import __version__
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHealthEndpoint:
|
|
|
|
|
"""Tests for /api/health endpoint."""
|
|
|
|
|
|
|
|
|
|
def test_health_returns_200(self, test_client):
|
|
|
|
|
"""Health endpoint should return 200 OK."""
|
|
|
|
|
response = test_client.get("/api/health")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
|
|
|
|
def test_health_returns_expected_fields(self, test_client):
|
|
|
|
|
"""Health endpoint should return expected fields."""
|
|
|
|
|
response = test_client.get("/api/health")
|
|
|
|
|
data = response.json()
|
|
|
|
|
assert data["status"] == "healthy"
|
|
|
|
|
assert data["version"] == __version__
|
|
|
|
|
assert data["docker_mode"] is False
|
|
|
|
|
assert "drive_mounted" in data
|
|
|
|
|
assert isinstance(data["drive_mounted"], bool)
|
|
|
|
|
|
|
|
|
|
def test_health_returns_docker_mode_true(self, test_client_docker_mode):
|
|
|
|
|
"""Health endpoint should return docker_mode=true when in Docker."""
|
|
|
|
|
response = test_client_docker_mode.get("/api/health")
|
|
|
|
|
data = response.json()
|
|
|
|
|
assert data["docker_mode"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDashboardEndpoint:
|
|
|
|
|
"""Tests for / dashboard endpoint (UI)."""
|
|
|
|
|
|
2026-05-15 21:10:30 -06:00
|
|
|
def test_dashboard_basic_response(self, test_client):
|
|
|
|
|
"""Dashboard should return 200 with HTML content-type and a recognisable body."""
|
2025-12-11 18:32:55 -07:00
|
|
|
response = test_client.get("/")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert "text/html" in response.headers["content-type"]
|
2025-12-24 16:43:37 -07:00
|
|
|
assert b"Controller" in response.content
|
|
|
|
|
assert b"WROLPi" in response.content
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-05-15 21:10:30 -06:00
|
|
|
@pytest.mark.parametrize("needle", [
|
|
|
|
|
# Status cards.
|
|
|
|
|
"CPU", "Memory", "Load", "Storage",
|
|
|
|
|
# Sections / structural ids.
|
|
|
|
|
"Disks", 'id="disks"', "Services", 'id="services"',
|
|
|
|
|
# System actions.
|
|
|
|
|
"System Actions", "Restart All Services",
|
|
|
|
|
# Mount modal.
|
|
|
|
|
'id="mount-modal"', 'id="mount-point-input"', 'id="mount-persist-checkbox"',
|
|
|
|
|
"Persistent (survive reboots)",
|
|
|
|
|
# Disks JS table.
|
|
|
|
|
"<th>Persist</th>", "function togglePersist", "api/disks/fstab",
|
|
|
|
|
])
|
|
|
|
|
def test_dashboard_contains(self, test_client, needle):
|
|
|
|
|
"""Dashboard body should contain each expected fragment (native mode)."""
|
2025-12-11 18:32:55 -07:00
|
|
|
response = test_client.get("/")
|
2026-05-15 21:10:30 -06:00
|
|
|
assert needle in response.text
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-05-15 21:10:30 -06:00
|
|
|
def test_dashboard_native_mode_shows_boot_column_and_hides_docker_banner(self, test_client):
|
|
|
|
|
"""Native (non-Docker) mode: Boot column visible, no Docker banner."""
|
2025-12-11 18:32:55 -07:00
|
|
|
response = test_client.get("/")
|
|
|
|
|
content = response.text
|
|
|
|
|
assert "<th>Boot</th>" in content
|
|
|
|
|
assert 'class="toggle"' in content
|
2026-05-15 21:10:30 -06:00
|
|
|
assert "Running in Docker mode" not in content
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-05-15 21:10:30 -06:00
|
|
|
def test_dashboard_docker_mode_shows_banner_and_hides_boot_column(self, test_client_docker_mode):
|
|
|
|
|
"""Docker mode: Docker banner visible, no Boot toggles in rendered tbody."""
|
2025-12-11 18:32:55 -07:00
|
|
|
response = test_client_docker_mode.get("/")
|
|
|
|
|
content = response.text
|
2026-05-15 21:10:30 -06:00
|
|
|
assert "Running in Docker mode" in content
|
2025-12-11 18:32:55 -07:00
|
|
|
assert "<th>Boot</th>" not in content
|
2026-05-15 21:10:30 -06:00
|
|
|
# JS template literals may mention 'id="boot-' but the server-rendered tbody must not.
|
2025-12-11 18:32:55 -07:00
|
|
|
tbody_match = re.search(r'<tbody>(.*?)</tbody>', content, re.DOTALL)
|
|
|
|
|
if tbody_match:
|
2026-05-15 21:10:30 -06:00
|
|
|
assert 'id="boot-' not in tbody_match.group(1)
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
2026-05-15 21:10:30 -06:00
|
|
|
class TestOpenAPI:
|
|
|
|
|
"""Tests for the OpenAPI schema and its expected endpoint coverage."""
|
2025-12-11 18:32:55 -07:00
|
|
|
|
2026-05-15 21:10:30 -06:00
|
|
|
@pytest.fixture
|
|
|
|
|
def openapi(self, test_client):
|
|
|
|
|
"""Fetch the OpenAPI schema once per parametrized test."""
|
2025-12-11 18:32:55 -07:00
|
|
|
response = test_client.get("/openapi.json")
|
|
|
|
|
assert response.status_code == 200
|
2026-05-15 21:10:30 -06:00
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
def test_openapi_metadata(self, openapi):
|
|
|
|
|
"""Schema title and version should match the package metadata."""
|
|
|
|
|
assert openapi["info"]["title"] == "WROLPi Controller"
|
|
|
|
|
assert openapi["info"]["version"] == __version__
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("path", [
|
|
|
|
|
# Health.
|
|
|
|
|
"/api/health",
|
|
|
|
|
# Stats (per-resource + aggregated).
|
|
|
|
|
"/api/stats/cpu", "/api/stats/memory", "/api/stats/load",
|
|
|
|
|
"/api/stats/drives/primary", "/api/stats/network", "/api/stats/power",
|
2026-07-29 14:57:28 -06:00
|
|
|
# Admin: hotspot, bluetooth, desktop, throttle, timezone, power.
|
|
|
|
|
"/api/hotspot/status", "/api/hotspot/start", "/api/hotspot/stop",
|
|
|
|
|
"/api/bluetooth/status", "/api/bluetooth/unblock", "/api/bluetooth/block",
|
|
|
|
|
"/api/desktop/status", "/api/desktop/start", "/api/desktop/stop",
|
controller: VNC toggle, gated on the Desktop running
Add a VNC toggle to the Controller (both UIs) that starts/stops whichever VNC
backend is installed: wayvnc for Wayland (Raspberry Pi OS), RealVNC for X11.
Runtime-only, like the Desktop toggle: the unit is never enabled, so VNC does
not return after a reboot. Users who want it permanently on can enable it with
raspi-config.
VNC serves the desktop session, so starting it without a desktop gives clients
a blank screen. The gate lives in the backend, not just the UI, so curl callers
get it too (409, distinct from a systemctl failure). Stopping is never gated so
a running VNC server can't be stranded on with no way to turn it off.
Backend selection deliberately avoids raspi-config's live-compositor probe
(pgrep labwc), which reports X11 — and would pick the wrong backend — whenever
the desktop is stopped. wayvnc also requires its config to exist; that is
checked directly rather than via the unit's ConditionResult, which reports 'no'
for any unit that has never been started.
Extracts _unit_properties()/_systemctl_unit() so Desktop and VNC share one
systemd probe and start/stop path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:53:45 -06:00
|
|
|
"/api/vnc/status", "/api/vnc/start", "/api/vnc/stop",
|
2026-05-15 21:10:30 -06:00
|
|
|
"/api/throttle/status", "/api/throttle/enable", "/api/throttle/disable",
|
|
|
|
|
"/api/timezone/status", "/api/timezone/set",
|
|
|
|
|
"/api/shutdown", "/api/reboot", "/api/restart",
|
|
|
|
|
# Disks.
|
|
|
|
|
"/api/disks", "/api/disks/mounts", "/api/disks/mount", "/api/disks/unmount",
|
|
|
|
|
"/api/disks/fstab", "/api/disks/smart",
|
|
|
|
|
# Services.
|
|
|
|
|
"/api/services", "/api/services/{name}",
|
|
|
|
|
"/api/services/{name}/start", "/api/services/{name}/stop",
|
|
|
|
|
"/api/services/{name}/restart", "/api/services/{name}/enable",
|
|
|
|
|
"/api/services/{name}/disable", "/api/services/{name}/logs",
|
|
|
|
|
# Scripts.
|
|
|
|
|
"/api/scripts", "/api/scripts/status",
|
|
|
|
|
"/api/scripts/{name}/start", "/api/scripts/{name}/output",
|
|
|
|
|
])
|
|
|
|
|
def test_openapi_has_path(self, openapi, path):
|
|
|
|
|
"""Every documented API path should be present in the OpenAPI schema."""
|
|
|
|
|
assert path in openapi["paths"], f"OpenAPI schema is missing {path}"
|
|
|
|
|
|
|
|
|
|
def test_openapi_has_aggregated_stats(self, openapi):
|
|
|
|
|
"""Aggregated /api/stats endpoint (may register with or without trailing slash)."""
|
|
|
|
|
paths = openapi["paths"]
|
|
|
|
|
assert "/api/stats" in paths or "/api/stats/" in paths
|
2025-12-11 18:32:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDocsEndpoint:
|
|
|
|
|
"""Tests for Swagger UI documentation endpoint."""
|
|
|
|
|
|
|
|
|
|
def test_docs_returns_200(self, test_client):
|
|
|
|
|
"""Docs endpoint should return 200 OK."""
|
|
|
|
|
response = test_client.get("/docs")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
|
|
|
|
def test_docs_returns_html(self, test_client):
|
|
|
|
|
"""Docs endpoint should return HTML."""
|
|
|
|
|
response = test_client.get("/docs")
|
|
|
|
|
assert "text/html" in response.headers["content-type"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDriveMountedStatus:
|
|
|
|
|
"""Tests for drive mounted status in responses."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint,mounted,expected", [
|
|
|
|
|
("/api/health", True, True),
|
|
|
|
|
("/api/health", False, False),
|
|
|
|
|
])
|
|
|
|
|
def test_drive_mounted_status(self, reset_runtime_config, mock_docker_mode, endpoint, mounted, expected):
|
|
|
|
|
"""Endpoints should return correct drive_mounted status."""
|
|
|
|
|
from controller.main import app
|
|
|
|
|
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"controller.main.is_primary_drive_mounted", return_value=mounted
|
|
|
|
|
):
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
response = client.get(endpoint)
|
|
|
|
|
data = response.json()
|
|
|
|
|
assert data["drive_mounted"] is expected
|
|
|
|
|
|
2026-03-14 14:56:32 -06:00
|
|
|
def test_dashboard_shows_onboarding_when_drive_not_mounted(self, reset_runtime_config, mock_docker_mode):
|
|
|
|
|
"""Dashboard should show onboarding banner in native mode when drive is not mounted."""
|
|
|
|
|
from controller.main import app
|
|
|
|
|
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"controller.main.is_primary_drive_mounted", return_value=False
|
|
|
|
|
):
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
response = client.get("/")
|
|
|
|
|
assert "Welcome to WROLPi" in response.text
|
|
|
|
|
|
|
|
|
|
def test_dashboard_shows_drive_not_mounted_banner_in_docker(self, reset_runtime_config, mock_docker_mode_enabled):
|
|
|
|
|
"""Dashboard should show info banner in Docker mode when drive is not mounted."""
|
2025-12-11 18:32:55 -07:00
|
|
|
from controller.main import app
|
|
|
|
|
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"controller.main.is_primary_drive_mounted", return_value=False
|
|
|
|
|
):
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
response = client.get("/")
|
|
|
|
|
assert "Primary drive not mounted" in response.text
|
2026-07-18 13:40:23 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCertDownloadReadiness:
|
|
|
|
|
"""Root CA download should stay hidden until drive + certs are ready."""
|
|
|
|
|
|
|
|
|
|
def test_dashboard_hides_ca_download_when_not_ready(self, test_client):
|
|
|
|
|
"""No CA download button when ca is not ready (default test env: no drive/CA)."""
|
|
|
|
|
response = test_client.get("/")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert "Download WROLPi Root CA" not in response.text
|
|
|
|
|
# Cert IIFE short-circuits before HTTPS probe when not ready.
|
|
|
|
|
assert "ca.crt missing" in response.text
|
|
|
|
|
|
|
|
|
|
def test_dashboard_shows_ca_download_when_ready(self, reset_runtime_config, mock_docker_mode, tmp_path, monkeypatch):
|
|
|
|
|
"""Download button is rendered once drive is mounted and ca.crt exists."""
|
|
|
|
|
from controller.main import app
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("MEDIA_DIRECTORY", str(tmp_path))
|
|
|
|
|
ca = tmp_path / "config" / "ssl" / "ca.crt"
|
|
|
|
|
ca.parent.mkdir(parents=True)
|
|
|
|
|
ca.write_text("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
|
|
|
|
|
|
|
|
|
|
with mock.patch("controller.main.is_primary_drive_mounted", return_value=True), \
|
|
|
|
|
mock.patch("controller.main.is_ca_certificate_available", return_value=True), \
|
|
|
|
|
mock.patch("controller.main.get_script_status", return_value={"running": False}):
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
response = client.get("/")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert "Download WROLPi Root CA" in response.text
|
|
|
|
|
assert 'href="/ca.crt"' in response.text
|
|
|
|
|
|
|
|
|
|
def test_dashboard_hides_ca_download_while_repair_running(
|
|
|
|
|
self, reset_runtime_config, mock_docker_mode, tmp_path, monkeypatch
|
|
|
|
|
):
|
|
|
|
|
"""Hide CA download while repair is regenerating certificates."""
|
|
|
|
|
from controller.main import app
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("MEDIA_DIRECTORY", str(tmp_path))
|
|
|
|
|
with mock.patch("controller.main.is_primary_drive_mounted", return_value=True), \
|
|
|
|
|
mock.patch("controller.main.is_ca_certificate_available", return_value=True), \
|
|
|
|
|
mock.patch(
|
|
|
|
|
"controller.main.get_script_status",
|
|
|
|
|
return_value={"running": True, "script_name": "repair"},
|
|
|
|
|
):
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
response = client.get("/")
|
|
|
|
|
assert "Download WROLPi Root CA" not in response.text
|
|
|
|
|
|
|
|
|
|
def test_ca_crt_404_when_drive_not_mounted(self, test_client):
|
|
|
|
|
"""/ca.crt should 404 before the media drive is mounted."""
|
|
|
|
|
with mock.patch("controller.main.is_primary_drive_mounted", return_value=False):
|
|
|
|
|
response = test_client.get("/ca.crt")
|
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
assert "not available until the media drive is mounted" in response.json()["error"]
|
|
|
|
|
|
|
|
|
|
def test_ca_crt_serves_file_when_ready(self, reset_runtime_config, mock_docker_mode, tmp_path, monkeypatch):
|
|
|
|
|
"""/ca.crt returns the PEM when drive is mounted and file exists."""
|
|
|
|
|
from controller.main import app
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("MEDIA_DIRECTORY", str(tmp_path))
|
|
|
|
|
ca = tmp_path / "config" / "ssl" / "ca.crt"
|
|
|
|
|
ca.parent.mkdir(parents=True)
|
|
|
|
|
body = "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"
|
|
|
|
|
ca.write_text(body)
|
|
|
|
|
|
|
|
|
|
with mock.patch("controller.main.is_primary_drive_mounted", return_value=True), \
|
|
|
|
|
mock.patch("controller.main.get_ca_certificate_path", return_value=ca):
|
|
|
|
|
with TestClient(app) as client:
|
|
|
|
|
response = client.get("/ca.crt")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert body.encode() in response.content
|
|
|
|
|
assert "attachment" in response.headers.get("content-disposition", "")
|