承接上一提交:把门户 favicon 从代理块里去掉后暴露两个问题:
- requireLogin 的站点 /favicon.ico 变成 302(浏览器标签与图标抓取都取不到);
- 而 favicon 本来就不该要求登录。
改为:代理块显式写
location = /favicon.ico { auth_request off; proxy_pass <上游>; ... }
location = /favicon.svg { auth_request off; proxy_pass <上游>; ... }
(https/自签上游附带 proxy_ssl_verify off)。
效果:浏览器标签显示各站点自己的图标、无需登录;抓取端也因此能在未登录时取到真实图标。
门户页/登录页/默认 404 块仍提供门户自己的 favicon。测试断言同步改为"直通上游"语义。
537 lines
19 KiB
Go
537 lines
19 KiB
Go
package main
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"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)
|
||
}
|
||
}
|