138 lines
5.9 KiB
Go
138 lines
5.9 KiB
Go
package deployment
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"maps"
|
|
"reflect"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
type journalCollisionClient struct {
|
|
*memoryClient
|
|
collisionAt int
|
|
calls int
|
|
collided string
|
|
}
|
|
|
|
func (c *journalCollisionClient) Upload(ctx context.Context, path string, data []byte, mode uint32) error {
|
|
c.calls++
|
|
if c.calls == c.collisionAt {
|
|
c.collided = path
|
|
c.files[path] = memoryFile{data: []byte("not-owned"), mode: 0o600}
|
|
return &uploadNotCreatedError{err: errors.New("模拟独占创建冲突")}
|
|
}
|
|
return c.memoryClient.Upload(ctx, path, data, mode)
|
|
}
|
|
|
|
func TestJournalUploadCollisionPreservesUnownedFile(t *testing.T) {
|
|
for _, position := range []int{1, 3} {
|
|
t.Run(strconv.Itoa(position), func(t *testing.T) {
|
|
client := &journalCollisionClient{memoryClient: newMemoryClient(), collisionAt: position}
|
|
server := testServer("journal-collision")
|
|
client.files[server.CertificatePath] = memoryFile{data: []byte("current"), mode: 0o600}
|
|
journal := &memoryTransactionJournal{}
|
|
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
|
|
ctx := WithTransactionContext(t.Context(), TransactionContext{TaskID: "task", AttemptID: "attempt", AttemptNumber: 1})
|
|
report := service.Deploy(ctx, []Target{{Server: server}}, testArtifact(t))
|
|
if report.Status != "failed" || client.collided == "" {
|
|
t.Fatalf("必须触发上传碰撞:%+v", report)
|
|
}
|
|
if string(client.files[client.collided].data) != "not-owned" {
|
|
t.Error("事务日志不得把计划路径当作已取得所有权的临时文件删除")
|
|
}
|
|
if string(client.files[server.CertificatePath].data) != "current" || len(client.commands) != 0 {
|
|
t.Fatal("上传碰撞不得改变当前证书或执行命令")
|
|
}
|
|
if len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusRecoveryRequired {
|
|
t.Fatal("碰撞路径归属未知,必须保留锁及事务等待核查")
|
|
}
|
|
for _, target := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} {
|
|
if _, exists := client.files[target+".scert-lock"]; !exists {
|
|
t.Fatal("未知文件仍存在时不得自动释放部署锁")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecoveryRejectsUnprovenTemporaryFileBeforeMutation(t *testing.T) {
|
|
for _, scenario := range []string{"changed", "unrecorded", "commit_changed", "legacy", "legacy_commit"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
server, client, record := recoveryPreflightFixture()
|
|
switch scenario {
|
|
case "legacy", "legacy_commit":
|
|
record.Evidence.TemporaryFiles = nil
|
|
case "unrecorded":
|
|
delete(record.Evidence.TemporaryFiles, record.TemporaryPaths[2])
|
|
}
|
|
if scenario == "commit_changed" || scenario == "legacy_commit" {
|
|
record.Phase = TransactionPhaseCommitting
|
|
}
|
|
client.files[record.TemporaryPaths[2]] = memoryFile{data: []byte("foreign-file"), mode: 0o600}
|
|
journal := &memoryTransactionJournal{pending: []TransactionRecord{record}}
|
|
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
|
|
service.VerifyRecoveryTarget = func(context.Context, Server, RecoveryBaseline, bool) error { return nil }
|
|
before := maps.Clone(client.files)
|
|
err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil })
|
|
if err == nil || !reflect.DeepEqual(client.files, before) || len(client.commands) != 0 {
|
|
t.Fatal("无法证明临时文件身份时必须保留完整现场,不得先删除其他文件")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJournalPartialUploadCleansOnlyProvenFiles(t *testing.T) {
|
|
client := newMemoryClient()
|
|
client.uploadError = "key.pem"
|
|
server := testServer("journal-partial")
|
|
for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} {
|
|
client.files[path] = memoryFile{data: []byte("original"), mode: 0o600}
|
|
}
|
|
before := maps.Clone(client.files)
|
|
journal := &memoryTransactionJournal{}
|
|
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
|
|
ctx := WithTransactionContext(t.Context(), TransactionContext{TaskID: "task", AttemptID: "attempt", AttemptNumber: 1})
|
|
report := service.Deploy(ctx, []Target{{Server: server}}, testArtifact(t))
|
|
if report.Status != "failed" || !reflect.DeepEqual(client.files, before) || len(client.commands) != 0 {
|
|
t.Fatalf("已取得归属的半份私钥应可安全清理,且不改变旧证书:%+v", report)
|
|
}
|
|
if len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusRolledBack {
|
|
t.Fatal("已证明归属的上传失败应完成回滚")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryLegacyWithoutTemporaryFilesStillRestores(t *testing.T) {
|
|
server, client, record := recoveryPreflightFixture()
|
|
record.Evidence.TemporaryFiles = nil
|
|
for _, path := range record.TemporaryPaths {
|
|
delete(client.files, path)
|
|
}
|
|
journal := &memoryTransactionJournal{pending: []TransactionRecord{record}}
|
|
service := NewServiceWithJournal(fixedClientFactory{client}, journal)
|
|
service.VerifyRecoveryTarget = func(context.Context, Server, RecoveryBaseline, bool) error { return nil }
|
|
if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err != nil {
|
|
t.Fatalf("没有残留临时文件且其他证据完整的旧事务仍应恢复:%v", err)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryRejectsMalformedTemporaryOwnershipBeforeAuthentication(t *testing.T) {
|
|
for _, scenario := range []string{"foreign_path", "invalid_digest", "duplicate_path"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
server, _, record := recoveryPreflightFixture()
|
|
switch scenario {
|
|
case "foreign_path":
|
|
record.Evidence.TemporaryFiles["/foreign/file"] = contentDigest([]byte("foreign"))
|
|
case "invalid_digest":
|
|
record.Evidence.TemporaryFiles[record.TemporaryPaths[0]] = "invalid"
|
|
case "duplicate_path":
|
|
record.TemporaryPaths = append(record.TemporaryPaths, record.TemporaryPaths[0])
|
|
}
|
|
if err := validateRecoveryEvidence(record, server); err == nil {
|
|
t.Fatal("认证前必须拒绝无效临时文件证据")
|
|
}
|
|
})
|
|
}
|
|
}
|