M2-T11: serve React SPA from FastAPI and remove Jinja pages
- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist): mounts /assets and a GET catch-all returning index.html for client routes; catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets, ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI) - delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/ (all replaced by /api/* + SPA; auth service layer kept) - remove/replace Jinja page tests (JSON coverage already in test_api_*); add tests/test_spa_hosting.py for the fallback contract - regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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(
|
||||
"<!DOCTYPE html><html><body>SPA INDEX</body></html>", 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()
|
||||
Reference in New Issue
Block a user