Scert/backend/internal/deployment/upload_ownership_test.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("认证前必须拒绝无效临时文件证据")
}
})
}
}