288 lines
8.9 KiB
Go
288 lines
8.9 KiB
Go
package dnsprovider
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/go-acme/lego/v5/challenge"
|
||
"github.com/go-acme/lego/v5/challenge/dns01"
|
||
)
|
||
|
||
type fakeFactory struct {
|
||
provider challenge.Provider
|
||
err error
|
||
}
|
||
|
||
func (f fakeFactory) Create(string, map[string]string) (challenge.Provider, error) {
|
||
return f.provider, f.err
|
||
}
|
||
|
||
type fakeProvider struct {
|
||
presented bool
|
||
cleaned bool
|
||
cleanupSawCanceled bool
|
||
presentErr error
|
||
cleanupErr error
|
||
domain string
|
||
keyAuth string
|
||
}
|
||
|
||
type fakeTXTResolver struct {
|
||
provider *fakeProvider
|
||
}
|
||
|
||
func (r fakeTXTResolver) LookupTXT(ctx context.Context, _ string) ([]string, error) {
|
||
if r.provider == nil || r.provider.domain == "" || r.provider.keyAuth == "" {
|
||
return nil, errors.New("测试 DNS 记录尚未创建")
|
||
}
|
||
return []string{dns01.GetChallengeInfo(ctx, r.provider.domain, r.provider.keyAuth).Value}, nil
|
||
}
|
||
|
||
func (p *fakeProvider) Present(_ context.Context, domain, _, keyAuth string) error {
|
||
p.presented = true
|
||
p.domain = domain
|
||
p.keyAuth = keyAuth
|
||
return p.presentErr
|
||
}
|
||
|
||
func (p *fakeProvider) CleanUp(ctx context.Context, _ string, _ string, _ string) error {
|
||
p.cleaned = true
|
||
p.cleanupSawCanceled = ctx.Err() != nil
|
||
return p.cleanupErr
|
||
}
|
||
|
||
type missingTXTResolver struct{}
|
||
|
||
func (missingTXTResolver) LookupTXT(context.Context, string) ([]string, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func TestTesterCreatesAndCleansChallengeRecord(t *testing.T) {
|
||
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
|
||
provider := &fakeProvider{}
|
||
tester := NewTesterWithResolver(fakeFactory{provider: provider}, fakeTXTResolver{provider: provider})
|
||
report, err := tester.Verify(t.Context(), Cloudflare, "example.com", map[string]string{"api_token": "secret-token"})
|
||
if err != nil {
|
||
t.Fatalf("DNS 测试失败:%v", err)
|
||
}
|
||
if !provider.presented || !provider.cleaned {
|
||
t.Fatalf("DNS 测试未完整执行:presented=%v cleaned=%v", provider.presented, provider.cleaned)
|
||
}
|
||
if report.RecordName == "" || len(report.Stages) != 3 {
|
||
t.Fatalf("DNS 测试阶段报告不完整:%+v", report)
|
||
}
|
||
for index, stage := range report.Stages {
|
||
if stage.Status != "completed" && !(index == 2 && stage.Status == "warning") {
|
||
t.Fatalf("DNS 测试阶段状态异常:%+v", report.Stages)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestTesterUsesConfiguredPropagationSettings(t *testing.T) {
|
||
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
|
||
provider := &fakeProvider{}
|
||
tester := NewTesterWithResolverAndSettings(fakeFactory{provider: provider}, missingTXTResolver{}, 20*time.Millisecond, 5*time.Millisecond)
|
||
started := time.Now()
|
||
_, err := tester.Verify(context.Background(), Cloudflare, "example.com", map[string]string{"api_token": "secret-token"})
|
||
if err == nil {
|
||
t.Fatal("missing TXT record should fail")
|
||
}
|
||
if elapsed := time.Since(started); elapsed > time.Second {
|
||
t.Fatalf("configured propagation timeout was ignored: %s", elapsed)
|
||
}
|
||
if !provider.cleaned {
|
||
t.Fatal("failed propagation check should clean up the record")
|
||
}
|
||
}
|
||
|
||
func TestTesterDoesNotCleanupWhenPresentFails(t *testing.T) {
|
||
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
|
||
provider := &fakeProvider{presentErr: errors.New("拒绝访问")}
|
||
tester := NewTester(fakeFactory{provider: provider})
|
||
_, err := tester.Verify(t.Context(), Cloudflare, "example.com", map[string]string{"api_token": "secret-token"})
|
||
if err == nil {
|
||
t.Fatal("创建测试记录失败时应返回错误")
|
||
}
|
||
if provider.cleaned {
|
||
t.Fatal("创建测试记录失败时不应执行清理")
|
||
}
|
||
}
|
||
|
||
func TestTesterCleansUpWithIndependentContextAfterQueryCancellation(t *testing.T) {
|
||
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
|
||
provider := &fakeProvider{}
|
||
tester := NewTesterWithResolver(fakeFactory{provider: provider}, missingTXTResolver{})
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel()
|
||
|
||
_, err := tester.Verify(ctx, Cloudflare, "example.com", map[string]string{"api_token": "secret-token"})
|
||
if err == nil {
|
||
t.Fatal("公网查询被取消时应返回错误")
|
||
}
|
||
if !provider.cleaned {
|
||
t.Fatal("公网查询被取消时仍应尝试清理测试记录")
|
||
}
|
||
if provider.cleanupSawCanceled {
|
||
t.Fatal("清理测试记录不应继承已取消的请求上下文")
|
||
}
|
||
}
|
||
|
||
type forbiddenTXTResolver struct{}
|
||
|
||
func (forbiddenTXTResolver) LookupTXT(context.Context, string) ([]string, error) {
|
||
return nil, errors.New("阿里云账号内权限验证不应查询公网 TXT 记录")
|
||
}
|
||
|
||
func TestTesterUsesAliyunAPIReadBackWithoutPublicDNSPropagation(t *testing.T) {
|
||
api := &authorizationRecordAPI{}
|
||
tester := &Tester{
|
||
resolver: forbiddenTXTResolver{},
|
||
aliyunAuthorization: newAuthorizationCheckerForTest(api),
|
||
}
|
||
report, err := tester.VerifyWithReport(t.Context(), Aliyun, "www.sub.example.com", scopeCredentials())
|
||
if err != nil {
|
||
t.Fatalf("阿里云权限验证失败:%v", err)
|
||
}
|
||
if report.RecordName == "" || len(report.Stages) != 3 {
|
||
t.Fatalf("阿里云阶段报告不完整:%+v", report)
|
||
}
|
||
for _, stage := range report.Stages {
|
||
if stage.Status != "completed" {
|
||
t.Fatalf("阿里云阶段未完成:%+v", report.Stages)
|
||
}
|
||
}
|
||
if report.Stages[0].Name != "创建记录" || report.Stages[1].Name != "查询记录" || report.Stages[2].Name != "清理记录" {
|
||
t.Fatalf("阿里云阶段顺序错误:%+v", report.Stages)
|
||
}
|
||
if !strings.Contains(report.Stages[1].Message, "未等待公网 DNS 传播") {
|
||
t.Fatalf("阿里云查询阶段必须明确不等待公网传播:%+v", report.Stages[1])
|
||
}
|
||
if got := callNames(api.calls); got != "add,describe,delete" {
|
||
t.Fatalf("阿里云不应调用公网 TXT 查询:%s", got)
|
||
}
|
||
}
|
||
|
||
func TestTesterReportsCleanupFailureAfterQueryFailure(t *testing.T) {
|
||
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
|
||
provider := &fakeProvider{cleanupErr: errors.New("权限不足")}
|
||
tester := NewTesterWithResolver(fakeFactory{provider: provider}, missingTXTResolver{})
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel()
|
||
|
||
report, err := tester.Verify(ctx, Cloudflare, "example.com", map[string]string{"api_token": "secret-token"})
|
||
if err == nil || !strings.Contains(err.Error(), "自动清理失败") {
|
||
t.Fatalf("查询失败且清理失败时应返回组合诊断:%v", err)
|
||
}
|
||
if len(report.Stages) != 3 || report.Stages[1].Status != "failed" || report.Stages[2].Status != "failed" {
|
||
t.Fatalf("查询和清理失败阶段报告不完整:%+v", report.Stages)
|
||
}
|
||
}
|
||
|
||
func TestValidateCredentialsRejectsUnknownField(t *testing.T) {
|
||
_, err := ValidateCredentials(Cloudflare, map[string]string{"api_token": "token", "unexpected": "value"})
|
||
if err == nil {
|
||
t.Fatal("未知凭据字段应被拒绝")
|
||
}
|
||
}
|
||
|
||
func TestCNAMEProviderIsAvailable(t *testing.T) {
|
||
descriptor, err := GetDescriptor(CNAME)
|
||
if err != nil {
|
||
t.Fatalf("failed to read CNAME descriptor: %v", err)
|
||
}
|
||
if !descriptor.Available {
|
||
t.Fatal("CNAME delegation adapter should be available")
|
||
}
|
||
}
|
||
|
||
func TestLegoFactoryCreatesConfiguredProviders(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
providerID string
|
||
credentials map[string]string
|
||
}{
|
||
{
|
||
name: "阿里云 DNS",
|
||
providerID: Aliyun,
|
||
credentials: map[string]string{
|
||
"access_key_id": "id",
|
||
"access_key_secret": "secret",
|
||
"managed_zones": "example.com",
|
||
"allowed_domains": "example.com",
|
||
},
|
||
},
|
||
{
|
||
name: "DNSPod",
|
||
providerID: DNSPod,
|
||
credentials: map[string]string{
|
||
"secret_id": "id",
|
||
"secret_key": "secret",
|
||
},
|
||
},
|
||
{
|
||
name: "DNSPod",
|
||
providerID: HuaweiCloud,
|
||
credentials: map[string]string{
|
||
"access_key_id": "id",
|
||
"secret_access_key": "secret",
|
||
"region": "invalid-region",
|
||
},
|
||
},
|
||
{
|
||
name: "华为云无效区域校验",
|
||
providerID: TencentCloud,
|
||
credentials: map[string]string{
|
||
"secret_id": "id",
|
||
"secret_key": "secret",
|
||
},
|
||
},
|
||
{
|
||
name: "AWS Route 53",
|
||
providerID: Route53,
|
||
credentials: map[string]string{
|
||
"access_key_id": "id",
|
||
"secret_access_key": "secret",
|
||
"region": "us-east-1",
|
||
},
|
||
},
|
||
}
|
||
|
||
factory := NewLegoFactory()
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
provider, err := factory.Create(test.providerID, test.credentials)
|
||
if test.providerID == HuaweiCloud {
|
||
if err == nil {
|
||
t.Fatal("无效的华为云区域应被拒绝")
|
||
}
|
||
if errors.Is(err, ErrProviderUnavailable) {
|
||
t.Fatalf("华为云已接入,不应返回未开放错误:%v", err)
|
||
}
|
||
return
|
||
}
|
||
if err != nil {
|
||
t.Fatalf("创建 Provider 失败:%v", err)
|
||
}
|
||
if provider == nil {
|
||
t.Fatal("创建 Provider 后返回空实例")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestGoogleCloudFactoryRejectsMalformedCredentials(t *testing.T) {
|
||
_, err := NewLegoFactory().Create(GoogleCloud, map[string]string{
|
||
"service_account_json": "{}",
|
||
"project": "test-project",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("格式错误的 Google Cloud 服务账号凭据应被拒绝")
|
||
}
|
||
if errors.Is(err, ErrProviderUnavailable) {
|
||
t.Fatalf("Google Cloud 已接入,不应返回未开放错误:%v", err)
|
||
}
|
||
}
|