864 lines
31 KiB
Python
864 lines
31 KiB
Python
"""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"(?<!_)\bget_session\(", body)
|
|
# Strip occurrences that are actually get_session_for_file_ops( — the
|
|
# regex above already excludes underscore prefix, so any remaining
|
|
# match is a raw get_session call.
|
|
if bare:
|
|
missing.append(name)
|
|
assert not missing, f"raw get_session() still used in: {missing}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Functional tests against api.models.get_session_for_file_ops
|
|
# ---------------------------------------------------------------------------
|
|
|
|
pytestmark_models = pytest.mark.requires_agent_modules
|
|
|
|
|
|
def _make_state_db(path: Path, sid: str) -> 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
|