支持同项目内域名与服务器双向复用,局部修改和清空不影响其他域名;在绑定保存、服务器编辑和部署前拒绝已知共享路径冲突。 新增仓储、接口、工作流与页面回归源码,同步任务状态和第三版接口交接。按要求先开发后统一测试,本批未运行测试、构建或部署,保留在途任务与主机别名等待办。
337 lines
14 KiB
Go
337 lines
14 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||
)
|
||
|
||
type workflowCertificateService struct {
|
||
result certificate.IssueResult
|
||
called bool
|
||
}
|
||
|
||
func (s *workflowCertificateService) Issue(context.Context, string) (certificate.IssueResult, error) {
|
||
s.called = true
|
||
return s.result, nil
|
||
}
|
||
|
||
func (s *workflowCertificateService) Import(context.Context, certificate.ImportRequest) (certificate.IssueResult, error) {
|
||
s.called = true
|
||
return s.result, nil
|
||
}
|
||
|
||
type workflowServerLister struct {
|
||
servers []deployment.Server
|
||
err error
|
||
}
|
||
|
||
func (s workflowServerLister) ListDeploymentServersByDomainID(context.Context, string) ([]deployment.Server, error) {
|
||
return s.servers, s.err
|
||
}
|
||
|
||
func TestCertificateWorkflowStopsBeforeRemoteWriteOnSharedPathConflict(t *testing.T) {
|
||
deployer := &workflowDeployer{}
|
||
workflow := &CertificateWorkflow{
|
||
Domains: &workflowDomainState{},
|
||
Servers: workflowServerLister{err: deployment.ErrSharedPathConflict},
|
||
Deployment: deployer,
|
||
}
|
||
_, err := workflow.deploy(t.Context(), certificate.IssueResult{DomainID: "shared-domain"})
|
||
if !errors.Is(err, deployment.ErrSharedPathConflict) || deployer.targets != nil {
|
||
t.Fatalf("路径冲突后仍尝试远端部署:%v,%+v", err, deployer.targets)
|
||
}
|
||
}
|
||
|
||
type workflowDeployer struct {
|
||
targets []deployment.Target
|
||
files deployment.SavedFiles
|
||
report deployment.Report
|
||
}
|
||
|
||
type workflowDomainState struct {
|
||
id string
|
||
status string
|
||
expiresAt *time.Time
|
||
updateErr error
|
||
updateCalled int
|
||
item domain.Domain
|
||
}
|
||
|
||
func (s *workflowDomainState) GetByID(_ context.Context, id string) (domain.Domain, error) {
|
||
item := s.item
|
||
if item.ID == "" {
|
||
item.ID = id
|
||
}
|
||
if item.Name == "" {
|
||
item.Name = "example.com"
|
||
}
|
||
if item.CertificateType == "" {
|
||
item.CertificateType = domain.CertificateSingle
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
func (s *workflowDomainState) UpdateCertificateState(_ context.Context, id, status string, expiresAt *time.Time) error {
|
||
s.updateCalled++
|
||
if s.updateErr != nil {
|
||
return s.updateErr
|
||
}
|
||
s.id, s.status = id, status
|
||
if expiresAt != nil {
|
||
value := *expiresAt
|
||
s.expiresAt = &value
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (d *workflowDeployer) DeploySaved(_ context.Context, targets []deployment.Target, files deployment.SavedFiles) (deployment.Report, error) {
|
||
d.targets = targets
|
||
d.files = files
|
||
return d.report, nil
|
||
}
|
||
|
||
func TestCertificateWorkflowDeploysIssuedCertificateToAssociatedServers(t *testing.T) {
|
||
certificateService := &workflowCertificateService{result: certificate.IssueResult{
|
||
DomainID: "domain-1",
|
||
Saved: certificate.SavedResource{CertificatePath: "cert.pem", FullChainPath: "fullchain.pem", PrivateKeyPath: "key.pem"},
|
||
}}
|
||
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
|
||
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}}
|
||
workflow := &CertificateWorkflow{
|
||
Certificates: certificateService,
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1", Name: "生产服务器"}}},
|
||
Deployment: deployer,
|
||
}
|
||
result, err := workflow.IssueWithReport(context.Background(), "domain-1")
|
||
if err != nil {
|
||
t.Fatalf("证书工作流失败:%v", err)
|
||
}
|
||
if !certificateService.called || result.Deployment.Status != "success" || len(deployer.targets) != 1 {
|
||
t.Fatalf("证书工作流结果错误:%+v %+v", result, deployer.targets)
|
||
}
|
||
if deployer.files.PrivateKeyPath != "key.pem" {
|
||
t.Fatalf("部署文件路径错误:%+v", deployer.files)
|
||
}
|
||
if deployer.targets[0].Server.CertificatePath != "/ssl/example.com/single/cert.pem" || deployer.targets[0].Server.PrivateKeyPath != "/ssl/example.com/single/key.pem" {
|
||
t.Fatalf("部署目标路径未按域名和证书类型解析:%+v", deployer.targets[0].Server)
|
||
}
|
||
}
|
||
|
||
func TestCertificateWorkflowReturnsPartialDeploymentError(t *testing.T) {
|
||
deployer := &workflowDeployer{report: deployment.Report{
|
||
Total: 2, Success: 1, Failed: 1, Status: "partial",
|
||
Results: []deployment.Result{
|
||
{ServerID: "server-1", Server: "primary", Status: "success", Message: "deployed"},
|
||
{ServerID: "server-2", Server: "secondary", Status: "failed", Message: "validation failed"},
|
||
},
|
||
}}
|
||
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "*.example.com", CertificateType: domain.CertificateWildcard}}
|
||
workflow := &CertificateWorkflow{
|
||
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1"}},
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}, {ID: "server-2"}}},
|
||
Deployment: deployer,
|
||
}
|
||
result, err := workflow.IssueWithReport(context.Background(), "domain-1")
|
||
if err == nil || result.Deployment.Status != "partial" {
|
||
t.Fatalf("部分部署应返回错误和报告:%+v %v", result, err)
|
||
}
|
||
if !strings.Contains(err.Error(), "secondary: validation failed") || strings.Contains(err.Error(), "primary: deployed") {
|
||
t.Fatalf("deployment error details are incomplete: %v", err)
|
||
}
|
||
if result.Certificate.DomainID != "domain-1" {
|
||
t.Fatalf("证书结果未保留:%+v", result.Certificate)
|
||
}
|
||
if state.id != "domain-1" || state.status != "error" {
|
||
t.Fatalf("部署失败后域名状态未标记为错误:%+v", state)
|
||
}
|
||
}
|
||
|
||
func TestCertificateWorkflowResolvesSameServerForDifferentDomains(t *testing.T) {
|
||
server := deployment.Server{ID: "server-1", Name: "共享服务器"}
|
||
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
|
||
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSAN}}
|
||
workflow := &CertificateWorkflow{
|
||
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1"}},
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{server}},
|
||
Deployment: deployer,
|
||
}
|
||
if _, err := workflow.IssueWithReport(context.Background(), "domain-1"); err != nil {
|
||
t.Fatalf("第一个域名部署失败:%v", err)
|
||
}
|
||
firstPath := deployer.targets[0].Server.CertificatePath
|
||
state.item = domain.Domain{ID: "domain-2", Name: "api.example.net", CertificateType: domain.CertificateSingle}
|
||
workflow.Certificates = &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-2"}}
|
||
if _, err := workflow.IssueWithReport(context.Background(), "domain-2"); err != nil {
|
||
t.Fatalf("第二个域名部署失败:%v", err)
|
||
}
|
||
secondPath := deployer.targets[0].Server.CertificatePath
|
||
if firstPath != "/ssl/example.com/san/cert.pem" || secondPath != "/ssl/api.example.net/single/cert.pem" || firstPath == secondPath {
|
||
t.Fatalf("共享服务器的域名部署路径发生覆盖:%s %s", firstPath, secondPath)
|
||
}
|
||
}
|
||
|
||
func TestCertificateWorkflowRetriesRemoteVerificationUntilSuccess(t *testing.T) {
|
||
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
|
||
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}}
|
||
verificationCalls := 0
|
||
waitCalls := 0
|
||
workflow := &CertificateWorkflow{
|
||
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}},
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
|
||
Deployment: deployer,
|
||
RemoteVerificationAttempts: 4,
|
||
RemoteVerificationInterval: 10 * time.Millisecond,
|
||
RemoteVerificationWait: func(ctx context.Context, interval time.Duration) error {
|
||
waitCalls++
|
||
if interval != 10*time.Millisecond {
|
||
t.Fatalf("HTTPS 校验重试间隔错误:%s", interval)
|
||
}
|
||
return ctx.Err()
|
||
},
|
||
RemoteVerifier: func(_ context.Context, endpoint string, expectedDomains []string) (certificate.ValidationResult, error) {
|
||
verificationCalls++
|
||
if endpoint != "https://example.com" || len(expectedDomains) != 1 || expectedDomains[0] != "example.com" {
|
||
t.Fatalf("HTTPS 校验参数错误:%s %+v", endpoint, expectedDomains)
|
||
}
|
||
if verificationCalls < 3 {
|
||
return certificate.ValidationResult{}, errors.New("仍返回旧证书")
|
||
}
|
||
return certificate.ValidationResult{}, nil
|
||
},
|
||
}
|
||
|
||
if _, err := workflow.IssueWithReport(context.Background(), "domain-1"); err != nil {
|
||
t.Fatalf("HTTPS 校验重试后应成功:%v", err)
|
||
}
|
||
if verificationCalls != 3 || waitCalls != 2 {
|
||
t.Fatalf("HTTPS 校验重试次数错误:校验 %d 次,等待 %d 次", verificationCalls, waitCalls)
|
||
}
|
||
}
|
||
|
||
func TestCertificateWorkflowRemoteVerificationStopsAfterConfiguredAttempts(t *testing.T) {
|
||
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
|
||
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}}
|
||
verificationCalls := 0
|
||
waitCalls := 0
|
||
workflow := &CertificateWorkflow{
|
||
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}},
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
|
||
Deployment: deployer,
|
||
RemoteVerificationAttempts: 3,
|
||
RemoteVerificationWait: func(context.Context, time.Duration) error {
|
||
waitCalls++
|
||
return nil
|
||
},
|
||
RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) {
|
||
verificationCalls++
|
||
return certificate.ValidationResult{}, errors.New("证书永久不匹配")
|
||
},
|
||
}
|
||
|
||
_, err := workflow.IssueWithReport(context.Background(), "domain-1")
|
||
if err == nil || !strings.Contains(err.Error(), "重试 3 次后仍未通过") {
|
||
t.Fatalf("永久校验错误应在有限重试后返回:%v", err)
|
||
}
|
||
if verificationCalls != 3 || waitCalls != 2 {
|
||
t.Fatalf("永久错误的重试次数错误:校验 %d 次,等待 %d 次", verificationCalls, waitCalls)
|
||
}
|
||
}
|
||
|
||
func TestCertificateWorkflowRemoteVerificationWaitHonorsContextCancellation(t *testing.T) {
|
||
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
|
||
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
verificationCalls := 0
|
||
workflow := &CertificateWorkflow{
|
||
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}},
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
|
||
Deployment: deployer,
|
||
RemoteVerificationAttempts: 6,
|
||
RemoteVerificationInterval: time.Minute,
|
||
RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) {
|
||
verificationCalls++
|
||
cancel()
|
||
return certificate.ValidationResult{}, errors.New("等待新证书")
|
||
},
|
||
}
|
||
|
||
_, err := workflow.IssueWithReport(ctx, "domain-1")
|
||
if !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("取消上下文后应立即终止 HTTPS 校验重试:%v", err)
|
||
}
|
||
if verificationCalls != 1 {
|
||
t.Fatalf("取消后不应继续校验,实际调用 %d 次", verificationCalls)
|
||
}
|
||
}
|
||
|
||
func TestCertificateWorkflowDeployCurrentRestoresValidState(t *testing.T) {
|
||
notAfter := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second)
|
||
state := &workflowDomainState{
|
||
status: "error",
|
||
item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle},
|
||
}
|
||
workflow := &CertificateWorkflow{
|
||
CurrentCertificate: func(context.Context, string) (certificate.IssueResult, error) {
|
||
return certificate.IssueResult{
|
||
DomainID: "domain-1",
|
||
Domains: []string{"example.com"},
|
||
Saved: certificate.SavedResource{NotAfter: notAfter},
|
||
}, nil
|
||
},
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
|
||
Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}},
|
||
RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) {
|
||
return certificate.ValidationResult{}, nil
|
||
},
|
||
}
|
||
|
||
result, err := workflow.DeployCurrent(context.Background(), "domain-1")
|
||
if err != nil {
|
||
t.Fatalf("当前证书重新部署应成功:%v", err)
|
||
}
|
||
if result.Deployment.Status != "success" || state.id != "domain-1" || state.status != "valid" || state.updateCalled != 1 {
|
||
t.Fatalf("成功部署后域名状态未恢复:%+v %+v", result.Deployment, state)
|
||
}
|
||
if state.expiresAt == nil || !state.expiresAt.Equal(notAfter) {
|
||
t.Fatalf("成功部署后证书到期时间未更新:%v", state.expiresAt)
|
||
}
|
||
}
|
||
|
||
func TestCertificateWorkflowDeployCurrentReturnsStateUpdateError(t *testing.T) {
|
||
state := &workflowDomainState{
|
||
status: "error",
|
||
updateErr: errors.New("数据库不可写"),
|
||
item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle},
|
||
}
|
||
workflow := &CertificateWorkflow{
|
||
CurrentCertificate: func(context.Context, string) (certificate.IssueResult, error) {
|
||
return certificate.IssueResult{DomainID: "domain-1"}, nil
|
||
},
|
||
Domains: state,
|
||
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
|
||
Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}},
|
||
}
|
||
|
||
result, err := workflow.DeployCurrent(context.Background(), "domain-1")
|
||
if err == nil || !strings.Contains(err.Error(), "更新域名证书状态失败") {
|
||
t.Fatalf("域名状态更新失败时应返回明确错误:%v", err)
|
||
}
|
||
if result.Deployment.Status != "success" || state.updateCalled != 1 || state.status != "error" {
|
||
t.Fatalf("状态更新失败时应保留部署报告和原状态,且不能静默忽略:%+v %+v", result.Deployment, state)
|
||
}
|
||
}
|