322 lines
10 KiB
Go
322 lines
10 KiB
Go
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])
|
||
}
|
||
}
|
||
}
|