Files
hermes-webui/tests/test_issue1560_password_env_var_lock.py
T
2026-05-08 16:40:16 +00:00

193 lines
8.4 KiB
Python

"""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 9 locales (static/i18n.js) ──────────────────
LOCALES = ['en', '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'