from pathlib import Path from typing import Generator import pytest from aiohttp import web from aiohttp.test_utils import make_mocked_request from app.library.config import Config from app.library.router import ROUTES from app.routes.api import _static @pytest.fixture(autouse=True) def reset_static_routes() -> Generator[None, None, None]: Config._reset_singleton() ROUTES.clear() _static.STATIC_STATE.root = None _static.STATIC_STATE.index_file = None yield _static.STATIC_STATE.root = None _static.STATIC_STATE.index_file = None ROUTES.clear() Config._reset_singleton() def _make_request(path: str, *, accept: str = "text/html") -> web.Request: return make_mocked_request("GET", path, headers={"Accept": accept}) def _configure_static_root(static_root: Path) -> None: _static.STATIC_STATE.root = static_root.resolve() _static.STATIC_STATE.index_file = (_static.STATIC_STATE.root / "index.html").resolve() class TestServeStaticFile: @pytest.mark.asyncio async def test_nested_doc_falls_back(self, tmp_path: Path) -> None: config = Config.get_instance() index_file = tmp_path / "index.html" index_file.write_text("root shell", encoding="utf-8") _configure_static_root(tmp_path) response = await _static.serve_static_file(_make_request("/docs/readme"), config) assert isinstance(response, web.FileResponse) assert response._path == index_file @pytest.mark.asyncio async def test_nested_index_uses_root(self, tmp_path: Path) -> None: config = Config.get_instance() root_index = tmp_path / "index.html" nested_index = tmp_path / "docs" / "readme" / "index.html" nested_index.parent.mkdir(parents=True) root_index.write_text("root shell", encoding="utf-8") nested_index.write_text("nested shell", encoding="utf-8") _configure_static_root(tmp_path) response = await _static.serve_static_file(_make_request("/docs/readme"), config) assert isinstance(response, web.FileResponse) assert response._path == root_index @pytest.mark.asyncio async def test_missing_asset_does_not_fall_back(self, tmp_path: Path) -> None: config = Config.get_instance() (tmp_path / "index.html").write_text("root shell", encoding="utf-8") assets_dir = tmp_path / "assets" assets_dir.mkdir() _configure_static_root(tmp_path) request = make_mocked_request( "GET", "/assets/missing.js", headers={"Accept": "*/*", "Sec-Fetch-Dest": "script"}, ) response = await _static.serve_static_file(request, config) assert isinstance(response, web.Response) body = response.body assert isinstance(body, bytes) assert response.status == web.HTTPNotFound.status_code assert b"missing.js" in body @pytest.mark.asyncio async def test_missing_unknown_no_fallback(self, tmp_path: Path) -> None: config = Config.get_instance() (tmp_path / "index.html").write_text("root shell", encoding="utf-8") assets_dir = tmp_path / "assets" assets_dir.mkdir() _configure_static_root(tmp_path) request = make_mocked_request( "GET", "/assets/missing.abcd123", headers={"Accept": "*/*", "Sec-Fetch-Dest": "script"}, ) response = await _static.serve_static_file(request, config) assert isinstance(response, web.Response) body = response.body assert isinstance(body, bytes) assert response.status == web.HTTPNotFound.status_code assert b"missing.abcd123" in body @pytest.mark.asyncio async def test_symlink_outside_rejected(self, tmp_path: Path) -> None: config = Config.get_instance() (tmp_path / "index.html").write_text("root shell", encoding="utf-8") outside_dir = tmp_path.parent / "outside-static-root" outside_dir.mkdir(exist_ok=True) outside_file = outside_dir / "secret.js" outside_file.write_text("console.log('outside')", encoding="utf-8") try: (tmp_path / "leak.js").symlink_to(outside_file) _configure_static_root(tmp_path) response = await _static.serve_static_file(_make_request("/leak.js"), config) assert isinstance(response, web.Response) body = response.body assert isinstance(body, bytes) assert response.status == web.HTTPNotFound.status_code assert b"/leak.js" in body finally: if (tmp_path / "leak.js").exists() or (tmp_path / "leak.js").is_symlink(): (tmp_path / "leak.js").unlink() if outside_file.exists(): outside_file.unlink() if outside_dir.exists(): outside_dir.rmdir() @pytest.mark.asyncio async def test_missing_api_path_does_not_fall_back(self, tmp_path: Path) -> None: config = Config.get_instance() (tmp_path / "index.html").write_text("root shell", encoding="utf-8") _configure_static_root(tmp_path) response = await _static.serve_static_file(_make_request("/api/missing"), config) assert isinstance(response, web.Response) body = response.body assert isinstance(body, bytes) assert response.status == web.HTTPNotFound.status_code assert b"/api/missing" in body @pytest.mark.asyncio async def test_dotted_browser_path_404(self, tmp_path: Path) -> None: config = Config.get_instance() (tmp_path / "index.html").write_text("root shell", encoding="utf-8") _configure_static_root(tmp_path) response = await _static.serve_static_file(_make_request("/browser/foo/bar.txt"), config) assert isinstance(response, web.Response) body = response.body assert isinstance(body, bytes) assert response.status == web.HTTPNotFound.status_code assert b"/browser/foo/bar.txt" in body def test_registers_root_routes(self, tmp_path: Path) -> None: config = Config.get_instance() static_root = tmp_path / "ui-exported" static_root.mkdir() (static_root / "index.html").write_text("root shell", encoding="utf-8") (static_root / "assets").mkdir() (static_root / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8") config.static_ui_path = str(static_root) _static.setup_static_routes(tmp_path, config) http_routes = ROUTES.get("http", {}) assert "index" in http_routes assert "static_fallback" in http_routes assert "/assets/app.js" not in {route.path for route in http_routes.values()}