Files
HomeAgent/internal/plugins/webui/starmap_vendor_test.go
JianFeeeee 7212ab4aad fix(webui): 星图依赖本地化 —— 不再从公网 CDN 拉 three.js
用户点「星图」看到「3D 星图不可用(CDN 加载失败)」。

服务端 curl 那两个 URL 都是 **200** ⇒ 不是服务端的问题,是**浏览器**
访问不到公网 CDN(内网 / 出口受限 / 断网)。dashboard.html 从 cdnjs 与
jsdelivr 拉 4 个库:three.js、OrbitControls、marked、DOMPurify。

换 CDN 只是把同一个赌注重下遍。HomeAgent 明确支持离线与内网部署,
前端却有 4 个硬依赖在公网上 ⇒ 断网即坏,且用户无从修复。

顺带两个收益:
- **安全**:DOMPurify 是净化 Markdown 的关键一环,它挂掉前端会退化到
  「不净化」分支(dashboard.js 里有 typeof 检查)—— 那是安全降级,
  比星图坏更值得修。
- **体积**:四个库共 ~700KB,embed 后由本服务同源提供,省掉 4 个跨域握手,
  也不再受第三方可用性影响。二进制 84MB → 84.7MB(+0.8%)。

- `internal/plugins/webui/static/` 放四个库(固定版本,随二进制走)
- `//go:embed static` + 新增 `/static/` 路由
- dashboard.html 的四个 src 改指 `/static/...`
- 加载失败兜底 8s → 3s(本地是毫秒级;仍超时就说明真有问题)

`/static/` **不走 requireWeb**:未登录时页面也要加载这些库才能渲染登录框,
加认证会让用户看到白屏(比 401 更难自查)。这些资源不含用户数据。

- TestDashboardHasNoExternalCDN:页面不得引用任何公网 CDN
- TestStarmapDependenciesAreLocal:星图两个库必须来自本地
- TestVendorFilesExistInSourceTree:vendor 文件必须在(embed 的前提)
- TestStaticVendorRoutesServeRealLibraries:路由**必须真的返回库内容**

★ 最后一条的判据强度是补出来的:初版只判状态码,变异「返回 200 + 空体」
时**仍然绿** —— 而空体在浏览器里的症状与 404 完全一样(都报加载失败)。
改为同时判体积与特征串(REVISION / OrbitControls / marked / DOMPurify)
后,变异「只写前 10 字节」判红。

变异验证 3 条:HTML 改回 CDN → 2 条判红;路由挂回 requireWeb → 503 判红;
截断响应体 → 体积判红。

全量 38 包全绿。
2026-09-26 14:41:32 +08:00

