Files
HomeDash/admin-server/gateway_test.go
HomeDash Bot b941d8e8e3 fix(gateway): 错误页必须跳过 auth_request,否则上游故障会变成 401
实测:上游连接被拒(502)时 nginx 内部跳转 /500.html,而服务端的
auth_request 被这个内部请求继承 → 用户收到 nginx 默认 401 页,
自定义 404/500 永远显示不出来。
错误页及其静态资源一律 auth_request off,并加单测锁住。
2026-09-11 22:33:45 +08:00

361 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 (
"os"
"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)
}
}
}