Files
CyberStrikeAI/internal/handler/update_http_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

392 lines
14 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/update"
"github.com/gin-gonic/gin"
)
// writeFakeBinary puts an executable-shaped file where update.StampBinary looks, so the
// "the process is running an older build than the disk" state can be produced for real.
func writeFakeBinary(t *testing.T, root, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, update.Binary), []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
// The HTTP contract of "update my own source in one click": status without a network
// round trip, an apply that returns a job handle instead of blocking a request open for
// the length of a compile, and refusals that stay distinguishable from accidents.
func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) {
return newUpdateRouterWithSource(root, restart, nil, nil)
}
func newUpdateRouterWithSource(root string, restart func(),
source func() string, save func(string) error) (*gin.Engine, *UpdateHandler) {
gin.SetMode(gin.TestMode)
h := NewUpdateHandler(root, nil, nil, restart, source, save)
router := gin.New()
router.GET("/api/system/update", h.GetStatus)
router.GET("/api/system/update/job", h.Job)
router.POST("/api/system/update/check", h.Check)
router.POST("/api/system/update/apply", h.Apply)
router.POST("/api/system/update/restart", h.Restart)
router.POST("/api/system/update/rollback", h.Rollback)
router.POST("/api/system/update/adopt", h.Adopt)
router.POST("/api/system/update/source", h.SaveSource)
return router, h
}
func doUpdate(router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
var reader *strings.Reader
if body == "" {
reader = strings.NewReader("")
} else {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestUpdateStatusIsServedWithoutTouchingTheNetwork(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodGet, "/api/system/update", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body)
}
var body struct {
Status struct {
Root string `json:"root"`
Installed bool `json:"installed"`
CheckError string `json:"checkError"`
CanBuild bool `json:"canBuild"`
} `json:"status"`
Job *struct{ ID string } `json:"job"`
CanRestart bool `json:"canRestart"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status.Root == "" || body.Status.Installed {
t.Errorf("a plain directory must report its root and that it is not a git tree: %+v", body.Status)
}
if body.Status.CheckError == "" {
t.Error("the reason an update cannot run must be readable on the page, not just a status code")
}
if body.Job != nil {
t.Error("job must be null before anything has been applied")
}
if body.CanRestart {
t.Error("canRestart must be false when no restart hook was wired")
}
}
func TestUpdateApplyReturnsAJobAndReportsAnHonestFailure(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart":false}`)
if w.Code != http.StatusAccepted {
t.Fatalf("apply should be accepted and return a handle, got %d %s", w.Code, w.Body)
}
var started struct {
JobID string `json:"job_id"`
State string `json:"state"`
}
if err := json.Unmarshal(w.Body.Bytes(), &started); err != nil {
t.Fatal(err)
}
if started.JobID == "" {
t.Fatal("apply must return the job id the page polls")
}
// The job runs against a non-git directory, so it finishes with a refusal rather
// than a merge. What matters is that the reported failure is the real reason.
deadline := time.Now().Add(10 * time.Second)
var finished struct {
Job struct {
State string `json:"state"`
Failure struct {
Reason string `json:"reason"`
} `json:"failure"`
} `json:"job"`
}
for time.Now().Before(deadline) {
w = doUpdate(router, http.MethodGet, "/api/system/update/job", "")
if err := json.Unmarshal(w.Body.Bytes(), &finished); err != nil {
t.Fatal(err)
}
if finished.Job.State != "running" {
break
}
time.Sleep(20 * time.Millisecond)
}
if finished.Job.State != "failed" {
t.Fatalf("job state = %q, want failed for a non-git install", finished.Job.State)
}
if finished.Job.Failure.Reason != "not_a_repo" {
t.Errorf("failure reason = %q, want not_a_repo", finished.Job.Failure.Reason)
}
}
func TestUpdateApplyRefusesRestartWhenNothingCanStartTheProcessAgain(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart":true}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: restarting without a supervisor would just stop the platform", w.Code)
}
if !strings.Contains(w.Body.String(), "重启") {
t.Errorf("the refusal must say why: %s", w.Body)
}
}
func TestUpdateApplyRejectsMalformedBody(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart": "yes please"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400 for a body that is not valid JSON", w.Code, w.Body)
}
// A rejected request must not have left a job behind: the page would then poll a
// phantom update.
if job := doUpdate(router, http.MethodGet, "/api/system/update/job", ""); !strings.Contains(job.Body.String(), `"job":null`) {
t.Errorf("a 400 apply must not create a job, got %s", job.Body)
}
}
func TestUpdateRollbackWithoutARecordedUpdateIsAConflict(t *testing.T) {
root := t.TempDir()
router, _ := newUpdateRouter(root, nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/rollback", "")
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body = %s, want 409", w.Code, w.Body)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["reason"] != "no_state" {
t.Errorf("reason = %v, want no_state so the page can say there is nothing to undo", body["reason"])
}
}
func TestUpdateSourceSaveValidatesBeforeWriting(t *testing.T) {
var saved []string
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
func() string { return "" },
func(repo string) error { saved = append(saved, repo); return nil })
// git's ext:: transport runs commands; it must never reach the config.
w := doUpdate(router, http.MethodPost, "/api/system/update/source", `{"repo":"ext::sh -c true"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400 for an ext:: address", w.Code, w.Body)
}
if len(saved) != 0 {
t.Fatalf("a refused save must not reach the config layer: %v", saved)
}
// A valid save is trimmed and lands in the config layer as one address.
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"repo":" https://github.com/Sycun/CyberStrikeAI.git "}`)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s, want 200", w.Code, w.Body)
}
if len(saved) != 1 || saved[0] != "https://github.com/Sycun/CyberStrikeAI.git" {
t.Fatalf("saved = %v, want the trimmed address", saved)
}
// An empty address is the way back to the official repository, not an error.
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"repo":""}`)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s, want 200 for the reset-to-default save", w.Code, w.Body)
}
if len(saved) != 2 || saved[1] != "" {
t.Fatalf("saved = %v, want the reset to reach the config layer", saved)
}
}
func TestUpdateStatusCarriesTheConfiguredSource(t *testing.T) {
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
func() string { return "https://github.com/Sycun/CyberStrikeAI.git" }, nil)
w := doUpdate(router, http.MethodGet, "/api/system/update", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var body struct {
Source struct {
Repo string `json:"repo"`
Configured bool `json:"configured"`
DefaultRepo string `json:"defaultRepo"`
} `json:"source"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !body.Source.Configured || body.Source.Repo != "https://github.com/Sycun/CyberStrikeAI.git" {
t.Fatalf("source = %+v, want the configured address", body.Source)
}
if body.Source.DefaultRepo != update.DefaultRepoURL {
t.Fatalf("defaultRepo = %q, want the official repository the page offers as the default", body.Source.DefaultRepo)
}
}
func TestUpdateAdoptRefusesAnAddressGitMustNeverSee(t *testing.T) {
// A plain directory with an unusable source: the refusal must come back before any
// fetch, so this test never touches the network.
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
func() string { return "ext::sh -c true" }, nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/adopt", `{}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400", w.Code, w.Body)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["reason"] != "bad_source" {
t.Fatalf("reason = %v, want bad_source so the page can say what is wrong", body["reason"])
}
}
// The "restart to activate" contract: the page must be able to tell that the binary on
// disk is no longer the one answering requests, the supervision hint must come from the
// environment rather than a guess, and the stand-down must never run when it cannot
// change anything.
func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) {
root := t.TempDir()
writeFakeBinary(t, root, "the build this process is running")
router, _ := newUpdateRouter(root, func() {})
type statusBody struct {
NeedsRestart bool `json:"needsRestart"`
BinaryBuiltAt string `json:"binaryBuiltAt"`
Supervised bool `json:"supervised"`
Status struct {
HasBinary bool `json:"hasBinary"`
} `json:"status"`
}
read := func() statusBody {
w := doUpdate(router, http.MethodGet, "/api/system/update", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body)
}
var body statusBody
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body
}
if got := read(); got.NeedsRestart || got.BinaryBuiltAt != "" {
t.Fatalf("a freshly started process must call the binary it is running current: %+v", got)
}
if !read().Status.HasBinary {
t.Fatal("the fake binary has to be visible as hasBinary for this test to mean anything")
}
// A swap (update, rollback or a CLI run) replaces the file; the process is now old and
// the page has to be able to say so.
writeFakeBinary(t, root, "a different build that is now on disk")
after := read()
if !after.NeedsRestart || after.BinaryBuiltAt == "" {
t.Fatalf("replacing the binary must be reported as pending, with when it was built: %+v", after)
}
// Supervision is read from the markers launchd/systemd leave in the environment.
t.Setenv("XPC_SERVICE_NAME", "")
t.Setenv("INVOCATION_ID", "")
t.Setenv("JOURNAL_STREAM", "")
if read().Supervised {
t.Error("with no supervisor markers in the environment, supervised must be false")
}
t.Setenv("XPC_SERVICE_NAME", "com.example.thing")
if !read().Supervised {
t.Error("XPC_SERVICE_NAME is launchd's own marker; the page's default must follow it")
}
}
func TestUpdateRestartOnlyStandsDownWhenSomethingIsPending(t *testing.T) {
root := t.TempDir()
writeFakeBinary(t, root, "v1")
fired := make(chan struct{}, 1)
router, _ := newUpdateRouter(root, func() { fired <- struct{}{} })
old := updateRestartDelay
updateRestartDelay = 20 * time.Millisecond
t.Cleanup(func() { updateRestartDelay = old })
// Nothing on disk to activate: a bounce would drop the service and change nothing.
w := doUpdate(router, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body = %s, want 409 when no binary is pending", w.Code, w.Body)
}
if !strings.Contains(w.Body.String(), "nothing_pending") {
t.Errorf("the refusal must carry its reason: %s", w.Body)
}
select {
case <-fired:
t.Fatal("a refused restart must not stand the process down")
case <-time.After(80 * time.Millisecond):
}
// A new binary is on disk now; the restart is exactly what makes it real.
writeFakeBinary(t, root, "v2")
w = doUpdate(router, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d body = %s, want 202", w.Code, w.Body)
}
select {
case <-fired:
case <-time.After(2 * time.Second):
t.Fatal("the stand-down hook was never called")
}
}
func TestUpdateRestartRefusesWithoutAHookOrDuringAJob(t *testing.T) {
root := t.TempDir()
writeFakeBinary(t, root, "v1")
// No hook: standing down would just stop the platform.
router, _ := newUpdateRouter(root, nil)
writeFakeBinary(t, root, "v2")
w := doUpdate(router, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400 without a restart hook", w.Code, w.Body)
}
// A running job owns the tree; standing down halfway through a merge is how an
// installation becomes unrecoverable.
router2, h := newUpdateRouter(root, func() {})
h.mu.Lock()
h.active = "busy"
h.jobs["busy"] = &updateJob{ID: "busy", Kind: "apply", State: "running"}
h.mu.Unlock()
w = doUpdate(router2, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body = %s, want 409 while a job is running", w.Code, w.Body)
}
if !strings.Contains(w.Body.String(), "busy") {
t.Errorf("the conflict must hand back the job that owns the tree: %s", w.Body)
}
}