实测:上游连接被拒(502)时 nginx 内部跳转 /500.html,而服务端的 auth_request 被这个内部请求继承 → 用户收到 nginx 默认 401 页, 自定义 404/500 永远显示不出来。 错误页及其静态资源一律 auth_request off,并加单测锁住。
361 lines
12 KiB
Go
361 lines
12 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|