"""Tests for issue #1560 — Settings password silently no-ops when HERMES_WEBUI_PASSWORD env var is set. Root cause: HERMES_WEBUI_PASSWORD takes precedence in api.auth.get_password_hash(), but the UI had no way to know — POST /api/settings happily wrote password_hash to settings.json, returned 200 + "Saved" toast, while every subsequent login still required the env-var password. Fix: surface env-var precedence in GET /api/settings (`password_env_var: bool`), refuse the write loudly (409) when shadowed, disable the field + show help-text banner in the UI, with i18n keys in all 9 locales. """ import json import os import pathlib import urllib.error import urllib.request REPO = pathlib.Path(__file__).parent.parent def _read(rel_path): return (REPO / rel_path).read_text(encoding='utf-8') # ── Backend (api/routes.py) ─────────────────────────────────────────────── def test_get_settings_surfaces_password_env_var_flag(): """GET /api/settings handler must include `password_env_var: bool(env)`.""" src = _read('api/routes.py') # Locate the GET /api/settings block (by handler comment + path string) start = src.index('if parsed.path == "/api/settings":') # Block ends at next top-level `if parsed.path == ...` or `if parsed.path.startswith` end = src.index('if parsed.path', start + 50) block = src[start:end] assert 'password_env_var' in block, \ 'GET /api/settings must expose password_env_var so UI can disable the field' assert 'HERMES_WEBUI_PASSWORD' in block, \ 'GET /api/settings must read HERMES_WEBUI_PASSWORD env var' def test_post_settings_refuses_set_password_when_env_var_shadowed(): """POST /api/settings with _set_password must return 409 when env var is set.""" src = _read('api/routes.py') # The guard lives near the POST /api/settings handler; locate it via the # canonical error-message substring (defense-in-depth comment + bad() call). assert 'HERMES_WEBUI_PASSWORD env var is set' in src, \ 'POST /api/settings must refuse with a clear message naming the env var' assert '409' in src, 'POST /api/settings must use HTTP 409 for env-var conflict' def test_post_settings_refuses_clear_password_when_env_var_shadowed(): """POST /api/settings with _clear_password=true must also be refused.""" src = _read('api/routes.py') # Same guard must cover both paths assert '_clear_password' in src # Find the guard and verify it tests both flags guard_idx = src.index('HERMES_WEBUI_PASSWORD env var is set') # Look back ~2KB for the conditional that triggers the guard window = src[max(0, guard_idx - 2000):guard_idx] assert 'requested_password' in window or '_set_password' in window assert 'requested_clear_password' in window or '_clear_password' in window, \ 'guard must cover both _set_password and _clear_password' # ── Frontend: lock UI elements (static/index.html) ──────────────────────── def test_settings_html_has_password_env_lock_banner(): """The settings password block must include a hidden lock banner element.""" html = _read('static/index.html') assert 'id="settingsPasswordEnvLock"' in html, \ 'settingsPasswordEnvLock banner element required (revealed when env var set)' assert 'data-i18n="password_env_var_locked"' in html, \ 'banner must use the i18n key password_env_var_locked' # ── Frontend: env-locked logic (static/panels.js) ───────────────────────── def test_panels_js_disables_password_when_env_locked(): """panels.js must disable the password field and show the banner when password_env_var is true.""" src = _read('static/panels.js') assert 'password_env_var' in src, \ 'panels.js must read settings.password_env_var from GET /api/settings' assert 'settingsPasswordEnvLock' in src, \ 'panels.js must toggle the settingsPasswordEnvLock banner' # The disable logic should set pwField.disabled assert 'pwField.disabled' in src or 'disabled=pwEnvLocked' in src.replace(' ', ''), \ 'password field must be disabled when env-locked' def test_panels_js_hides_disable_auth_button_when_env_locked(): """The Disable Auth button must be hidden when env var shadows the settings password.""" src = _read('static/panels.js') # When env-locked, btnDisableAuth should be set display:none # We verify by locating the env-locked block and checking it touches btnDisableAuth idx = src.index('pwEnvLocked') # Look in a window after the first env-locked reference for btnDisableAuth handling window = src[idx:idx + 3000] assert 'btnDisableAuth' in window, \ 'Disable Auth button must be hidden in the env-locked code path' # ── i18n: keys present in all 10 locales (static/i18n.js) ────────────────── LOCALES = ['en', 'it', 'ja', 'ru', 'es', 'de', 'zh', 'zh-Hant', 'pt', 'ko'] def _split_locales(i18n_src): """Split i18n.js into per-locale source slices. Locale block headers look like ` en: {` or ` 'zh-Hant': {`. We slice each block from its header to the next sibling header at the same indentation. """ import re pattern = re.compile(r"^ ['\"]?([\w\-]+)['\"]?: \{$", re.MULTILINE) matches = list(pattern.finditer(i18n_src)) blocks = {} for i, m in enumerate(matches): name = m.group(1) start = m.start() end = matches[i + 1].start() if i + 1 < len(matches) else len(i18n_src) blocks[name] = i18n_src[start:end] return blocks def test_i18n_password_env_var_locked_in_all_locales(): """Every locale must define the password_env_var_locked banner string.""" src = _read('static/i18n.js') blocks = _split_locales(src) missing = [loc for loc in LOCALES if loc not in blocks or 'password_env_var_locked:' not in blocks[loc]] assert not missing, \ f"Locales missing password_env_var_locked: {missing}" def test_i18n_password_env_var_locked_placeholder_in_all_locales(): """Every locale must define the password_env_var_locked_placeholder string.""" src = _read('static/i18n.js') blocks = _split_locales(src) missing = [loc for loc in LOCALES if loc not in blocks or 'password_env_var_locked_placeholder:' not in blocks[loc]] assert not missing, \ f"Locales missing password_env_var_locked_placeholder: {missing}" def test_i18n_locked_string_mentions_env_var_name_in_all_locales(): """Each locale's banner must literally mention HERMES_WEBUI_PASSWORD so users can find it.""" src = _read('static/i18n.js') blocks = _split_locales(src) for loc in LOCALES: block = blocks.get(loc, '') # Find the password_env_var_locked entry idx = block.find('password_env_var_locked:') assert idx != -1, f"{loc}: missing password_env_var_locked" # Take the rest of that line (the message string) line_end = block.index('\n', idx) line = block[idx:line_end] assert 'HERMES_WEBUI_PASSWORD' in line, \ f"{loc}: banner must literally name HERMES_WEBUI_PASSWORD" # ── Live HTTP smoke test (env var NOT set in pytest) ────────────────────── def test_get_settings_returns_password_env_var_false_when_unset(monkeypatch): """When HERMES_WEBUI_PASSWORD is not set in the test process, GET /api/settings must include `password_env_var: False`.""" # Test the unset branch explicitly. Some suite neighbors intentionally set # HERMES_WEBUI_PASSWORD while exercising the locked-password path. monkeypatch.delenv('HERMES_WEBUI_PASSWORD', raising=False) # The conftest server inherits a sanitized env; verify this process is clean. assert not os.getenv('HERMES_WEBUI_PASSWORD', '').strip(), \ 'this test requires HERMES_WEBUI_PASSWORD to be unset' from tests._pytest_port import BASE req = urllib.request.Request(BASE + '/api/settings') try: with urllib.request.urlopen(req, timeout=10) as r: payload = json.loads(r.read()) except urllib.error.HTTPError as e: payload = json.loads(e.read()) assert 'password_env_var' in payload, \ 'GET /api/settings must always include password_env_var key' assert payload['password_env_var'] is False, \ 'env var unset => password_env_var must be False'