"""生产模式下服务端托管前端构建产物(AGENTLAB_WEB_DIST)。 用临时目录伪造一个 dist,覆盖这几条: - 文件存在就返回文件;其余非 /api 的 GET 回退到 index.html(单页应用); - /api 下不存在的路径照旧是 JSON 404,不能被回退吞掉——POST 也一样,不能变成 405; - WebSocket 不受影响; - index.html 不缓存,assets/ 下带哈希的文件长缓存; - 没设 AGENTLAB_WEB_DIST 时行为完全不变。 """ from __future__ import annotations import json import os import subprocess import sys from pathlib import Path import httpx import pytest from fastapi import APIRouter, FastAPI, WebSocket from fastapi.testclient import TestClient from starlette.websockets import WebSocketDisconnect from app.core.config import BACKEND_DIR, PROJECT_DIR, Settings INDEX = "fake-agentlab
" JS = "console.log('fake bundle')" @pytest.fixture def dist(tmp_path: Path) -> Path: root = tmp_path / "dist" (root / "assets").mkdir(parents=True) (root / "index.html").write_text(INDEX) (root / "favicon.svg").write_text("") (root / "assets" / "index-AbC123.js").write_text(JS) (root / "assets" / "index-AbC123.css").write_text("body{}") # dist 外面的文件:路径穿越不能读到它 (tmp_path / "secret.txt").write_text("TOP-SECRET") return root def _app(dist: Path) -> FastAPI: from app.web import mount_web app = FastAPI() api = APIRouter(prefix="/api") @api.get("/ping") async def ping() -> dict[str, str]: return {"pong": "1"} @api.websocket("/echo") async def echo(ws: WebSocket) -> None: await ws.accept() await ws.send_text(await ws.receive_text()) await ws.close() app.include_router(api) mount_web(app, dist) return app async def _get(app: FastAPI, path: str, method: str = "GET") -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: return await client.request(method, path) async def test_index_and_spa_fallback(dist: Path) -> None: app = _app(dist) for path in ("/", "/index.html", "/studio", "/runs/abc/evidence", "/studio/"): r = await _get(app, path) assert r.status_code == 200, path assert r.text == INDEX, path assert r.headers["content-type"].startswith("text/html"), path assert "no-cache" in r.headers["cache-control"], path async def test_existing_files_are_served(dist: Path) -> None: app = _app(dist) r = await _get(app, "/assets/index-AbC123.js") assert r.status_code == 200 assert r.text == JS assert "javascript" in r.headers["content-type"] assert "immutable" in r.headers["cache-control"] assert "max-age=31536000" in r.headers["cache-control"] r = await _get(app, "/assets/index-AbC123.css") assert r.status_code == 200 and r.headers["content-type"].startswith("text/css") r = await _get(app, "/favicon.svg") assert r.status_code == 200 assert r.headers["content-type"].startswith("image/svg+xml") assert "immutable" not in r.headers.get("cache-control", "") async def test_missing_asset_is_404_not_index(dist: Path) -> None: # 发版后旧页面请求旧哈希的文件:回退成 index.html 会被当脚本解析,还会被长缓存 r = await _get(_app(dist), "/assets/index-old999.js") assert r.status_code == 404 assert r.text != INDEX async def test_api_routes_win_and_unknown_api_is_json_404(dist: Path) -> None: app = _app(dist) r = await _get(app, "/api/ping") assert r.status_code == 200 and r.json() == {"pong": "1"} for path in ("/api/nope", "/api", "/api/", "/api/runs/xyz/nope"): r = await _get(app, path) assert r.status_code == 404, path assert r.headers["content-type"].startswith("application/json"), path assert r.json() == {"detail": "Not Found"}, path # 路径规则里就排除了 /api:否则「路径对上、方法不对」会让 POST 变成 405 r = await _get(app, "/api/nope", method="POST") assert r.status_code == 404 assert r.json() == {"detail": "Not Found"} # 只保留 api 这一段,别的前缀照常回退 r = await _get(app, "/apiary") assert r.status_code == 200 and r.text == INDEX async def test_head_and_non_get(dist: Path) -> None: app = _app(dist) r = await _get(app, "/studio", method="HEAD") assert r.status_code == 200 r = await _get(app, "/studio", method="POST") assert r.status_code == 405 async def test_path_traversal_stays_inside_dist(dist: Path) -> None: app = _app(dist) for path in ("/%2e%2e/secret.txt", "/..%2fsecret.txt", "/assets/%2e%2e/%2e%2e/secret.txt"): r = await _get(app, path) assert "TOP-SECRET" not in r.text, path def test_websocket_unaffected(dist: Path) -> None: client = TestClient(_app(dist)) with client.websocket_connect("/api/echo") as ws: ws.send_text("hi") assert ws.receive_text() == "hi" # 没有这条 WebSocket 路由的地址不会被页面回退接走 with pytest.raises(WebSocketDisconnect): with client.websocket_connect("/studio") as ws: ws.receive_text() def test_missing_index_fails_fast(tmp_path: Path) -> None: from app.web import mount_web with pytest.raises(RuntimeError, match="index.html"): mount_web(FastAPI(), tmp_path) def test_setting_parsing(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("AGENTLAB_WEB_DIST", raising=False) assert Settings().web_dist is None monkeypatch.setenv("AGENTLAB_WEB_DIST", "") assert Settings().web_dist is None # 相对路径按仓库根目录算,和从哪个目录启动无关 monkeypatch.setenv("AGENTLAB_WEB_DIST", "frontend/dist") assert Settings().web_dist == PROJECT_DIR / "frontend" / "dist" monkeypatch.setenv("AGENTLAB_WEB_DIST", "/srv/web") assert Settings().web_dist == Path("/srv/web") async def test_default_app_unchanged_without_setting() -> None: from app.core.config import settings from app.main import app assert settings.web_dist is None r = await _get(app, "/studio") assert r.status_code == 404 assert r.json() == {"detail": "Not Found"} _PROBE = r""" import asyncio, json, httpx from app.main import app async def main(): t = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=t, base_url="http://t") as c: out = {} for m, p in (("GET", "/api/health"), ("GET", "/"), ("GET", "/studio"), ("GET", "/assets/index-AbC123.js"), ("GET", "/api/nope"), ("POST", "/api/nope")): r = await c.request(m, p) out[f"{m} {p}"] = [r.status_code, r.text[:200]] print(json.dumps(out)) asyncio.run(main()) """ def test_main_app_mounts_when_setting_present(dist: Path, tmp_path: Path) -> None: """真实的 app.main 在设了 AGENTLAB_WEB_DIST 时托管页面、接口照旧。 settings 和 app 都是模块级单例,同一进程里改不了,所以起一个子进程导入。 """ env = { **os.environ, "AGENTLAB_DATA_DIR": str(tmp_path / "data"), "AGENTLAB_WEB_DIST": str(dist), "AGENTLAB_SECRET_KEY": "test-only-not-a-real-key", } proc = subprocess.run( [sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env, capture_output=True, text=True, timeout=120, ) assert proc.returncode == 0, proc.stderr[-2000:] out = json.loads(proc.stdout.strip().splitlines()[-1]) assert out["GET /api/health"][0] == 200 and '"agentlab"' in out["GET /api/health"][1] assert out["GET /"] == [200, INDEX] assert out["GET /studio"] == [200, INDEX] assert out["GET /assets/index-AbC123.js"] == [200, JS] assert out["GET /api/nope"][0] == 404 and json.loads(out["GET /api/nope"][1]) == {"detail": "Not Found"} assert out["POST /api/nope"][0] == 404