Scert/backend/internal/certificate/service_test.go

235 lines
8.1 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 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)}),
}
}