Files
CyberStrikeAI/internal/update/adopt_test.go
T
Sycun 94a18263b9 feat: make the update source one repository address, the official repo by default
The update source used to be three things - a remote name or an address, plus an
optional branch - and unset it followed whatever this directory's remotes and
upstream happened to say. That asked people to understand git topology before
they could update, and the person it was built for could not tell what "follow
the remote this tree tracks" meant. It is now one field: config.yaml update.repo,
an address, empty meaning the official repository
(https://github.com/AIPentest/CyberStrikeAI.git). Point it at a mirror (GitHub is
hard to reach in some places) or at your own fork and that is the whole
configuration.

The branch nothing is any more: an update follows the source repository's own
default branch, resolved from the source itself (ls-remote --symref HEAD), so a
fork whose default is not "main" still works and nobody has to name a branch.
Remote names, the mine/origin/upstream ranking, @{upstream} tracking and the
adopt flow's branch argument are all gone with it.

The console page loses two input fields: the source section is one address, a
hint that empty means the official repository, and a save button; clearing the
address is the way back to the default. The install facts drop the remote name,
the check section shows the source's default branch once the network answer
arrives, and the adopt entry no longer requires saving an address first - the
official default makes connecting a tarball install a zero-configuration action.

The CLI now reads the same update.repo field the page saves (LoadUpdateRepo reads
just that field, without validating the rest of the file - a broken unrelated
section must not block the path that fixes the tree), so the keyboard and the
button cannot update from different repositories. -check-update prints the source
it is about to use.

Tests: the source suite now pins the default (official), the single-address save
(including the empty-string reset), and that the followed branch comes from the
source's own default branch rather than a hardcoded "main"; the console suite
posts the one field and renders the default hint; docs, config.example.yaml and
upgrade.sh describe the one-field story.
2026-10-08 13:46:14 +08:00

165 lines
6.5 KiB
Go

