通过本机Unix套接字报告HTTPS监听状态,同步服务配置与安装模板,保持初始化业务就绪隔离。补充生命周期回归并修复旧CLI测试误用正式目录的问题。更新Linux原生测试证据,明确新候选尚未部署、生产验收未放行。
185 lines
6.4 KiB
Go
185 lines
6.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
|
"git.pcux.cn/xuans/Scert/backend/internal/setup"
|
|
)
|
|
|
|
func initTestConfig(t *testing.T) config.Config {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
cfg := isolatedCLIConfig(root)
|
|
cfg.Paths.Config = filepath.Join(root, "conf")
|
|
cfg.Paths.Certificate = filepath.Join(root, "certificates")
|
|
cfg.Paths.Backup = filepath.Join(root, "backups")
|
|
cfg.Admin.Username, cfg.Admin.Email, cfg.Admin.PasswordHash = "admin", "admin@example.com", "test-only-hash"
|
|
cfg.Web.Hostname, cfg.Web.PublicIP = "panel.example.com", ""
|
|
cfg.DNS.Provider, cfg.DNS.CredentialRef, cfg.DNS.VerificationState = "aliyun", "secret://dns/aliyun/init-test", "verified"
|
|
return cfg
|
|
}
|
|
|
|
func initTestResource(t *testing.T) certificate.Resource {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(time.Now().UnixNano()), DNSNames: []string{"panel.example.com"},
|
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(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)
|
|
}
|
|
private, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return certificate.Resource{
|
|
Domains: []string{"panel.example.com"}, Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
|
PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: private}),
|
|
}
|
|
}
|
|
|
|
func TestCompleteInitCommitsOnlyAfterCertificateAndRollsBackFailures(t *testing.T) {
|
|
for _, scenario := range []string{"success", "issue_failure", "invalid_files", "firewall_failure", "save_failure", "cancelled", "rollback_failure"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
cfg := initTestConfig(t)
|
|
path := filepath.Join(cfg.Paths.Config, "scert.json")
|
|
manager := &cliFakeFirewallManager{result: firewall.Result{Created: true}}
|
|
if scenario == "firewall_failure" {
|
|
manager.openErr = errors.New("测试防火墙拒绝")
|
|
}
|
|
if scenario == "save_failure" {
|
|
blocked := filepath.Join(t.TempDir(), "blocked")
|
|
if err := os.WriteFile(blocked, []byte("blocked"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path = filepath.Join(blocked, "scert.json")
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
var saved certificate.SavedResource
|
|
issue := func(ctx context.Context, candidate config.Config) (certificate.SavedResource, error) {
|
|
if candidate.SetupReady || candidate.Web.InitReady {
|
|
t.Fatal("签发开始前不得标记就绪")
|
|
}
|
|
if scenario == "issue_failure" {
|
|
return saved, errors.New("测试签发失败")
|
|
}
|
|
store := certificate.FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup}
|
|
var err error
|
|
saved, err = store.SaveForType(ctx, cfg.Web.Hostname, "single", initTestResource(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if scenario == "invalid_files" || scenario == "rollback_failure" {
|
|
if err := os.WriteFile(saved.PrivateKeyPath, []byte("invalid-test-key"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if scenario == "cancelled" {
|
|
cancel()
|
|
}
|
|
return saved, nil
|
|
}
|
|
rolledBack := false
|
|
rollback := func(ctx context.Context, saved certificate.SavedResource) error {
|
|
rolledBack = true
|
|
if ctx.Err() != nil {
|
|
t.Fatal("回滚必须使用独立的有效上下文")
|
|
}
|
|
if err := certificate.RollbackSavedResource(ctx, saved); err != nil {
|
|
return err
|
|
}
|
|
if scenario == "rollback_failure" {
|
|
return errors.New("测试回滚报告失败")
|
|
}
|
|
return nil
|
|
}
|
|
_, err := completeInit(ctx, &cfg, path, manager, issue, rollback)
|
|
if scenario == "success" {
|
|
if err != nil || rolledBack || !cfg.SetupReady || !cfg.Web.InitReady {
|
|
t.Fatalf("正常初始化未提交就绪状态:%v", err)
|
|
}
|
|
stored, err := config.Load(path)
|
|
if err != nil || !stored.SetupReady || stored.Server.TLSCert != saved.FullChainPath || stored.Server.TLSKey != saved.PrivateKeyPath {
|
|
t.Fatalf("证书配置未正确保存:%v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || cfg.SetupReady || cfg.Web.InitReady {
|
|
t.Fatalf("失败时不得发布就绪状态:%v", err)
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Fatal("失败时不得留下已完成的配置")
|
|
}
|
|
if scenario != "issue_failure" && !rolledBack {
|
|
t.Fatal("后续阶段失败必须回滚证书")
|
|
}
|
|
if scenario == "save_failure" && !manager.rollbackCall {
|
|
t.Fatal("保存失败必须回滚新建防火墙规则")
|
|
}
|
|
if scenario == "rollback_failure" && !strings.Contains(err.Error(), "手动检查") {
|
|
t.Fatal("必须明确报告回滚失败")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type initAuthorizationChecker struct {
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (c *initAuthorizationChecker) VerifyDomain(context.Context, string, string, map[string]string) error {
|
|
c.calls++
|
|
return c.err
|
|
}
|
|
|
|
func TestExistingDNSReferenceCannotBypassVerification(t *testing.T) {
|
|
cfg := initTestConfig(t)
|
|
store, err := secretstore.Open(cfg.Paths.Config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Put(t.Context(), cfg.DNS.CredentialRef, map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, denied := range []bool{false, true} {
|
|
input := &setup.Input{DNSProvider: "aliyun", DNSCredentialRef: cfg.DNS.CredentialRef, DNSVerified: true, WebHostname: cfg.Web.Hostname}
|
|
checker := &initAuthorizationChecker{}
|
|
if denied {
|
|
checker.err = errors.New("测试撤权")
|
|
}
|
|
cleanup, err := verifyExistingDNSCredentials(t.Context(), cfg, input, io.Discard, checker)
|
|
cleanup()
|
|
if checker.calls != 1 || (err != nil) != denied || input.DNSVerified == denied {
|
|
t.Fatal("已有引用或已验证参数不得绕过当前权限检查")
|
|
}
|
|
if exists, err := store.Has(t.Context(), cfg.DNS.CredentialRef); err != nil || !exists {
|
|
t.Fatal("失败清理不得删除用户原有凭据")
|
|
}
|
|
}
|
|
}
|