Files
HomeDash/admin-server/main_test.go
HomeDash Bot bb41b61ad6 fix(gateway): 路由表接口只替换路由,不再整份配置重置(agent 友好)
真实验证(干净环境练兵)抓到:POST /admin/api/gateway/routes 会把整个
GatewayConfig 反序列化为零值后保存,因此
- 只提交单个路由对象 → {"routes":0,"status":"saved"}:**静默清空整张路由表**
- 提交 {"routes":[…] } → 其余字段被重置为默认(实测入口端口 13080 → 8081,
  TLS/认证地址一并丢失),随后 apply 生成的配置与预期完全不符

对 agent 尤其危险:照文档提交一条路由就会改掉入口端口与所有其它路由。

修复:
- 只补路由表:以现有配置为基准,仅替换 Routes,其它字段原样保留
- 请求体必须是 {"routes":[...]};缺该键 → 400 并说明正确形状(不再静默变空表)
- 非空表要清空必须显式传 force:true,否则 400 拒绝
- 测试:单个对象被拒;数组提交后 listenPort/TLS/认证地址/证书路径全部保留;
  清空被拒、force 允许
- README:修正路由示例为完整数组形状,补"替换整张表"与 /admin/api 前缀说明
2026-09-12 10:28:03 +08:00

341 lines
12 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 main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBeianFooter(t *testing.T) {
cases := []struct {
name, icp, gongan string
wantParts []string
wantEmpty bool
}{
{name: "both", icp: "京ICP备00000000号-1", gongan: "京公网安备00000000000000号",
wantParts: []string{"class=\"navi-footer\"", "beian.miit.gov.cn", "京ICP备00000000号-1", "navi-footer-sep", "beian.mps.gov.cn", "京公网安备00000000000000号"}},
{name: "only icp", icp: " 京ICP备12345678号 ", wantParts: []string{"class=\"navi-footer\"", "京ICP备12345678号"}},
{name: "only gongan", gongan: "沪公网安备31010102002555号", wantParts: []string{"class=\"navi-footer\"", "沪公网安备31010102002555号"}},
{name: "blank", icp: " ", gongan: "", wantEmpty: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := beianFooter(tc.icp, tc.gongan)
if tc.wantEmpty {
if got != "" {
t.Fatalf("want empty footer, got %q", got)
}
return
}
for _, p := range tc.wantParts {
if !strings.Contains(got, p) {
t.Fatalf("footer %q missing %q", got, p)
}
}
if strings.Contains(got, " ") {
t.Fatalf("footer keeps stray whitespace: %q", got)
}
})
}
}
// 备案号里若混入引号/尖括号,必须被转义,不能原样拼进 HTML。
func TestBeianFooterEscapesInput(t *testing.T) {
got := beianFooter(`"><script>alert(1)</script>`, "")
if strings.Contains(got, "<script>") {
t.Fatalf("unescaped input leaked into footer: %q", got)
}
if !strings.Contains(got, "&lt;script&gt;") && !strings.Contains(got, "&#34;") {
t.Fatalf("expected escaped output, got %q", got)
}
}
func TestMapString(t *testing.T) {
m := map[string]any{"a": "x", "b": 1, "c": nil}
if got := mapString(m, "a"); got != "x" {
t.Fatalf("mapString(a) = %q; want x", got)
}
if got := mapString(m, "b"); got != "" {
t.Fatalf("mapString(b) = %q; want empty for non-string", got)
}
if got := mapString(m, "missing"); got != "" {
t.Fatalf("mapString(missing) = %q; want empty", got)
}
}
func TestFrameBlockReason(t *testing.T) {
cases := []struct {
name string
xfo string
csp string
want string
}{
{"无任何头 → 可内嵌", "", "", ""},
{"XFO SAMEORIGIN 拒绝", "SAMEORIGIN", "", "X-Frame-Options: SAMEORIGIN"},
{"XFO DENY 拒绝", "DENY", "", "X-Frame-Options: DENY"},
{"CSP frame-ancestors 'self' 拒绝", "", "default-src 'self'; frame-ancestors 'self'", "frame-ancestors 'self'"},
{"CSP frame-ancestors * 放行", "", "frame-ancestors *", ""},
{"CSP 其它指令不影响", "", "default-src 'self'; img-src https:", ""},
}
for _, c := range cases {
h := http.Header{}
if c.xfo != "" {
h.Set("X-Frame-Options", c.xfo)
}
if c.csp != "" {
h.Set("Content-Security-Policy", c.csp)
}
if got := frameBlockReason(h); got != c.want {
t.Errorf("%s: frameBlockReason() = %q; want %q", c.name, got, c.want)
}
}
}
func TestFrameableVerdict(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ok":
w.WriteHeader(http.StatusOK)
case "/xfo":
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.WriteHeader(http.StatusOK)
case "/waf":
// WAF 拦截:403 且不带任何 X-Frame-Options —— 这是之前误判的根源
w.WriteHeader(http.StatusForbidden)
case "/csp":
w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'")
w.WriteHeader(http.StatusOK)
case "/redir":
http.Redirect(w, r, "https://example.invalid/login", http.StatusFound)
case "/echo-cookie":
if r.Header.Get("Cookie") == "" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.WriteHeader(http.StatusOK)
}
}))
defer srv.Close()
cases := []struct {
path string
frameable bool
conclusive bool
wantReasonPart string
}{
{"/ok", true, true, ""},
{"/xfo", false, true, "SAMEORIGIN"},
{"/csp", false, true, "frame-ancestors"},
{"/waf", true, false, "HTTP 403"},
{"/echo-cookie", false, true, "SAMEORIGIN"},
}
for _, c := range cases {
got := frameableVerdict(srv.URL+c.path, "portal_session=abc")
if got.Frameable != c.frameable || got.Conclusive != c.conclusive {
t.Errorf("%s: got frameable=%v conclusive=%v; want %v/%v",
c.path, got.Frameable, got.Conclusive, c.frameable, c.conclusive)
}
if c.wantReasonPart != "" && !strings.Contains(got.Reason, c.wantReasonPart) {
t.Errorf("%s: reason = %q; want containing %q", c.path, got.Reason, c.wantReasonPart)
}
}
}
func TestFrameableVerdictWithoutCookieIsInconclusive(t *testing.T) {
// 门户后面的应用对未登录请求会 302 到登录页;带了 Cookie 才拿到真正的响应头。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Cookie") == "" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
withCookie := frameableVerdict(srv.URL, "portal_session=abc")
if withCookie.Frameable || !withCookie.Conclusive {
t.Fatalf("带 Cookie 应判为确定不可内嵌,got %+v", withCookie)
}
without := frameableVerdict(srv.URL, "")
if without.Conclusive {
t.Fatalf("无 Cookie 应判为无法确认,got %+v", without)
}
}
// 分组增删改:历史分组受保护;删非空分组必须显式 force。
func TestSectionHandlers(t *testing.T) {
dir := t.TempDir()
oldConf := confFile
confFile = filepath.Join(dir, "conf.yml")
defer func() { confFile = oldConf }()
orig := `appConfig: {}
pageInfo: {}
sections:
- name: 常用服务
icon: fas fa-star
items:
- title: A
url: https://a.example
- name: 历史访问
icon: fas fa-history
items: []
`
if err := os.WriteFile(confFile, []byte(orig), 0o644); err != nil {
t.Fatal(err)
}
call := func(h http.HandlerFunc, method, target, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, strings.NewReader(body))
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// 新增
if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 200 {
t.Fatalf("新增分组应成功,got %d %s", rec.Code, rec.Body.String())
}
// 重名
if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 409 {
t.Fatalf("同名分组应 409,got %d", rec.Code)
}
// 不能手工创建历史分组
if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"历史访问"}`); rec.Code != 409 {
t.Fatalf("手工创建历史分组应 409,got %d", rec.Code)
}
// 改名
if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":2,"name":"改名后"}`); rec.Code != 200 {
t.Fatalf("改名应成功,got %d %s", rec.Code, rec.Body.String())
}
// 历史分组不能改名 / 删除
if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":1,"name":"X"}`); rec.Code != 409 {
t.Fatalf("改历史分组应 409,got %d", rec.Code)
}
if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":1}`); rec.Code != 409 {
t.Fatalf("删历史分组应 409,got %d", rec.Code)
}
// 非空分组不给 force 应 409
if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":0}`); rec.Code != 409 {
t.Fatalf("删非空分组应 409,got %d", rec.Code)
}
// 给 force 才删
if rec := call(handleDeleteSection, http.MethodDelete, "/api/section?force=1", `{"index":0}`); rec.Code != 200 {
t.Fatalf("force 删除应成功,got %d %s", rec.Code, rec.Body.String())
}
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(cfg.Sections))
for _, s := range cfg.Sections {
names = append(names, s.Name)
}
want := []string{"历史访问", "改名后"}
if len(names) != len(want) || names[0] != want[0] || names[1] != want[1] {
t.Fatalf("删除/改名后分组 = %v; want %v", names, want)
}
}
// 深色模式必须换一层深色遮罩,否则亮色遮罩会把壁纸洗白。
func TestApplyWallpaperBlockEmitsDarkScrim(t *testing.T) {
out := applyWallpaperBlock("", "/wallpapers/w.jpg", 0.5)
if !strings.Contains(out, "linear-gradient(rgba(255,255,255,0.50)") {
t.Fatalf("缺少亮色遮罩:\n%s", out)
}
if !strings.Contains(out, "html.navi-dark body.navi-dark") ||
!strings.Contains(out, "rgba(6,12,24,0.65)") {
t.Fatalf("缺少深色遮罩规则:\n%s", out)
}
if strings.Count(out, "/wallpapers/w.jpg") != 2 {
t.Fatalf("深浅两种模式都应引用同一张壁纸:\n%s", out)
}
// 移除壁纸时应把两种规则一起清掉
if got := applyWallpaperBlock(out, "", 0.5); strings.Contains(got, "navi-dark body") {
t.Fatalf("移除壁纸后仍残留深色规则:\n%s", got)
}
}
// 路由表为空而现有配置含路由时必须拒绝(防止静默清空入口路由)。
func TestApplyRefusesEmptyRoutesOverExisting(t *testing.T) {
dir := t.TempDir()
oldC, oldG := confFile, gatewayFile
defer func() { confFile, gatewayFile = oldC, oldG }()
confFile = filepath.Join(dir, "conf.yml")
gatewayFile = filepath.Join(dir, "gateway.yml")
if err := os.WriteFile(gatewayFile, []byte("routes: []\n"), 0o644); err != nil {
t.Fatal(err)
}
// 造一份"已有路由"的生成产物
conf := gatewayFileFor()
if err := os.MkdirAll(filepath.Dir(conf), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(conf, []byte("server {\n server_name a.com;\n}\n"), 0o644); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
handleGatewayApply(rec, httptest.NewRequest(http.MethodPost, "/api/gateway/apply", nil))
if rec.Code != 400 {
t.Fatalf("应拒绝并返回 400,实际 %d:%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "拒绝覆盖") {
t.Fatalf("错误信息应说明原因:%s", rec.Body.String())
}
}
// 提交路由表不得改动入口端口/TLS/认证地址等其它配置(旧实现会重置为默认)。
func TestGatewayRoutesPostPreservesOtherConfig(t *testing.T) {
dir := t.TempDir()
oldC, oldG := confFile, gatewayFile
defer func() { confFile, gatewayFile = oldC, oldG }()
confFile = filepath.Join(dir, "conf.yml")
gatewayFile = filepath.Join(dir, "gateway.yml")
// 该接口同时会同步 Dashy 导航数据,需要一份 conf.yml
if err := os.WriteFile(confFile, []byte("pageInfo: {}\nsections: []\n"), 0o644); err != nil {
t.Fatal(err)
}
seed := "listenPort: 13080\nlistenTlsPort: 18443\nredirectPort: 13081\nlistenHttps: false\n" +
"certFile: /etc/nginx/certs/fullchain.pem\nkeyFile: /etc/nginx/certs/privkey.pem\n" +
"authUrl: http://127.0.0.1:3005/auth/verify\nloginUrl: https://login.example.com/login\n" +
"routes:\n - host: a.example.com\n upstream: http://127.0.0.1:3000\n"
if err := os.WriteFile(gatewayFile, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
post := func(body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
handleGatewayRoutes(rec, httptest.NewRequest(http.MethodPost, "/api/gateway/routes", strings.NewReader(body)))
return rec
}
// ① 只给单个路由对象(旧接口会当成"整份配置"并把其它字段清零)
if rec := post(`{"host":"b.example.com","upstream":"http://127.0.0.1:4000"}`); rec.Code != 400 {
t.Fatalf("单个对象应被拒绝并提示,实际 %d: %s", rec.Code, rec.Body.String())
}
// ② 正确形状:数组
if rec := post(`{"routes":[{"host":"b.example.com","upstream":"http://127.0.0.1:4000"}]}`); rec.Code != 200 {
t.Fatalf("数组提交应成功,实际 %d: %s", rec.Code, rec.Body.String())
}
got, _ := os.ReadFile(gatewayFile)
body := string(got)
for _, want := range []string{"listenPort: 13080", "listenTlsPort: 18443", "redirectPort: 13081",
"certFile: /etc/nginx/certs/fullchain.pem", "authUrl: http://127.0.0.1:3005/auth/verify",
"loginUrl: https://login.example.com/login", "host: b.example.com"} {
if !strings.Contains(body, want) {
t.Fatalf("保存后丢失 %q:\n%s", want, body)
}
}
// ③ 防误清空
if rec := post(`{"routes":[]}`); rec.Code != 400 {
t.Fatalf("清空应被拒绝,实际 %d", rec.Code)
}
if rec := post(`{"routes":[],"force":true}`); rec.Code != 200 {
t.Fatalf("force 清空应被允许,实际 %d: %s", rec.Code, rec.Body.String())
}
}