Files
hermes-webui/tests/test_auth_session_persistence.py
T
nesquena-hermes f9acf464e9 ci: run test suite in 3 parallel shards + make suite shard-safe
Completes the test-sharding half of #3197 (Docker-cache half shipped v0.51.177).
Adds pytest-shard 3-way split to tests.yml (3 shards x 3 Python = 9 jobs,
fail-fast: false). pytest-shard is 0-indexed so the matrix uses [0,1,2] — the
original #3197 used [1,2,3] which would have crashed the out-of-range job and
silently skipped shard 0's tests.

Made the suite shard-safe by fixing 4 cross-test state-pollution bugs that
passed sequentially but failed when sharded:
- test_onboarding_mvp: reset onboarding_completed flag (settings.json) in the
  autouse fixture; the config-cleanup only cleared config.yaml/.env.
- test_issue693_system_health_panel: invalidate the process-wide password-hash
  cache before/after so a prior test's "no password" cache doesn't defeat the
  auth-gate assertion.
- test_auth_session_persistence: assert against auth._SESSIONS_FILE (where auth
  actually writes) instead of a local _TEST_STATE path that only matched under a
  lucky import order.
- test_profile_env_isolation (root cause of the worst leak): stop deleting +
  re-importing api.profiles under a temp HERMES_BASE_HOME — that swapped the
  module object and poisoned the cached _DEFAULT_HERMES_HOME for every later
  test (broke test_title_aux_routing's load_config). Now points the cached path
  via monkeypatch.setattr (auto-restored, no module swap).
- conftest: autouse fixture restores HERMES_HOME/HERMES_BASE_HOME after each
  test as defense-in-depth against future switch_profile leaks.

Verified: all 3 shards green (6912 passed, 0 failed); full sequential run still
green (6957 passed, 0 failed). Slowest shard ~70s vs ~180s sequential.
2026-05-30 21:09:25 +00:00

112 lines
4.6 KiB
Python

"""Regression tests: auth sessions persist across process restarts.
_sessions is an in-memory dict. Without persistence, any restart (launchd,
systemd, container) invalidates all active browser sessions and floods clients
with 401s until they clear cookies. The HMAC signing key already persists to
STATE_DIR; this PR persists the session table using the same pattern.
"""
import importlib
import json
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
# Isolate state dir so tests never touch real sessions
_TEST_STATE = Path(tempfile.mkdtemp())
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
sys.path.insert(0, str(Path(__file__).parent.parent))
import api.auth as auth
class TestSessionPersistence(unittest.TestCase):
"""Sessions survive a simulated process restart (module reload)."""
def setUp(self) -> None:
auth._sessions.clear()
sessions_file = _TEST_STATE / '.sessions.json'
if sessions_file.exists():
sessions_file.unlink()
def _simulate_restart(self) -> None:
"""Reload auth module to simulate a fresh process start.
api.auth does `from api.config import STATE_DIR` at module level, so
`_SESSIONS_FILE` is computed from api.config.STATE_DIR at reload time.
We temporarily override api.config.STATE_DIR so the reload uses the
test state dir without reloading api.config itself (which would
invalidate imported references like STREAM_PARTIAL_TEXT in other tests).
"""
import api.config as _config
_saved = _config.STATE_DIR
_config.STATE_DIR = _TEST_STATE
try:
importlib.reload(auth)
finally:
_config.STATE_DIR = _saved
def test_session_survives_restart(self) -> None:
"""A session created before restart should still verify after reload."""
cookie = auth.create_session()
self.assertTrue(auth.verify_session(cookie))
self._simulate_restart()
self.assertTrue(auth.verify_session(cookie),
"Session must survive process restart via persisted .sessions.json")
def test_invalidated_session_does_not_survive_restart(self) -> None:
"""Invalidating a session must be reflected after reload."""
cookie = auth.create_session()
auth.invalidate_session(cookie)
self._simulate_restart()
self.assertFalse(auth.verify_session(cookie),
"Invalidated session must not be reinstated after restart")
def test_expired_sessions_pruned_on_load(self) -> None:
"""Sessions that expire between restarts must not be loaded."""
sessions_file = _TEST_STATE / '.sessions.json'
# Write a sessions file with one expired and one valid entry
now = time.time()
sessions_file.write_text(json.dumps({
"expired_token": now - 10,
"valid_token": now + 3600,
}))
self._simulate_restart()
self.assertNotIn("expired_token", auth._sessions)
self.assertIn("valid_token", auth._sessions)
def test_sessions_file_permissions(self) -> None:
"""Sessions file must be owner-read-only (0600)."""
auth.create_session()
# Check the path auth actually writes to (auth._SESSIONS_FILE is computed
# from api.config.STATE_DIR at import time). Asserting against a local
# _TEST_STATE assumption is fragile under sharded / reordered runs where
# this module may import before api.config.STATE_DIR resolves to _TEST_STATE.
sessions_file = auth._SESSIONS_FILE
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
mode = oct(sessions_file.stat().st_mode & 0o777)
self.assertEqual(mode, oct(0o600),
f".sessions.json permissions {mode} — expected 0o600")
def test_malformed_sessions_file_starts_fresh(self) -> None:
"""A corrupt sessions file must not crash auth — start with empty dict."""
sessions_file = _TEST_STATE / '.sessions.json'
sessions_file.write_text("not valid json {{{{")
self._simulate_restart()
self.assertEqual(auth._sessions, {},
"Corrupt sessions file must result in empty session dict")
def test_sessions_file_wrong_type_starts_fresh(self) -> None:
"""A sessions file containing a non-dict must be ignored."""
sessions_file = _TEST_STATE / '.sessions.json'
sessions_file.write_text(json.dumps(["list", "not", "dict"]))
self._simulate_restart()
self.assertEqual(auth._sessions, {})
if __name__ == "__main__":
unittest.main()