Smail/backend/internal/release/recovery_test.go
smail 33eab66a50
Some checks are pending
Security Checks / security (push) Waiting to run
修复发布备份保护与回滚状态并补齐隔离验收
2026-09-21 20:56:02 +08:00

185 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package release
import (
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"git.pcux.cn/xuans/Smail/internal/buildinfo"
)
type releaseFixture struct {
target string
main string
admin string
names []string
pair Pair
}
func newReleaseFixture(t *testing.T) releaseFixture {
t.Helper()
root := t.TempDir()
f := releaseFixture{target: filepath.Join(root, "bin"), main: filepath.Join(root, "main-source"), admin: filepath.Join(root, "admin-source")}
mainName, adminName := executableNames()
f.names = []string{mainName, adminName, "release.json"}
info := buildinfo.Info{Version: "v2", GitHash: "abc", ProtocolVersion: buildinfo.ProtocolVersion}
f.pair = Pair{Main: info, Admin: info}
if err := os.Mkdir(f.target, 0700); err != nil {
t.Fatal(err)
}
for _, path := range []string{f.main, f.admin} {
if err := os.WriteFile(path, []byte("new"), 0600); err != nil {
t.Fatal(err)
}
}
for _, name := range f.names {
if err := os.WriteFile(filepath.Join(f.target, name), []byte("old-"+name), 0600); err != nil {
t.Fatal(err)
}
}
return f
}
func assertOldRelease(t *testing.T, f releaseFixture) {
t.Helper()
for _, name := range f.names {
data, err := os.ReadFile(filepath.Join(f.target, name))
if err != nil || string(data) != "old-"+name {
t.Fatalf("旧版本未完整保留:%s,错误:%v", name, err)
}
}
}
func TestInstallPairPreservesExistingRecoveryBackup(t *testing.T) {
mainName, adminName := executableNames()
for _, name := range []string{mainName, adminName, "release.json"} {
t.Run(name, func(t *testing.T) {
f := newReleaseFixture(t)
backup := filepath.Join(f.target, name+".previous")
if err := os.WriteFile(backup, []byte("retained-evidence"), 0600); err != nil {
t.Fatal(err)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeBackupConflict) {
t.Fatal("已有恢复备份时仍执行了升级")
}
assertOldRelease(t, f)
data, err := os.ReadFile(backup)
if err != nil || string(data) != "retained-evidence" {
t.Fatal("已有恢复备份被删除或改写")
}
})
}
}
func TestInstallPairRestoresEveryRenameFailure(t *testing.T) {
for failure := 1; failure <= 6; failure++ {
t.Run(strconv.Itoa(failure), func(t *testing.T) {
f := newReleaseFixture(t)
original := renamePath
t.Cleanup(func() { renamePath = original })
calls := 0
renamePath = func(source, target string) error {
calls++
if calls == failure {
return errors.New("simulated rename failure")
}
return original(source, target)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeRollback) || errors.Is(err, ErrUpgradeRollbackFailed) {
t.Fatal("替换失败没有返回完整回滚状态")
}
assertOldRelease(t, f)
})
}
}
func TestInstallPairRollsBackFirstInstallation(t *testing.T) {
f := newReleaseFixture(t)
f.target = filepath.Join(filepath.Dir(f.target), "empty-bin")
original := renamePath
t.Cleanup(func() { renamePath = original })
_, adminName := executableNames()
renamePath = func(source, target string) error {
if filepath.Base(source) == adminName {
return errors.New("simulated first installation failure")
}
return original(source, target)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeRollback) {
t.Fatal("首次安装失败没有回滚")
}
entries, err := os.ReadDir(f.target)
if err != nil || len(entries) != 0 {
t.Fatal("首次安装失败后留下不完整程序或备份")
}
}
func TestInstallPairRejectsDirectoryRecoveryBackup(t *testing.T) {
f := newReleaseFixture(t)
backup := filepath.Join(f.target, "release.json.previous")
if err := os.Mkdir(backup, 0700); err != nil {
t.Fatal(err)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeBackupConflict) {
t.Fatal("目录形式的已有备份未被保护")
}
assertOldRelease(t, f)
if info, err := os.Stat(backup); err != nil || !info.IsDir() {
t.Fatal("备份目录被删除或改写")
}
}
func TestInstallPairDoesNotClaimSuccessfulRollbackWhenRestoreFails(t *testing.T) {
f := newReleaseFixture(t)
original := renamePath
t.Cleanup(func() { renamePath = original })
mainName, adminName := executableNames()
renamePath = func(source, target string) error {
if (filepath.Base(source) == adminName && filepath.Dir(source) != f.target) || filepath.Base(source) == mainName+".previous" {
return errors.New("simulated install and restore failure")
}
return original(source, target)
}
err := InstallPair(f.main, f.admin, f.target, f.pair)
if !errors.Is(err, ErrUpgradeRollbackFailed) || errors.Is(err, ErrUpgradeRollback) {
t.Fatal("恢复失败仍被标记为回滚成功")
}
data, readErr := os.ReadFile(filepath.Join(f.target, mainName+".previous"))
if readErr != nil || string(data) != "old-"+mainName {
t.Fatal("恢复失败后未保留旧版本备份")
}
}
func TestInstallPairReportsRollbackDurabilityFailure(t *testing.T) {
f := newReleaseFixture(t)
original := syncReleaseDirectory
t.Cleanup(func() { syncReleaseDirectory = original })
syncReleaseDirectory = func(string) error { return errors.New("simulated directory sync failure") }
err := InstallPair(f.main, f.admin, f.target, f.pair)
if !errors.Is(err, ErrUpgradeRollbackFailed) || errors.Is(err, ErrUpgradeRollback) {
t.Fatal("回滚持久化失败没有报告恢复未完成")
}
assertOldRelease(t, f)
}
func TestInstallPairRestoresAfterCommitSyncFailure(t *testing.T) {
f := newReleaseFixture(t)
original := syncReleaseDirectory
t.Cleanup(func() { syncReleaseDirectory = original })
calls := 0
syncReleaseDirectory = func(path string) error {
calls++
if calls == 1 {
return errors.New("simulated commit sync failure")
}
return original(path)
}
err := InstallPair(f.main, f.admin, f.target, f.pair)
if !errors.Is(err, ErrUpgradeRollback) || errors.Is(err, ErrUpgradeRollbackFailed) {
t.Fatal("持久化失败后的完整回滚状态错误")
}
assertOldRelease(t, f)
}