Scert/backend/internal/deployment/recovery_preflight_test.go

252 lines
10 KiB
Go

package deployment
import (
"context"
"errors"
"maps"
"path/filepath"
"reflect"
"testing"
)
func recoveryPreflightFixture(roots ...string) (Server, *memoryClient, TransactionRecord) {
server := testServer("preflight").Normalize()
if len(roots) == 1 {
server.CertificatePath = filepath.ToSlash(filepath.Join(roots[0], "cert.pem"))
server.CertificateChainPath = filepath.ToSlash(filepath.Join(roots[0], "fullchain.pem"))
server.PrivateKeyPath = filepath.ToSlash(filepath.Join(roots[0], "key.pem"))
}
client := newMemoryClient()
r := TransactionRecord{
ID: "preflight-transaction", TaskID: "task", AttemptID: "attempt", AttemptNumber: 1,
ServerID: server.ID, ServerName: server.Name,
Paths: []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath},
Phase: TransactionPhaseReloadAttempted, Status: TransactionStatusPending, ReloadAttempted: true,
Evidence: &RecoveryEvidence{
Version: 1, LockToken: contentDigest([]byte("preflight-owner")),
Address: server.Address, Port: server.Port, HostKeyFingerprint: server.HostKeyFingerprint,
ValidationCommand: server.ValidationCommand, ReloadCommand: server.ReloadCommand,
Files: map[string]string{}, Baseline: RecoveryBaseline{
Endpoint: "https://example.com", Domains: []string{"example.com"},
PreviousFingerprint: "old", ExpectedFingerprint: "new",
},
},
}
for _, path := range r.Paths {
lock, backup, temporary := path+".scert-lock", path+".scert-backup-"+r.ID, path+".scert-tmp-"+r.ID
r.LockPaths = append(r.LockPaths, lock)
r.TemporaryPaths = append(r.TemporaryPaths, temporary)
r.InstalledPaths = append(r.InstalledPaths, path)
r.Backups = append(r.Backups, TransactionBackup{
Original: path, Saved: backup, OriginalExists: true, OriginalDigest: contentDigest([]byte("old")),
})
r.Evidence.Files[path] = contentDigest([]byte("new"))
client.files[lock] = memoryFile{}
client.files[lock+"/owner"] = memoryFile{data: []byte(r.Evidence.LockToken), mode: 0o600}
client.files[backup] = memoryFile{data: []byte("old"), mode: 0o600}
client.files[path] = memoryFile{data: []byte("new"), mode: 0o600}
client.files[temporary] = memoryFile{data: []byte("partial"), mode: 0o600}
}
return server, client, r
}
func TestRecoveryPreflightRejectsUntrustedStateWithoutMutation(t *testing.T) {
for _, scenario := range []string{
"foreign_lock", "missing_owner", "changed_backup", "changed_target", "missing_backup_and_target",
"commit_changed_backup", "acquiring_without_owner", "verified_foreign_lock",
} {
t.Run(scenario, func(t *testing.T) {
server, client, r := recoveryPreflightFixture()
switch scenario {
case "foreign_lock", "verified_foreign_lock":
client.files[r.LockPaths[0]+"/owner"] = memoryFile{data: []byte(contentDigest([]byte("another-owner")))}
if scenario == "verified_foreign_lock" {
r.Phase = TransactionPhaseRollbackVerified
}
case "missing_owner", "acquiring_without_owner":
delete(client.files, r.LockPaths[0]+"/owner")
if scenario == "acquiring_without_owner" {
r.Phase = TransactionPhaseAcquiring
}
case "changed_backup":
client.files[r.Backups[0].Saved] = memoryFile{data: []byte("unexpected-backup")}
case "changed_target":
client.files[r.Paths[0]] = memoryFile{data: []byte("unexpected-target")}
case "missing_backup_and_target":
delete(client.files, r.Backups[0].Saved)
delete(client.files, r.Paths[0])
case "commit_changed_backup":
r.Phase = TransactionPhaseCommitting
client.files[r.Backups[2].Saved] = memoryFile{data: []byte("unexpected-backup")}
}
before := maps.Clone(client.files)
journal := &memoryTransactionJournal{pending: []TransactionRecord{r}}
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
service.VerifyRecoveryTarget = func(context.Context, Server, RecoveryBaseline, bool) error { return nil }
err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil })
if err == nil {
t.Error("证据不可信时必须拒绝恢复")
}
if !reflect.DeepEqual(before, client.files) || len(client.commands) != 0 {
t.Error("证据核验失败前不得修改文件、释放锁或执行命令")
}
if len(journal.finished) != 0 || len(journal.updated) != 1 || journal.updated[0].Status != TransactionStatusRecoveryRequired {
t.Fatalf("必须保留事务等待人工核查:%+v", journal)
}
})
}
}
func TestRecoveryVerifiedCleanupIsRestartable(t *testing.T) {
for _, phase := range []string{TransactionPhaseRollbackVerified, TransactionPhaseCommitVerified} {
t.Run(phase, func(t *testing.T) {
server, client, r := recoveryPreflightFixture()
r.Phase = phase
delete(client.files, r.LockPaths[2])
delete(client.files, r.LockPaths[2]+"/owner")
journal := &memoryTransactionJournal{pending: []TransactionRecord{r}}
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err != nil {
t.Fatalf("已核验事务的部分解锁必须可重入:%v", err)
}
want := TransactionStatusRolledBack
if phase == TransactionPhaseCommitVerified {
want = TransactionStatusCommitted
}
if len(journal.finished) != 1 || journal.finished[0].status != want {
t.Fatalf("收尾状态不正确:%+v", journal.finished)
}
})
}
}
func TestRecoveryPreflightAllowsInterruptedFileStates(t *testing.T) {
for _, scenario := range []string{"before_backup_rename", "after_backup_rename", "partly_restored", "new_target", "commit_partly_cleaned", "acquiring_partly_locked"} {
t.Run(scenario, func(t *testing.T) {
server, client, r := recoveryPreflightFixture()
switch scenario {
case "before_backup_rename", "partly_restored":
delete(client.files, r.Backups[0].Saved)
client.files[r.Paths[0]] = memoryFile{data: []byte("old"), mode: 0o600}
case "after_backup_rename":
delete(client.files, r.Paths[0])
case "new_target":
r.Backups[0].OriginalExists = false
r.Backups[0].OriginalDigest = ""
delete(client.files, r.Backups[0].Saved)
case "commit_partly_cleaned":
r.Phase = TransactionPhaseCommitting
delete(client.files, r.Backups[0].Saved)
case "acquiring_partly_locked":
r.Phase = TransactionPhaseAcquiring
delete(client.files, r.LockPaths[2])
delete(client.files, r.LockPaths[2]+"/owner")
}
journal := &memoryTransactionJournal{pending: []TransactionRecord{r}}
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
service.VerifyRecoveryTarget = func(_ context.Context, _ Server, _ RecoveryBaseline, committed bool) error {
if committed != (scenario == "commit_partly_cleaned") {
t.Fatal("恢复核验方向错误")
}
return nil
}
if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err != nil {
t.Fatalf("可信中断状态必须可以恢复:%v", err)
}
wantStatus := TransactionStatusRolledBack
if scenario == "commit_partly_cleaned" {
wantStatus = TransactionStatusCommitted
}
if len(journal.finished) != 1 || journal.finished[0].status != wantStatus {
t.Fatalf("恢复状态不正确:%+v", journal.finished)
}
for index, target := range r.Paths {
if _, ok := client.files[r.LockPaths[index]]; ok {
t.Fatal("可信恢复完成后不应残留锁")
}
if scenario == "new_target" && index == 0 {
if _, ok := client.files[target]; ok {
t.Fatal("新增目标未回滚")
}
continue
}
want := "old"
if scenario == "commit_partly_cleaned" || scenario == "acquiring_partly_locked" {
want = "new"
}
if string(client.files[target].data) != want {
t.Fatal("目标文件恢复内容不正确")
}
}
})
}
}
func TestRecoveryPreflightHTTPSFailureKeepsLocksUntilReverified(t *testing.T) {
server, client, r := recoveryPreflightFixture()
journal := &memoryTransactionJournal{pending: []TransactionRecord{r}}
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
service.VerifyRecoveryTarget = func(context.Context, Server, RecoveryBaseline, bool) error {
return errors.New("模拟旧证书未实际生效")
}
resolve := func(context.Context, string) (Server, error) { return server, nil }
if err := service.RecoverPending(t.Context(), resolve); err == nil {
t.Fatal("HTTPS 核验失败不得宣称恢复成功")
}
if len(journal.finished) != 0 || len(journal.updated) != 1 || journal.updated[0].Status != TransactionStatusRecoveryRequired {
t.Fatal("HTTPS 核验失败必须保留待核查事务")
}
for index, target := range r.Paths {
if string(client.files[target].data) != "old" {
t.Fatal("文件应已恢复为旧版本")
}
if _, ok := client.files[r.LockPaths[index]]; !ok {
t.Fatal("站点未核验时不得释放部署锁")
}
}
journal.pending = append([]TransactionRecord(nil), journal.updated...)
service.VerifyRecoveryTarget = func(context.Context, Server, RecoveryBaseline, bool) error { return nil }
if err := service.RecoverPending(t.Context(), resolve); err != nil {
t.Fatalf("再次核验可信旧文件应可恢复:%v", err)
}
if len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusRolledBack {
t.Fatal("再次核验后没有完成回滚")
}
}
type recoveryConnectCounter struct{ calls int }
func (f *recoveryConnectCounter) Connect(context.Context, Server) (Client, error) {
f.calls++
return nil, errors.New("不应尝试连接身份已变更的服务器")
}
func TestRecoveryPreflightChecksIdentityBeforeAuthentication(t *testing.T) {
for _, scenario := range []string{"address", "port", "host_key", "command", "missing_evidence"} {
t.Run(scenario, func(t *testing.T) {
server, _, r := recoveryPreflightFixture()
switch scenario {
case "address":
server.Address = "192.0.2.55"
case "port":
server.Port++
case "host_key":
server.HostKeyFingerprint = "SHA256:changed"
case "command":
server.ReloadCommand = []string{"true"}
case "missing_evidence":
r.Evidence = nil
}
factory := &recoveryConnectCounter{}
journal := &memoryTransactionJournal{pending: []TransactionRecord{r}}
service := NewServiceWithJournal(factory, journal)
if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err == nil {
t.Fatal("恢复身份不一致时必须拒绝恢复")
}
if factory.calls != 0 {
t.Fatal("验证恢复身份之前不得向服务器发送认证请求")
}
})
}
}