Scert/backend/internal/deployment/recovery_preflight_sftp_test.go
xuans 857fb0e632 修复后端全量测试环境兼容问题
修正 Windows 本地回环 SFTP 测试的远程绝对路径映射,并避免在不支持 POSIX 权限位的环境中误判上传临时文件权限。后端全量测试和 go vet 已通过。
2026-10-03 02:48:07 +08:00

142 lines
4.8 KiB
Go

package deployment
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// TestRecoveryPreflightOverPasswordSFTP 使用回环 SSH 和真实临时文件核验协议行为,不连接业务服务器。
func TestRecoveryPreflightOverPasswordSFTP(t *testing.T) {
hostSigner := newTestECDSAHostSigner(t)
endpoint := startTestPasswordSSHServerWithHandler(t, func(password string) error {
if password != "test-password" {
return errors.New("隔离测试密码不匹配")
}
return nil
}, serveTestSFTP, hostSigner)
for _, scenario := range []string{"foreign_lock", "missing_owner", "changed_backup", "changed_target", "changed_temporary", "unrecorded_temporary", "valid_restore"} {
t.Run(scenario, func(t *testing.T) {
root := t.TempDir()
server, memory, r := recoveryPreflightFixture(root)
model := testSSHServerModel(endpoint, "secret://ssh/preflight", ssh.FingerprintSHA256(hostSigner.PublicKey()))
server.Address, server.Port, server.Username = model.Address, model.Port, model.Username
server.AuthMethod, server.PasswordRef, server.HostKeyFingerprint = model.AuthMethod, model.PasswordRef, model.HostKeyFingerprint
r.Evidence.Address, r.Evidence.Port, r.Evidence.HostKeyFingerprint = server.Address, server.Port, server.HostKeyFingerprint
r.ReloadAttempted, r.Phase = false, TransactionPhaseInstalled
switch scenario {
case "foreign_lock":
memory.files[r.LockPaths[0]+"/owner"] = memoryFile{data: []byte(contentDigest([]byte("another-owner")))}
case "missing_owner":
delete(memory.files, r.LockPaths[0]+"/owner")
case "changed_backup":
memory.files[r.Backups[0].Saved] = memoryFile{data: []byte("unexpected-backup")}
case "changed_target":
memory.files[r.Paths[0]] = memoryFile{data: []byte("unexpected-target")}
case "changed_temporary":
memory.files[r.TemporaryPaths[2]] = memoryFile{data: []byte("unexpected-temporary")}
case "unrecorded_temporary":
delete(r.Evidence.TemporaryFiles, r.TemporaryPaths[2])
}
for path, file := range memory.files {
localPath := localSFTPPath(root, path)
if strings.HasSuffix(path, ".scert-lock") {
if err := os.MkdirAll(localPath, 0o700); err != nil {
t.Fatal(err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(localPath, file.data, 0o600); err != nil {
t.Fatal(err)
}
}
before := snapshotRecoveryDirectory(t, root)
factory := SSHFactory{
Secrets: staticSecretReader{server.PasswordRef: {"password": "test-password"}},
Timeout: 5 * time.Second,
}
journal := &memoryTransactionJournal{pending: []TransactionRecord{r}}
service := NewServiceWithJournal(factory, journal)
err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil })
if scenario == "valid_restore" {
if err != nil {
t.Fatalf("真实 SFTP 可信恢复失败:%v", err)
}
if len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusRolledBack {
t.Fatal("真实 SFTP 恢复未完成")
}
for _, target := range r.Paths {
data, err := os.ReadFile(localSFTPPath(root, target))
if err != nil || string(data) != "old" {
t.Fatalf("真实文件恢复错误:%s %v", target, err)
}
}
if len(snapshotRecoveryDirectory(t, root)) != 4 {
t.Fatal("恢复后应仅保留目录和三个原始文件")
}
return
}
if err == nil {
t.Fatal("真实 SFTP 必须拒绝不可信恢复")
}
if !reflect.DeepEqual(before, snapshotRecoveryDirectory(t, root)) {
t.Fatal("真实 SFTP 拒绝恢复时修改了磁盘现场")
}
if len(journal.finished) != 0 || len(journal.updated) != 1 || journal.updated[0].Status != TransactionStatusRecoveryRequired {
t.Fatal("真实 SFTP 未保留待核查事务")
}
})
}
}
func localSFTPPath(root, remotePath string) string {
if volume := filepath.ToSlash(filepath.VolumeName(root)); volume != "" {
remoteVolume := "/" + volume
if strings.HasPrefix(remotePath, remoteVolume) {
return filepath.FromSlash(strings.TrimPrefix(remotePath, "/"))
}
}
return filepath.FromSlash(remotePath)
}
func snapshotRecoveryDirectory(t *testing.T, root string) map[string]string {
t.Helper()
result := map[string]string{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := entry.Info()
if err != nil {
return err
}
if entry.IsDir() {
result[path] = info.Mode().String()
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
result[path] = fmt.Sprintf("%s:%x", info.Mode(), sha256.Sum256(data))
return nil
})
if err != nil {
t.Fatal(err)
}
return result
}