Scert/backend/internal/app/certificate_workflow_test.go
xuans 3cc8fb6470 完成部署后校验失败恢复整改并更新实机进度
延后事务提交,校验失败时恢复目标文件、配置和服务状态;新增恢复边界回归、前端提示及生产测试记录。
2026-09-30 23:14:42 +08:00

378 lines
16 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 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 (d *workflowDeployer) DeploySavedVerified(ctx context.Context, targets []deployment.Target, files deployment.SavedFiles, verification deployment.Verification) (deployment.Report, error) {
report, err := d.DeploySaved(ctx, targets, files)
if err != nil || report.Status != "success" {
return report, err
}
report.VerificationStatus = "failed"
if err := verification.Verify(ctx); err != nil {
report.Status = "failed"
return report, err
}
report.VerificationStatus = "success"
return 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 TestCertificateWorkflowRejectsDomainWithoutDeploymentServers(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}}
workflow := &CertificateWorkflow{
Domains: state,
Servers: workflowServerLister{},
Deployment: deployer,
}
report, err := workflow.deploy(context.Background(), certificate.IssueResult{DomainID: "domain-1"})
if err == nil || !strings.Contains(err.Error(), "\u5c1a\u672a\u5173\u8054\u4efb\u4f55\u90e8\u7f72\u670d\u52a1\u5668") {
t.Fatalf("\u672a\u5173\u8054\u670d\u52a1\u5668\u65f6\u5e94\u8fd4\u56de\u660e\u786e\u9519\u8bef\xef\xbc\x9a%v", err)
}
if report.Status != "failed" || deployer.targets != nil {
t.Fatalf("\u672a\u5173\u8054\u670d\u52a1\u5668\u65f6\u4e0d\u5f97\u6267\u884c\u8fdc\u7a0b\u90e8\u7f72\xef\xbc\x9a%+v %+v", report, deployer.targets)
}
}
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) {
saved := workflowSavedCertificate(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"}, Saved: saved}},
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 < 4 {
return certificate.ValidationResult{}, errors.New("仍返回旧证书")
}
return certificate.ValidationResult{FingerprintSHA256: saved.FingerprintSHA256}, nil
},
}
if _, err := workflow.IssueWithReport(context.Background(), "domain-1"); err != nil {
t.Fatalf("HTTPS 校验重试后应成功:%v", err)
}
if verificationCalls != 4 || waitCalls != 2 {
t.Fatalf("HTTPS 校验重试次数错误:校验 %d 次,等待 %d 次", verificationCalls, waitCalls)
}
}
func TestCertificateWorkflowRemoteVerificationStopsAfterConfiguredAttempts(t *testing.T) {
saved := workflowSavedCertificate(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"}, Saved: saved}},
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 != 4 || waitCalls != 2 {
t.Fatalf("永久错误的重试次数错误:校验 %d 次,等待 %d 次", verificationCalls, waitCalls)
}
}
func TestCertificateWorkflowRemoteVerificationWaitHonorsContextCancellation(t *testing.T) {
saved := workflowSavedCertificate(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"}, Saved: saved}},
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++
if verificationCalls == 1 {
return certificate.ValidationResult{FingerprintSHA256: saved.FingerprintSHA256}, nil
}
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 != 2 {
t.Fatalf("取消后不应继续校验,实际调用 %d 次", verificationCalls)
}
}
func TestCertificateWorkflowDeployCurrentRestoresValidState(t *testing.T) {
notAfter := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second)
saved := workflowSavedCertificate(t)
saved.NotAfter = notAfter
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: saved,
}, 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{FingerprintSHA256: saved.FingerprintSHA256}, 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)
}
}