package services import ( "path/filepath" "strings" "testing" beego "github.com/beego/beego/v2/server/web" ) // TestNormalizeHost 覆盖用户在绑定框里可能输入的各种写法。 // 归一化结果直接写进 yz_system_tenant_domain.full_domain, // 而官网渲染是按 Host 精确等值查这张表,所以这里一旦放宽就会出现「绑了但打不开」。 func TestNormalizeHost(t *testing.T) { cases := []struct { name string in string want string }{ {"普通子域名", "www.example.com", "www.example.com"}, {"大写转小写", "WWW.Example.COM", "www.example.com"}, {"两端空格", " www.example.com ", "www.example.com"}, {"带 http 协议", "http://www.example.com", "www.example.com"}, {"带 https 与路径", "https://www.example.com/news/1", "www.example.com"}, {"带端口", "www.example.com:8080", "www.example.com"}, {"带协议端口路径", "https://www.example.com:443/a/b?c=1", "www.example.com"}, {"FQDN 末尾点", "www.example.com.", "www.example.com"}, {"通配符写法", "*.example.com", "example.com"}, {"根域名", "example.com", "example.com"}, {"多级子域名", "a.b.c.example.com", "a.b.c.example.com"}, {"含连字符", "my-site.example.com", "my-site.example.com"}, {"新顶级域", "example.technology", "example.technology"}, {"空字符串", "", ""}, {"纯空格", " ", ""}, {"无顶级域", "localhost", ""}, {"纯 IP", "1.2.3.4", ""}, {"顶级域含数字", "example.c0m", ""}, {"标签以连字符开头", "-bad.example.com", ""}, {"标签以连字符结尾", "bad-.example.com", ""}, {"含下划线", "bad_name.example.com", ""}, {"连续点", "www..example.com", ""}, {"以点开头", ".example.com", ""}, {"含空格", "www .example.com", ""}, {"路径穿越写法", "../../etc/passwd", ""}, {"单标签顶级域太短", "example.c", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := NormalizeHost(c.in); got != c.want { t.Errorf("NormalizeHost(%q) = %q, 期望 %q", c.in, got, c.want) } }) } } // TestNormalizeHostTooLong 超过 253 字节的主机名不合法,必须拒绝。 func TestNormalizeHostTooLong(t *testing.T) { long := strings.Repeat("a.", 130) + "com" // 远超 253 if got := NormalizeHost(long); got != "" { t.Errorf("超长域名应被拒绝,实际返回 %q", got) } } // TestCertPathsStaysInsideCertDir 证书目录名来自数据库里的域名, // 必须保证拼出来的路径落在 ssl_cert_dir 内,不能被 ../ 之类的脏数据带出去。 func TestCertPathsStaysInsideCertDir(t *testing.T) { root := filepath.Clean(SSLCertDir()) for _, host := range []string{ "www.example.com", "../../etc/ssl", "..", "", "/absolute/path", } { certPath, keyPath := CertPaths(host) for _, p := range []string{certPath, keyPath} { cleaned := filepath.Clean(p) if !strings.HasPrefix(cleaned, root+string(filepath.Separator)) { t.Errorf("host=%q 生成的路径 %q 逃出了证书目录 %q", host, cleaned, root) } } } } // TestCertPathsFileNames 落盘文件名必须与 Nginx 配置里引用的名字一致。 func TestCertPathsFileNames(t *testing.T) { certPath, keyPath := CertPaths("www.example.com") if filepath.Base(certPath) != "fullchain.pem" { t.Errorf("证书文件名应为 fullchain.pem,实际 %q", filepath.Base(certPath)) } if filepath.Base(keyPath) != "privkey.pem" { t.Errorf("私钥文件名应为 privkey.pem,实际 %q", filepath.Base(keyPath)) } if filepath.Base(filepath.Dir(certPath)) != "www.example.com" { t.Errorf("证书应放在以域名命名的目录下,实际 %q", filepath.Dir(certPath)) } } // TestVerifyCustomDomainDNSWithoutConfig 平台没配解析目标时, // 不能去打 DNS 也不能误判通过,要直接给出「联系管理员」的提示。 func TestVerifyCustomDomainDNSWithoutConfig(t *testing.T) { // 测试环境没有加载 app.conf,GetCustomDomainGuide 返回空配置 guide := GetCustomDomainGuide() if guide.CnameTarget != "" || len(guide.AIPs) > 0 { t.Skip("当前环境读到了解析目标配置,跳过空配置分支") } passed, msg := VerifyCustomDomainDNS("www.example.com") if passed { t.Error("平台未配置解析目标时不应判定通过") } if !strings.Contains(msg, "平台管理员") { t.Errorf("提示文案应引导联系平台管理员,实际 %q", msg) } } // TestVerifyCustomDomainDNSRejectsBadHost 非法域名在检测入口就要拦住。 func TestVerifyCustomDomainDNSRejectsBadHost(t *testing.T) { passed, msg := VerifyCustomDomainDNS("not a domain") if passed { t.Error("非法域名不应判定通过") } if msg == "" { t.Error("非法域名应给出说明文案") } } // withTempCertDir 把 ssl_cert_dir 指到临时目录。 // 默认值是 /www/wwwroot/ssl-certs,在 Windows 上会落到当前盘根目录, // 跑测试不该在仓库外面留文件。 func withTempCertDir(t *testing.T) string { t.Helper() dir := t.TempDir() old, _ := beego.AppConfig.String("ssl_cert_dir") if err := beego.AppConfig.Set("ssl_cert_dir", dir); err != nil { t.Skipf("当前环境不支持运行时改配置: %v", err) } t.Cleanup(func() { _ = beego.AppConfig.Set("ssl_cert_dir", old) }) return dir } // TestChallengeTokenValidation token 会被拼进文件名, // 必须先过正则;否则 `../../` 这类 token 能让 Go 读写证书目录外的文件。 func TestChallengeTokenValidation(t *testing.T) { // 合法:base64url,长度 16-128 valid := []string{ strings.Repeat("a", 16), strings.Repeat("a", 128), "abcABC012_-abcABC012_-", } for _, tok := range valid { if !challengeTokenRe.MatchString(tok) { t.Errorf("token %q 应被接受", tok) } } invalid := []string{ "", "short", strings.Repeat("a", 129), "../../etc/passwd", "has/slash1234567890", "has\\backslash12345678", "has.dot1234567890", "has space1234567890", } for _, tok := range invalid { if challengeTokenRe.MatchString(tok) { t.Errorf("token %q 应被拒绝", tok) } } } // TestGetHTTP01ChallengeRejectsBadToken 未命中内存时, // 非法 token 不能进到读文件那一步。 func TestGetHTTP01ChallengeRejectsBadToken(t *testing.T) { if _, ok := GetHTTP01Challenge("../../etc/passwd"); ok { t.Error("非法 token 不应返回内容") } if _, ok := GetHTTP01Challenge(""); ok { t.Error("空 token 不应返回内容") } } // TestChallengeStoreRoundTrip 内存与磁盘两层的存取与删除。 // 磁盘那层是给「签发进程」与「应答 CA 回访的进程」不是同一个的部署方式兜底的。 func TestChallengeStoreRoundTrip(t *testing.T) { dir := withTempCertDir(t) token := strings.Repeat("t", 32) keyAuth := token + ".someThumbprint" putHTTP01Challenge(token, keyAuth) t.Cleanup(func() { deleteHTTP01Challenge(token) }) got, ok := GetHTTP01Challenge(token) if !ok || got != keyAuth { t.Fatalf("取回的 keyAuth = %q ok=%v,期望 %q true", got, ok, keyAuth) } // 磁盘兜底:清掉内存后仍要能读到 challengeMu.Lock() delete(challengeStore, token) challengeMu.Unlock() fromDisk, ok := GetHTTP01Challenge(token) if !ok || fromDisk != keyAuth { t.Errorf("应能从磁盘读回 keyAuth,实际 %q ok=%v(目录 %s)", fromDisk, ok, dir) } deleteHTTP01Challenge(token) if _, ok := GetHTTP01Challenge(token); ok { t.Error("删除后内存与磁盘都不应再返回内容") } } // TestTryLockIssueSerializes 同一域名不能并发签发, // 否则会向 CA 重复下单,白白消耗频控额度。 func TestTryLockIssueSerializes(t *testing.T) { host := "lock-test.example.com" // 清掉可能残留的状态 t.Cleanup(func() { unlockIssue(host, false) }) ok, _ := tryLockIssue(host) if !ok { t.Fatal("首次抢锁应成功") } ok2, reason := tryLockIssue(host) if ok2 { t.Error("已在签发中时不应再次拿到锁") } if reason == "" { t.Error("拒绝时应返回原因") } unlockIssue(host, false) if ok3, _ := tryLockIssue(host); !ok3 { t.Error("解锁后应能重新抢到") } unlockIssue(host, false) } // TestTryLockIssueCooldown 失败后进入冷却期,避免租户连点把 CA 额度打满。 func TestTryLockIssueCooldown(t *testing.T) { host := "cooldown-test.example.com" t.Cleanup(func() { unlockIssue(host, false) }) ok, _ := tryLockIssue(host) if !ok { t.Fatal("首次抢锁应成功") } unlockIssue(host, true) // 标记失败 ok2, reason := tryLockIssue(host) if ok2 { t.Error("冷却期内不应放行") } if !strings.Contains(reason, "分钟") { t.Errorf("冷却提示应包含等待时间,实际 %q", reason) } // 成功一次要把冷却清掉 unlockIssue(host, false) if ok3, reason3 := tryLockIssue(host); !ok3 { t.Errorf("成功后应清除冷却,实际被拒: %s", reason3) } unlockIssue(host, false) } // TestIssueCertificateAsyncRejectsBadHost 非法域名不该发起签发流程。 func TestIssueCertificateAsyncRejectsBadHost(t *testing.T) { if ok, _ := IssueCertificateAsync(0, "not a domain"); ok { t.Error("非法域名不应发起签发") } if ok, _ := IssueCertificateAsync(0, ""); ok { t.Error("空域名不应发起签发") } } // TestGetCustomDomainGuideParsing 配置里的 A 记录是逗号分隔的自由文本, // 非法 IP 必须被丢掉,否则会把错误的地址当成期望值展示给租户。 func TestGetCustomDomainGuideParsing(t *testing.T) { restore := func(key, val string) { if err := beego.AppConfig.Set(key, val); err != nil { t.Fatalf("恢复配置 %s 失败: %v", key, err) } } oldTarget, _ := beego.AppConfig.String("customdomain_cname_target") oldIPs, _ := beego.AppConfig.String("customdomain_a_ips") t.Cleanup(func() { restore("customdomain_cname_target", oldTarget) restore("customdomain_a_ips", oldIPs) }) if err := beego.AppConfig.Set("customdomain_cname_target", " Sites.Example.COM. "); err != nil { t.Skipf("当前环境不支持运行时改配置: %v", err) } if err := beego.AppConfig.Set("customdomain_a_ips", "1.2.3.4, , not-an-ip ,5.6.7.8,999.1.1.1"); err != nil { t.Skipf("当前环境不支持运行时改配置: %v", err) } guide := GetCustomDomainGuide() if guide.CnameTarget != "sites.example.com" { t.Errorf("CNAME 目标应被归一化为 sites.example.com,实际 %q", guide.CnameTarget) } want := []string{"1.2.3.4", "5.6.7.8"} if len(guide.AIPs) != len(want) { t.Fatalf("应只保留 %d 个合法 IP,实际 %v", len(want), guide.AIPs) } for i := range want { if guide.AIPs[i] != want[i] { t.Errorf("第 %d 个 IP = %q,期望 %q", i, guide.AIPs[i], want[i]) } } }