目标:一个 HomeDash 容器取代 navi + portal-nginx + portal-auth。 - Route 扩展为"反代 + 导航"一条记录:kind(proxy/portal/auth)、streaming、 insecureTls、bypassPaths、injectBeian,以及 navEnabled/navSection/navName/ navDescription/navUrl/navIcon/navAutoIcon - 保存路由时**同时**写 gateway.yml 与 conf.yml 的导航项(gateway_nav.go): 同 host 接管而不是重复、未勾选导航的会被清理、无关手工项与系统「历史访问」分组 一律不动;两步任一步失败都回滚(避免后台显示与导航页各说各话) - 未知域名与上游故障改用**内置自定义 404/500 页面**(portal/ 目录), 不再是一行纯文本;HTTP→HTTPS 跳转段用 RedirectPort 复刻原 3081 设计 - 内置统一登录(auth.go):HMAC 签名 cookie、恒定时间比较、登录跳转白名单校验, portal-auth 容器可以退场 - 自动抓图标对托管导航项同样生效(history-server 识别 naviGatewayAutoIcon) - 后台卡片补齐"加入导航/分组/名称/描述/图标/自动抓图标/服务类型"字段 - 内部入口端口 8080 → 18080:为一体化 host 模式让开宿主机 blog(:8080) - 修掉上一轮编辑写坏的 deploy/zd.sh 一行(会让脚本直接失败) 测试:新增自定义错误页、portal 类型、导航字段校验、导航同步(接管/清理/保留)、 分组不存在必须报错等用例;admin-server 与 history-server 全绿。
301 lines
9.9 KiB
Go
301 lines
9.9 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 块")
|
||
}
|
||
if contains(open, "auth_request") {
|
||
t.Fatalf("未受保护的域名不应有 auth_request:\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("分组不存在时应报错")
|
||
}
|
||
}
|