Files
tick-stock-panel/backend/tests/test_pages_admin.py
T
shy3130 01257c83ee feat(pages): 设置三页合一为「页面管理」+ 源码扩展删除/URL 嵌入页/源码导入
- 菜单设置/扩展页面/页面插件三个设置 tab 合并为「页面管理」(?tab=pages, 旧链接与 /analysis 重定向兼容)
- 源码扩展页(frontend/src/custom/<ns>/)支持界面内物理删除: 先自动备份 zip 到 data/backups/pages/(可下载)再删目录, 命名校验+防穿越, 产物部署无源码树 409
- 扩展页面新增 URL 嵌入模板: 仅填菜单名+网址生成 iframe 内嵌页, 复用看板外链安全约定(仅 http(s)/拒同源/sandbox 白名单/新窗口兜底)
- 页面插件新增源码导入: 上传 .tsx 或从本项目 custom/ 选择, 后端用自带 esbuild 编译+契约适配(component→mount, 独立 react 实例自挂 Provider/Router, 图标映射白名单), 产物即标准页面插件; 无工具链部署 409
2026-10-22 17:47:59 +08:00

85 lines
3.5 KiB
Python

"""页面管理源码扩展删除: 校验拒绝路径、先备份后删除、备份下载穿越防御。"""
from __future__ import annotations
import io
import zipfile
from pathlib import Path
from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import pages_admin
def _make_client(tmp_path: Path, monkeypatch, custom_root: Path) -> TestClient:
app = FastAPI()
app.include_router(pages_admin.router)
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path / "data"))
monkeypatch.setattr(pages_admin, "_custom_root", lambda: custom_root)
return TestClient(app)
def _seed_custom(root: Path, ns: str = "zz_test_delete") -> Path:
ns_dir = root / ns
ns_dir.mkdir(parents=True)
(ns_dir / "extension.tsx").write_text("export default { id: 'zz_test_delete' }", encoding="utf-8")
(root / "_template").mkdir(exist_ok=True)
return ns_dir
def test_delete_creates_backup_then_removes_dir(tmp_path, monkeypatch):
root = tmp_path / "frontend" / "src" / "custom"
_seed_custom(root)
client = _make_client(tmp_path, monkeypatch, root)
r = client.delete("/api/pages-admin/source-extensions/zz_test_delete")
assert r.status_code == 200
body = r.json()
assert body["deleted"] == "zz_test_delete"
assert body["backup"].startswith("zz_test_delete-") and body["backup"].endswith(".zip")
# 目录已删, 备份存在且包含带命名空间前缀的源文件
assert not (root / "zz_test_delete").exists()
backup = tmp_path / "data" / "backups" / "pages" / body["backup"]
assert backup.is_file()
with zipfile.ZipFile(backup) as zf:
assert "zz_test_delete/extension.tsx" in zf.namelist()
def test_rejects_template_illegal_and_missing(tmp_path, monkeypatch):
root = tmp_path / "frontend" / "src" / "custom"
_seed_custom(root)
client = _make_client(tmp_path, monkeypatch, root)
assert client.delete("/api/pages-admin/source-extensions/_template").status_code == 400
# 编码斜杠被路由层判 404、正则不匹配判 400 — 两种都是拒绝, 关键是不触发删除
assert client.delete("/api/pages-admin/source-extensions/..%2Fetc").status_code in (400, 404)
assert client.delete("/api/pages-admin/source-extensions/Has-Caps").status_code == 400
assert client.delete("/api/pages-admin/source-extensions/not_exist").status_code == 404
# 模板目录必须原样保留
assert (root / "_template").is_dir()
def test_rejects_when_no_frontend_source_tree(tmp_path, monkeypatch):
client = _make_client(tmp_path, monkeypatch, tmp_path / "no-such-root")
r = client.delete("/api/pages-admin/source-extensions/zz_test_delete")
assert r.status_code == 409
def test_backup_download_serves_only_own_archives(tmp_path, monkeypatch):
root = tmp_path / "frontend" / "src" / "custom"
_seed_custom(root)
client = _make_client(tmp_path, monkeypatch, root)
backup_name = client.delete("/api/pages-admin/source-extensions/zz_test_delete").json()["backup"]
ok = client.get(f"/api/pages-admin/backups/{backup_name}")
assert ok.status_code == 200
assert ok.headers["content-disposition"].startswith("attachment")
assert zipfile.ZipFile(io.BytesIO(ok.content)).namelist()
# 穿越与非本模块命名的文件一律 404
assert client.get("/api/pages-admin/backups/evil.zip").status_code == 404
assert client.get("/api/pages-admin/backups/..%2Fsecrets.zip").status_code == 404
assert client.get("/api/pages-admin/backups/zz-20990101-000000.zip").status_code == 404