|
|
|
@ -0,0 +1,858 @@ |
|
|
|
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" |