Files

292 lines
12 KiB
Python

"""Web App 站点产物模式测试:签名令牌 / 站点发现 / 能力降级 / 构建脚本。"""
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from deerflow.webapp.engine import (
SITE_NAME_RE_OK,
capability_status,
find_sites,
guess_site_mime,
)
from deerflow.webapp.security import mint_preview_token, verify_preview_token
BACKEND_DIR = Path(__file__).resolve().parents[1]
SCRIPT_PATH = BACKEND_DIR / "templates" / "web-app-template" / "scripts" / "build_site.py"
# ── 签名令牌 ────────────────────────────────────────────────────────────────
def test_preview_token_roundtrip(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
import deerflow.webapp.security as security
security._secret_cache = None
token, expires_at = mint_preview_token("thread-1", "my-site", ttl_seconds=600)
assert verify_preview_token(token) == ("thread-1", "my-site")
assert expires_at > time.time()
def test_preview_token_expires(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
import deerflow.webapp.security as security
security._secret_cache = None
token, expires_at = mint_preview_token("thread-1", "my-site", ttl_seconds=60)
assert verify_preview_token(token) == ("thread-1", "my-site")
# 把时钟拨到过期之后:签名仍有效,但应命中 expired 分支
monkeypatch.setattr(security.time, "time", lambda: expires_at + 1)
with pytest.raises(ValueError, match="expired"):
verify_preview_token(token)
def test_preview_token_tamper_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
import deerflow.webapp.security as security
security._secret_cache = None
token, _ = mint_preview_token("thread-1", "site-a", ttl_seconds=600)
# 换 secret 后旧签名失效
security._secret_cache = b"1" * 64
with pytest.raises(ValueError, match="signature"):
verify_preview_token(token)
with pytest.raises(ValueError, match="malformed"):
verify_preview_token("not-a-token")
with pytest.raises(ValueError, match="malformed"):
verify_preview_token("a.b.c")
# ── 站点发现 ────────────────────────────────────────────────────────────────
def _make_site(outputs: Path, name: str, title: str = "示例", with_dist: bool = False) -> None:
site_dir = outputs / name
(site_dir / "public").mkdir(parents=True, exist_ok=True)
(site_dir / "site.json").write_text(
json.dumps({"title": title, "nav": [{"page": "overview", "label": "总览"}]}, ensure_ascii=False),
encoding="utf-8",
)
if with_dist:
(site_dir / "dist").mkdir(exist_ok=True)
(site_dir / "dist" / "index.html").write_text("<html></html>", encoding="utf-8")
def test_find_sites_discovers_manifest_dirs(tmp_path: Path) -> None:
outputs = tmp_path / "outputs"
outputs.mkdir()
_make_site(outputs, "alpha-site", title="甲")
_make_site(outputs, "beta-site", title="乙", with_dist=True)
(outputs / "plain.txt").write_text("not a dir", encoding="utf-8")
(outputs / "not-a-site").mkdir()
(outputs / ".hidden").mkdir()
(outputs / ".hidden" / "site.json").write_text("{}", encoding="utf-8")
sites = find_sites(outputs)
# .hidden 不满足站点名规则(必须字母数字开头),被排除
assert sorted(s["name"] for s in sites) == ["alpha-site", "beta-site"]
beta = next(s for s in sites if s["name"] == "beta-site")
assert beta["title"] == "乙"
assert beta["dist"]["exists"] is True
alpha = next(s for s in sites if s["name"] == "alpha-site")
assert alpha["dist"]["exists"] is False
def test_find_sites_missing_dir_returns_empty(tmp_path: Path) -> None:
assert find_sites(tmp_path / "missing") == []
def test_guess_site_mime_overrides(tmp_path: Path) -> None:
assert "text/javascript" in guess_site_mime(tmp_path / "app.js")
assert "text/javascript" in guess_site_mime(tmp_path / "app.mjs")
assert "application/json" in guess_site_mime(tmp_path / "data.geojson")
assert guess_site_mime(tmp_path / "blob.wasm") == "application/wasm"
assert "text/html" in guess_site_mime(tmp_path / "index.html")
def test_site_name_regex_blocks_traversal() -> None:
assert SITE_NAME_RE_OK.match("my-site")
assert SITE_NAME_RE_OK.match("Site_2.b")
assert not SITE_NAME_RE_OK.match("../etc")
assert not SITE_NAME_RE_OK.match(".hidden")
assert not SITE_NAME_RE_OK.match("a/b")
# ── 能力降级 ────────────────────────────────────────────────────────────────
def _stub_config(*, enabled: bool, template_dir: str) -> SimpleNamespace:
return SimpleNamespace(
web_app=SimpleNamespace(
enabled=enabled,
template_dir=template_dir,
build_timeout_seconds=10,
preview_token_ttl_seconds=600,
node_path="",
)
)
def test_capability_unavailable_when_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
import deerflow.webapp.engine as engine
monkeypatch.setattr(engine, "get_app_config", lambda: _stub_config(enabled=False, template_dir=str(tmp_path)))
cap = capability_status()
assert cap.available is False
assert cap.checks["enabled"]["ok"] is False
def test_capability_unavailable_when_template_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
import deerflow.webapp.engine as engine
monkeypatch.setattr(
engine, "get_app_config", lambda: _stub_config(enabled=True, template_dir=str(tmp_path / "nope"))
)
cap = capability_status()
assert cap.available is False
assert cap.checks["template"]["ok"] is False
def test_capability_never_raises_on_bad_config(monkeypatch: pytest.MonkeyPatch) -> None:
import deerflow.webapp.engine as engine
def boom() -> None:
raise RuntimeError("config broken")
monkeypatch.setattr(engine, "get_app_config", boom)
cap = capability_status()
assert cap.available is False
assert cap.checks["internal"]["ok"] is False
# ── build_site.py 脚本 ──────────────────────────────────────────────────────
def _run_script(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT_PATH), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(cwd),
timeout=120,
)
def test_script_scaffold_creates_site_without_runtime_artifacts(tmp_path: Path) -> None:
workdir = tmp_path / "work"
workdir.mkdir()
proc = _run_script(["scaffold", "my-site"], cwd=workdir)
assert proc.returncode == 0, proc.stderr
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["ok"] is True
site = workdir / "my-site"
assert (site / "site.json").is_file()
assert (site / "src" / "pages" / "overview.tsx").is_file()
assert (site / "scripts").exists() is False # 构建脚本不入站点
assert (site / "node_modules").exists() is False
def test_script_scaffold_rejects_traversal(tmp_path: Path) -> None:
workdir = tmp_path / "work"
workdir.mkdir()
proc = _run_script(["scaffold", "../escape"], cwd=workdir)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["ok"] is False
assert "非法" in payload["error"]
assert not (tmp_path / "escape").exists()
def test_script_scaffold_accepts_host_rewritten_path(tmp_path: Path) -> None:
"""LocalSandbox 会把 /mnt/user-data/... 改写为宿主绝对路径,脚本须接受该形态。"""
user_data = tmp_path / "data" / "user-data"
(user_data / "outputs").mkdir(parents=True)
site_dir = user_data / "outputs" / "host-site"
proc = _run_script(["scaffold", str(site_dir)], cwd=tmp_path)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["ok"] is True, payload
assert (site_dir / "site.json").is_file()
def test_script_scaffold_rejects_out_of_root_absolute_path(tmp_path: Path) -> None:
outside = tmp_path / "elsewhere"
outside.mkdir()
proc = _run_script(["scaffold", str(outside / "site")], cwd=tmp_path)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["ok"] is False
assert "user-data/outputs" in payload["error"]
assert not (outside / "site").exists()
def test_script_build_requires_site_json(tmp_path: Path) -> None:
workdir = tmp_path / "work"
(workdir / "not-a-site").mkdir(parents=True)
proc = _run_script(["build", "not-a-site"], cwd=workdir)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["ok"] is False
assert "site.json" in payload["error"]
def test_script_check_reports_environment(tmp_path: Path) -> None:
proc = _run_script(["check"], cwd=tmp_path)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["ok"] is True # check 命令本身成功
assert set(payload["checks"]) == {"enabled", "template", "node_modules", "node"}
def test_script_check_respects_config_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""check 探测读取 config.yaml 的 web_app.enabled(宿主侧语义)。"""
config = tmp_path / "config.yaml"
def check_with(cfg_text: str | None, env_flag: str | None = None) -> bool:
monkeypatch.delenv("DEERFLOW_WEBAPP_ENABLED", raising=False)
if env_flag is not None:
monkeypatch.setenv("DEERFLOW_WEBAPP_ENABLED", env_flag)
if cfg_text is None:
monkeypatch.delenv("DEERFLOW_WEBAPP_CONFIG", raising=False)
else:
config.write_text(cfg_text, encoding="utf-8")
monkeypatch.setenv("DEERFLOW_WEBAPP_CONFIG", str(config))
proc = _run_script(["check"], cwd=tmp_path)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
return bool(payload["checks"]["enabled"]["ok"])
# 显式 true → 开
assert check_with("web_app:\n enabled: true\n") is True
# 显式 false → 关
assert check_with("web_app:\n enabled: false\n") is False
# 有配置文件但无 web_app 段 → 与 pydantic 默认一致:关
assert check_with("sandbox:\n use: x\n") is False
# env 覆盖优先于配置文件
assert check_with("web_app:\n enabled: false\n", env_flag="true") is True
assert check_with("web_app:\n enabled: true\n", env_flag="false") is False
# 指定的配置文件不存在(沙箱视图:模板挂载点向上没有 config.yaml)→
# 视为开启,由「平台是否挂载模板」表达开关
monkeypatch.delenv("DEERFLOW_WEBAPP_ENABLED", raising=False)
monkeypatch.setenv("DEERFLOW_WEBAPP_CONFIG", str(tmp_path / "missing-config.yaml"))
proc = _run_script(["check"], cwd=tmp_path)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["checks"]["enabled"]["ok"] is True
def test_script_status_reports_dist(tmp_path: Path) -> None:
workdir = tmp_path / "work"
workdir.mkdir()
_run_script(["scaffold", "my-site"], cwd=workdir)
(workdir / "my-site" / "dist").mkdir()
(workdir / "my-site" / "dist" / "index.html").write_text("<html></html>", encoding="utf-8")
proc = _run_script(["status", "my-site"], cwd=workdir)
payload = json.loads(proc.stdout.strip().splitlines()[-1])
assert payload["ok"] is True
assert payload["dist"]["exists"] is True
assert payload["dist"]["file_count"] == 1