Files
CyberStrikeAI/internal/update/update_test.go
T
Sycun 0d931569be feat: carry the release version across an update, so the header matches the code
The repository's config.example.yaml ships the release version this code was cut
from (upstream bumps it in the release commit: v1.7.21's config.example says
v1.7.21), while the live config.yaml is operator data that no merge ever touches.
So after an update the header badge - and the ?v= on every static asset - kept
saying the version the installation was born with while the tree already ran
newer code.

An update, an adoption, or a rollback now keeps the two together: after the merge
the update reads the version the new code carries (config.example.yaml) and writes
it into the live config's version field - that one line, temp file plus atomic
rename, everything else byte-for-byte preserved, and only when the value actually
differs. The job log records the change (the page shows the step), and the state
file records both numbers so a rollback puts the old version back with the old
code; a number hand-edited after the update is the operator's and is left alone.
The side effect worth naming: the version bump also changes the ?v= query on
static assets, so a browser stops serving the pre-update JavaScript.

Trees without a live config or without a shipped version are left untouched -
a source checkout has nothing to sync, and nothing is ever guessed.

internal/config gains the two focused helpers (FileVersion / WriteVersion, line
level like the tarball upgrade path always did); tests pin the replace, the
insert-when-absent, byte preservation, the mode, the malformed-value refusal, and
the update/adopt/rollback/leave-alone behaviours end to end.
2026-10-08 13:57:11 +08:00

544 lines
21 KiB
Go

