package handler import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "cyberstrike-ai/internal/config" "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) } } func TestUpdateSourceSaveAndRestartRespectInstallLock(t *testing.T) { root := t.TempDir() saved := false router, _ := newUpdateRouterWithSource(root, func() {}, nil, func(string) error { saved = true; return nil }) release, err := update.LockInstall(update.Options{Root: root}) if err != nil { t.Fatal(err) } defer release() for _, endpoint := range []string{"source", "restart"} { w := doUpdate(router, http.MethodPost, "/api/system/update/"+endpoint, `{"repo":""}`) if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "update_busy") { t.Fatalf("%s did not respect CLI lock: %d %s", endpoint, w.Code, w.Body) } } if saved { t.Fatal("source changed while another operation owned the installation") } } func TestUpdateRestartRefusesMissingExecutable(t *testing.T) { root := t.TempDir() writeFakeBinary(t, root, "old") router, _ := newUpdateRouter(root, func() { t.Error("missing binary must not cause shutdown") }) if err := os.Remove(filepath.Join(root, update.Binary)); err != nil { t.Fatal(err) } w := doUpdate(router, http.MethodPost, "/api/system/update/restart", "") if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "no_binary") { t.Fatalf("restart accepted a missing executable: %d %s", w.Code, w.Body) } } func TestFailedUpdateSourceWriteKeepsActiveSource(t *testing.T) { const original = "https://github.com/AIPentest/CyberStrikeAI.git" h := &ConfigHandler{configPath: filepath.Join(t.TempDir(), "missing.yaml"), config: &config.Config{Update: config.UpdateConfig{Repo: original}}} if err := h.SetUpdateSource("https://example.com/another.git"); err == nil { t.Fatal("missing config must refuse the write") } if got := h.UpdateSource(); got != original { t.Fatalf("failed save changed live update source to %q", got) } }