fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
"""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
|
2026-06-27 01:38:24 +00:00
|
|
|
from unittest import mock
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
|
|
|
|
# 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:
|
2026-06-29 05:50:56 +00:00
|
|
|
# Bind api.auth's STATE_DIR-derived globals to THIS file's _TEST_STATE,
|
|
|
|
|
# regardless of import/reload order. pytest-shard distributes individual
|
|
|
|
|
# test items, so a shard may run a key/session test WITHOUT any of the
|
|
|
|
|
# _simulate_restart() tests that (as a reload side effect) rebind
|
|
|
|
|
# auth.STATE_DIR to _TEST_STATE. Without this, auth.STATE_DIR keeps
|
|
|
|
|
# conftest's TEST_STATE_DIR: _load_key() then reads the wrong dir (so the
|
|
|
|
|
# sentinel key is "missing", no OSError fires, and assertLogs sees no
|
|
|
|
|
# WARNING), and the warning's "STATE_DIR=..." never contains _TEST_STATE.
|
|
|
|
|
# Patching the actual bindings the code reads makes every test in this
|
|
|
|
|
# class self-contained. Saved values are restored in tearDown.
|
|
|
|
|
self._saved_state_dir = auth.STATE_DIR
|
|
|
|
|
self._saved_sessions_file = auth._SESSIONS_FILE
|
|
|
|
|
self._saved_login_attempts_file = auth._LOGIN_ATTEMPTS_FILE
|
|
|
|
|
auth.STATE_DIR = _TEST_STATE
|
|
|
|
|
auth._SESSIONS_FILE = _TEST_STATE / '.sessions.json'
|
|
|
|
|
auth._LOGIN_ATTEMPTS_FILE = _TEST_STATE / '.login_attempts.json'
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
auth._sessions.clear()
|
2026-06-26 23:06:12 +00:00
|
|
|
auth._PBKDF2_KEY_CACHE = None
|
|
|
|
|
auth._SIGNING_KEY_CACHE = None
|
|
|
|
|
for name in ('.sessions.json', '.signing_key', '.pbkdf2_key'):
|
|
|
|
|
path = _TEST_STATE / name
|
|
|
|
|
if path.exists():
|
|
|
|
|
path.unlink()
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
2026-06-29 05:50:56 +00:00
|
|
|
def tearDown(self) -> None:
|
|
|
|
|
auth.STATE_DIR = self._saved_state_dir
|
|
|
|
|
auth._SESSIONS_FILE = self._saved_sessions_file
|
|
|
|
|
auth._LOGIN_ATTEMPTS_FILE = self._saved_login_attempts_file
|
|
|
|
|
auth._sessions.clear()
|
|
|
|
|
auth._PBKDF2_KEY_CACHE = None
|
|
|
|
|
auth._SIGNING_KEY_CACHE = None
|
|
|
|
|
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
def _simulate_restart(self) -> None:
|
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
|
|
|
"""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
|
2026-06-27 01:38:24 +00:00
|
|
|
test state dir without reloading api.config itself, which would
|
|
|
|
|
invalidate imported references like STREAM_PARTIAL_TEXT in other tests.
|
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
|
|
|
"""
|
|
|
|
|
import api.config as _config
|
2026-06-27 01:38:24 +00:00
|
|
|
|
|
|
|
|
saved_state_dir = _config.STATE_DIR
|
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
|
|
|
_config.STATE_DIR = _TEST_STATE
|
|
|
|
|
try:
|
|
|
|
|
importlib.reload(auth)
|
|
|
|
|
finally:
|
2026-06-27 01:38:24 +00:00
|
|
|
_config.STATE_DIR = saved_state_dir
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
|
|
|
|
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()
|
2026-06-27 01:38:24 +00:00
|
|
|
self.assertTrue(
|
|
|
|
|
auth.verify_session(cookie),
|
|
|
|
|
"Session must survive process restart via persisted .sessions.json",
|
|
|
|
|
)
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
|
|
|
|
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()
|
2026-06-27 01:38:24 +00:00
|
|
|
self.assertFalse(
|
|
|
|
|
auth.verify_session(cookie),
|
|
|
|
|
"Invalidated session must not be reinstated after restart",
|
|
|
|
|
)
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
|
|
|
|
def test_expired_sessions_pruned_on_load(self) -> None:
|
|
|
|
|
"""Sessions that expire between restarts must not be loaded."""
|
|
|
|
|
sessions_file = _TEST_STATE / '.sessions.json'
|
|
|
|
|
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:
|
2026-06-15 19:42:33 +00:00
|
|
|
"""Sessions file must stay strict on POSIX and still be created on Windows."""
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
auth.create_session()
|
2026-05-30 21:09:25 +00:00
|
|
|
sessions_file = auth._SESSIONS_FILE
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
|
2026-06-15 19:42:33 +00:00
|
|
|
if os.name == "nt":
|
|
|
|
|
self.assertTrue(sessions_file.is_file(), ".sessions.json was not written as a file")
|
|
|
|
|
return
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
mode = oct(sessions_file.stat().st_mode & 0o777)
|
2026-06-27 01:38:24 +00:00
|
|
|
self.assertEqual(
|
|
|
|
|
mode,
|
|
|
|
|
oct(0o600),
|
|
|
|
|
f".sessions.json permissions {mode} - expected 0o600",
|
|
|
|
|
)
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
|
|
|
|
def test_malformed_sessions_file_starts_fresh(self) -> None:
|
2026-06-27 01:38:24 +00:00
|
|
|
"""A corrupt sessions file must not crash auth, start with an empty dict."""
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
sessions_file = _TEST_STATE / '.sessions.json'
|
|
|
|
|
sessions_file.write_text("not valid json {{{{")
|
2026-06-26 23:06:12 +00:00
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
self._simulate_restart()
|
2026-06-27 01:38:24 +00:00
|
|
|
self.assertEqual(
|
|
|
|
|
auth._sessions,
|
|
|
|
|
{},
|
|
|
|
|
"Corrupt sessions file must result in empty session dict",
|
|
|
|
|
)
|
2026-06-26 23:06:12 +00:00
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Ignoring malformed auth session store', warning)
|
|
|
|
|
self.assertIn(str(sessions_file), warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
|
|
|
|
|
|
|
|
|
def test_session_read_failure_warns_with_state_dir_and_starts_fresh(self) -> None:
|
|
|
|
|
"""Unreadable sessions files must warn and start with an empty dict."""
|
|
|
|
|
sessions_file = _TEST_STATE / '.sessions.json'
|
|
|
|
|
sessions_file.write_text(json.dumps({"token": time.time() + 3600}))
|
|
|
|
|
with mock.patch.object(Path, 'read_text', side_effect=OSError('read failed')):
|
|
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
self._simulate_restart()
|
|
|
|
|
self.assertEqual(auth._sessions, {})
|
|
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Auth session store read failed', warning)
|
|
|
|
|
self.assertIn(str(sessions_file), warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
|
|
|
|
|
2026-06-26 23:29:55 +00:00
|
|
|
def test_session_decode_failure_warns_with_state_dir_and_starts_fresh(self) -> None:
|
|
|
|
|
"""Invalid UTF-8 in the sessions file must warn and start with an empty dict."""
|
|
|
|
|
sessions_file = _TEST_STATE / '.sessions.json'
|
|
|
|
|
sessions_file.write_bytes(b'\xff')
|
2026-06-27 01:38:24 +00:00
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
self._simulate_restart()
|
2026-06-26 23:29:55 +00:00
|
|
|
self.assertEqual(auth._sessions, {})
|
|
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Ignoring malformed auth session store', warning)
|
|
|
|
|
self.assertIn(str(sessions_file), warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
|
|
|
|
|
2026-06-27 01:38:24 +00:00
|
|
|
def test_session_recursion_failure_warns_with_state_dir_and_starts_fresh(self) -> None:
|
|
|
|
|
"""Deeply nested JSON must warn and fall back to an empty session table."""
|
|
|
|
|
sessions_file = _TEST_STATE / '.sessions.json'
|
|
|
|
|
depth = 50000
|
|
|
|
|
sessions_file.write_text('{"token":' * depth + '0' + '}' * depth)
|
|
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
self._simulate_restart()
|
|
|
|
|
self.assertEqual(auth._sessions, {})
|
|
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Ignoring malformed auth session store', warning)
|
|
|
|
|
self.assertIn('RecursionError', warning)
|
|
|
|
|
self.assertIn(str(sessions_file), warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
|
|
|
|
|
2026-06-26 23:06:12 +00:00
|
|
|
def test_session_save_failure_warns_with_state_dir_and_keeps_in_process_session(self) -> None:
|
|
|
|
|
"""Write failures must warn but keep the live session usable in-process."""
|
2026-06-27 01:38:24 +00:00
|
|
|
sentinel_token = 'deadbeef' * 8
|
|
|
|
|
with mock.patch.object(auth.secrets, 'token_hex', return_value=sentinel_token):
|
|
|
|
|
with mock.patch.object(auth.os, 'replace', side_effect=OSError('replace failed')):
|
|
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
cookie = auth.create_session()
|
2026-06-26 23:06:12 +00:00
|
|
|
self.assertTrue(auth.verify_session(cookie))
|
|
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Auth session persistence failed', warning)
|
|
|
|
|
self.assertIn('.sessions.json', warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
2026-06-27 01:38:24 +00:00
|
|
|
self.assertNotIn(sentinel_token, warning)
|
|
|
|
|
self.assertNotIn(cookie, warning)
|
2026-06-26 23:06:12 +00:00
|
|
|
|
2026-06-27 01:38:24 +00:00
|
|
|
def test_signing_key_read_failure_warns_with_state_dir(self) -> None:
|
2026-06-26 23:06:12 +00:00
|
|
|
"""Unreadable signing keys must warn and fall back to a fresh key."""
|
|
|
|
|
key_file = _TEST_STATE / '.signing_key'
|
2026-06-27 01:38:24 +00:00
|
|
|
sentinel_key = 'secret-key-material'
|
|
|
|
|
key_file.write_text(sentinel_key)
|
2026-06-26 23:06:12 +00:00
|
|
|
with mock.patch.object(Path, 'read_bytes', side_effect=OSError('read failed')):
|
|
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
key = auth._load_key('.signing_key')
|
|
|
|
|
self.assertIsInstance(key, bytes)
|
|
|
|
|
self.assertEqual(len(key), 32)
|
|
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Auth key read failed', warning)
|
|
|
|
|
self.assertIn('.signing_key', warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
2026-06-27 01:38:24 +00:00
|
|
|
self.assertNotIn(sentinel_key, warning)
|
2026-06-26 23:06:12 +00:00
|
|
|
|
|
|
|
|
def test_signing_key_persist_failure_warns_with_state_dir(self) -> None:
|
|
|
|
|
"""Key write failures must warn and still return a generated key."""
|
|
|
|
|
with mock.patch.object(Path, 'write_bytes', side_effect=OSError('write failed')):
|
|
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
key = auth._load_key('.signing_key')
|
|
|
|
|
self.assertIsInstance(key, bytes)
|
|
|
|
|
self.assertEqual(len(key), 32)
|
|
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Auth key persistence failed', warning)
|
|
|
|
|
self.assertIn('.signing_key', warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
|
|
|
|
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"]))
|
2026-06-26 23:20:29 +00:00
|
|
|
with self.assertLogs('api.auth', level='WARNING') as captured:
|
|
|
|
|
self._simulate_restart()
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
self.assertEqual(auth._sessions, {})
|
2026-06-26 23:20:29 +00:00
|
|
|
warning = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('Ignoring malformed auth session store', warning)
|
|
|
|
|
self.assertIn(str(sessions_file), warning)
|
|
|
|
|
self.assertIn(str(_TEST_STATE), warning)
|
fix(auth): persist sessions across restarts via STATE_DIR/.sessions.json (#962)
_sessions is an in-memory dict, so every process restart (launchd bounce,
systemd restart, container recycle) invalidates all active browser sessions.
Users get 401 on every authenticated endpoint until they clear cookies.
The HMAC signing key already persists to STATE_DIR/.signing_key via atomic
owner-only write. This PR applies the same pattern to the session table:
- _load_sessions(): reads .sessions.json on module import, prunes expired
entries, tolerates missing/malformed files (returns {} on any error)
- _save_sessions(): atomic write via tempfile + os.replace(), chmod 0600,
mirrors .signing_key write pattern exactly
- create_session(): saves after inserting new token
- invalidate_session(): saves after removing token (only if token existed)
- _prune_expired_sessions(): saves only when entries are actually removed
Cookie format and signing are unchanged; existing sessions survive upgrade.
6 regression tests cover: restart survival, invalidation persistence,
expiry pruning on load, 0600 permissions, corrupt-file tolerance.
Co-authored with Claude Sonnet 4.6 / Anthropic.
2026-04-24 18:21:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|