package update
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// These tests drive real git repositories in temporary directories. A fake would let the
// thing this package exists for - "git says no because your files would be overwritten" -
// pass silently.
func requireGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
}
type tree struct {
upstream string
install string
}
// newTree builds an upstream repository with one code file and one shipped role, then
// clones it the way an operator would (the clone's origin is the user's own fork, not a
// fixed third-party repository).
func newTree(t *testing.T) *tree {
t.Helper()
root := t.TempDir()
up := filepath.Join(root, "fork")
install := filepath.Join(root, "install")
mustGit(t, root, "init", "-q", "-b", "main", up)
writeFile(t, filepath.Join(up, "internal_service.go"), "package service\n\nconst Version = \"1\"\n")
writeFile(t, filepath.Join(up, "roles", "shipped.yaml"), "name: shipped\ndescription: upstream copy\n")
writeFile(t, filepath.Join(up, "run.sh"), "#!/bin/sh\necho old\n")
mustGit(t, up, "add", ".")
mustGit(t, up, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "first")
mustGit(t, root, "clone", "-q", up, install)
mustGit(t, install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "--allow-empty", "-m", "clone marker")
// The clone's own commit makes HEAD and origin/main differ only by the operator's
// history; drop it so the tree starts exactly level with its remote.
mustGit(t, install, "reset", "--hard", "--quiet", "origin/main")
// The live config file is operator data that is never in the repository.
writeFile(t, filepath.Join(install, "config.yaml"), "server:\n port: 8088\n")
return &tree{upstream: up, install: install}
}
func (t *tree) upstreamCommit(t2 *testing.T, message string, files map[string]string) {
t2.Helper()
for path, content := range files {
writeFile(t2, filepath.Join(t.upstream, path), content)
}
mustGit(t2, t.upstream, "add", ".")
mustGit(t2, t.upstream, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", message)
}
// opts points an update at the tree's own source repository: the explicit address is
// now the whole configuration, so tests name it the way an operator would.
func opts(tr *tree) Options {
return Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"}
}
func TestStatusWithoutNetworkDescribesTheTree(t *testing.T) {
requireGit(t)
tr := newTree(t)
snap, err := Status(context.Background(), opts(tr))
if err != nil {
t.Fatal(err)
}
if !snap.Installed {
t.Fatal("a clone must be reported as an installable tree")
}
if snap.Branch != "main" {
t.Errorf("local branch = %q, want main", snap.Branch)
}
if snap.Repo != tr.upstream || !snap.RepoConfigured {
t.Errorf("repo = %q configured=%v, want the configured source %q", snap.Repo, snap.RepoConfigured, tr.upstream)
}
if snap.Commit == "" {
t.Error("commit is empty")
}
// config.yaml is operator data and is untracked, so it is not a blocking change.
if len(snap.BlockingChanges) != 0 {
t.Errorf("untracked config.yaml must not block an update: %+v", snap.BlockingChanges)
}
}
func TestStatusReportsTheInstallRootInAbsoluteForm(t *testing.T) {
requireGit(t)
tr := newTree(t)
// A relative config path gives filepath.Dir as "."; the page must still be able to say
// which directory is being updated.
t.Chdir(tr.install)
snap, err := Status(context.Background(), Options{Root: ".", Repo: tr.upstream, BinaryName: "none"})
if err != nil {
t.Fatal(err)
}
if !filepath.IsAbs(snap.Root) || filepath.Base(snap.Root) != "install" {
t.Errorf("root = %q, want the absolute install directory", snap.Root)
}
if _, err := Status(context.Background(), Options{Root: " "}); err == nil {
t.Error("an empty install root must be an error, not a scan of the process CWD")
}
if _, err := Rollback(context.Background(), Options{Root: ""}); err == nil {
t.Error("rollback must refuse an empty install root for the same reason")
}
}
func TestStatusOnNonGitTreeIsAStateNotAnError(t *testing.T) {
requireGit(t)
dir := t.TempDir()
snap, err := Status(context.Background(), Options{Root: dir})
if err != nil {
t.Fatalf("a tarball install is a readable state, not a fault: %v", err)
}
if snap.Installed || snap.CheckError == "" {
t.Errorf("installed=%v checkError=%q, want false with a reason", snap.Installed, snap.CheckError)
}
if snap.Repo != DefaultRepoURL || snap.RepoConfigured {
t.Errorf("repo = %q configured=%v, want the official repository by default", snap.Repo, snap.RepoConfigured)
}
}
func TestCheckFetchesAndListsIncoming(t *testing.T) {
requireGit(t)
tr := newTree(t)
tr.upstreamCommit(t, "second: bump service", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"})
tr.upstreamCommit(t, "third: new role", map[string]string{"roles/extra.yaml": "name: extra\n"})
snap, err := Check(context.Background(), opts(tr))
if err != nil {
t.Fatal(err)
}
if snap.CheckError != "" {
t.Fatalf("check failed: %s", snap.CheckError)
}
if !snap.UpdateAvailable || snap.Behind != 2 || snap.Ahead != 0 || snap.Diverged {
t.Errorf("behind=%d ahead=%d available=%v diverged=%v, want 2/0/true/false", snap.Behind, snap.Ahead, snap.UpdateAvailable, snap.Diverged)
}
if len(snap.Incoming) != 2 || snap.Incoming[0].Subject != "third: new role" {
t.Errorf("incoming = %+v, want the two new commits newest first", snap.Incoming)
}
if snap.IncomingTotal != 2 {
t.Errorf("incomingTotal = %d, want 2", snap.IncomingTotal)
}
}
// TestApplyKeepsOperatorContentIsThePointOfTheWholePackage: an update moves code and does
// not destroy the roles, skills or tools the operator made or edited from the console.
func TestApplyKeepsOperatorContentAndMovesCode(t *testing.T) {
requireGit(t)
tr := newTree(t)
// The operator edits a shipped role and adds their own; upstream later rewrites that
// same role and takes the untracked file's name for itself.
writeFile(t, filepath.Join(tr.install, "roles", "shipped.yaml"), "name: shipped\ndescription: mine, do not touch\n")
writeFile(t, filepath.Join(tr.install, "roles", "mine.yaml"), "name: mine\n")
tr.upstreamCommit(t, "upstream rewrites roles and code", map[string]string{
"roles/shipped.yaml": "name: shipped\ndescription: upstream rewrite\n",
"roles/mine.yaml": "name: mine\ndescription: upstream stole the name\n",
"internal_service.go": "package service\n\nconst Version = \"3\"\n",
})
res, err := Apply(context.Background(), opts(tr), nil)
if err != nil {
t.Fatalf("apply failed: %v\nresult: %+v", err, res)
}
if res.Commits == 0 || res.FromCommit == res.ToCommit {
t.Errorf("nothing moved: %+v", res)
}
if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"3\"") {
t.Errorf("code was not updated, got %q", got)
}
if got := readFile(t, filepath.Join(tr.install, "roles", "shipped.yaml")); !strings.Contains(got, "mine, do not touch") {
t.Errorf("the operator's role was overwritten by upstream: %q", got)
}
if _, err := os.Stat(filepath.Join(tr.install, "roles", "mine.yaml")); err != nil {
t.Errorf("the operator's own role must survive: %v", err)
}
if got := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(got, "8088") {
t.Errorf("config.yaml was disturbed: %q", got)
}
if len(res.KeptContent) == 0 {
t.Error("keptContent must name the files that were protected, not silently drop them")
}
found := false
for _, p := range res.KeptContent {
if p == "roles/shipped.yaml" {
found = true
}
}
if !found {
t.Errorf("keptContent = %v, want roles/shipped.yaml in it", res.KeptContent)
}
if res.BinaryBuilt {
t.Error("BinaryName none must not claim a build")
}
if !res.NeedsRestart {
t.Error("an update that moved code reports that a restart is needed")
}
if _, ok := readState(tr.install); !ok {
t.Error("state file missing: rollback would be unavailable")
}
}
func TestApplyRefusesLocalSourceEdits(t *testing.T) {
requireGit(t)
tr := newTree(t)
tr.upstreamCommit(t, "new upstream", map[string]string{"internal_service.go": "package service\n\nconst Version = \"9\"\n"})
writeFile(t, filepath.Join(tr.install, "internal_service.go"), "package service\n\nconst Version = \"local hand edit\"\n")
_, err := Apply(context.Background(), opts(tr), nil)
ue, ok := err.(*Error)
if !ok {
t.Fatalf("want *Error, got %v", err)
}
if ue.Reason != "local_source_edits" {
t.Errorf("reason = %q, want local_source_edits", ue.Reason)
}
if !strings.Contains(strings.Join(ue.Items, " "), "internal_service.go") {
t.Errorf("the refusal must name the file, got %v", ue.Items)
}
if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "local hand edit") {
t.Errorf("a refused update must not touch anything, got %q", got)
}
}
func TestApplyRefusesDivergedBranch(t *testing.T) {
requireGit(t)
tr := newTree(t)
tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"9\"\n"})
writeFile(t, filepath.Join(tr.install, "my_patch.go"), "package service\n")
mustGit(t, tr.install, "add", "my_patch.go")
mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "my own commit")
_, err := Apply(context.Background(), opts(tr), nil)
ue, ok := err.(*Error)
if !ok || ue.Reason != "diverged" {
t.Fatalf("a branch with its own commits is a merge decision, not a download; got %v", err)
}
}
func TestApplyWhenAlreadyUpToDateIsANoOp(t *testing.T) {
requireGit(t)
tr := newTree(t)
res, err := Apply(context.Background(), opts(tr), nil)
if err != nil {
t.Fatalf("up to date must not be an error: %v", err)
}
if res.Commits != 0 || res.ToCommit != res.FromCommit {
t.Errorf("res = %+v, want zero movement", res)
}
if _, err := os.Stat(statePath(tr.install)); !os.IsNotExist(err) {
t.Error("a no-op update must not write a rollback state")
}
}
func TestRollbackMovesBackToTheRecordedCommit(t *testing.T) {
requireGit(t)
tr := newTree(t)
before := mustGit(t, tr.install, "rev-parse", "HEAD")
tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
// Rollback restores the binary kept before a swap; stand one up the way installBinary
// would have left it.
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "new binary bytes")
writeFile(t, bin+".prev", "old binary bytes")
res, err := Rollback(context.Background(), Options{Root: tr.install})
if err != nil {
t.Fatalf("rollback failed: %v", err)
}
if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != strings.TrimSpace(before) {
t.Errorf("HEAD after rollback = %s, want %s", got, before)
}
if got := readFile(t, bin); !strings.Contains(got, "old binary") {
t.Errorf("binary after rollback = %q, want the kept previous one", got)
}
if res.ToCommit == "" {
t.Error("rollback must report where it went")
}
if _, err := os.Stat(statePath(tr.install)); !os.IsNotExist(err) {
t.Error("a completed rollback must clear the state file")
}
}
func TestRollbackRefusesWhenHeadMoved(t *testing.T) {
requireGit(t)
tr := newTree(t)
tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "new")
writeFile(t, bin+".prev", "old")
// Somebody else moved the tree after the update.
writeFile(t, filepath.Join(tr.install, "extra.go"), "package service\n")
mustGit(t, tr.install, "add", "extra.go")
mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "later work")
_, err := Rollback(context.Background(), Options{Root: tr.install})
ue, ok := err.(*Error)
if !ok || ue.Reason != "moved_since_update" {
t.Fatalf("rollback must refuse rather than discard work done after the update, got %v", err)
}
if _, err := os.Stat(bin + ".prev"); err != nil {
t.Error("a refused rollback must leave the .prev binary where it was")
}
}
func TestProtectedCoversOperatorDataAndNotCode(t *testing.T) {
yes := []string{"roles/CTF.yaml", "skills/x/SKILL.md", "tools/semgrep.yaml", "agents/a.md", "bundles/pack/bundle.yaml",
"knowledge_base/note.md", "data/conversations.db", "log/server.log", "config.yaml", ".env", "venv/bin/python"}
no := []string{"internal/handler/plugin.go", "web/static/js/plugins.js", "cmd/server/main.go", "Makefile", "docs/zh-CN/c2.md"}
for _, p := range yes {
if !Protected(p) {
t.Errorf("%s must be protected from an update", p)
}
}
for _, p := range no {
if Protected(p) {
t.Errorf("%s is product source and must be updated", p)
}
}
}
func TestValidNameRejectsArgumentInjection(t *testing.T) {
for _, bad := range []string{"", "-C", "--upload-pack=touch /tmp/x", "a b", "a;b", "a\nb", "-x/y", "/abs/path", "../evil"} {
if ValidName(bad) {
t.Errorf("%q must be rejected before it reaches git's argv", bad)
}
}
for _, good := range []string{"origin", "mine", "main", "feature/docker", "v1.2.3"} {
if !ValidName(good) {
t.Errorf("%q is a legitimate remote or branch name", good)
}
}
}
func TestInstallBinaryKeepsPrevAndRestoresOnFailedSwap(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "cyberstrike-ai")
src := filepath.Join(dir, "new")
writeFile(t, dst, "old")
writeFile(t, src, "new")
prev, err := installBinary(src, dst)
if err != nil {
t.Fatal(err)
}
if readFile(t, dst) != "new" || readFile(t, prev) != "old" {
t.Errorf("swap result: dst=%q prev=%q", readFile(t, dst), readFile(t, prev))
}
if err := restorePrevBinary(dst); err != nil {
t.Fatal(err)
}
if readFile(t, dst) != "old" {
t.Errorf("rollback binary = %q, want old", readFile(t, dst))
}
if fileExists(dst + ".prev") {
t.Error("restoring the previous binary must consume the .prev file")
}
}
func TestTailKeepsTheEndOfABuildLog(t *testing.T) {
long := strings.Repeat("download\n", 500) + "vet: real error at the end"
got := tail(long, 200)
if !strings.Contains(got, "real error at the end") {
t.Error("tail must keep the compiler's actual error")
}
if len(got) > 260 {
t.Errorf("tail did not truncate: %d bytes", len(got))
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(data)
}
func mustGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
// The release version rides with the code: after an update the live config has to say the
// version of the code now on disk (the header badge reads it), and a rollback has to put
// the old number back next to the old code.
func TestApplySyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "release: bump version", map[string]string{
"config.example.yaml": "version: \"v1.1.0\"\nserver:\n port: 8088\n",
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
})
res, err := Apply(context.Background(), opts(tr), nil)
if err != nil {
t.Fatalf("apply failed: %v\nresult: %+v", err, res)
}
live := readFile(t, filepath.Join(tr.install, "config.yaml"))
if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") {
t.Fatalf("live config after update = %q, want the new version with everything else untouched", live)
}
st, ok := readState(tr.install)
if !ok || st.VersionBefore != "v1.0.0" || st.VersionAfter != "v1.1.0" {
t.Fatalf("state = %+v, want the version change recorded for the rollback", st)
}
// Rollback puts the number back with the code.
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "new binary bytes")
writeFile(t, bin+".prev", "old binary bytes")
if _, err := Rollback(context.Background(), Options{Root: tr.install}); err != nil {
t.Fatalf("rollback failed: %v", err)
}
live = readFile(t, filepath.Join(tr.install, "config.yaml"))
if !strings.Contains(live, `version: "v1.0.0"`) {
t.Fatalf("live config after rollback = %q, want the old version back", live)
}
}
func TestRollbackDoesNotClobberAManuallyChangedVersion(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "release: bump version", map[string]string{
"config.example.yaml": "version: \"v1.1.0\"\n",
})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
// Somebody pins their own version after the update; a rollback is not theirs to undo.
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v9.9.9-mine\"\nserver:\n port: 8088\n")
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "new")
writeFile(t, bin+".prev", "old")
if _, err := Rollback(context.Background(), Options{Root: tr.install}); err != nil {
t.Fatal(err)
}
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v9.9.9-mine") {
t.Fatalf("a hand-set version must survive a rollback, got %q", live)
}
}
func TestApplyLeavesTheVersionAloneWhenTheTreeCarriesNone(t *testing.T) {
requireGit(t)
tr := newTree(t)
// The fixture's upstream has no config.example.yaml at all: nothing to sync, and the
// live config must not be rewritten with a guess.
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "second: bump service", map[string]string{
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v1.0.0") {
t.Fatalf("live config = %q, want it untouched", live)
}
if st, _ := readState(tr.install); st.VersionAfter != "" || st.VersionBefore != "" {
t.Fatalf("state = %+v, want no version fields when nothing changed", st)
}
}
func TestAdoptSyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) {
requireGit(t)
tr, dir := newAdoptFixture(t)
writeFile(t, filepath.Join(dir, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "release: bump version", map[string]string{
"config.example.yaml": "version: \"v1.1.0\"\n",
})
res, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil)
if err != nil {
t.Fatalf("adopt failed: %v\nresult: %+v", err, res)
}
live := readFile(t, filepath.Join(dir, "config.yaml"))
if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") {
t.Fatalf("live config after adopt = %q, want the connected code's version with the rest untouched", live)
}
}
// A tree that is already current still gets its bookkeeping aligned: the version field is
// the one thing an update may correct with no commits to move (that is exactly the state a
// pre-feature installation is in).
func TestApplyAlignsTheVersionEvenWithNothingToPull(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n")
writeFile(t, filepath.Join(tr.install, "config.example.yaml"), "version: \"v1.1.0\"\n")
res, err := Apply(context.Background(), opts(tr), nil)
if err != nil {
t.Fatalf("no-op apply failed: %v", err)
}
if res.Commits != 0 {
t.Fatalf("res = %+v, want zero movement", res)
}
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, `version: "v1.1.0"`) {
t.Fatalf("live config = %q, want the version aligned even though nothing moved", live)
}
// Bookkeeping only: a version-only correction writes no rollback state.
if _, ok := readState(tr.install); ok {
t.Fatal("a version-only alignment must not claim to be a rollback point")
}
}