Files
CyberStrikeAI/internal/update/safety_test.go
T

331 lines
11 KiB
Go

package update
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestApplyRefusesRepositorySubdirectory(t *testing.T) {
requireGit(t)
tr := newTree(t)
sub := filepath.Join(tr.install, "settings")
if err := os.Mkdir(sub, 0755); err != nil {
t.Fatal(err)
}
head := mustGit(t, tr.install, "rev-parse", "HEAD")
tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"})
_, err := Apply(context.Background(), Options{Root: sub, Repo: tr.upstream, BinaryName: "none"}, nil)
if err == nil {
t.Fatal("updating a repository subdirectory must be refused")
}
if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head {
t.Fatal("refusal changed parent repository")
}
}
func TestInstallLockReleasedWhenProcessIsKilled(t *testing.T) {
if root := os.Getenv("CSAI_TEST_LOCK_ROOT"); root != "" {
release, err := LockInstall(Options{Root: root})
if err != nil {
t.Fatal(err)
}
defer release()
fmt.Println("locked")
time.Sleep(time.Hour)
return
}
root := t.TempDir()
bin, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-test.run=^TestInstallLockReleasedWhenProcessIsKilled$")
cmd.Env = append(os.Environ(), "CSAI_TEST_LOCK_ROOT="+root)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer cmd.Process.Kill()
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || line != "locked\n" {
t.Fatalf("child did not acquire lock: %q %v", line, err)
}
if release, err := LockInstall(Options{Root: root}); err == nil {
release()
t.Fatal("parent acquired child's lock")
}
if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
_ = cmd.Wait()
release, err := LockInstall(Options{Root: root})
if err != nil {
t.Fatalf("crashed process left a stale lock: %v", err)
}
release()
}
func TestFirstBuildDoesNotUseAnUnrelatedPreviousBinary(t *testing.T) {
requireGit(t)
tr := newTree(t)
scriptFakeGo(t)
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin+".prev", "unrelated old binary")
tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"})
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
t.Fatal(err)
}
head := mustGit(t, tr.install, "rev-parse", "HEAD")
if _, err := Rollback(context.Background(), buildOpts(tr)); err == nil {
t.Fatal("first build must not roll back to an unrelated .prev")
}
if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head {
t.Fatal("refusal reset source")
}
}
func TestInterruptedAdoptionCanResumeFromUnbornHead(t *testing.T) {
requireGit(t)
tr, dir := newAdoptFixture(t)
scriptFakeGo(t)
role := filepath.Join(dir, "roles", "shipped.yaml")
original := readFile(t, role)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "remote", "add", "origin", tr.upstream)
branch, _, _, err := fetchSource(context.Background(), dir, tr.upstream)
if err != nil {
t.Fatal(err)
}
scan, err := scanAdoption(context.Background(), dir, dir, "origin/"+branch)
if err != nil {
t.Fatal(err)
}
backup := filepath.Join(dir, ".update-backup", "interrupted")
for _, rel := range append(scan.changed, scan.kept...) {
dst := filepath.Join(backup, "overwritten", rel)
if Protected(rel) {
dst = filepath.Join(backup, rel)
}
if err := copyFile(filepath.Join(dir, rel), dst, 0644); err != nil {
t.Fatal(err)
}
}
target := mustGit(t, dir, "rev-parse", "origin/"+branch)
if err := writeBuildPending(dir, buildPendingState{Commit: target, AdoptBranch: branch, BackupDir: backup, KeptContent: scan.kept, OverwrittenContent: scan.changed, PreviousBinaryAbsent: true}); err != nil {
t.Fatal(err)
}
if _, err := Apply(context.Background(), Options{Root: dir, Repo: tr.upstream}, nil); err != nil {
t.Fatalf("resume: %v", err)
}
if got := readFile(t, role); got != original {
t.Fatalf("resume lost role: %q", got)
}
if buildPending(dir) {
t.Fatal("resume left a pending marker")
}
}
func TestInterruptedRollbackResumesAfterSourceReset(t *testing.T) {
for _, swapped := range []bool{false, true} {
t.Run(fmt.Sprint(swapped), func(t *testing.T) {
requireGit(t)
tr := newTree(t)
scriptFakeGo(t)
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "original binary")
tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"})
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
t.Fatal(err)
}
st, _ := readState(tr.install)
role := filepath.Join(tr.install, "roles", "shipped.yaml")
writeFile(t, role, "name: operator edit after update\n")
changes, _, _ := localChanges(context.Background(), tr.install)
backup, kept, deleted, err := stashProtectedEdits(context.Background(), tr.install, st.PreviousCommit, changes)
if err != nil {
t.Fatal(err)
}
liveHash, _ := fileSHA256(bin)
previousHash, _ := fileSHA256(bin + ".prev")
pending := rollbackPendingState{State: st, BackupDir: backup, Kept: kept, Deleted: deleted, RestoreBinary: true, LiveSHA256: liveHash, PreviousSHA256: previousHash}
if err := writeRollbackPending(tr.install, pending); err != nil {
t.Fatal(err)
}
mustGit(t, tr.install, "reset", "--hard", st.PreviousCommit)
if swapped {
if err := restorePrevBinary(bin); err != nil {
t.Fatal(err)
}
}
if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil {
t.Fatal("update must refuse an interrupted rollback")
}
if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil {
t.Fatalf("resume rollback: %v", err)
}
if got := readFile(t, bin); got != "original binary" {
t.Fatalf("wrong binary after resume: %q", got)
}
if got := readFile(t, role); got != "name: operator edit after update\n" {
t.Fatalf("role lost after resume: %q", got)
}
if pending, err := readRollbackPending(tr.install); err != nil || pending != nil {
t.Fatal("rollback journal not cleared")
}
})
}
}
func TestRollbackRemovesVersionInsertedIntoUnversionedConfig(t *testing.T) {
requireGit(t)
tr := newTree(t)
scriptFakeGo(t)
writeFile(t, filepath.Join(tr.install, Binary), "old binary")
before := readFile(t, filepath.Join(tr.install, "config.yaml"))
tr.upstreamCommit(t, "release", map[string]string{"config.example.yaml": "version: v2.0.0\n"})
if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil {
t.Fatal(err)
}
if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil {
t.Fatal(err)
}
if got := readFile(t, filepath.Join(tr.install, "config.yaml")); got != before {
t.Fatalf("rollback did not restore an unversioned config: %q", got)
}
}
func TestApplyRefusesIgnoredSourceCollision(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, ".git", "info", "exclude"), "local.go\n")
writeFile(t, filepath.Join(tr.install, "local.go"), "package operator\n")
head := mustGit(t, tr.install, "rev-parse", "HEAD")
tr.upstreamCommit(t, "add source", map[string]string{"local.go": "package upstream\n"})
if _, err := Apply(context.Background(), opts(tr), nil); err == nil {
t.Fatal("ignored source collision must be refused")
}
if got := readFile(t, filepath.Join(tr.install, "local.go")); got != "package operator\n" {
t.Fatal("ignored source was lost")
}
if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head {
t.Fatal("refusal changed HEAD")
}
}
func TestBuildRefusesSourceChangesMadeAfterPreflight(t *testing.T) {
requireGit(t)
tr := newTree(t)
scriptFakeGo(t)
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "old binary")
tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"})
_, err := Apply(context.Background(), buildOpts(tr), func(step Step) {
if step.Phase == "build" {
writeFile(t, filepath.Join(tr.install, "run.sh"), "operator edit during build\n")
}
})
if ue, ok := err.(*Error); !ok || ue.Reason != "local_source_edits" {
t.Fatalf("want refusal of changed source, got %v", err)
}
if got := readFile(t, bin); got != "old binary" {
t.Fatal("unmatched binary installed")
}
if !buildPending(tr.install) {
t.Fatal("refusal must keep the recovery marker")
}
}
func TestApplyKeepsProtectedDeletion(t *testing.T) {
requireGit(t)
tr := newTree(t)
role := filepath.Join(tr.install, "roles", "shipped.yaml")
if err := os.Remove(role); err != nil {
t.Fatal(err)
}
tr.upstreamCommit(t, "rewrite role", map[string]string{"roles/shipped.yaml": "name: new upstream\n"})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(role); !os.IsNotExist(err) {
t.Fatalf("deleted role resurrected: %v", err)
}
}
func TestAdoptRefusesExistingInvalidGitMetadata(t *testing.T) {
requireGit(t)
tr, dir := newAdoptFixture(t)
metadata := filepath.Join(dir, ".git", "operator-note")
writeFile(t, metadata, "keep this metadata")
_, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil)
if err == nil {
t.Fatal("existing .git must never be taken over")
}
if got := readFile(t, metadata); got != "keep this metadata" {
t.Fatal("existing metadata changed")
}
}
func TestAdoptionRefusesFileDirectoryConflict(t *testing.T) {
requireGit(t)
tr, dir := newAdoptFixture(t)
if err := os.Remove(filepath.Join(dir, "run.sh")); err != nil {
t.Fatal(err)
}
valuable := filepath.Join(dir, "run.sh", "local-note")
writeFile(t, valuable, "operator content")
if _, err := Preview(context.Background(), Options{Root: dir, Repo: tr.upstream}); err == nil {
t.Fatal("preview must report a directory conflict")
}
if _, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil); err == nil {
t.Fatal("adoption must refuse a directory conflict")
}
if got := readFile(t, valuable); got != "operator content" {
t.Fatal("directory content lost")
}
}
func TestApplyKeepsIgnoredConfigWhenUpstreamStartsTrackingIt(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, ".git", "info", "exclude"), "config.yaml\n")
before := readFile(t, filepath.Join(tr.install, "config.yaml"))
tr.upstreamCommit(t, "track example config", map[string]string{"config.yaml": "server:\n port: 9999\n"})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
if got := readFile(t, filepath.Join(tr.install, "config.yaml")); got != before {
t.Fatalf("ignored config overwritten: %q", got)
}
}
func TestUpdateOperationsShareInstallationLock(t *testing.T) {
requireGit(t)
tr := newTree(t)
release, err := LockInstall(opts(tr))
if err != nil {
t.Fatal(err)
}
defer release()
for _, run := range []func() error{
func() error { _, err := Check(context.Background(), opts(tr)); return err },
func() error { _, err := Apply(context.Background(), opts(tr), nil); return err },
func() error { _, err := Rollback(context.Background(), opts(tr)); return err },
func() error { _, err := Adopt(context.Background(), opts(tr), nil); return err },
} {
if ue, ok := run().(*Error); !ok || ue.Reason != "update_busy" {
t.Fatalf("concurrent operation was not refused: %v", ue)
}
}
}