162 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// ===== 星图依赖 CDN 不可达 =====
//
// 真实现象:用户点「星图」看到「3D 星图不可用(CDN 加载失败)」。
// 服务端 curl 同一批 URL 是 200 —— 说明**不是**服务端的问题,是浏览器
// 访问不到那两个公共 CDN(内网 / 出口受限 / 断网 / 被墙)。
//
// 为什么必须本地化而不是「换个 CDN」:
// 换 CDN 只是把同一个赌注重下一遍。HomeAgent 明确支持离线/内网部署,
// 前端却有 4 个硬依赖在公网上 ⇒ 断网时星图必坏、且用户无从修复。
// marked / DOMPurify 同理(Markdown 渲染与 XSS 净化)。
//
// 判据钉住:页面**不得**再引用任何外部 CDN。
// html 不得引用公网 CDN 资源(这是根因的直接判据)。
func TestDashboardHasNoExternalCDN(t *testing.T) {
html := readDashboardHTML(t)
// 只查 src/href 的绝对 URL;页面里出现的普通文本不算依赖。
re := regexp.MustCompile(`(?:src|href)\s*=\s*["'](https?://[^"']+)["']`)
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
url := m[1]
t.Errorf("dashboard.html 仍引用外部 CDN:%s\n"+
" 断网/内网/出口受限环境下该功能必坏(星图即因此失效)。\n"+
" 修法:把库 vendor 进二进制,由本服务同源提供。", url)
}
}
// 具体到星图:three.js 与 OrbitControls 必须来自本地路由。
func TestStarmapDependenciesAreLocal(t *testing.T) {
html := readDashboardHTML(t)
if strings.Contains(html, "cdnjs.cloudflare.com/ajax/libs/three.js") {
t.Error("three.js 仍走 cdnjs CDN —— 星图在无公网环境下必坏")
}
if strings.Contains(html, "three@0.128.0/examples/js/controls/OrbitControls.js") {
t.Error("OrbitControls 仍走 jsdelivr CDN —— 星图无法旋转视角")
}
// 必须有本地引用
if !strings.Contains(html, "/static/three.min.js") {
t.Error("未引用本地 /static/three.min.js")
}
if !strings.Contains(html, "/static/OrbitControls.js") {
t.Error("未引用本地 /static/OrbitControls.js")
}
}
// 本地路由必须真的能返回这些库(且是 JS 内容类型,不是 HTML 错误页)。
//
// ★ 这一条是「容易假绿」的地方:路由写了但没 embed 成功时,
//
// 返回 404 或空体,页面依然报「CDN 加载失败」,症状与修复前完全一样。
// 所以判据必须实际取内容并验证它**像 three.js**(含特征串)。
func TestStaticVendorRoutesServeRealLibraries(t *testing.T) {
h, _ := newTestHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
cases := []struct {
path string
mustHave string
}{
// three.js UMD 头里有 REVISION;OrbitControls 是非压缩源码
{"/static/three.min.js", "REVISION"},
{"/static/OrbitControls.js", "OrbitControls"},
{"/static/marked.min.js", "marked"},
{"/static/purify.min.js", "DOMPurify"},
}
for _, c := range cases {
resp, err := http.Get(srv.URL + c.path)
if err != nil {
t.Errorf("%s: %v", c.path, err)
continue
}
body := readAllLimited(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("%s 应 200(否则浏览器拿到的是错误页,症状与修复前一模一样),实际 %d",
c.path, resp.StatusCode)
continue
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("%s 的 Content-Type = %q,应为 javascript(浏览器会拒绝执行)", c.path, ct)
}
// ★ 必须同时判「状态码」与「内容」。变异验证发现:把 w.Write(data)
// 去掉(只写 200 头 + 空体)时,**只判状态码的判据会通过** ——
// 而浏览器拿到空体同样报「加载失败」,症状与修复前一模一样。
// 这类「假绿」是本轮踩到的真陷阱,所以体积与特征串都要判。
if len(body) < 1000 {
t.Errorf("%s 体积仅 %d 字节,疑似空体/占位(这几个库都是几万~几十万字节)。"+
"空体在浏览器里的症状与 404 完全一样,但更难看出是文件没写出来",
c.path, len(body))
continue
}
if !strings.Contains(body, c.mustHave) {
t.Errorf("%s 内容不含特征串 %q —— 不是真正的库(可能被 embed 成了错误文本)",
c.path, c.mustHave)
}
}
}
// vendor 文件必须真的存在于源码树(embed 的前提)。
//
// 单独判这一条的原因://go:embed 找不到文件会**编译失败**,
// 但如果有人改成运行时读文件,缺文件就变成「运行时才发现」——
// 从「编译期报错」退化成「用户看到星图坏了」。用判据钉住前提。
func TestVendorFilesExistInSourceTree(t *testing.T) {
dir := filepath.Join(".", "static")
for _, name := range []string{
"three.min.js", "OrbitControls.js", "marked.min.js", "purify.min.js",
} {
p := filepath.Join(dir, name)
st, err := os.Stat(p)
if err != nil {
t.Errorf("缺少 vendor 文件 %s —— 星图/Markdown 依赖它,缺了就静默失效", p)
continue
}
if st.Size() < 1000 {
t.Errorf("%s 仅 %d 字节,疑似下载失败的占位文件", p, st.Size())
}
}
}
func readDashboardHTML(t *testing.T) string {
t.Helper()
raw, err := dashboardFS.ReadFile("dashboard.html")
if err != nil {
t.Fatalf("读 dashboard.html: %v", err)
}
// 模板占位符不影响 CDN 检查,保留原样即可
return string(raw)
}
func readAllLimited(t *testing.T, resp *http.Response) string {
t.Helper()
buf := make([]byte, 0, 64*1024)
tmp := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil || len(buf) > 2*1024*1024 {
break
}
}
return string(buf)
}