Files
yunzerwebsiteallinone/go/services/domain_verify_test.go
T

322 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 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])
}
}
}