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).
This commit is contained in:
maziggy 2026-06-25 15:19:28 +02:00
parent 70857af393
commit c236fdc650
3 changed files with 39 additions and 6 deletions

View file

@ -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)

View file

@ -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):

View file

@ -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)