后台此前只能往已有分组里加服务,分组本身是写死在 conf.yml 里的,
用户无法增删。现在新增「导航分组」卡片:
- POST /admin/api/section 新增(重名 409、空名 400)
- POST /admin/api/section/update 改名/换图标
- DELETE /admin/api/section 删除;非空分组必须 force=1(二次确认),
避免误点一下连带删掉一组服务
「历史访问」分组由 history-server 依据浏览记录重建,改名/删除/手工新建
都会被它覆写回来,所以一律 409 并说明原因 —— 否则用户以为删掉了,
下次同步又"长"回来。
初始状态无分组:conf.example.yml 的 sections 改为空数组,后台在两个位置
给空态提示(「还没有任何分组」/「先创建分组」,并把"添加服务"置灰)。
补上原本缺失的一环:rebuildDashySections() 原先只更新已存在的
「历史访问」分组、从不创建它,新装用户永远看不到历史分组;现在缺了就补建
(完全空配置时不补,保持"初始无分组")。
测试覆盖:新增/重名/改历史分组/删历史分组/删非空(无 force 拒绝)/force 删除,
以及删除后的分组名列表断言。
245 lines
8.0 KiB
Go
245 lines
8.0 KiB
Go
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, "<script>") && !strings.Contains(got, """) {
|
||
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)
|
||
}
|
||
}
|