Files
hermes-webui/tests/test_issue3994_materialize_session.py
nesquena-hermes 7a5c3bc374 fix(#3994): scope happy-path guard to read_only flag; complete #1436 mock stub
Refined the Codex-CORE fix: the happy-path (already-stored session) guard rejects
only an explicit read_only flag — a stored messaging session already owns its
sidecar, so the messaging-fork risk is specific to the materialize FALLBACK
(which creates a sidecar) where the _is_messaging_session_record check stays.
Also completed test_issue1436's _stub_session MagicMock (read_only=False,
_loaded_metadata_only=False) — bare MagicMock auto-attrs were truthy, tripping
the new read-only guard + _ensure_full_session_before_mutation reload.
2026-06-11 21:45:36 +00:00

117 lines
5.4 KiB
Python

"""Regression tests for #3994 / #3985 — _get_or_materialize_session().
rename / move / update of a CLI/agent session that isn't yet in the WebUI store
should materialize it from CLI metadata (mirroring /api/session/archive) instead
of 404ing — while still refusing to mutate a read-only (messaging / Claude Code)
session.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
def test_materialize_returns_in_store_session_directly():
"""When get_session() succeeds, the helper returns it (after full-load) untouched."""
import api.routes as routes
existing = SimpleNamespace(session_id="s1", profile="default", messages=[])
with patch("api.routes.get_session", return_value=existing), \
patch("api.routes._ensure_full_session_before_mutation", return_value=existing):
out = routes._get_or_materialize_session("s1")
assert out is existing
def test_materialize_missing_everywhere_raises_keyerror():
"""No WebUI session and no CLI metadata → KeyError (caller maps to 404)."""
import api.routes as routes
with patch("api.routes.get_session", side_effect=KeyError("s1")), \
patch("api.routes._lookup_cli_session_metadata", return_value={}):
with pytest.raises(KeyError):
routes._get_or_materialize_session("s1")
def test_materialize_readonly_session_raises_permissionerror():
"""A read-only imported session (messaging / Claude Code) must not be materialized
for mutation — the helper raises PermissionError (caller maps to 403)."""
import api.routes as routes
with patch("api.routes.get_session", side_effect=KeyError("ro1")), \
patch("api.routes._lookup_cli_session_metadata", return_value={"read_only": True, "source_tag": "claude_code"}):
with pytest.raises(PermissionError):
routes._get_or_materialize_session("ro1")
def test_materialize_cli_session_imports_full_history():
"""A regular (non-messaging, non-read-only) CLI session is materialized via
import_cli_session with its message history."""
import api.routes as routes
cli_meta = {
"read_only": False,
"title": "CLI chat",
"model": "gpt-test",
"profile": "default",
"source_tag": "cli",
}
imported = SimpleNamespace(session_id="cli1", profile="default", messages=[{"role": "user", "content": "hi"}])
with patch("api.routes.get_session", side_effect=KeyError("cli1")), \
patch("api.routes._lookup_cli_session_metadata", return_value=cli_meta), \
patch("api.routes._is_messaging_session_record", return_value=False), \
patch("api.routes.get_cli_session_messages", return_value=[{"role": "user", "content": "hi"}]), \
patch("api.routes.title_from", return_value="CLI chat"), \
patch("api.routes.import_cli_session", return_value=imported) as mock_import:
out = routes._get_or_materialize_session("cli1")
assert out is imported
assert mock_import.called
# source metadata is stamped onto the materialized session
assert getattr(out, "is_cli_session", None) is True
def test_materialize_rejects_stored_readonly_session():
"""An already-STORED read-only session must be refused on the happy path too —
get_session() succeeding doesn't make it mutable (Codex CORE #1)."""
import api.routes as routes
ro = SimpleNamespace(session_id="ro_stored", profile="default", messages=[], read_only=True)
with patch("api.routes.get_session", return_value=ro), \
patch("api.routes._ensure_full_session_before_mutation", return_value=ro):
with pytest.raises(PermissionError):
routes._get_or_materialize_session("ro_stored")
def test_materialize_allows_stored_messaging_session_without_readonly():
"""A stored messaging session that ALREADY owns its sidecar is mutable on the
happy path (the messaging-fork concern only applies to the materialize
fallback that would CREATE a sidecar). Only an explicit read_only flag blocks
a stored session."""
import api.routes as routes
msg = SimpleNamespace(session_id="msg_stored", profile="default", messages=[],
session_source="messaging", source_tag="telegram", read_only=False)
with patch("api.routes.get_session", return_value=msg), \
patch("api.routes._ensure_full_session_before_mutation", return_value=msg):
out = routes._get_or_materialize_session("msg_stored")
assert out is msg
def test_materialize_rejects_messaging_cli_meta_without_readonly_flag():
"""Messaging cli_meta lacking an explicit read_only flag must still be refused
— agent rows normalize messaging sources without setting read_only, and
state.db is the source of truth, so a writable sidecar would fork it (Codex CORE #2)."""
import api.routes as routes
cli_meta = {"title": "tg chat", "model": "gpt-test", "source_tag": "telegram", "session_source": "messaging"}
with patch("api.routes.get_session", side_effect=KeyError("msg1")), \
patch("api.routes._lookup_cli_session_metadata", return_value=cli_meta), \
patch("api.routes.import_cli_session") as mock_import, \
patch("api.routes.Session") as MockSession:
with pytest.raises(PermissionError):
routes._get_or_materialize_session("msg1")
assert not mock_import.called, "must not import a messaging session"
assert not MockSession.called, "must not build a writable messaging stub"