Files
HomeDash/admin-server/gateway_test.go
HomeDash Bot 53beff646a feat(admin): 现有配置导入 + 添加表单补齐认证/导航开关 + 统一登录账号设置 + 设置分组
针对反馈的五个问题:

1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」,
   把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿
   (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。
   解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。
2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 /
   图标 / 自动抓取」。
3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」,
   并补上 WebSocket 透传与上传上限。
4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷
   (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量,
   改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。
5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级),
   外观卡片并入站点组。

另修两个本轮引入的缺陷:
- 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080;
  部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。
- kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。

测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、
垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
2026-09-11 22:00:09 +08:00

317 lines
10 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 块")
}
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("分组不存在时应报错")
}
}
// 登录域名必须指向容器内自带的登录服务,不能生成空的 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)
}
}