|
@@ -0,0 +1,119 @@
|
|
|
|
|
+"""Tests for the public /settings/ui-preferences endpoint (#1293).
|
|
|
|
|
+
|
|
|
|
|
+Reporter @Tivonfeng: granting `printers:clear_plate` alone wasn't enough to
|
|
|
|
|
+make the Clear Plate button work — the frontend also needs `require_plate_clear`
|
|
|
|
|
+from /settings, which requires SETTINGS_READ (and also surfaces SMTP/LDAP/MQTT
|
|
|
|
|
+secrets). The fix is a public subset endpoint that returns only UI rendering
|
|
|
|
|
+fields, so the frontend doesn't have to demand SETTINGS_READ for non-admin UX.
|
|
|
|
|
+
|
|
|
|
|
+The two guarantees pinned here:
|
|
|
|
|
+1. The endpoint is accessible without SETTINGS_READ.
|
|
|
|
|
+2. The endpoint NEVER returns sensitive fields (SMTP/LDAP/MQTT credentials,
|
|
|
|
|
+ API tokens, HA bearer token, etc.) — even if a future commit accidentally
|
|
|
|
|
+ adds one of those keys to _UI_PREFERENCE_FIELDS, this test fails loudly.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+from httpx import AsyncClient
|
|
|
|
|
+
|
|
|
|
|
+# Anything in this list MUST NOT appear in the /ui-preferences response.
|
|
|
|
|
+# Mirror of _SENSITIVE_FIELDS_FOR_API_KEY in backend/app/api/routes/settings.py
|
|
|
|
|
+# plus a wider net for any *_password / *_token / *_key suffix.
|
|
|
|
|
+_SENSITIVE_KEYS = {
|
|
|
|
|
+ "smtp_password",
|
|
|
|
|
+ "smtp_username",
|
|
|
|
|
+ "smtp_from_email",
|
|
|
|
|
+ "smtp_host",
|
|
|
|
|
+ "smtp_port",
|
|
|
|
|
+ "mqtt_password",
|
|
|
|
|
+ "mqtt_username",
|
|
|
|
|
+ "mqtt_broker",
|
|
|
|
|
+ "ha_token",
|
|
|
|
|
+ "ha_url",
|
|
|
|
|
+ "prometheus_token",
|
|
|
|
|
+ "virtual_printer_access_code",
|
|
|
|
|
+ "ldap_bind_password",
|
|
|
|
|
+ "ldap_bind_dn",
|
|
|
|
|
+ "ldap_server_url",
|
|
|
|
|
+ "external_url",
|
|
|
|
|
+ "bambu_studio_api_url",
|
|
|
|
|
+ "orcaslicer_api_url",
|
|
|
|
|
+ "local_backup_path",
|
|
|
|
|
+ "github_token",
|
|
|
|
|
+ "gitea_token",
|
|
|
|
|
+ "obico_api_key",
|
|
|
|
|
+ "obico_endpoint_url",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestUiPreferencesEndpoint:
|
|
|
|
|
+ """The new public endpoint must work without SETTINGS_READ and must
|
|
|
|
|
+ never return sensitive fields."""
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_endpoint_returns_200_without_auth(self, async_client: AsyncClient):
|
|
|
|
|
+ """No SETTINGS_READ required — that's the whole point of the endpoint."""
|
|
|
|
|
+ response = await async_client.get("/api/v1/settings/ui-preferences")
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ data = response.json()
|
|
|
|
|
+ assert isinstance(data, dict)
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_returns_require_plate_clear(self, async_client: AsyncClient):
|
|
|
|
|
+ """The field that drove #1293: PrintersPage gates the Clear Plate button
|
|
|
|
|
+ on this. Must be present in the response."""
|
|
|
|
|
+ response = await async_client.get("/api/v1/settings/ui-preferences")
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ data = response.json()
|
|
|
|
|
+ assert "require_plate_clear" in data
|
|
|
|
|
+ # Type must be bool (frontend does === true checks)
|
|
|
|
|
+ assert isinstance(data["require_plate_clear"], bool)
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_returns_expected_field_set(self, async_client: AsyncClient):
|
|
|
|
|
+ """Pin the exact set of fields the endpoint exposes — adding a sensitive
|
|
|
|
|
+ field to _UI_PREFERENCE_FIELDS by accident should fail this assert and
|
|
|
|
|
+ force the author to reconsider."""
|
|
|
|
|
+ response = await async_client.get("/api/v1/settings/ui-preferences")
|
|
|
|
|
+ data = response.json()
|
|
|
|
|
+ expected = {
|
|
|
|
|
+ "require_plate_clear",
|
|
|
|
|
+ "check_printer_firmware",
|
|
|
|
|
+ "camera_view_mode",
|
|
|
|
|
+ "time_format",
|
|
|
|
|
+ "date_format",
|
|
|
|
|
+ "drying_presets",
|
|
|
|
|
+ "ams_humidity_good",
|
|
|
|
|
+ "ams_humidity_fair",
|
|
|
|
|
+ "ams_temp_good",
|
|
|
|
|
+ "ams_temp_fair",
|
|
|
|
|
+ "bed_cooled_threshold",
|
|
|
|
|
+ }
|
|
|
|
|
+ assert set(data.keys()) == expected
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_response_excludes_sensitive_fields(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """Even with sensitive fields seeded in the DB, none of them must
|
|
|
|
|
+ appear in the response — the endpoint is opt-in, not opt-out."""
|
|
|
|
|
+ from backend.app.models.settings import Settings
|
|
|
|
|
+
|
|
|
|
|
+ # Seed every sensitive field with a unique recognizable value so a leak
|
|
|
|
|
+ # would be obvious in failure output.
|
|
|
|
|
+ for i, key in enumerate(_SENSITIVE_KEYS):
|
|
|
|
|
+ db_session.add(Settings(key=key, value=f"SECRET_VALUE_{i}_DO_NOT_LEAK"))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ response = await async_client.get("/api/v1/settings/ui-preferences")
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ data = response.json()
|
|
|
|
|
+
|
|
|
|
|
+ # No sensitive key should appear in the response keys
|
|
|
|
|
+ leaked_keys = _SENSITIVE_KEYS & set(data.keys())
|
|
|
|
|
+ assert leaked_keys == set(), f"Leaked sensitive fields: {leaked_keys}"
|
|
|
|
|
+
|
|
|
|
|
+ # And the recognizable values shouldn't appear in any value either
|
|
|
|
|
+ response_text = response.text
|
|
|
|
|
+ for i in range(len(_SENSITIVE_KEYS)):
|
|
|
|
|
+ assert f"SECRET_VALUE_{i}_DO_NOT_LEAK" not in response_text, (
|
|
|
|
|
+ f"Sensitive value index {i} leaked into response body"
|
|
|
|
|
+ )
|