From c236fdc65051cfca235dd0712ed102c836bc7942 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 25 Jun 2026 15:19:28 +0200 Subject: [PATCH] fix(auth): expose /api/v1/system/appliance through the auth middleware allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /system/appliance endpoint is fetched by the SPA's i18n bootstrap on mount to seed locale, hostname, timezone, and the chrony NTP-gate state BEFORE any login state exists. The route handler itself has no auth dependency and the test_route_auth_coverage allowlist correctly marks it public, but the global auth_middleware in main.py — which short-circuits every /api/ path not in PUBLIC_API_ROUTES — was never told about it. Result: every browser session on an auth-enabled install logged a 401 on the appliance endpoint before login. Added /api/v1/system/appliance to PUBLIC_API_ROUTES with a comment pointing at the dual-list pattern so this doesn't drift again, and a regression test in TestAuthMiddlewarePublicRoutes that posts /auth/setup to turn auth on, then asserts the endpoint returns 200 with the documented shape (hostname / timezone / locale / time_synced fields all present). --- backend/app/main.py | 8 ++++++++ backend/tests/integration/test_auth_api.py | 19 +++++++++++++++++++ .../unit/services/test_virtual_printer.py | 18 ++++++++++++------ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 6fb5a08d7..edbc3f652 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6351,6 +6351,14 @@ PUBLIC_API_ROUTES = { "/api/v1/updates/version", # Metrics endpoint handles its own prometheus_token authentication "/api/v1/metrics", + # Appliance bootstrap (#1589 follow-up): the SPA's i18n setup polls + # this BEFORE a JWT is available to pick up the firstboot wizard's + # hostname / timezone / locale and the chrony NTP-gate state. The + # response contains user-set defaults and a public sync flag — no + # secrets. Without this entry the global auth middleware returns 401 + # before the route handler runs, regardless of the route's own + # "no auth required" intent. + "/api/v1/system/appliance", } # Route prefixes that are public (for routes with dynamic segments) diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py index 72a260556..442f3635c 100644 --- a/backend/tests/integration/test_auth_api.py +++ b/backend/tests/integration/test_auth_api.py @@ -854,6 +854,25 @@ class TestAuthMiddlewarePublicRoutes: assert response.status_code == 200 assert "auth_enabled" in response.json() + @pytest.mark.asyncio + @pytest.mark.integration + async def test_system_appliance_is_public(self, async_client: AsyncClient, enabled_auth): + """Verify /api/v1/system/appliance is reachable without a JWT. + + The SPA's i18n bootstrap fetches this BEFORE login to seed locale, + hostname, timezone, and NTP-gate state. The route handler has no + auth dependency, but the global auth_middleware blocks every + /api/ path not in PUBLIC_API_ROUTES — so without an explicit + allowlist entry the user sees a 401 in the browser console on + every page load. + """ + response = await async_client.get("/api/v1/system/appliance") + assert response.status_code == 200, response.text + body = response.json() + # Shape contract (no-auth surface): + for key in ("hostname", "timezone", "locale", "time_synced"): + assert key in body + @pytest.mark.asyncio @pytest.mark.integration async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth): diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 19d940723..9c5b54883 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -2964,7 +2964,7 @@ class TestSlicerProxyManager: slicer and printer for all protocols except MQTT, which must be TLS-terminated to rewrite the printer's IP in MQTT payloads. """ - from unittest.mock import AsyncMock, patch + from unittest.mock import patch from backend.app.services.virtual_printer.tcp_proxy import ( SlicerProxyManager, @@ -2984,16 +2984,22 @@ class TestSlicerProxyManager: bind_address="10.0.0.1", ) - # Mock asyncio.create_task and asyncio.gather to prevent actual server start + # Mock asyncio.create_task and asyncio.gather to prevent actual + # server start. Close every coroutine handed to gather — otherwise + # the ~110 run_with_logging() coros built inside start() are + # garbage-collected unfinalized and surface later as + # PytestUnraisableExceptionWarning at random in other tests. + async def _close_pending(*coros, **_): + for c in coros: + if asyncio.iscoroutine(c): + c.close() + with ( patch("asyncio.create_task") as mock_create_task, - patch("asyncio.gather", new_callable=AsyncMock), + patch("asyncio.gather", side_effect=_close_pending), patch.object(SlicerProxyManager, "_log_activity"), ): mock_create_task.return_value = MagicMock() - # start() will create proxies then try to gather tasks — we just - # need to verify the proxy types after creation. - # Trigger start but let gather return immediately. await mgr.start() # FTP, FileTransfer, RTSP should be TCPProxy (transparent)