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