package update
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// newAdoptFixture builds the shape a tarball installation has: the upstream project's files
// on disk with the repository removed, plus operator content and one locally edited
// product file that the adoption is expected to name (and keep a copy of).
func newAdoptFixture(t *testing.T) (*tree, string) {
t.Helper()
tr := newTree(t)
dir := filepath.Join(filepath.Dir(tr.upstream), "release")
mustGit(t, filepath.Dir(tr.upstream), "clone", "-q", tr.upstream, dir)
if err := os.RemoveAll(filepath.Join(dir, ".git")); err != nil {
t.Fatal(err)
}
// Operator content: the live config (never in the repository), their own role, and an
// edit to a shipped role.
writeFile(t, filepath.Join(dir, "config.yaml"), "server:\n port: 8088\n")
writeFile(t, filepath.Join(dir, "roles", "mine.yaml"), "name: mine\n")
writeFile(t, filepath.Join(dir, "roles", "shipped.yaml"), "name: shipped\ndescription: mine, do not touch\n")
// A locally edited product file.
writeFile(t, filepath.Join(dir, "run.sh"), "#!/bin/sh\necho hacked\n")
return tr, dir
}
func TestPreviewNamesWhatAdoptionWouldReplaceWithoutTouchingTheDirectory(t *testing.T) {
requireGit(t)
tr, dir := newAdoptFixture(t)
plan, err := Preview(context.Background(), Options{Root: dir, Repo: tr.upstream})
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, ".git")); !os.IsNotExist(err) {
t.Fatal("a preview must not create a repository")
}
if plan.Incoming < 3 {
t.Fatalf("incoming = %d, want at least the fixture's files", plan.Incoming)
}
if !contains(plan.Overwritten, "run.sh") {
t.Errorf("the edited product file must be named: %v", plan.Overwritten)
}
if contains(plan.Overwritten, "roles/shipped.yaml") {
t.Errorf("operator content must not be listed as replaced: %v", plan.Overwritten)
}
if !contains(plan.Protected, "roles/shipped.yaml") {
t.Errorf("the edited shipped role must be listed as protected: %v", plan.Protected)
}
if got := readFile(t, filepath.Join(dir, "run.sh")); !strings.Contains(got, "hacked") {
t.Errorf("preview must not change file contents: %q", got)
}
}
func TestAdoptConnectsTheDirectoryAndKeepsOperatorContent(t *testing.T) {
requireGit(t)
tr, dir := newAdoptFixture(t)
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)
}
if !res.Adopted || res.ToCommit == "" {
t.Fatalf("res = %+v, want an adopted result", res)
}
// The directory is now a work tree tracking the chosen source.
if got := mustGit(t, dir, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" {
t.Errorf("HEAD branch = %q, want main", got)
}
if got := mustGit(t, dir, "rev-parse", "--abbrev-ref", "@{upstream}"); got != "origin/main" {
t.Errorf("upstream = %q, want origin/main - from here the tree tracks its source by itself", got)
}
// The target's content replaced the local edit; the local edit survives in the backup.
if got := readFile(t, filepath.Join(dir, "run.sh")); !strings.Contains(got, "echo old") {
t.Errorf("run.sh = %q, want the target's version", got)
}
if !contains(res.Overwritten, "run.sh") {
t.Errorf("result must name the replaced file: %v", res.Overwritten)
}
if got := readFile(t, filepath.Join(res.BackupDir, "overwritten", "run.sh")); !strings.Contains(got, "hacked") {
t.Errorf("the replaced file must be kept for recovery, got %q", got)
}
// Operator content survived: the live config untouched, their own role untouched, their
// edit to a shipped role restored over the target's version.
if got := readFile(t, filepath.Join(dir, "config.yaml")); !strings.Contains(got, "8088") {
t.Errorf("config.yaml was disturbed: %q", got)
}
if got := readFile(t, filepath.Join(dir, "roles", "mine.yaml")); !strings.Contains(got, "name: mine") {
t.Errorf("the operator's own role must survive: %q", got)
}
if got := readFile(t, filepath.Join(dir, "roles", "shipped.yaml")); !strings.Contains(got, "do not touch") {
t.Errorf("the operator's edit to a shipped role must win: %q", got)
}
if !contains(res.KeptContent, "roles/shipped.yaml") {
t.Errorf("keptContent = %v, want roles/shipped.yaml", res.KeptContent)
}
// From now on it is a normal installation: the next upstream commit updates it, and the
// first real update writes the first rollback point.
tr.upstreamCommit(t, "second: bump service", map[string]string{
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
})
snap, err := Check(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"})
if err != nil {
t.Fatal(err)
}
if snap.CheckError != "" || snap.Behind != 1 {
t.Fatalf("the adopted tree must update like any other: checkError=%q behind=%d", snap.CheckError, snap.Behind)
}
res2, err := Apply(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil)
if err != nil {
t.Fatalf("apply after adopt failed: %v", err)
}
if res2.ToCommit == "" || res2.Adopted {
t.Fatalf("res2 = %+v", res2)
}
if _, ok := readState(dir); !ok {
t.Error("the first real update must write a rollback point")
}
if got := readFile(t, filepath.Join(dir, "internal_service.go")); !strings.Contains(got, "Version = \"2\"") {
t.Errorf("code after the first update = %q", got)
}
}
func TestAdoptRefusesWhatItCannotHonestlyDo(t *testing.T) {
requireGit(t)
tr := newTree(t)
// Already a work tree: that is an update, not an adoption.
if _, err := Adopt(context.Background(), Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"}, nil); asReason(err) != "already_a_repo" {
t.Errorf("err = %v, want already_a_repo", err)
}
// Nothing to adopt onto: the directory has to exist.
plain := t.TempDir()
missing := filepath.Join(plain, "not-a-directory")
if _, err := Adopt(context.Background(), Options{Root: missing}, nil); asReason(err) != "bad_root" {
t.Errorf("err = %v, want bad_root", err)
}
// An address git must never be handed. (An unset address is no longer an error: the
// official repository is the default update source.)
if _, err := Adopt(context.Background(), Options{Root: plain, Repo: "ext::sh -c true", BinaryName: "none"}, nil); asReason(err) != "bad_source" {
t.Errorf("err = %v, want bad_source", err)
}
}
func contains(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
func asReason(err error) string {
if ue, ok := err.(*Error); ok {
return ue.Reason
}
return ""
}