Files
HomeDash/admin-server/gateway_test.go
HomeDash Bot ca80ba2510 fix(gateway): auth 类路由重复写 /favicon.ico 导致 nginx -t 失败(v96 部署被回滚的真因)
v96 部署时脚本报"网关配置重新生成失败 → 回滚到 ui-v95"。根因:auth 类路由的 Upstream
是在 writeErrorPages 之后才赋值的,所以它先按"门户自己渲染"拿到了门户 favicon,
随后新的"favicon 直通上游"块又写了同一个 location → nginx 拒绝重复 location。

修法:直通块对 auth 类跳过(门户页/登录页继续用门户自己的图标)。
另加回归测试 TestNoDuplicateExactLocations:解析渲染结果,断言任一 server 块内
不得出现重复的 `location = X`;已用"临时移除守卫"做过反向对照,确认该测试真能抓到。
2026-09-13 08:58:41 +08:00

578 lines
21 KiB
Go
Raw Permalink 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 (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestRenderGatewayConfAuthPerRoute(t *testing.T) {
cfg := &GatewayConfig{
ListenPort: 8081,
AuthURL: "http://127.0.0.1:4001/_auth",
LoginURL: "https://login.example.com/login",
Routes: []Route{
{Host: "protected.example.com", Upstream: "http://10.0.0.1:3000", RequireLogin: true, Websocket: true, MaxBodyMB: 64},
{Host: "open.example.com", Upstream: "http://10.0.0.2:8000"},
},
}
out, err := RenderGatewayConf(cfg)
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
// 受保护的域名要有 auth_request 与登录跳转
if !contains(out, "server_name protected.example.com;") {
t.Fatalf("缺少受保护域名的 server 块:\n%s", out)
}
if !contains(out, "auth_request /_auth;") {
t.Fatalf("受保护域名缺少 auth_request:\n%s", out)
}
if !contains(out, "location @login") {
t.Fatalf("缺少登录跳转")
}
if !contains(out, "proxy_set_header Upgrade $http_upgrade;") {
t.Fatalf("缺少 websocket 透传")
}
if !contains(out, "client_max_body_size 64m;") {
t.Fatalf("缺少上传上限")
}
// 不受保护的域名不得出现 auth_request("哪些服务受保护"是按路由决定的)
open := section(out, "server_name open.example.com;")
if open == "" {
t.Fatalf("缺少开放域名的 server 块")
}
// 断言的是"强制闸门" auth_request /_auth;,不是裸字符串:
// 错误页会合法地带 auth_request off;,用裸字符串会把正确配置判成错误。
if contains(open, "auth_request /_auth;") {
t.Fatalf("未受保护的域名不应有强制认证闸门:\n%s", open)
}
if !contains(open, "auth_request off;") {
t.Fatalf("错误页应显式关闭认证:\n%s", open)
}
// 兜底 server:未知域名必须被拒绝,而不是落进某条路由
if !contains(out, "default_server") || !contains(out, "return 404") {
t.Fatalf("缺少未知域名兜底 server:\n%s", out)
}
if contains(open, "client_max_body_size") {
t.Fatalf("未设置上限时不应生成 client_max_body_size:\n%s", open)
}
}
func TestRenderGatewayConfRejectsBadInput(t *testing.T) {
base := func(r Route) *GatewayConfig {
return &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{r}}
}
cases := []struct {
name string
cfg *GatewayConfig
}{
{"空域名", base(Route{Host: "", Upstream: "http://a"})},
{"域名带协议", base(Route{Host: "http://a.com", Upstream: "http://a"})},
{"后端非 http", base(Route{Host: "a.com", Upstream: "10.0.0.1:80"})},
{"域名重复", &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{
{Host: "a.com", Upstream: "http://a"}, {Host: "a.com", Upstream: "http://b"},
}}},
{"需认证但无认证地址", &GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}}},
{"启用HTTPS但无证书", &GatewayConfig{ListenPort: 8081, ListenHTTPS: true, Routes: []Route{{Host: "a.com", Upstream: "http://a"}}}},
}
for _, c := range cases {
if _, err := RenderGatewayConf(c.cfg); err == nil {
t.Errorf("%s: 应当报错但没有", c.name)
}
}
}
func TestGatewayRoundTrip(t *testing.T) {
dir := t.TempDir()
oldFile, oldOut := gatewayFile, gatewayOutDir
gatewayFile = dir + "/gateway.yml"
gatewayOutDir = dir + "/gateway"
defer func() { gatewayFile, gatewayOutDir = oldFile, oldOut }()
// 文件不存在时应给出默认配置而不是报错
cfg, err := loadGateway()
if err != nil {
t.Fatalf("缺文件应回落默认配置: %v", err)
}
if cfg.ListenPort != 8081 || len(cfg.Routes) != 0 {
t.Fatalf("默认配置不对: %+v", cfg)
}
cfg.Routes = []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}
if err := saveGateway(cfg); err != nil {
t.Fatalf("保存失败: %v", err)
}
got, err := loadGateway()
if err != nil || len(got.Routes) != 1 || !got.Routes[0].RequireLogin {
t.Fatalf("回读不一致: %+v err=%v", got, err)
}
}
func contains(hay, needle string) bool {
return len(hay) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}
// section 取出 server_name 所属的那个 server 块(到下一个 server{ 之前)
func section(all, marker string) string {
i := indexOf(all, marker)
if i < 0 {
return ""
}
start := i
for start > 0 && all[start-1] != '\n' {
start--
}
rest := all[i:]
if j := indexOf(rest, "\nserver {"); j >= 0 {
return rest[:j]
}
return rest
}
func indexOf(hay, needle string) int {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return i
}
}
return -1
}
// 未知域名必须走内置自定义 404 页面,而不是一行纯文本。
func TestRenderGatewayConfUsesCustomErrorPages(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth",
LoginURL: "https://l/login", Routes: []Route{{Host: "a.com", Upstream: "http://a"}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
for _, want := range []string{
"default_server",
"error_page 404 /404.html;",
"error_page 500 502 503 504 /500.html;",
"root /app/portal;",
} {
if !contains(out, want) {
t.Fatalf("缺少 %q:\n%s", want, out)
}
}
if contains(out, `return 404 "unknown host`) {
t.Fatalf("不应再返回纯文本 404:\n%s", out)
}
}
// 门户静态页类型:不需要后端,且必须能直接给出 404 页面。
func TestRenderGatewayConfPortalKind(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{
{Host: "example.com", Kind: "portal"},
}})
if err != nil {
t.Fatalf("portal 类型不应要求后端: %v", err)
}
if !contains(out, "server_name example.com;") || !contains(out, "try_files $uri $uri/ /404.html;") {
t.Fatalf("portal 段生成不对:\n%s", out)
}
}
// 加入导航必须同时给出分组名与名称;历史访问是系统分组。
func TestValidateRouteNavigation(t *testing.T) {
cases := []struct {
name string
r Route
wantErr bool
}{
{"缺少导航名称", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavSection: "常用服务"}, true},
{"缺少分组", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A"}, true},
{"占用历史分组", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A", NavSection: "历史访问"}, true},
{"合法", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A", NavSection: "常用服务"}, false},
{"未加入导航则不校验", Route{Host: "a.com", Upstream: "http://a"}, false},
{"免认证路径非法", Route{Host: "a.com", Upstream: "http://a", BypassPaths: []string{"search"}}, true},
}
for _, c := range cases {
err := ValidateRoute(c.r)
if c.wantErr && err == nil {
t.Errorf("%s: 应当报错", c.name)
}
if !c.wantErr && err != nil {
t.Errorf("%s: 不应报错,得到 %v", c.name, err)
}
}
}
// 路由即导航:写入分组、同 host 去重、清理已移除的托管项、不动历史分组。
func TestSyncGatewayNavigation(t *testing.T) {
dir := t.TempDir()
oldConf, oldURL := confFile, historyBaseURL
confFile, historyBaseURL = dir+"/conf.yml", "http://127.0.0.1:1"
defer func() { confFile, historyBaseURL = oldConf, oldURL }()
seed := `appConfig: {}
pageInfo: {}
sections:
- name: 常用服务
icon: fas fa-star
items:
- title: Gitea
url: https://gitea.example.com
- title: 手工项
url: https://manual.example.com
- name: 历史访问
icon: fas fa-history
items:
- title: 历史项
url: https://history.example.com
`
if err := os.WriteFile(confFile, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
gw := &GatewayConfig{Routes: []Route{
{Host: "gitea.example.com", Upstream: "http://127.0.0.1:3000", NavEnabled: true, NavSection: "常用服务", NavName: "Gitea", NavDescription: "代码仓库", NavAutoIcon: true},
{Host: "new.example.com", Upstream: "http://127.0.0.1:9000", NavEnabled: true, NavSection: "常用服务", NavName: "新服务"},
{Host: "hidden.example.com", Upstream: "http://127.0.0.1:9001", NavEnabled: false},
}}
if err := syncGatewayNavigation(gw); err != nil {
t.Fatalf("同步失败: %v", err)
}
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
names := map[string]int{}
for _, s := range cfg.Sections {
for _, it := range s.Items {
names[it.Title]++
if s.Name == "历史访问" && it.Title != "历史项" {
t.Fatalf("历史分组被写入手工项: %+v", it)
}
}
}
if names["Gitea"] != 1 {
t.Fatalf("同 host 应被接管而不是重复,Gitea 出现 %d 次", names["Gitea"])
}
if names["新服务"] != 1 {
t.Fatalf("新托管项未写入")
}
if names["手工项"] != 1 {
t.Fatalf("无关手工项被误删")
}
if names["历史项"] != 1 {
t.Fatalf("历史分组内容被改动")
}
// 未加入导航的路由不得出现在导航里
if names["hidden.example.com"] != 0 {
t.Fatalf("未勾选导航的路由不应写入")
}
// 再次同步:勾选取消后,托管项应被清理;手工项与历史项保留
gw.Routes = []Route{{Host: "gitea.example.com", Upstream: "http://127.0.0.1:3000"}}
if err := syncGatewayNavigation(gw); err != nil {
t.Fatal(err)
}
cfg, _ = loadConfig()
after := map[string]int{}
for _, s := range cfg.Sections {
for _, it := range s.Items {
after[it.Title]++
}
}
if after["Gitea"] != 0 || after["新服务"] != 0 {
t.Fatalf("托管项未被清理: %+v", after)
}
if after["手工项"] != 1 || after["历史项"] != 1 {
t.Fatalf("非托管项被误删: %+v", after)
}
}
// 分组不存在时必须报错,避免把导航写进不存在的栏目。
func TestSyncGatewayNavigationMissingSection(t *testing.T) {
dir := t.TempDir()
oldConf, oldURL := confFile, historyBaseURL
confFile, historyBaseURL = dir+"/conf.yml", "http://127.0.0.1:1"
defer func() { confFile, historyBaseURL = oldConf, oldURL }()
_ = os.WriteFile(confFile, []byte("appConfig: {}\npageInfo: {}\nsections: []\n"), 0o644)
err := syncGatewayNavigation(&GatewayConfig{Routes: []Route{
{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavSection: "不存在", NavName: "A"},
}})
if err == nil {
t.Fatalf("分组不存在时应报错")
}
}
// 登录域名必须指向容器内自带的登录服务,不能生成空的 proxy_pass。
func TestRenderGatewayConfAuthKind(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{
{Host: "login.example.com", Kind: "auth"},
}})
if err != nil {
t.Fatalf("auth 类型不应要求后端: %v", err)
}
if !contains(out, "proxy_pass http://127.0.0.1:3005;") {
t.Fatalf("auth 类型应指向内置登录服务:\n%s", out)
}
if contains(out, "proxy_pass ;") {
t.Fatalf("生成了空的 proxy_pass:\n%s", out)
}
}
// TLS 段不得使用 nginx 1.22 不认识的 `http2 on;`,必须写成 listen 参数。
func TestRenderGatewayConfTLSHttp2Compatible(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenHTTPS: true, ListenPort: 18443, ListenTLSPort: 18443,
CertFile: "/c/fullchain.pem", KeyFile: "/c/privkey.pem",
Routes: []Route{{Host: "a.com", Upstream: "http://a"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if contains(out, "http2 on;") {
t.Fatalf("不得输出 `http2 on;`(nginx 1.22 不支持):\n%s", out)
}
if !contains(out, "listen 18443 ssl http2;") {
t.Fatalf("TLS 监听应带 http2 参数:\n%s", out)
}
if !contains(out, "ssl_certificate '/c/fullchain.pem';") {
t.Fatalf("证书路径未写入")
}
}
// 错误页必须 auth_request off:否则上游故障会以 401 呈现,自定义错误页永远看不到。
func TestRenderGatewayConfErrorPagesSkipAuth(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth",
LoginURL: "https://l/login", Routes: []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
for _, want := range []string{
"location = /500.html { internal; auth_request off; }",
"location = /404.html { internal; auth_request off; }",
"location ^~ /css/ { auth_request off;",
} {
if !contains(out, want) {
t.Fatalf("错误页缺少 auth_request off(%q):\n%s", want, out)
}
}
}
// 门户首页必须走 admin-server 渲染(否则 pageInfo 文案不可配置、占位符会裸露)。
func TestPortalKindUsesRenderer(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "p.com", Kind: "portal"}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !contains(out, "proxy_pass http://127.0.0.1:3005/portal-page;") {
t.Fatalf("门户首页未指向渲染器:\n%s", out)
}
if !contains(out, "root /app/portal;") {
t.Fatalf("静态资源根目录丢失:\n%s", out)
}
}
// nginx 不允许**同一个 server 段**内出现重复的 root 指令(实测 emerg)。
// 注意:不同 server 段各有一个 root 是正常的,所以必须按段计数。
func TestPortalKindNoDuplicateRoot(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "p.com", Kind: "portal"}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
block := section(out, "server_name p.com;")
if block == "" {
t.Fatalf("未找到门户段:\n%s", out)
}
if n := strings.Count(block, "root "); n != 1 {
t.Fatalf("门户段内 root 指令应恰好 1 次,实际 %d 次:\n%s", n, block)
}
}
// redirectWithPort=true 时跳转目标带入口端口(对外端口=入口端口的部署)。
func TestRedirectTargetIncludesEntryPort(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 3080, ListenTLSPort: 8443, RedirectPort: 3081, ListenHTTPS: true, RedirectWithPort: true,
CertFile: "/etc/nginx/certs/fullchain.pem", KeyFile: "/etc/nginx/certs/privkey.pem",
AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.x.com/login",
Routes: []Route{{Host: "x.com", Kind: "portal"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "return 302 https://$host:3080$request_uri;") {
t.Fatalf("跳转目标未带端口:\n%s", out)
}
}
// 标准 443 部署不应出现 :443 后缀。
func TestRedirectTargetOmitsPortOn443(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 443, RedirectPort: 3081, ListenHTTPS: true,
CertFile: "/etc/nginx/certs/fullchain.pem", KeyFile: "/etc/nginx/certs/privkey.pem",
AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.x.com/login",
Routes: []Route{{Host: "x.com", Kind: "portal"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "return 302 https://$host$request_uri;") || strings.Contains(out, "$host:443") {
t.Fatalf("443 部署跳转目标不正确:\n%s", out)
}
}
// 默认(redirectWithPort=false)即使入口不是 443 也不带端口——
// 生产就是这种:监听 3080,对外经 frp 是 443,带端口会把公网用户导向 :3080。
func TestRedirectDefaultsNoPortOnNonStandardPort(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 3080, RedirectPort: 3081, ListenHTTPS: true, RedirectWithPort: false,
CertFile: "/etc/nginx/certs/fullchain.pem", KeyFile: "/etc/nginx/certs/privkey.pem",
AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.x.com/login",
Routes: []Route{{Host: "x.com", Kind: "portal"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "return 302 https://$host$request_uri;") || strings.Contains(out, "$host:3080") {
t.Fatalf("默认不应带端口:\n%s", out)
}
}
// 注进被代理页面的备案号必须来自站点信息(可配置),不能再写死。
func TestBeianInjectionUsesPageInfo(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: 京ICP备12345678号-1\n gongan: 京公网安备11000000000001号\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "京ICP备12345678号-1") || !strings.Contains(out, "京公网安备11000000000001号") {
t.Fatalf("未使用配置的备案号:\n%s", out)
}
}
// 未配置备案号时不注入(不得残留任何硬编码备案号)。
func TestBeianInjectionSkippedWhenUnset(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if strings.Contains(out, "navi-proxy-beian") {
t.Fatalf("未配置备案号却注入了:\n%s", out)
}
}
// 备案号里的引号/美元符不能破坏 nginx 配置。
func TestBeianInjectionSanitized(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: \"a';b$c\"\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if strings.Contains(out, "';b") || strings.Contains(out, "b$c") {
t.Fatalf("危险字符未清理:\n%s", out)
}
}
// 代理到上游的 server 块:favicon 必须"免登录直通上游",而不是在门户 root 里 try_files。
// try_files 会把被代理站点的 /favicon.ico 顶成门户图标(实测 pi / ittools / vikunja 三站同图)。
func TestProxyRoutePassesFaviconThrough(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 3081,
AuthURL: "http://127.0.0.1:3005/auth/verify",
LoginURL: "https://login.example/login",
Routes: []Route{
{Host: "proxy.example", Upstream: "http://127.0.0.1:8080", RequireLogin: true, Websocket: true},
{Host: "portal.example", Kind: "portal"},
},
})
if err != nil {
t.Fatal(err)
}
var proxyBlock, portalBlock string
for _, b := range strings.Split(out, "server {") {
if strings.Contains(b, "server_name proxy.example;") {
proxyBlock = b
}
if strings.Contains(b, "server_name portal.example;") {
portalBlock = b
}
}
if proxyBlock == "" || portalBlock == "" {
t.Fatalf("server blocks not found\n%s", out)
}
if !strings.Contains(proxyBlock, "location = /favicon.ico") {
t.Errorf("代理块应有 favicon 直通 location:\n%s", proxyBlock)
}
if strings.Contains(proxyBlock, "try_files $uri =404;\n }") && strings.Contains(proxyBlock, "location = /favicon.ico { auth_request off; try_files") {
t.Errorf("代理块的 favicon 不应走门户静态文件(会顶掉上游图标):\n%s", proxyBlock)
}
if !strings.Contains(proxyBlock, "proxy_pass http://127.0.0.1:8080;") {
t.Errorf("代理块 favicon 应 proxy_pass 到上游:\n%s", proxyBlock)
}
if !strings.Contains(portalBlock, "location = /favicon.ico { auth_request off; try_files $uri =404; }") {
t.Errorf("门户块应保留门户自己的 favicon:\n%s", portalBlock)
}
}
// 同一 server 块内不得出现重复的 "location = X"。
// 实测踩到:auth 类路由的上游在 writeErrorPages 之后才赋值,导致
// /favicon.ico 被写两次 → nginx -t 失败 → 部署脚本回滚。
func TestNoDuplicateExactLocations(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 3081,
AuthURL: "http://127.0.0.1:3005/auth/verify",
LoginURL: "https://login.example/login",
Routes: []Route{
{Host: "portal.example", Kind: "portal"},
{Host: "auth.example", Kind: "auth"},
{Host: "plain.example", Upstream: "http://127.0.0.1:8080"},
{Host: "guarded.example", Upstream: "http://127.0.0.1:9090", RequireLogin: true,
Websocket: true, BypassPaths: []string{"/search", "/api/"}},
{Host: "tls.example", Upstream: "https://192.0.2.10:5667", InsecureTLS: true},
},
})
if err != nil {
t.Fatal(err)
}
for i, block := range strings.Split(out, "server {") {
if !strings.Contains(block, "server_name ") {
continue
}
seen := map[string]int{}
for _, m := range regexp.MustCompile(`(?m)^\s*location\s+=\s+(\S+)\s*\{`).FindAllStringSubmatch(block, -1) {
seen[m[1]]++
}
for loc, n := range seen {
if n > 1 {
t.Errorf("第 %d 个 server 块里 location = %s 出现 %d 次(nginx 会拒绝)", i, loc, n)
}
}
}
// 代理块应把 favicon 直通上游;auth/portal 块保留门户图标
if !strings.Contains(out, "location = /favicon.ico") {
t.Error("完全没有 favicon location")
}
}