import errno import os import runpy from pathlib import Path import anyio import pytest from fastapi import APIRouter, FastAPI, HTTPException, Request, WebSocket from fastapi.testclient import TestClient from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.responses import PlainTextResponse, Response from starlette.routing import BaseRoute, Match, NoMatchFound, Route def write_file(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) def test_frontend_exact_prefix_path_serves_index(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app") app = FastAPI() app.frontend("/app", directory=dist) response = TestClient(app).get("/app") assert response.status_code == 200 assert response.text == "app" def test_apirouter_frontend_with_router_prefix_and_frontend_subpath(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "asset.txt", "asset") router = APIRouter(prefix="/internal") router.frontend("/ui", directory=dist) app = FastAPI() app.include_router(router, prefix="/prefix") response = TestClient(app).get("/prefix/internal/ui/asset.txt") assert response.status_code == 200 assert response.text == "asset" def test_frontend_fallback_rejects_invalid_fallback(tmp_path: Path): dist = tmp_path / "dist" dist.mkdir() app = FastAPI() with pytest.raises(AssertionError, match="fallback"): app.frontend("/", directory=dist, fallback="invalid") # type: ignore[arg-type] # ty: ignore[invalid-argument-type] def test_index_fallback_ignores_invalid_q_value(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get( "/dashboard/settings", headers={"accept": "text/html; q=wat"} ) assert response.status_code == 200 assert response.text == "app shell" def test_frontend_static_files_lookup_errors(monkeypatch, tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app") app = FastAPI() app.frontend("/", directory=dist) frontend_routes = app.router._frontend_routes assert frontend_routes is not None static_files = frontend_routes.routes[0].app def raise_permission_error(path: str): raise PermissionError monkeypatch.setattr(static_files, "lookup_path", raise_permission_error) response = TestClient(app).get("/asset.txt") assert response.status_code == 401 def raise_value_error(path: str): raise ValueError monkeypatch.setattr(static_files, "lookup_path", raise_value_error) response = TestClient(app).get("/asset.txt") assert response.status_code == 404 def raise_name_too_long(path: str): raise OSError(errno.ENAMETOOLONG, "name too long") monkeypatch.setattr(static_files, "lookup_path", raise_name_too_long) response = TestClient(app).get("/asset.txt") assert response.status_code == 404 def raise_os_error(path: str): raise OSError(5, "other") monkeypatch.setattr(static_files, "lookup_path", raise_os_error) with pytest.raises(OSError): TestClient(app).get("/asset.txt") def test_frontend_route_group_helpers(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app") app = FastAPI() app.frontend("/", directory=dist) route_group = app.router._frontend_routes assert route_group is not None match, child_scope = route_group.matches({"type": "websocket", "path": "/"}) assert match == Match.NONE assert child_scope == {} with pytest.raises(StarletteHTTPException) as exc_info: anyio.run( route_group.with_prefix("/app").handle, {"type": "http", "path": "/missing", "method": "GET"}, None, None, ) assert exc_info.value.status_code == 404 with pytest.raises(NoMatchFound): route_group.url_path_for("frontend") with pytest.raises(NoMatchFound): route_group.routes[0].url_path_for("frontend") def test_included_low_priority_routes_cache_is_reused(): async def low_priority_endpoint(request: Request): return PlainTextResponse("low") router = APIRouter() router._low_priority_routes.append(Route("/low", low_priority_endpoint)) router._mark_routes_changed() app = FastAPI() app.include_router(router, prefix="/prefix") included_router = next( route for route in app.router.routes if hasattr(route, "effective_low_priority_routes") ) first = included_router.effective_low_priority_routes() # ty: ignore[call-non-callable] second = included_router.effective_low_priority_routes() # ty: ignore[call-non-callable] response = TestClient(app).get("/prefix/low") assert first is second assert response.status_code == 200 assert response.text == "low" def test_low_priority_api_route_handles_with_context(): app = FastAPI() async def endpoint(request: Request) -> Response: return PlainTextResponse(request.scope["path_params"]["item_id"]) route = app.router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"]) app.router._low_priority_routes.append(route) app.router._mark_routes_changed() response = TestClient(app).get("/low/abc") assert response.status_code == 200 assert response.text == "abc" def test_included_low_priority_api_route_handles_with_context(): router = APIRouter() async def endpoint(request: Request) -> Response: return PlainTextResponse(request.scope["path_params"]["item_id"]) route = router.route_class("/low/{item_id}", endpoint=endpoint, methods=["GET"]) router._low_priority_routes.append(route) router._mark_routes_changed() app = FastAPI() app.include_router(router, prefix="/prefix") response = TestClient(app).get("/prefix/low/abc") assert response.status_code == 200 assert response.text == "abc" def test_normal_route_partial_match_returns_before_frontend(tmp_path: Path): class PartialRoute(BaseRoute): def matches(self, scope): return Match.PARTIAL, {} async def handle(self, scope, receive, send): response = PlainTextResponse("partial", status_code=405) await response(scope, receive, send) dist = tmp_path / "dist" write_file(dist / "index.html", "frontend") app = FastAPI() app.router.routes.append(PartialRoute()) app.frontend("/", directory=dist) response = TestClient(app).get("/anything") assert response.status_code == 405 assert response.text == "partial" def test_normal_route_partial_match_wins_before_frontend(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "api", "frontend") app = FastAPI() @app.get("/api") def read_api(): return {"source": "api"} app.frontend("/", directory=dist) client = TestClient(app) response = client.get("/api") assert response.status_code == 200 assert response.json() == {"source": "api"} response = client.post("/api") assert response.status_code == 405 def test_basic_file_serving(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "assets" / "app.js", "console.log('ok')") app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).get("/assets/app.js") assert response.status_code == 200 assert response.text == "console.log('ok')" assert "etag" in response.headers assert "last-modified" in response.headers def test_existing_api_route_wins_over_frontend(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "api" / "users", "frontend") app = FastAPI() @app.get("/api/users") def read_users(): return {"source": "api"} app.frontend("/", directory=dist) response = TestClient(app).get("/api/users") assert response.status_code == 200 assert response.json() == {"source": "api"} def test_api_route_404_is_not_replaced_by_frontend_fallback(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "frontend") app = FastAPI() @app.get("/api/users") def read_users(): raise HTTPException(status_code=404, detail="api missing") app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get("/api/users", headers={"accept": "text/html"}) assert response.status_code == 404 assert response.json() == {"detail": "api missing"} def test_index_fallback_for_navigation_request(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get( "/dashboard/settings", headers={"accept": "text/html"} ) assert response.status_code == 200 assert response.text == "app shell" def test_index_fallback_parses_accept_parameters(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get( "/dashboard/settings", headers={"accept": "text/html; q=0.8"} ) assert response.status_code == 200 assert response.text == "app shell" def test_index_fallback_ignores_q_zero_accept(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get( "/dashboard/settings", headers={"accept": "text/html; q=0.0"} ) assert response.status_code == 404 def test_index_fallback_respects_explicit_html_rejection_with_wildcard( tmp_path: Path, ): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get( "/dashboard/settings", headers={"accept": "text/html; q=0, */*; q=1"}, ) assert response.status_code == 404 def test_index_fallback_respects_explicit_xhtml_rejection_with_wildcard( tmp_path: Path, ): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get( "/dashboard/settings", headers={"accept": "application/xhtml+xml; q=0, */*; q=1"}, ) assert response.status_code == 404 @pytest.mark.parametrize( ("path", "accept"), [ ("/assets/missing.js", "*/*"), ("/assets/missing.css", "text/css"), ("/assets/missing.png", "image/png"), ("/api/missing", "application/json"), ("/users/jane.doe", "text/html"), ], ) def test_index_fallback_does_not_handle_asset_like_or_non_html_requests( tmp_path: Path, path: str, accept: str ): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback="index.html") response = TestClient(app).get(path, headers={"accept": accept}) assert response.status_code == 404 assert response.text != "app shell" def test_404_fallback_handles_missing_assets(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "404.html", "missing") app = FastAPI() app.frontend("/", directory=dist, fallback="404.html") response = TestClient(app).get("/assets/missing.js") assert response.status_code == 404 assert response.text == "missing" def test_auto_fallback_prefers_404_over_index(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") write_file(dist / "404.html", "missing") app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).get("/dashboard", headers={"accept": "text/html"}) assert response.status_code == 404 assert response.text == "missing" def test_auto_fallback_uses_index_when_404_is_missing(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).get("/dashboard", headers={"accept": "text/html"}) assert response.status_code == 200 assert response.text == "app shell" def test_auto_fallback_returns_normal_404_without_fallback_files(tmp_path: Path): dist = tmp_path / "dist" dist.mkdir() app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).get("/dashboard", headers={"accept": "text/html"}) assert response.status_code == 404 assert response.json() == {"detail": "Not Found"} def test_no_fallback_returns_normal_404(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app shell") app = FastAPI() app.frontend("/", directory=dist, fallback=None) response = TestClient(app).get("/dashboard", headers={"accept": "text/html"}) assert response.status_code == 404 assert response.json() == {"detail": "Not Found"} def test_directory_index_and_redirect(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "about" / "index.html", "about") app = FastAPI() app.frontend("/", directory=dist) client = TestClient(app) redirect = client.get("/about", follow_redirects=False) response = client.get("/about/") assert redirect.status_code == 307 assert redirect.headers["location"] == "http://testserver/about/" assert response.status_code == 200 assert response.text == "about" def test_path_validation_and_trailing_slash_normalization(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "asset.txt", "ok") app = FastAPI() with pytest.raises(AssertionError): app.frontend("", directory=dist) with pytest.raises(AssertionError): app.frontend("app", directory=dist) app.frontend("/app/", directory=dist) response = TestClient(app).get("/app/asset.txt") assert response.status_code == 200 assert response.text == "ok" def test_frontend_path_matching_uses_segment_boundaries(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app") app = FastAPI() app.frontend("/app", directory=dist, fallback="index.html") response = TestClient(app).get("/application", headers={"accept": "text/html"}) assert response.status_code == 404 def test_multiple_frontends_use_longest_matching_prefix(tmp_path: Path): site = tmp_path / "site" admin = tmp_path / "admin" write_file(site / "index.html", "site") write_file(admin / "index.html", "admin") app = FastAPI() app.frontend("/", directory=site, fallback="index.html") app.frontend("/admin", directory=admin, fallback="index.html") response = TestClient(app).get("/admin/settings", headers={"accept": "text/html"}) assert response.status_code == 200 assert response.text == "admin" def test_apirouter_frontend_uses_include_prefix(tmp_path: Path): dist = tmp_path / "admin" write_file(dist / "index.html", "admin") router = APIRouter() router.frontend("/", directory=dist, fallback="index.html") app = FastAPI() app.include_router(router, prefix="/admin") response = TestClient(app).get("/admin/settings", headers={"accept": "text/html"}) assert response.status_code == 200 assert response.text == "admin" def test_global_priority_across_included_routers(tmp_path: Path): dist = tmp_path / "site" write_file(dist / "index.html", "site") site_router = APIRouter() site_router.frontend("/", directory=dist, fallback="index.html") api_router = APIRouter() @api_router.get("/api/users") def read_users(): return {"source": "api"} app = FastAPI() app.include_router(site_router) app.include_router(api_router) response = TestClient(app).get("/api/users", headers={"accept": "text/html"}) assert response.status_code == 200 assert response.json() == {"source": "api"} def test_nested_apirouter_frontend_uses_all_include_prefixes(tmp_path: Path): dist = tmp_path / "admin" write_file(dist / "index.html", "admin") child_router = APIRouter() child_router.frontend("/", directory=dist, fallback="index.html") parent_router = APIRouter() parent_router.include_router(child_router, prefix="/child") app = FastAPI() app.include_router(parent_router, prefix="/parent") response = TestClient(app).get( "/parent/child/settings", headers={"accept": "text/html"} ) assert response.status_code == 200 assert response.text == "admin" def test_low_priority_cache_updates_after_route_added_to_included_router( tmp_path: Path, ): dist = tmp_path / "site" write_file(dist / "index.html", "site") router = APIRouter() router.frontend("/", directory=dist, fallback="index.html") app = FastAPI() app.include_router(router, prefix="/app") client = TestClient(app) frontend_response = client.get("/app/dashboard", headers={"accept": "text/html"}) @router.get("/dashboard") def read_dashboard(): return {"source": "api"} api_response = client.get("/app/dashboard", headers={"accept": "text/html"}) assert frontend_response.status_code == 200 assert frontend_response.text == "site" assert api_response.status_code == 200 assert api_response.json() == {"source": "api"} def test_normal_route_slash_redirect_wins_before_frontend_redirect(tmp_path: Path): dist = tmp_path / "site" write_file(dist / "api" / "index.html", "frontend") app = FastAPI() @app.get("/api/") def read_api(): return {"source": "api"} app.frontend("/", directory=dist) response = TestClient(app).get("/api", follow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == "http://testserver/api/" followed = TestClient(app).get("/api/") assert followed.status_code == 200 assert followed.json() == {"source": "api"} def test_frontend_respects_root_path(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "assets" / "app.js", "console.log('ok')") app = FastAPI() app.frontend("/app", directory=dist) response = TestClient(app, root_path="/proxy").get("/app/assets/app.js") assert response.status_code == 200 assert response.text == "console.log('ok')" def test_websocket_route_wins_over_frontend(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "ws", "frontend") app = FastAPI() @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() await websocket.send_text("websocket") await websocket.close() app.frontend("/", directory=dist) with TestClient(app).websocket_connect("/ws") as websocket: data = websocket.receive_text() assert data == "websocket" def test_head_requests_work(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "asset.txt", "ok") app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).head("/asset.txt") assert response.status_code == 200 assert response.text == "" assert response.headers["content-length"] == "2" def test_unsupported_methods_return_405(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "asset.txt", "ok") app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).post("/asset.txt") assert response.status_code == 405 @pytest.mark.parametrize( "path", [ "/../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt", "/%5c..%5csecret.txt", "/..%5csecret.txt", ], ) def test_path_traversal_cannot_escape_directory(tmp_path: Path, path: str): dist = tmp_path / "dist" write_file(dist / "index.html", "app") write_file(tmp_path / "secret.txt", "secret") app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).get(path) assert response.status_code == 404 assert response.text != "secret" def test_symlink_outside_directory_is_not_served(tmp_path: Path): dist = tmp_path / "dist" dist.mkdir() outside = tmp_path / "secret.txt" outside.write_text("secret") link = dist / "secret.txt" try: os.symlink(outside, link) except (OSError, NotImplementedError): # pragma: no cover pytest.skip("symlinks are not supported") app = FastAPI() app.frontend("/", directory=dist) response = TestClient(app).get("/secret.txt") assert response.status_code == 404 assert response.text != "secret" def test_check_dir_true_fails_early_for_missing_directory(monkeypatch, tmp_path: Path): app = FastAPI() monkeypatch.chdir(tmp_path) with pytest.raises(RuntimeError, match="does not exist") as exc_info: app.frontend("/", directory="missing") message = str(exc_info.value) assert "'missing'" in message assert str(tmp_path / "missing") in message def test_check_dir_false_allows_missing_directory_and_fails_on_request(tmp_path: Path): app = FastAPI() app.frontend("/", directory=tmp_path / "missing", check_dir=False) with pytest.raises(RuntimeError, match="does not exist"): TestClient(app).get("/asset.txt") def test_explicit_fallback_files_fail_clearly_when_missing(monkeypatch, tmp_path: Path): dist = tmp_path / "dist" dist.mkdir() monkeypatch.chdir(tmp_path) app = FastAPI() with pytest.raises(RuntimeError, match="index.html") as exc_info: app.frontend("/", directory="dist", fallback="index.html") message = str(exc_info.value) assert "directory 'dist'" in message assert str(dist) in message app = FastAPI() app.frontend("/", directory="dist", fallback="404.html", check_dir=False) with pytest.raises(RuntimeError, match="404.html") as exc_info: TestClient(app).get("/missing.js") message = str(exc_info.value) assert "directory 'dist'" in message assert str(dist) in message def test_frontend_routes_are_not_in_openapi(tmp_path: Path): dist = tmp_path / "dist" write_file(dist / "index.html", "app") app = FastAPI() @app.get("/api") def read_api(): return {"ok": True} app.frontend("/", directory=dist, fallback="index.html") schema = TestClient(app).get("/openapi.json").json() assert set(schema["paths"]) == {"/api"} response = TestClient(app).get("/api") assert response.status_code == 200 assert response.json() == {"ok": True} @pytest.mark.parametrize( ("example", "files", "path", "status_code", "body"), [ ( "tutorial001_py310.py", {"asset.txt": "asset"}, "/asset.txt", 200, "asset", ), ( "tutorial002_py310.py", {"index.html": "index"}, "/dashboard", 200, "index", ), ( "tutorial003_py310.py", {"404.html": "missing"}, "/missing", 404, "missing", ), ( "tutorial004_py310.py", {"index.html": "index"}, "/app/dashboard", 200, "index", ), ( "tutorial005_py310.py", {"index.html": "index"}, "/dashboard", 404, '{"detail":"Not Found"}', ), ( "tutorial006_py310.py", {"asset.txt": "asset"}, "/asset.txt", 200, "asset", ), ], ) def test_docs_frontend_examples( tmp_path: Path, monkeypatch, example: str, files: dict[str, str], path: str, status_code: int, body: str, ): dist = tmp_path / "dist" for file, content in files.items(): write_file(dist / file, content) monkeypatch.chdir(tmp_path) namespace = runpy.run_path( str(Path(__file__).parents[1] / "docs_src" / "frontend" / example) ) app = namespace["app"] assert isinstance(app, FastAPI) response = TestClient(app).get(path, headers={"accept": "text/html"}) assert response.status_code == status_code assert response.text == body def test_low_priority_routes_can_store_non_frontend_routes(): async def low_priority_endpoint(request): return PlainTextResponse("low") app = FastAPI() app.router._low_priority_routes.append(Route("/low", low_priority_endpoint)) app.router._mark_routes_changed() response = TestClient(app).get("/low") assert response.status_code == 200 assert response.text == "low" def test_included_low_priority_routes_can_store_non_frontend_routes(): async def low_priority_endpoint(request): return PlainTextResponse("low") router = APIRouter() router._low_priority_routes.append(Route("/low", low_priority_endpoint)) router._mark_routes_changed() app = FastAPI() app.include_router(router, prefix="/prefix") response = TestClient(app).get("/prefix/low") assert response.status_code == 200 assert response.text == "low"