Files
HomeDash/admin-server/main_test.go

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: "沪公网安备12345678901号", wantParts: []string{"class=\"navi-footer\"", "沪公网安备12345678901号"}},
{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())
}
}