Kaynağa Gözat

fix(auth): expose /api/v1/system/appliance through the auth middleware allowlist

  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).
maziggy 2 ay önce
ebeveyn
işleme
c236fdc650

+ 8 - 0
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)

+ 19 - 0
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):

+ 12 - 6
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)