Files
bambuddy/backend/tests/integration/test_settings_ui_preferences.py
maziggy 68b9d741d9 feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)
Reporter @thenewguy runs an engineering farm with one AMS per material
  (PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
  threshold (default 60%) was driving both the queue / ambient auto-drying
  trigger AND the hourly humidity alarm uniformly, which is wrong for
  multi-material setups where Nylon wants <10% and PLA is fine at 60%.

  Drying RUN parameters were already per-filament via drying_presets;
  this commit adds the missing per-filament TRIGGER.

  New setting ams_humidity_thresholds — JSON map of filament-type to
  threshold percent with a "default" key for unknown / unmapped types.
  Empty / unset → both consumers fall back to ams_humidity_fair so the
  upgrade is silent.

  Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
  thresholds, fallback) — picks the lowest (most-restrictive) threshold
  across all loaded tray types, matching the conservative-params strategy
  _get_conservative_drying_params already uses for temp / hours. Empty
  tray slots contribute no constraint; all-empty AMS falls through to the
  "default" key. Filament names normalized to uppercase base (so
  "PLA Basic" / "pla basic" both map to PLA).

  Two consumer sites rewired through the same resolver so the scheduler
  and the alarm path can never disagree about whether an AMS is "too
  humid":
    - print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
      for start / stop / skip decisions.
    - main.py AMS sensor / alarm worker — hourly humidity alarm notifier.

  UI: new table in Settings → Workflow → Auto-Drying, below the existing
  Drying Presets table. Default row + 8 default filament types
  (PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
  current ams_humidity_fair value so the editor starts sensibly.

  Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
  state per row). onChange only updates the draft; onBlur (and Enter)
  parses + clamps to [5, 95] + commits. Empty value on blur clears the
  override and falls back to default. Caught mid-PR via a typing test:
  the naive per-keystroke clamp snapped "3" → 5 before the user could
  type the second digit of "30".

  Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
  as drying_presets and ams_humidity_fair — non-sensitive integer map,
  no SETTINGS_READ permission required for badge-color rendering).
2026-06-21 11:55:37 +02:00

125 lines
4.9 KiB
Python

"""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_thresholds",
"ams_humidity_good",
"ams_humidity_fair",
"ams_temp_good",
"ams_temp_fair",
"bed_cooled_threshold",
"nozzle_temp_presets",
"bed_temp_presets",
"chamber_temp_presets",
"fan_speed_presets",
}
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"
)