package certificate import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "math/big" "os" "path/filepath" "testing" "time" "github.com/go-acme/lego/v5/challenge" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/domain" ) type serviceRepository struct { item domain.Domain states []string } func (r *serviceRepository) GetByID(_ context.Context, _ string) (domain.Domain, error) { return r.item, nil } func (r *serviceRepository) UpdateCertificateState(_ context.Context, _ string, status string, expiresAt *time.Time) error { r.states = append(r.states, status) r.item.Status = status r.item.ExpiresAt = expiresAt return nil } type serviceCredentials struct{} func (serviceCredentials) Get(context.Context, string) (map[string]string, error) { return map[string]string{"api_token": "test-token"}, nil } type serviceProvider struct{} func (serviceProvider) Present(context.Context, string, string, string) error { return nil } func (serviceProvider) CleanUp(context.Context, string, string, string) error { return nil } type serviceProviderFactory struct{} func (serviceProviderFactory) Create(string, map[string]string) (challenge.Provider, error) { return serviceProvider{}, nil } type serviceAccounts struct{} func (serviceAccounts) LoadOrCreate(context.Context, string, string) (*AccountUser, error) { return &AccountUser{}, nil } func (serviceAccounts) Save(context.Context, string, *AccountUser) error { return nil } type serviceIssuer struct { request ObtainRequest resource Resource err error } func (i *serviceIssuer) Obtain(_ context.Context, request ObtainRequest) (Resource, error) { i.request = request return i.resource, i.err } func TestServiceIssuesSANCertificateAndUpdatesState(t *testing.T) { root := t.TempDir() cfg := certificateTestConfig(root) item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSAN, []string{"example.com", "www.example.com"}, true, domain.ACMEEnvironmentStaging, "cloudflare") if err != nil { t.Fatal(err) } repository := &serviceRepository{item: item} issuer := &serviceIssuer{resource: testResourceForDomains(t, []string{"example.com", "www.example.com"})} service := NewService(cfg, repository, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } result, err := service.Issue(context.Background(), item.ID) if err != nil { t.Fatalf("申请 SAN 证书失败:%v", err) } if len(issuer.request.Domains) != 2 { t.Fatalf("SAN 申请域名错误:%v", issuer.request.Domains) } if len(repository.states) != 2 || repository.states[0] != "issuing" || repository.states[1] != "valid" { t.Fatalf("证书状态流转错误:%v", repository.states) } if _, err := os.Stat(result.Saved.PrivateKeyPath); err != nil { t.Fatalf("证书私钥未保存:%v", err) } } func TestServiceFailureKeepsExistingCertificate(t *testing.T) { root := t.TempDir() cfg := certificateTestConfig(root) item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSingle, nil, true, domain.ACMEEnvironmentStaging, "cloudflare") if err != nil { t.Fatal(err) } oldExpiry := time.Now().UTC().Add(10 * 24 * time.Hour) item.ExpiresAt = &oldExpiry store := FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup} oldResource := testResource(t, "example.com") saved, err := store.Save(context.Background(), item.Name, oldResource) if err != nil { t.Fatal(err) } before, err := os.ReadFile(saved.CertificatePath) if err != nil { t.Fatal(err) } repository := &serviceRepository{item: item} issuer := &serviceIssuer{err: errors.New("模拟 ACME 申请失败")} service := NewService(cfg, repository, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } if _, err := service.Issue(context.Background(), item.ID); err == nil { t.Fatal("ACME 申请失败时应返回错误") } after, err := os.ReadFile(saved.CertificatePath) if err != nil { t.Fatal(err) } if string(after) != string(before) { t.Fatal("申请失败时不应覆盖现有证书") } if len(repository.states) != 2 || repository.states[1] != "failed" { t.Fatalf("失败状态未正确记录:%v", repository.states) } if repository.item.ExpiresAt == nil || !repository.item.ExpiresAt.Equal(oldExpiry) { t.Fatal("申请失败时应保留原证书到期时间") } } func TestServiceImportsOwnedCertificate(t *testing.T) { root := t.TempDir() cfg := certificateTestConfig(root) item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSAN, []string{"example.com", "api.example.com"}, true, domain.ACMEEnvironmentStaging, "cloudflare") if err != nil { t.Fatal(err) } resource := testResourceForDomains(t, []string{"example.com", "api.example.com"}) certificatePath := filepath.Join(root, "owned-cert.pem") privateKeyPath := filepath.Join(root, "owned-key.pem") if err := os.WriteFile(certificatePath, resource.Certificate, 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(privateKeyPath, resource.PrivateKey, 0o600); err != nil { t.Fatal(err) } repository := &serviceRepository{item: item} service := NewService(cfg, repository, nil, nil, nil) result, err := service.Import(context.Background(), ImportRequest{ DomainID: item.ID, Certificate: certificatePath, PrivateKey: privateKeyPath, }) if err != nil { t.Fatalf("导入自有证书失败:%v", err) } if len(result.Domains) != 2 || repository.item.Status != "valid" { t.Fatalf("导入结果错误:%+v,状态:%s", result, repository.item.Status) } if _, err := os.Stat(result.Saved.FullChainPath); err != nil { t.Fatalf("导入证书未保存:%v", err) } } func TestServiceRejectsOwnedCertificateForWrongDomain(t *testing.T) { root := t.TempDir() cfg := certificateTestConfig(root) item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSingle, nil, true, domain.ACMEEnvironmentStaging, "cloudflare") if err != nil { t.Fatal(err) } resource := testResource(t, "other.example.com") certificatePath := filepath.Join(root, "wrong-cert.pem") privateKeyPath := filepath.Join(root, "wrong-key.pem") if err := os.WriteFile(certificatePath, resource.Certificate, 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(privateKeyPath, resource.PrivateKey, 0o600); err != nil { t.Fatal(err) } service := NewService(cfg, &serviceRepository{item: item}, nil, nil, nil) if _, err := service.Import(context.Background(), ImportRequest{ DomainID: item.ID, Certificate: certificatePath, PrivateKey: privateKeyPath, }); err == nil { t.Fatal("证书不覆盖托管域名时应拒绝导入") } if _, err := os.Stat(filepath.Join(cfg.Paths.Certificate, "example.com", "cert.pem")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("导入失败时不应写入证书:%v", err) } } func certificateTestConfig(root string) config.Config { cfg := config.Default(root) cfg.Paths.Certificate = filepath.Join(root, "ssl") cfg.Paths.Backup = filepath.Join(root, "backups") cfg.DNS.Provider = "cloudflare" cfg.DNS.CredentialRef = "secret://dns/cloudflare/test" cfg.DNS.VerificationState = "verified" cfg.Notification.Recipients = []string{"admin@example.com"} return cfg } func testResourceForDomains(t *testing.T, names []string) Resource { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } now := time.Now().UTC() template := &x509.Certificate{ SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(90 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { t.Fatal(err) } return Resource{ Domains: names, Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), } }