修正 Windows 本地回环 SFTP 测试的远程绝对路径映射,并避免在不支持 POSIX 权限位的环境中误判上传临时文件权限。后端全量测试和 go vet 已通过。
142 lines
4.8 KiB
Go
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
|
|
}
|