Files
echo-core/tests/test_dashboard_files_sandbox.py

108 lines
3.6 KiB
Python

"""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"}