"""Tests for M2-T11: SPA hosting + fallback behavior in app/main.py. Uses SPA_DIST_DIR env var to point at a temporary directory containing a fake index.html and an asset file, so tests are hermetic and don't depend on the real frontend/dist build. """ from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from app.config import get_settings from app.db import reset_db_caches from app.main import create_app @pytest.fixture def spa_dist(tmp_path: Path) -> Path: """Create a minimal fake SPA dist directory. Layout: tmp_path/ secret.txt ← OUTSIDE dist — must never be served fake_dist/ index.html assets/ main.js """ # A secret file placed OUTSIDE the dist dir — used by traversal tests. (tmp_path / "secret.txt").write_text("TOP_SECRET_SENTINEL", encoding="utf-8") dist = tmp_path / "fake_dist" dist.mkdir() (dist / "index.html").write_text( "SPA INDEX", encoding="utf-8" ) assets = dist / "assets" assets.mkdir() (assets / "main.js").write_text("console.log('app');", encoding="utf-8") return dist @pytest.fixture def spa_client(spa_dist: Path, auth_database, monkeypatch: pytest.MonkeyPatch) -> TestClient: """TestClient with a fresh app wired to the fake SPA dist.""" monkeypatch.setenv("SPA_DIST_DIR", str(spa_dist)) get_settings.cache_clear() reset_db_caches() app = create_app() with TestClient(app) as client: yield client get_settings.cache_clear() reset_db_caches() # --------------------------------------------------------------------------- # SPA fallback — client routes served as index.html # --------------------------------------------------------------------------- def test_spa_root_returns_index_html(spa_client: TestClient) -> None: """GET / returns the SPA index.html (200).""" response = spa_client.get("/", follow_redirects=False) assert response.status_code == 200 assert "SPA INDEX" in response.text def test_spa_config_route_returns_index_html(spa_client: TestClient) -> None: """/config is a client-side route; the fallback must serve index.html.""" response = spa_client.get("/config", follow_redirects=False) assert response.status_code == 200 assert "SPA INDEX" in response.text def test_spa_records_route_returns_index_html(spa_client: TestClient) -> None: """/records is a client-side route; the fallback must serve index.html.""" response = spa_client.get("/records", follow_redirects=False) assert response.status_code == 200 assert "SPA INDEX" in response.text def test_spa_deep_link_returns_index_html(spa_client: TestClient) -> None: """/some/deep/path that doesn't exist on disk returns index.html (deep-link support).""" response = spa_client.get("/some/deep/path", follow_redirects=False) assert response.status_code == 200 assert "SPA INDEX" in response.text # --------------------------------------------------------------------------- # SPA asset serving # --------------------------------------------------------------------------- def test_spa_asset_is_served(spa_client: TestClient) -> None: """/assets/main.js must be served directly from the dist/assets directory.""" response = spa_client.get("/assets/main.js") assert response.status_code == 200 assert "console.log" in response.text # --------------------------------------------------------------------------- # API routes not swallowed by fallback # --------------------------------------------------------------------------- def test_unauthenticated_api_session_returns_401_not_index(spa_client: TestClient) -> None: """/api/session without a session cookie must return 401, not index.html.""" response = spa_client.get("/api/session") assert response.status_code == 401 assert "SPA INDEX" not in response.text def test_unknown_api_path_returns_404_not_index(spa_client: TestClient) -> None: """/api/does-not-exist must return 404, not index.html.""" response = spa_client.get("/api/does-not-exist") assert response.status_code == 404 assert "SPA INDEX" not in response.text def test_api_typo_returns_404_not_index(spa_client: TestClient) -> None: """/api/typo returns 404 (the fallback must not serve index.html for /api/*).""" response = spa_client.get("/api/typo") assert response.status_code == 404 assert "SPA INDEX" not in response.text # --------------------------------------------------------------------------- # FastAPI built-in endpoints not swallowed by fallback # --------------------------------------------------------------------------- def test_openapi_json_is_served(spa_client: TestClient) -> None: """/openapi.json must be served by FastAPI, not the SPA fallback.""" response = spa_client.get("/openapi.json") assert response.status_code == 200 body = response.json() assert "openapi" in body def test_docs_endpoint_is_served(spa_client: TestClient) -> None: """/docs must be served by FastAPI Swagger UI, not index.html.""" response = spa_client.get("/docs") assert response.status_code == 200 assert "SPA INDEX" not in response.text def test_status_endpoint_is_served(spa_client: TestClient) -> None: """/status must remain reachable and return JSON.""" response = spa_client.get("/status") assert response.status_code == 200 assert response.json() == {"status": "ok"} # --------------------------------------------------------------------------- # Path-traversal containment — security regression tests # --------------------------------------------------------------------------- def test_path_traversal_encoded_dotdot_slash_returns_index_not_secret( spa_client: TestClient, spa_dist: Path ) -> None: """GET /..%2fsecret.txt must NOT return the secret file outside the dist dir. Starlette URL-decodes {full_path:path} but does not normalise it, so an encoded '../' can escape the dist root without the containment guard. The guarded implementation resolves the candidate and checks is_relative_to; a path that escapes the root falls back to index.html instead. """ response = spa_client.get("/..%2fsecret.txt", follow_redirects=False) # Must not expose the secret content. assert "TOP_SECRET_SENTINEL" not in response.text # Should be a successful SPA index response (not a server error). assert response.status_code == 200 assert "SPA INDEX" in response.text def test_path_traversal_pct_encoded_dotdot_returns_index_not_secret( spa_client: TestClient, spa_dist: Path ) -> None: """GET /%2e%2e%2fsecret.txt must NOT expose the file outside dist. Covers the %2e%2e encoding variant of '..'. """ response = spa_client.get("/%2e%2e%2fsecret.txt", follow_redirects=False) assert "TOP_SECRET_SENTINEL" not in response.text assert response.status_code == 200 assert "SPA INDEX" in response.text def test_path_traversal_nested_encoded_dotdot_returns_index_not_secret( spa_client: TestClient, spa_dist: Path ) -> None: """GET /fake_dist/..%2f..%2fsecret.txt (deeper traversal) must not leak.""" response = spa_client.get("/fake_dist/..%2f..%2fsecret.txt", follow_redirects=False) assert "TOP_SECRET_SENTINEL" not in response.text assert response.status_code == 200 assert "SPA INDEX" in response.text def test_legit_nested_asset_inside_dist_is_served( spa_client: TestClient, spa_dist: Path ) -> None: """A real file inside the dist dir is still served correctly after the fix. Place a nested asset directly inside dist (not under /assets) and confirm the catch-all serves it. """ nested = spa_dist / "nested" / "chunk.js" nested.parent.mkdir() nested.write_text("// nested chunk", encoding="utf-8") response = spa_client.get("/nested/chunk.js", follow_redirects=False) assert response.status_code == 200 assert "nested chunk" in response.text # --------------------------------------------------------------------------- # SPA disabled when dist dir is missing # --------------------------------------------------------------------------- def test_spa_disabled_when_dist_missing( tmp_path: Path, auth_database, monkeypatch: pytest.MonkeyPatch ) -> None: """When SPA_DIST_DIR points to a non-existent directory, the app still starts and API routes work normally — the SPA fallback is simply absent.""" empty = tmp_path / "no_dist_here" monkeypatch.setenv("SPA_DIST_DIR", str(empty)) get_settings.cache_clear() reset_db_caches() app = create_app() with TestClient(app) as client: response = client.get("/status") assert response.status_code == 200 assert response.json() == {"status": "ok"} # API still returns 401 for unauthenticated access api_response = client.get("/api/session") assert api_response.status_code == 401 get_settings.cache_clear() reset_db_caches()