mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 14:07:36 +02:00
config.yaml gains an optional update section (remote / remote_url / branch). The resolver keeps its default - whatever remote this tree already tracks - and the explicit choice wins when set, which is also what lets a tarball installation that never saw git get on the update rails at all. - RemoteURL is fetched straight into a ref namespace of its own, so a URL source cannot be shadowed by a same-named remote's stale refs. ValidRemoteURL allowlists https/http/ssh/git/file and local absolute paths, and rejects git's ext:: transport (which executes commands) before anything reaches argv. - Adopt connects a plain directory to the source. Preview is side-effect free (the fetch happens in a throwaway bare repository) and lists every local file whose content the target would replace, plus the operator content that is put aside. The confirmed run inits, adds origin, stashes operator content, keeps every replaced file under .update-backup/<ts>/overwritten/ so "the update ate my edit" has an address, lands the target content, sets upstream, rebuilds the binary. A failure removes only the .git it created itself; once the content has landed the tree stays adopted even if the build fails. - Console/API: the status envelope carries the configured source; POST /system/update/source writes it through the same config path every other setting uses (validated, audited); POST /system/update/adopt is preview-then-job, with the job's kind recorded. OpenAPI updated; RBAC rides the existing update:apply prefix and the route inventory stays green. Tests: URL-mode check/apply against real repositories (including the address beating a remote name), URL validation table, and the full adoption story - preview touches nothing, operator content survives, the replaced edit is in the backup, and the adopted tree updates and rolls back like any other installation.
247 lines
8.7 KiB
Go
247 lines
8.7 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// 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, string, string), save func(string, string, 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/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, string, string) { return "", "", "" },
|
|
func(r, u, b string) error { saved = append(saved, r+"|"+u+"|"+b); return nil })
|
|
|
|
// A name and an address are two ways to say the same thing; guessing which one the
|
|
// operator meant is worse than asking again.
|
|
w := doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remote":"origin","remoteUrl":"https://github.com/x/y.git"}`)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d body = %s, want 400 for remote+remoteUrl together", w.Code, w.Body)
|
|
}
|
|
// git's ext:: transport runs commands; it must never reach the config.
|
|
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":"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 three values.
|
|
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":" https://github.com/Sycun/CyberStrikeAI.git ","branch":"main"}`)
|
|
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|main" {
|
|
t.Fatalf("saved = %v, want the trimmed address and branch", saved)
|
|
}
|
|
}
|
|
|
|
func TestUpdateStatusCarriesTheConfiguredSource(t *testing.T) {
|
|
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
|
|
func() (string, string, string) { return "", "https://github.com/Sycun/CyberStrikeAI.git", "main" }, 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 {
|
|
Remote string `json:"remote"`
|
|
RemoteURL string `json:"remoteUrl"`
|
|
Branch string `json:"branch"`
|
|
Configured bool `json:"configured"`
|
|
} `json:"source"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !body.Source.Configured || body.Source.RemoteURL != "https://github.com/Sycun/CyberStrikeAI.git" || body.Source.Branch != "main" {
|
|
t.Fatalf("source = %+v, want the configured address and branch", body.Source)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAdoptPreviewRefusesWithoutASource(t *testing.T) {
|
|
router, _ := newUpdateRouter(t.TempDir(), 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"] != "no_source" {
|
|
t.Fatalf("reason = %v, want no_source so the page can say what is missing", body["reason"])
|
|
}
|
|
}
|