Files
hermes-webui/tests/test_auth_settings_safety.py
T
nesquena-hermes 8ea7ad582e rebase #3581 onto master (v0.51.546) for review+ship-gate — require current password before disabling auth
starship-s: sudo-mode re-auth on POST /api/settings before any change/clear/passwordless transition
of password auth, + cosmetic auth-disabled-acknowledged nav badge. Operator-hardening (rubric-named).
Security-boundary + visible UI -> Nathan visual sign-off. Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-06-21 02:26:20 +00:00

286 lines
11 KiB
Python

"""Auth-disable safety tests.
Verify that POST /api/settings requires _current_password when password auth
is already enabled and the user wants to change, clear, or go passwordless.
"""
import io
import json
import os
from urllib.parse import urlparse
import pytest
@pytest.fixture(autouse=True)
def _isolate_auth_settings_state(tmp_path, monkeypatch):
"""Keep these unit tests from mutating the shared pytest server state.
The full CI shard may already have the test server running. If these tests
write password_hash into the shared test settings.json, the server process can
observe/cache it and unrelated later API tests start returning 401. Point the
in-process config module at a per-test settings file instead.
"""
import api.config as cfg
from api.auth import _invalidate_password_hash_cache
monkeypatch.setattr(cfg, "SETTINGS_FILE", tmp_path / "settings.json")
_invalidate_password_hash_cache()
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
yield
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
_invalidate_password_hash_cache()
class _FakeHandler:
def __init__(self, body_bytes: bytes = b"", cookie: str = ""):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.rfile = io.BytesIO(body_bytes)
self.headers = {
"Content-Length": str(len(body_bytes)),
}
if cookie:
self.headers["Cookie"] = cookie
self.request = None
# First-password setup is gated to local/private clients when auth is
# disabled. These in-process settings tests exercise the local bootstrap
# path, so model a loopback request explicitly.
self.client_address = ("127.0.0.1", 0)
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def header(self, name):
for key, value in self.sent_headers:
if key.lower() == name.lower():
return value
return None
def json_body(self):
return json.loads(bytes(self.body).decode("utf-8"))
def _post_settings(body_dict, cookie=""):
from api.routes import handle_post
raw = json.dumps(body_dict).encode("utf-8")
handler = _FakeHandler(body_bytes=raw, cookie=cookie)
parsed = urlparse("http://example.com/api/settings")
handle_post(handler, parsed)
return handler
def _get_settings():
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse("http://example.com/api/settings")
handle_get(handler, parsed)
return handler.json_body(), handler.status
def _set_password_raw(pw):
from api.config import save_settings
from api.auth import _invalidate_password_hash_cache
save_settings({"_set_password": pw})
_invalidate_password_hash_cache()
def _clear_password_raw():
from api.config import save_settings
from api.auth import _invalidate_password_hash_cache
save_settings({"_clear_password": True})
_invalidate_password_hash_cache()
class TestChangePasswordRequiresCurrentPassword:
def test_cannot_change_password_without_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword"})
assert handler.status == 403
payload = handler.json_body()
assert "current password" in payload.get("error", "").lower()
def test_cannot_change_password_with_wrong_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword", "_current_password": "wrongpw"})
assert handler.status == 403
payload = handler.json_body()
assert "incorrect" in payload.get("error", "").lower()
def test_can_change_password_with_correct_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword", "_current_password": "oldpassword"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is True
_clear_password_raw()
class TestClearPasswordRequiresCurrentPassword:
def test_cannot_clear_password_without_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True})
assert handler.status == 403
payload = handler.json_body()
assert "current password" in payload.get("error", "").lower()
def test_cannot_clear_password_with_wrong_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True, "_current_password": "wrongpw"})
assert handler.status == 403
def test_cannot_clear_password_with_non_string_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True, "_current_password": 123})
assert handler.status == 403
payload = handler.json_body()
assert "current password" in payload.get("error", "").lower()
def test_can_clear_password_with_correct_current_password(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_clear_password": True, "_current_password": "oldpassword"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is False
class TestFirstTimePasswordNoCurrentRequired:
def test_first_time_password_works_without_current_password(self):
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
handler = _post_settings({"_set_password": "firstpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is True
_clear_password_raw()
_invalidate_password_hash_cache()
def test_first_time_password_uses_auth_enabled_state_not_stale_hash(self, monkeypatch):
"""Disabled-auth bootstrap must not be blocked by stale password-hash state."""
monkeypatch.setattr("api.auth.is_auth_enabled", lambda: False)
monkeypatch.setattr("api.auth.get_password_hash", lambda: "stale-hash")
handler = _post_settings({"_set_password": "firstpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is False
class TestEnvVarPasswordLockStillRejects:
def test_env_var_lock_rejects_password_change(self):
os.environ["HERMES_WEBUI_PASSWORD"] = "envpw"
handler = _post_settings({"_set_password": "newpw", "_current_password": "envpw"})
assert handler.status == 409
def test_env_var_lock_rejects_password_clear(self):
os.environ["HERMES_WEBUI_PASSWORD"] = "envpw"
handler = _post_settings({"_clear_password": True, "_current_password": "envpw"})
assert handler.status == 409
class TestCurrentPasswordNotPersisted:
def test_current_password_not_in_settings_response(self):
_set_password_raw("oldpassword")
handler = _post_settings({"_set_password": "newpassword", "_current_password": "oldpassword"})
assert handler.status == 200
payload = handler.json_body()
assert "_current_password" not in payload
assert "current_password" not in payload
_clear_password_raw()
def test_current_password_not_in_get_settings(self):
payload, status = _get_settings()
assert status == 200
assert "_current_password" not in payload
assert "current_password" not in payload
class TestSettingsExposesAuthStateFields:
def test_get_settings_has_auth_enabled(self):
payload, status = _get_settings()
assert status == 200
assert "auth_enabled" in payload
assert "password_auth_enabled" in payload
def test_get_settings_never_exposes_password_hash(self):
payload, status = _get_settings()
assert status == 200
assert "password_hash" not in payload
def test_post_settings_includes_auth_state_fields(self):
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
handler = _post_settings({"send_key": "enter"})
assert handler.status == 200
payload = handler.json_body()
assert "auth_enabled" in payload
assert "password_auth_enabled" in payload
class TestAuthDisabledAcknowledged:
def test_acknowledged_can_be_set_when_auth_disabled(self):
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
handler = _post_settings({"_auth_disabled_acknowledged": True})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_disabled_acknowledged") is True
def test_acknowledged_resets_when_auth_enabled(self):
_set_password_raw("testpw")
handler = _post_settings({"_auth_disabled_acknowledged": True, "_current_password": "testpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_disabled_acknowledged") is False
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
def test_acknowledged_resets_when_setting_first_password(self):
_clear_password_raw()
handler = _post_settings({"_auth_disabled_acknowledged": True})
assert handler.status == 200
assert handler.json_body().get("auth_disabled_acknowledged") is True
handler = _post_settings({"_set_password": "firstpw"})
assert handler.status == 200
payload = handler.json_body()
assert payload.get("auth_enabled") is True
assert payload.get("auth_disabled_acknowledged") is False
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()
def test_auth_status_exposes_acknowledgement_only_while_disabled(self):
_clear_password_raw()
handler = _post_settings({"_auth_disabled_acknowledged": True})
assert handler.status == 200
from api.routes import handle_get
status_handler = _FakeHandler()
handle_get(status_handler, urlparse("http://example.com/api/auth/status"))
status_payload = status_handler.json_body()
assert status_payload.get("auth_enabled") is False
assert status_payload.get("auth_disabled_acknowledged") is True
_set_password_raw("testpw")
status_handler = _FakeHandler()
handle_get(status_handler, urlparse("http://example.com/api/auth/status"))
status_payload = status_handler.json_body()
assert status_payload.get("auth_enabled") is True
assert status_payload.get("auth_disabled_acknowledged") is False
_clear_password_raw()
from api.auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache()