"""Regression tests for #3280 — file manager falls back to state.db for external (Telegram/CLI) sessions instead of returning 404. Covers: (a) WebUI session — existing behavior preserved (get_session path). (b) state.db-only session — fallback returns a workspace-bearing view. (c) Unknown session — KeyError still propagates so callers 404. (d) Static check: every file-manager handler in api/routes.py calls get_session_for_file_ops, not the raw get_session. """ from __future__ import annotations import gc import io import json import logging import re import sqlite3 import threading import weakref from io import BytesIO from pathlib import Path from types import SimpleNamespace from urllib.parse import urlparse import pytest ROOT = Path(__file__).resolve().parents[1] ROUTES_PY = ROOT / "api" / "routes.py" FILE_HANDLERS = [ "_handle_escape_authorize", "_handle_escape_list_dir", "_handle_escape_file_read", "_handle_escape_file_raw", "_handle_folder_download", "_handle_file_raw", "_handle_file_read", "_handle_file_delete", "_handle_file_save", "_handle_file_create", "_handle_file_rename", "_handle_create_dir", "_handle_file_reveal", "_handle_file_path", "_handle_file_open_vscode", "_handle_office_file_save", "_handle_file_move", ] def _handler_body(src: str, name: str) -> str: start = src.index(f"def {name}(") # next top-level def or class m = re.search(r"\n(?:def |class )", src[start + 1 :]) end = (start + 1 + m.start()) if m else len(src) return src[start:end] def test_routes_file_handlers_use_fallback(): src = ROUTES_PY.read_text(encoding="utf-8") assert "get_session_for_file_ops" in src, "fallback helper must be imported" missing = [] for name in FILE_HANDLERS: body = _handler_body(src, name) # Must not call get_session(...) directly inside the handler. # (get_session_for_file_ops also contains "get_session(" as a substring, # so check word-boundary occurrences.) bare = re.findall(r"(? None: conn = sqlite3.connect(str(path)) conn.executescript( """ CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER DEFAULT 0, started_at TEXT, source TEXT ); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content TEXT, timestamp TEXT ); """ ) conn.execute( "INSERT INTO sessions (id, title, model, message_count, started_at, source) " "VALUES (?, 'telegram session', 'gpt-x', 1, '2026-01-01T00:00:00Z', 'telegram')", (sid,), ) conn.commit() conn.close() @pytest.fixture def models_module(): return pytest.importorskip("api.models") def test_get_session_for_file_ops_webui_passthrough(models_module, monkeypatch): """(a) WebUI session — delegates to get_session, no state.db consulted.""" profiles_module = pytest.importorskip("api.profiles") sentinel = SimpleNamespace(profile=None) called = {"get_session": 0, "profile_match": 0, "state_db": 0} def fake_get_session(sid, metadata_only=False): called["get_session"] += 1 return sentinel def fake_profiles_match(session_profile, active_profile): called["profile_match"] += 1 assert session_profile is None assert active_profile == "default" return True def fake_has(_sid): called["state_db"] += 1 return True monkeypatch.setattr(models_module, "get_session", fake_get_session) monkeypatch.setattr(models_module, "state_db_has_session", fake_has) monkeypatch.setattr(profiles_module, "_profiles_match", fake_profiles_match) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") result = models_module.get_session_for_file_ops("webui-sid") assert result is sentinel assert called == {"get_session": 1, "profile_match": 1, "state_db": 0} def test_get_session_for_file_ops_recovers_missing_implicit_workspace( models_module, monkeypatch, tmp_path ): """A deleted sidecar workspace reloads fully before persisting its binding.""" profiles_module = pytest.importorskip("api.profiles") workspace_module = pytest.importorskip("api.workspace") stale = tmp_path / "deleted-workspace" fallback = tmp_path / "fallback" fallback.mkdir() metadata_session = SimpleNamespace( session_id="stale-webui-sid", profile=None, workspace=str(stale), _loaded_metadata_only=True, ) session_dir = tmp_path / "sessions" session_dir.mkdir() sidecar = session_dir / "stale-webui-sid.json" sidecar.write_text( json.dumps( { "session_id": "stale-webui-sid", "workspace": str(stale), "messages": [{"role": "user", "content": "preserve me"}], "future_field": {"preserve": True}, } ), encoding="utf-8", ) def get_session(_sid, metadata_only=False): assert metadata_only is True return metadata_session monkeypatch.setattr(models_module, "get_session", get_session) monkeypatch.setattr(models_module, "SESSION_DIR", session_dir) monkeypatch.setattr(models_module, "_write_session_index", lambda **_kwargs: None) monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(fallback)) monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") monkeypatch.setattr(workspace_module, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace_module, "load_workspaces", lambda: []) recovered = models_module.get_session_for_file_ops(metadata_session.session_id) assert recovered is metadata_session assert recovered.session_id == metadata_session.session_id assert Path(recovered.workspace) == fallback.resolve() persisted = json.loads(sidecar.read_text(encoding="utf-8")) assert persisted["workspace"] == str(fallback.resolve()) assert persisted["messages"] == [{"role": "user", "content": "preserve me"}] assert persisted["future_field"] == {"preserve": True} def test_get_session_for_file_ops_recovery_save_failure_fails_closed( models_module, monkeypatch, tmp_path ): profiles_module = pytest.importorskip("api.profiles") workspace_module = pytest.importorskip("api.workspace") stale = tmp_path / "deleted-workspace" fallback = tmp_path / "fallback" fallback.mkdir() metadata_session = SimpleNamespace( session_id="stale-save-failure", profile=None, workspace=str(stale), _loaded_metadata_only=True, ) session_dir = tmp_path / "sessions" session_dir.mkdir() sidecar = session_dir / f"{metadata_session.session_id}.json" sidecar.write_text( json.dumps( { "session_id": metadata_session.session_id, "workspace": str(stale), "messages": [{"role": "user", "content": "preserve me"}], } ), encoding="utf-8", ) monkeypatch.setattr( models_module, "get_session", lambda _sid, metadata_only=False: metadata_session, ) monkeypatch.setattr(models_module, "SESSION_DIR", session_dir) monkeypatch.setattr( models_module, "_safe_replace", lambda *_args: (_ for _ in ()).throw(OSError("disk full")), ) monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(fallback)) monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") monkeypatch.setattr(workspace_module, "_home_path", lambda: tmp_path) monkeypatch.setattr(workspace_module, "load_workspaces", lambda: []) with pytest.raises(models_module.WorkspaceBindingPersistenceError): models_module.get_session_for_file_ops(metadata_session.session_id) persisted = json.loads(sidecar.read_text(encoding="utf-8")) assert metadata_session.workspace == str(stale) assert persisted["workspace"] == str(stale) assert persisted["messages"] == [{"role": "user", "content": "preserve me"}] def test_recovered_workspace_compare_rejects_a_stale_concurrent_binding( models_module, monkeypatch, tmp_path ): stale = tmp_path / "deleted-workspace" fallback_a = tmp_path / "fallback-a" fallback_b = tmp_path / "fallback-b" fallback_a.mkdir() fallback_b.mkdir() metadata_session = SimpleNamespace( session_id="stale-concurrent-binding", workspace=str(stale), _loaded_metadata_only=True, ) session_dir = tmp_path / "sessions" session_dir.mkdir() sidecar = session_dir / f"{metadata_session.session_id}.json" sidecar.write_text( json.dumps( { "session_id": metadata_session.session_id, "workspace": str(fallback_a.resolve()), "messages": [{"role": "user", "content": "preserve me"}], } ), encoding="utf-8", ) monkeypatch.setattr(models_module, "SESSION_DIR", session_dir) with pytest.raises( models_module.WorkspaceBindingPersistenceError, match="session workspace changed", ): models_module.persist_recovered_workspace_binding( metadata_session, fallback_b ) persisted = json.loads(sidecar.read_text(encoding="utf-8")) assert metadata_session.workspace == str(stale) assert persisted["workspace"] == str(fallback_a.resolve()) assert persisted["messages"] == [{"role": "user", "content": "preserve me"}] def test_recovery_cas_uses_the_workspace_seen_when_recovery_was_decided( models_module, monkeypatch, tmp_path ): stale = tmp_path / "deleted-workspace" fallback_a = tmp_path / "fallback-a" explicit_b = tmp_path / "explicit-b" fallback_a.mkdir() explicit_b.mkdir() session_dir = tmp_path / "sessions" session_dir.mkdir() session = SimpleNamespace( session_id="explicit-switch-wins", workspace=str(explicit_b.resolve()), _loaded_metadata_only=True, ) sidecar = session_dir / f"{session.session_id}.json" sidecar.write_text( json.dumps( { "session_id": session.session_id, "workspace": str(explicit_b.resolve()), "messages": [{"role": "user", "content": "preserve me"}], } ), encoding="utf-8", ) monkeypatch.setattr(models_module, "SESSION_DIR", session_dir) with pytest.raises( models_module.WorkspaceBindingPersistenceError, match="session workspace changed", ): models_module.persist_recovered_workspace_binding( session, fallback_a, expected_workspace=str(stale), ) persisted = json.loads(sidecar.read_text(encoding="utf-8")) assert session.workspace == str(explicit_b.resolve()) assert persisted["workspace"] == str(explicit_b.resolve()) assert persisted["messages"] == [{"role": "user", "content": "preserve me"}] def test_recovery_never_recreates_a_missing_session_sidecar( models_module, monkeypatch, tmp_path ): session_dir = tmp_path / "sessions" session_dir.mkdir() stale = tmp_path / "deleted-workspace" fallback = tmp_path / "fallback" fallback.mkdir() saves = {"count": 0} session = SimpleNamespace( session_id="deleted-before-recovery", workspace=str(stale), _loaded_metadata_only=False, save=lambda **_kwargs: saves.__setitem__("count", saves["count"] + 1), ) monkeypatch.setattr(models_module, "SESSION_DIR", session_dir) with pytest.raises( models_module.WorkspaceBindingPersistenceError, match="session sidecar is missing", ): models_module.persist_recovered_workspace_binding( session, fallback, expected_workspace=str(stale), ) assert saves["count"] == 0 assert session.workspace == str(stale) assert not (session_dir / f"{session.session_id}.json").exists() class _DeleteJSONHandler: def __init__(self, body: dict): body_bytes = json.dumps(body).encode() self.status = None self.rfile = BytesIO(body_bytes) self.wfile = BytesIO() self.headers = {"Content-Length": str(len(body_bytes))} def send_response(self, status): self.status = status def send_header(self, _key, _value): pass def end_headers(self): pass def _safe_webui_print(self, *_args, **_kwargs): pass def test_delete_serializes_with_workspace_recovery_and_sidecar_stays_deleted( models_module, monkeypatch, tmp_path ): routes_module = pytest.importorskip("api.routes") config_module = pytest.importorskip("api.config") upload_module = pytest.importorskip("api.upload") turn_journal_module = pytest.importorskip("api.turn_journal") run_journal_module = pytest.importorskip("api.run_journal") background_module = pytest.importorskip("api.background_process") terminal_module = pytest.importorskip("api.terminal") session_dir = tmp_path / "sessions" session_dir.mkdir() stale = tmp_path / "deleted-workspace" fallback = tmp_path / "fallback" fallback.mkdir() sid = "delete-recovery-race" session = SimpleNamespace( session_id=sid, workspace=str(stale), profile=None, _loaded_metadata_only=True, ) sidecar = session_dir / f"{sid}.json" sidecar.write_text( json.dumps( { "session_id": sid, "workspace": str(stale), "messages": [{"role": "user", "content": "preserve me"}], } ), encoding="utf-8", ) replace_entered = threading.Event() allow_replace = threading.Event() original_replace = models_module._safe_replace def paused_replace(source, target): replace_entered.set() assert allow_replace.wait(timeout=5) original_replace(source, target) monkeypatch.setattr(models_module, "SESSION_DIR", session_dir) monkeypatch.setattr(routes_module, "SESSION_DIR", session_dir) monkeypatch.setattr(models_module, "_safe_replace", paused_replace) monkeypatch.setattr(models_module, "_write_session_index", lambda **_kwargs: None) monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True) monkeypatch.setattr(routes_module, "get_session", lambda *_args, **_kwargs: session) monkeypatch.setattr(routes_module, "_lookup_cli_session_metadata", lambda _sid: {}) monkeypatch.setattr(routes_module, "_session_is_subagent_view_only", lambda _sid: False) monkeypatch.setattr(routes_module, "_is_messaging_session_id", lambda _sid: False) monkeypatch.setattr( routes_module, "_worktree_retained_payload_for_session_id", lambda _sid: {} ) monkeypatch.setattr(routes_module, "prune_session_from_index", lambda _sid: None) monkeypatch.setattr( routes_module, "_record_webui_deleted_session_tombstone", lambda _sid: None ) monkeypatch.setattr( routes_module, "_publish_session_list_changed", lambda *_args, **_kwargs: None ) monkeypatch.setattr(config_module, "_evict_session_agent", lambda _sid: None) monkeypatch.setattr(models_module, "delete_cli_session", lambda _sid: True) monkeypatch.setattr( upload_module, "_session_attachment_dir", lambda _sid: tmp_path / "attachments" / _sid, ) monkeypatch.setattr(turn_journal_module, "delete_turn_journal", lambda _sid: None) monkeypatch.setattr(run_journal_module, "delete_run_journal", lambda _sid: None) monkeypatch.setattr( background_module, "forget_bg_task_completion_dedup", lambda _sid: None ) monkeypatch.setattr(terminal_module, "close_terminal", lambda _sid: None) recovery_errors = [] def recover(): try: models_module.persist_recovered_workspace_binding( session, fallback, ) except Exception as exc: recovery_errors.append(exc) recovery_thread = threading.Thread(target=recover) recovery_thread.start() assert replace_entered.wait(timeout=5) delete_result = {} def delete(): handler = _DeleteJSONHandler({"session_id": sid}) routes_module.handle_post( handler, SimpleNamespace(path="/api/session/delete") ) delete_result["status"] = handler.status delete_thread = threading.Thread(target=delete) delete_thread.start() delete_thread.join(timeout=0.2) assert delete_thread.is_alive(), "delete must wait for the recovery mutation lock" allow_replace.set() recovery_thread.join(timeout=5) delete_thread.join(timeout=5) assert not recovery_errors assert delete_result["status"] == 200 assert not sidecar.exists() def test_delete_returns_503_without_mutation_when_session_lock_is_busy( models_module, monkeypatch, tmp_path ): routes_module = pytest.importorskip("api.routes") sid = "delete-busy-session" session_dir = tmp_path / "sessions" session_dir.mkdir() sidecar = session_dir / f"{sid}.json" sidecar.write_text( json.dumps({"session_id": sid, "messages": [{"role": "user", "content": "keep"}]}), encoding="utf-8", ) cached_session = SimpleNamespace(session_id=sid, profile=None) observed = {"acquire_timeouts": [], "released": 0, "mutations": []} class ContendedLock: def acquire(self, timeout=None): observed["acquire_timeouts"].append(timeout) return timeout is None def release(self): observed["released"] += 1 def __enter__(self): assert self.acquire() return self def __exit__(self, *_args): self.release() monkeypatch.setattr(routes_module, "SESSION_DIR", session_dir) monkeypatch.setattr(routes_module, "SESSIONS", {sid: cached_session}) monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True) monkeypatch.setattr( routes_module, "get_session", lambda *_args, **_kwargs: cached_session, ) monkeypatch.setattr(routes_module, "_lookup_cli_session_metadata", lambda _sid: {}) monkeypatch.setattr( routes_module, "_session_is_subagent_view_only", lambda _sid: False ) monkeypatch.setattr(routes_module, "_is_messaging_session_id", lambda _sid: False) monkeypatch.setattr( routes_module, "_worktree_retained_payload_for_session_id", lambda _sid: {} ) monkeypatch.setattr( routes_module, "_get_session_agent_lock", lambda _sid: ContendedLock() ) monkeypatch.setattr( routes_module, "prune_session_from_index", lambda _sid: observed["mutations"].append("index"), ) monkeypatch.setattr( routes_module, "_record_webui_deleted_session_tombstone", lambda _sid: observed["mutations"].append("tombstone"), ) monkeypatch.setattr( routes_module, "_publish_session_list_changed", lambda *_args, **_kwargs: observed["mutations"].append("publish"), ) monkeypatch.setattr( models_module, "delete_cli_session", lambda _sid: observed["mutations"].append("state-db"), ) handler = _DeleteJSONHandler({"session_id": sid}) routes_module.handle_post(handler, SimpleNamespace(path="/api/session/delete")) payload = json.loads(handler.wfile.getvalue()) assert handler.status == 503 assert payload == {"error": "Session busy, try again"} assert observed == {"acquire_timeouts": [5], "released": 0, "mutations": []} assert sidecar.exists() assert routes_module.SESSIONS[sid] is cached_session def test_session_lock_registry_reuses_live_lock_and_reclaims_unused_entry(): config_module = pytest.importorskip("api.config") sid = "weak-session-lock" with config_module.SESSION_AGENT_LOCKS_LOCK: config_module.SESSION_AGENT_LOCKS.pop(sid, None) first = config_module._get_session_agent_lock(sid) first_ref = weakref.ref(first) assert config_module._get_session_agent_lock(sid) is first del first gc.collect() assert first_ref() is None with config_module.SESSION_AGENT_LOCKS_LOCK: assert sid not in config_module.SESSION_AGENT_LOCKS def test_compression_lock_alias_keeps_old_and_new_ids_on_one_live_lock(): config_module = pytest.importorskip("api.config") old_sid = "compression-old-lock" new_sid = "compression-new-lock" with config_module.SESSION_AGENT_LOCKS_LOCK: config_module.SESSION_AGENT_LOCKS.pop(old_sid, None) config_module.SESSION_AGENT_LOCKS.pop(new_sid, None) compression_lock = config_module._get_session_agent_lock(old_sid) waiter_reference = compression_lock config_module._alias_session_agent_lock( old_sid, new_sid, compression_lock, ) assert config_module._get_session_agent_lock(old_sid) is waiter_reference assert config_module._get_session_agent_lock(new_sid) is waiter_reference streaming_source = (Path(__file__).parents[1] / "api" / "streaming.py").read_text( encoding="utf-8" ) assert "_alias_session_agent_lock(old_sid, new_sid, _agent_lock)" in streaming_source def test_get_session_for_file_ops_does_not_fallback_existing_untrusted_workspace( models_module, monkeypatch, tmp_path ): """Recovery must not replace a non-missing trust rejection with a fallback.""" profiles_module = pytest.importorskip("api.profiles") workspace_module = pytest.importorskip("api.workspace") home = tmp_path / "home" fallback = home / "fallback" fallback.mkdir(parents=True) outside = tmp_path / "outside" outside.mkdir() session = SimpleNamespace( session_id="untrusted-webui-sid", profile=None, workspace=str(outside), ) monkeypatch.setattr(models_module, "get_session", lambda *args, **kwargs: session) monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(fallback)) monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") monkeypatch.setattr(workspace_module, "_home_path", lambda: home) monkeypatch.setattr(workspace_module, "load_workspaces", lambda: []) monkeypatch.setattr(workspace_module, "_BOOT_DEFAULT_WORKSPACE", fallback) result = models_module.get_session_for_file_ops(session.session_id) assert result is session assert result.workspace == str(outside) @pytest.mark.parametrize( "terminal_cfg", [ pytest.param( {"backend": "ssh", "cwd": "/Users/joeyshiue"}, id="cwd-absolute", ), pytest.param({"backend": "ssh"}, id="cwd-omitted"), pytest.param({"backend": "ssh", "cwd": ""}, id="cwd-empty"), pytest.param({"backend": "ssh", "cwd": "."}, id="cwd-dot"), ], ) def test_get_session_for_file_ops_does_not_recover_remote_trust_rejection( models_module, monkeypatch, tmp_path, terminal_cfg ): """A local miss cannot prove that an out-of-scope remote path was deleted.""" config_module = pytest.importorskip("api.config") profiles_module = pytest.importorskip("api.profiles") workspace_module = pytest.importorskip("api.workspace") candidate = "/Users/other/projects/demo" fallback_path = tmp_path / "fallback" fallback_path.mkdir() session = SimpleNamespace( session_id="remote-untrusted-webui-sid", profile=None, workspace=candidate, ) fallback_calls = {"count": 0} monkeypatch.setattr(models_module, "get_session", lambda *args, **kwargs: session) monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") monkeypatch.setattr( config_module, "get_config", lambda: {"terminal": terminal_cfg}, ) monkeypatch.setattr(workspace_module, "_home_path", lambda: tmp_path) def fallback(): fallback_calls["count"] += 1 return fallback_path monkeypatch.setattr(models_module, "get_last_workspace", fallback) result = models_module.get_session_for_file_ops(session.session_id) assert result is session assert result.workspace == candidate assert fallback_calls["count"] == 0 def test_get_session_for_file_ops_rejects_foreign_profile( models_module, monkeypatch, tmp_path, caplog ): """WebUI sessions must belong to the active profile before file access.""" profiles_module = pytest.importorskip("api.profiles") foreign_session = SimpleNamespace(profile="research", workspace=str(tmp_path)) called = {"get_session": 0, "profile_match": 0, "state_db": 0} def fake_get_session(sid, metadata_only=False): called["get_session"] += 1 return foreign_session def fake_profiles_match(session_profile, active_profile): called["profile_match"] += 1 assert session_profile == "research" assert active_profile == "default" return False def fake_has(_sid): called["state_db"] += 1 return True monkeypatch.setattr(models_module, "get_session", fake_get_session) monkeypatch.setattr(models_module, "state_db_has_session", fake_has) monkeypatch.setattr(profiles_module, "_profiles_match", fake_profiles_match) monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") with caplog.at_level(logging.DEBUG, logger=models_module.logger.name): with pytest.raises(KeyError): models_module.get_session_for_file_ops("foreign-webui-sid") # A found-but-foreign WebUI sidecar is an authorization failure, not a # missing-session condition that can fall through to the state.db fallback. assert called == {"get_session": 1, "profile_match": 1, "state_db": 0} assert "Rejected file-manager session for foreign profile" in caplog.text assert "foreign-webui-sid" in caplog.text assert "session_profile='research'" in caplog.text assert "active_profile='default'" in caplog.text def test_file_read_rejects_foreign_profile_session( models_module, monkeypatch, tmp_path ): """A default-profile file route cannot read a named-profile workspace.""" profiles_module = pytest.importorskip("api.profiles") routes_module = pytest.importorskip("api.routes") workspace = tmp_path / "named-workspace" workspace.mkdir() (workspace / "marker.txt").write_text("foreign profile marker") session = models_module.Session( session_id="foreign-profile-file-read", workspace=str(workspace), profile="research", ) models_module.SESSIONS[session.session_id] = session class Handler: command = "GET" headers = {} def __init__(self): self.status = None self.headers_sent = [] self.wfile = io.BytesIO() def send_response(self, code): self.status = code def send_header(self, key, value): self.headers_sent.append((key, value)) def end_headers(self): pass monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default") try: handler = Handler() routes_module._handle_file_read( handler, urlparse( "/api/file?session_id=foreign-profile-file-read&path=marker.txt" ), ) assert handler.status == 404 assert b"foreign profile marker" not in handler.wfile.getvalue() finally: models_module.SESSIONS.pop(session.session_id, None) def test_get_session_for_file_ops_state_db_fallback( models_module, monkeypatch, tmp_path ): """(b) state.db-only session — returns view with workspace populated.""" db = tmp_path / "state.db" _make_state_db(db, "tg-123") workspace = tmp_path / "ws" workspace.mkdir() (workspace / "hello.txt").write_text("hi from telegram session") def raise_key(sid, metadata_only=False): raise KeyError(sid) monkeypatch.setattr(models_module, "get_session", raise_key) monkeypatch.setattr(models_module, "_active_state_db_path", lambda: db) monkeypatch.setattr( models_module, "get_last_workspace", lambda: str(workspace) ) view = models_module.get_session_for_file_ops("tg-123") assert view.session_id == "tg-123" assert Path(view.workspace) == workspace # The workspace is real and readable — file-manager handlers will # successfully serve files relative to it instead of returning 404. assert (Path(view.workspace) / "hello.txt").read_text() == "hi from telegram session" def test_get_session_for_file_ops_unknown_session_raises( models_module, monkeypatch, tmp_path ): """(c) Unknown session — KeyError propagates so callers still 404.""" db = tmp_path / "state.db" _make_state_db(db, "tg-123") def raise_key(sid, metadata_only=False): raise KeyError(sid) monkeypatch.setattr(models_module, "get_session", raise_key) monkeypatch.setattr(models_module, "_active_state_db_path", lambda: db) monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(tmp_path)) with pytest.raises(KeyError): models_module.get_session_for_file_ops("does-not-exist") def test_state_db_has_session_missing_db(models_module, monkeypatch, tmp_path): monkeypatch.setattr( models_module, "_active_state_db_path", lambda: tmp_path / "missing.db" ) assert models_module.state_db_has_session("any") is False def test_state_db_has_session_present(models_module, monkeypatch, tmp_path): db = tmp_path / "state.db" _make_state_db(db, "cli-9") monkeypatch.setattr(models_module, "_active_state_db_path", lambda: db) assert models_module.state_db_has_session("cli-9") is True assert models_module.state_db_has_session("nope") is False