"""Tests for the /api/files sandbox — _resolve_sandboxed must block path traversal.""" from __future__ import annotations import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] DASH = PROJECT_ROOT / "dashboard" if str(DASH) not in sys.path: sys.path.insert(0, str(DASH)) @pytest.fixture(scope="module") def files_module(): from handlers import files as _f # type: ignore return _f @pytest.fixture def handler(files_module): class _Stub(files_module.FilesHandlers): def __init__(self): self.captured = None self.captured_code = None def send_json(self, data, code=200): self.captured = data self.captured_code = code return _Stub() @pytest.fixture def sandboxed(tmp_path, monkeypatch): """Replace ALLOWED_WORKSPACES with a single tmp_path to isolate tests.""" import constants # type: ignore root = tmp_path / "repo" root.mkdir() monkeypatch.setattr(constants, "ALLOWED_WORKSPACES", [root]) return root def test_resolve_accepts_file_inside_workspace(handler, sandboxed): (sandboxed / "notes.md").write_text("hello", encoding="utf-8") target, workspace = handler._resolve_sandboxed("notes.md") assert target is not None assert target == (sandboxed / "notes.md").resolve() assert workspace == sandboxed def test_resolve_accepts_nested_file(handler, sandboxed): (sandboxed / "sub").mkdir() (sandboxed / "sub" / "file.txt").write_text("x", encoding="utf-8") target, workspace = handler._resolve_sandboxed("sub/file.txt") assert target == (sandboxed / "sub" / "file.txt").resolve() assert workspace == sandboxed def test_resolve_rejects_parent_traversal(handler, sandboxed): """../etc/passwd-style requests must resolve OUTSIDE the workspace and be refused by returning (None, None).""" target, workspace = handler._resolve_sandboxed("../../../etc/passwd") assert target is None assert workspace is None def test_resolve_rejects_absolute_escape(handler, sandboxed): """Absolute path that lands outside the workspace must be refused. Note: Path('/base') / '/etc/passwd' == Path('/etc/passwd') because the second path is absolute. The resolver must still refuse it. """ target, workspace = handler._resolve_sandboxed("/etc/passwd") assert target is None, "absolute outside path must be refused" assert workspace is None def test_handle_files_get_returns_403_on_denied(handler, sandboxed): """End-to-end: /api/files with a traversal path must 403.""" # Simulate HTTP path parsing: the endpoint reads self.path handler.path = "/api/files?path=../../../etc/passwd&action=list" handler.handle_files_get() assert handler.captured_code == 403 assert handler.captured["error"] == "Access denied" def test_handle_files_get_reads_a_file(handler, sandboxed): (sandboxed / "hello.txt").write_text("hi there", encoding="utf-8") handler.path = "/api/files?path=hello.txt&action=list" handler.handle_files_get() assert handler.captured_code == 200 or handler.captured_code is None assert handler.captured["type"] == "file" assert handler.captured["content"] == "hi there" def test_handle_files_get_lists_a_dir(handler, sandboxed): (sandboxed / "a.md").write_text("a", encoding="utf-8") (sandboxed / "b.md").write_text("b", encoding="utf-8") (sandboxed / "nested").mkdir() handler.path = "/api/files?path=&action=list" handler.handle_files_get() assert handler.captured["type"] == "dir" names = {item["name"] for item in handler.captured["items"]} assert names == {"a.md", "b.md", "nested"}