- 1.6 + 16.4.7:安全入口=路径式 `/<ADMIN_PATH>`(env `ADMIN_PATH`,默认 `admin`;`xxx/admin` 亦可,
非法值回退 `/admin`)。命中前缀 → 送注入了 `<base href="/">` + `window.__CYRENE_ADMIN_ENTRY__` 的面板入口页
(深链直达);**形如面板入口但前缀不符 → 404**(不回落用户端聊天壳);`/api/*`、`/ws`、webhook 先放行
⇒ **API 不藏**。前端路由改**路径式**(`router.ts` 只读注入,无硬编兜底;移除 hash 入口)
- 1.5 / 12.4.3 核对:暴露面默认 `0.0.0.0` 与裁决一致,无需改代码(仅补注释)
- 12.4.2:`DATA_DIR` 支持 `platform` 哨兵(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式空值行为不变
(显式空值保持原兜底,避免正在运行实例突然换库)
- 复核:build/vet 绿 + config/gateway 包测试绿 + linux/darwin 交叉编译绿;新增 7 条用例,
并用真实构建产物(`webui.Assets()`)跑通「浏览器直开 /admin 能启动面板」(`<base>` 必须在资源引用之前)
- 变异 2 条(关掉错前缀 404 / 前端默认前缀兜底)已还原
- ⚠️ 运行时序:前端已生效而后端 hatch 未生效期间**面板打不开** ⇒ 需紧接一次「build 到位 + restart」
138 lines
5.7 KiB
Go
138 lines
5.7 KiB
Go
package gateway
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
// adminEntryTestIndex 是最小的 index.html:注入点 `<head>` 与
|
|
// 站点根锚点都由 handler 追加,因此这里只保证两者都存在。
|
|
const adminEntryTestIndex = `<html><head><meta charset="utf-8"></head><body><div id="root"></div></body></html>`
|
|
|
|
// adminEntryEnv 构造一个挂了前端、并按 adminPath 配置安全入口的网关。
|
|
func adminEntryEnv(t *testing.T, adminPath string) *httptest.Server {
|
|
t.Helper()
|
|
cfg := cfgDefault()
|
|
cfg.Server.AdminPath = adminPath
|
|
front := fstest.MapFS{
|
|
"index.html": &fstest.MapFile{Data: []byte(adminEntryTestIndex)},
|
|
"assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")},
|
|
}
|
|
_, ts, _ := buildEnv(t, cfg, Deps{Frontend: front})
|
|
return ts
|
|
}
|
|
|
|
// TestAdminEntry_DefaultPrefix 守住默认前缀下的安全入口(裁决 1.6/16.4.7):
|
|
// `/admin` 与深链直达面板入口页(带注入),不再回落用户端聊天壳;用户端
|
|
// 路径与真实静态资源不受影响。
|
|
func TestAdminEntry_DefaultPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
ts := adminEntryEnv(t, "")
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/admin", nil, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /admin status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if !strings.Contains(string(body), `window.__CYRENE_ADMIN_ENTRY__={"prefix":"/admin"}`) {
|
|
t.Fatalf("GET /admin missing injected entry info: %s", body)
|
|
}
|
|
if !strings.Contains(string(body), `<base href="/">`) {
|
|
t.Fatalf("GET /admin missing root base href: %s", body)
|
|
}
|
|
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
|
|
t.Fatalf("GET /admin Cache-Control = %q, want no-store", got)
|
|
}
|
|
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/admin/personas", nil, "")
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/admin"`) {
|
|
t.Fatalf("GET /admin/personas = %d body=%s, want the injected admin entry", resp.StatusCode, body)
|
|
}
|
|
|
|
// 真实静态资源仍按文件送出(不注入)。
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
|
|
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
|
|
t.Fatalf("GET /assets/app.js = %d body=%q, want the raw asset", resp.StatusCode, body)
|
|
}
|
|
|
|
// 用户端回落路径不带入口信息(用户端 bundle 与页面都不该出现面板前缀)。
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat/somewhere", nil, "")
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
|
t.Fatalf("GET /chat/somewhere = %d body=%s, want the user shell", resp.StatusCode, body)
|
|
}
|
|
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
|
|
t.Fatalf("user shell leaked the admin entry info: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestAdminEntry_CustomPrefix 守住自定义前缀(`host/xxx/admin` 形态):正确
|
|
// 前缀直达,猜错前缀(含默认 /admin)一律 404,绝不回落用户端聊天壳。
|
|
func TestAdminEntry_CustomPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
ts := adminEntryEnv(t, "xxx/admin")
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/xxx/admin", nil, "")
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/xxx/admin"`) {
|
|
t.Fatalf("GET /xxx/admin = %d body=%s, want the injected custom prefix", resp.StatusCode, body)
|
|
}
|
|
|
|
for _, p := range []string{"/admin", "/admin/personas", "/other/admin", "/admin/"} {
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+p, nil, "")
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("GET %s = %d body=%s, want 404 (wrong prefix must not fall back)", p, resp.StatusCode, body)
|
|
}
|
|
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
|
|
t.Fatalf("GET %s leaked the admin entry info", p)
|
|
}
|
|
}
|
|
|
|
// 用户端路径依旧正常。
|
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat", nil, "")
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
|
t.Fatalf("GET /chat = %d body=%s, want the user shell", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// TestAdminEntry_APIRouteUntouched 守住「API 不藏」:前缀即便被配成含 api 段
|
|
// 的值,也不得截住 REST 路由(design/07 §0.1)。
|
|
func TestAdminEntry_APIRouteUntouched(t *testing.T) {
|
|
t.Parallel()
|
|
ts := adminEntryEnv(t, "api/v1/admin")
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/health", nil, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /api/v1/health = %d body=%s, want the health payload", resp.StatusCode, body)
|
|
}
|
|
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
|
|
t.Fatalf("API route was served the admin entry page: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestAdminEntry_PrefixOverAssetDir 守住前缀撞上真实资源目录的退化:把前缀
|
|
// 配成 assets 时,真实资源必须仍由文件服务器送出,不能被面板入口页顶掉。
|
|
func TestAdminEntry_PrefixOverAssetDir(t *testing.T) {
|
|
t.Parallel()
|
|
ts := adminEntryEnv(t, "assets")
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
|
|
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
|
|
t.Fatalf("GET /assets/app.js = %d body=%q, want the real asset (not the shell)", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// TestAdminEntryHTML_NoHead 守住注入的两个回退点:没有 `<head>` 时注入到
|
|
// 文档最前(仍先于任何资源引用),且 `<base>` 与入口信息一并出现。
|
|
func TestAdminEntryHTML_NoHead(t *testing.T) {
|
|
t.Parallel()
|
|
out := adminEntryHTML([]byte("<html><body>x</body></html>"), "/admin")
|
|
s := string(out)
|
|
if !strings.HasPrefix(s, `<base href="/">`) {
|
|
t.Fatalf("injection without <head> = %q, want it prepended", s)
|
|
}
|
|
if !strings.Contains(s, `"prefix":"/admin"`) {
|
|
t.Fatalf("injection without <head> lost the entry info: %s", s)
|
|
}
|
|
}
|