package test import ( "RenewCMS/api" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/update" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/storage" infraUpdate "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "sync" "testing" "time" "github.com/go-chi/jwtauth/v5" ) const restartPath = "/update/restart" type restartHTTP struct { router http.Handler token *jwtauth.JWTAuth dataDir string markerPath string resultPath string store *fakeUpdateStore jobs *fakeJobStore helper *fakeHelperDetector clock *fakeClock markers update.RestartRequestStore resultFiles update.ResultStore } func newRestartHTTP(t *testing.T) *restartHTTP { t.Helper() validator.InitValidator() dataDir := t.TempDir() h := &restartHTTP{ token: jwtauth.New("HS256", []byte("test-secret"), nil), dataDir: dataDir, markerPath: filepath.Join(dataDir, update.RestartRequestFileName), resultPath: filepath.Join(dataDir, update.ResultFileName), store: &fakeUpdateStore{result: newStoredUpdateResult(), found: true}, jobs: &fakeJobStore{}, helper: &fakeHelperDetector{installed: true}, clock: &fakeClock{now: restartBaseTime}, } h.markers = infraUpdate.NewJSONRestartRequestStore(h.markerPath) h.resultFiles = infraUpdate.NewJSONResultStore(h.resultPath) container := &api.UseCases{ Token: h.token, GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase( h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now), RequestRestartUseCase: useCases.NewRequestRestartUseCase( h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now, discardRecorder), } h.router = api.NewSystemRouter(container, nil) return h } func (h *restartHTTP) readyJob() { h.jobs.put(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: restartBaseTime.Add(-time.Hour)}) } func (h *restartHTTP) post(t *testing.T, userId uint32, tokenVersion uint32, body string) *httptest.ResponseRecorder { t.Helper() return h.request(t, http.MethodPost, restartPath, userId, tokenVersion, body) } func (h *restartHTTP) postAsOwner(t *testing.T, body string) *httptest.ResponseRecorder { t.Helper() return h.post(t, updateOwnerId, 2, body) } func (h *restartHTTP) request(t *testing.T, method string, target string, userId uint32, tokenVersion uint32, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, strings.NewReader(body)) if userId != 0 { _, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) if err != nil { t.Fatal(err) } req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) } rec := httptest.NewRecorder() h.router.ServeHTTP(rec, req) return rec } func (h *restartHTTP) getAs(t *testing.T, userId uint32) *httptest.ResponseRecorder { t.Helper() tokenVersion := uint32(0) if userId == updateOwnerId { tokenVersion = 2 } return h.request(t, http.MethodGet, "/update", userId, tokenVersion, "") } func (h *restartHTTP) markerBytes() ([]byte, bool) { content, err := os.ReadFile(h.markerPath) return content, err == nil } func (h *restartHTTP) seedResult(t *testing.T, result update.Result) { t.Helper() if err := h.resultFiles.Write(result); err != nil { t.Fatal(err) } } func (h *restartHTTP) assertNoMarkerAndResultKept(t *testing.T, resultKept bool) { t.Helper() if _, found := h.markerBytes(); found { t.Error("no marker may be written") } if _, found := h.resultFiles.Read(); found != resultKept { t.Errorf("result present = %v, want %v", found, resultKept) } } func TestRestartHTTP(t *testing.T) { t.Run("Request an update restart, happy path", func(t *testing.T) { for name, body := range map[string]string{"no body": "", "kind update": `{"kind":"update"}`} { t.Run(name, func(t *testing.T) { h := newRestartHTTP(t) h.readyJob() h.seedResult(t, update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate, Tag: downloadTag, StartedAt: restartBaseTime.Add(-time.Hour), FinishedAt: finishedAt(restartBaseTime.Add(-time.Hour))}) rec := h.postAsOwner(t, body) if rec.Code != http.StatusAccepted { t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) } status := decodeUpdateBody(t, rec) assertUpdateKeys(t, status) if status["state"] != "restarting" || status["started_at"] != "2026-09-30T09:50:00Z" || status["error"] != nil || status["finished_at"] != nil { t.Errorf("status = %v", status) } marker, err := h.markers.Read() if err != nil || marker.Kind != "update" || marker.Tag != downloadTag || marker.RequestedBy != updateOwnerId || !marker.RequestedAt.Equal(restartBaseTime) { t.Errorf("marker = %+v, err = %v", marker, err) } if _, found := h.resultFiles.Read(); found { t.Error("update-result.json must be deleted first") } entries, _ := os.ReadDir(h.dataDir) if len(entries) != 1 { t.Errorf("only the marker may exist in the data directory, got %d entries", len(entries)) } asAdmin := decodeUpdateBody(t, h.getAs(t, updateAdminId)) if asAdmin["state"] != "restarting" || asAdmin["can_apply"] != false { t.Errorf("admin view = %v", asAdmin) } }) } }) t.Run("Request a plain restart", func(t *testing.T) { for name, body := range map[string]string{"no body": "", "kind restart": `{"kind":"restart"}`, "empty object": `{}`} { t.Run(name, func(t *testing.T) { h := newRestartHTTP(t) rec := h.postAsOwner(t, body) marker, err := h.markers.Read() if rec.Code != http.StatusAccepted || err != nil || marker.Kind != "restart" || marker.Tag != "" { t.Fatalf("status = %d, marker = %+v, err = %v", rec.Code, marker, err) } if decodeUpdateBody(t, h.getAs(t, updateOwnerId))["state"] != "restarting" { t.Error("GET must report restarting") } }) } }) t.Run("The kind must match the state", func(t *testing.T) { cases := []struct { name string prepare func(*restartHTTP) kind string }{ {"no job ready (idle) with update", func(*restartHTTP) {}, "update"}, {"a job failed with update", func(h *restartHTTP) { h.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed, Error: "download"}) }, "update"}, {"a job ready with restart", func(h *restartHTTP) { h.readyJob() }, "restart"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newRestartHTTP(t) h.seedResult(t, update.Result{Outcome: update.OutcomeFailed, StartedAt: restartBaseTime, FinishedAt: finishedAt(restartBaseTime)}) tc.prepare(h) jobBefore, _ := h.jobs.Load() rec := h.postAsOwner(t, `{"kind":"`+tc.kind+`"}`) jobAfter, _ := h.jobs.Load() if rec.Code != http.StatusConflict || jobAfter != jobBefore { t.Errorf("status = %d", rec.Code) } h.assertNoMarkerAndResultKept(t, true) }) } }) t.Run("Restart site never installs an update another owner just made ready", func(t *testing.T) { h := newRestartHTTP(t) h.readyJob() rec := h.postAsOwner(t, `{"kind":"restart"}`) if rec.Code != http.StatusConflict { t.Fatalf("status = %d", rec.Code) } h.assertNoMarkerAndResultKept(t, false) if job, found := h.jobs.Load(); !found || job.State != update.StateReady { t.Error("the ready job must stay ready") } if decodeUpdateBody(t, h.getAs(t, updateOwnerId))["state"] != "ready" { t.Error("the card must read Ready to install after the reload") } }) t.Run("A bad body is a 400", func(t *testing.T) { for _, body := range []string{`{"kind":"upgrade"}`, `{"kind":""}`, `{"kind":7}`, `{"kind":"update"`, `not json`} { t.Run(body, func(t *testing.T) { h := newRestartHTTP(t) h.readyJob() h.seedResult(t, update.Result{Outcome: update.OutcomeFailed}) rec := h.postAsOwner(t, body) if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.BodyErrorMessage+"\n" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } h.assertNoMarkerAndResultKept(t, true) }) } }) t.Run("POST is guarded like the other system routes", func(t *testing.T) { cases := []struct { name string userId uint32 tokenVersion uint32 status int body string }{ {"no jwt cookie", 0, 0, http.StatusUnauthorized, ""}, {"a stale token version", updateOwnerId, 1, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage + "\n"}, {"an unverified owner", updateUnverifiedOwner, 0, http.StatusForbidden, apperr.VerificationRequiredErrorMessage + "\n"}, {"an admin", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"}, {"an admin with a bad body", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newRestartHTTP(t) h.seedResult(t, update.Result{Outcome: update.OutcomeFailed}) body := "" if strings.Contains(tc.name, "bad body") { body = "not json" } rec := h.post(t, tc.userId, tc.tokenVersion, body) if rec.Code != tc.status || (tc.body != "" && rec.Body.String() != tc.body) { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } h.assertNoMarkerAndResultKept(t, true) }) } }) t.Run("The checks run in the documented order", func(t *testing.T) { h := newRestartHTTP(t) h.helper.installed = false if rec := h.postAsOwner(t, "not json"); rec.Code != http.StatusBadRequest { t.Errorf("a bad body with can_apply off = %d, want 400", rec.Code) } if rec := h.postAsOwner(t, `{"kind":"update"}`); rec.Code != http.StatusConflict { t.Errorf("can_apply off with a mismatching kind = %d, want 409", rec.Code) } }) t.Run("Restart is refused with 409", func(t *testing.T) { cases := []struct { name string prepare func(*restartHTTP, *testing.T) }{ {"can_apply is false (no update-helper.json)", func(h *restartHTTP, _ *testing.T) { h.helper.installed = false }}, {"the job state is downloading", func(h *restartHTTP, _ *testing.T) { h.jobs.put(update.Job{Tag: downloadTag, State: update.StateDownloading}) }}, {"the job state is verifying", func(h *restartHTTP, _ *testing.T) { h.jobs.put(update.Job{Tag: downloadTag, State: update.StateVerifying}) }}, {"a marker is already pending", func(h *restartHTTP, t *testing.T) { if err := h.markers.Write(update.RestartRequest{Kind: "restart", RequestedAt: restartBaseTime, RequestedBy: 9}); err != nil { t.Fatal(err) } }}, {"the result file reads running (helper at work)", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: update.OutcomeRunning, Kind: "update", StartedAt: restartBaseTime.Add(-30 * time.Second)}) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newRestartHTTP(t) tc.prepare(h, t) markerBefore, markerExisted := h.markerBytes() resultBefore, resultExisted := os.ReadFile(h.resultPath) rec := h.postAsOwner(t, "") markerAfter, markerExistsNow := h.markerBytes() resultAfter, resultExistsNow := os.ReadFile(h.resultPath) if rec.Code != http.StatusConflict { t.Fatalf("status = %d", rec.Code) } if markerExisted != markerExistsNow || string(markerBefore) != string(markerAfter) || (resultExisted == nil) != (resultExistsNow == nil) || string(resultBefore) != string(resultAfter) { t.Error("the existing marker and result must be untouched and no new marker written") } }) } }) t.Run("Two restarts at once write one marker", func(t *testing.T) { h := newRestartHTTP(t) var wait sync.WaitGroup codes := make([]int, 2) for i, userId := range []uint32{updateOwnerId, updateSecondOwner} { wait.Add(1) go func() { defer wait.Done() tokenVersion := uint32(0) if userId == updateOwnerId { tokenVersion = 2 } codes[i] = h.post(t, userId, tokenVersion, "").Code }() } wait.Wait() accepted, refused := 0, 0 for _, code := range codes { switch code { case http.StatusAccepted: accepted++ case http.StatusConflict: refused++ } } entries, _ := os.ReadDir(h.dataDir) if accepted != 1 || refused != 1 || len(entries) != 1 { t.Errorf("codes = %v, entries = %d", codes, len(entries)) } }) t.Run("The marker cannot be written", func(t *testing.T) { assertFailure := func(t *testing.T, h *restartHTTP) { t.Helper() jobBefore, _ := h.jobs.Load() rec := h.postAsOwner(t, "") jobAfter, _ := h.jobs.Load() if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), h.dataDir) || jobAfter != jobBefore { t.Error("no path may leak and the job record must not change") } } t.Run("a directory sits where the marker goes", func(t *testing.T) { h := newRestartHTTP(t) h.readyJob() if err := os.Mkdir(h.markerPath, 0o755); err != nil { t.Fatal(err) } assertFailure(t, h) if info, err := os.Stat(h.markerPath); err != nil || !info.IsDir() { t.Error("the directory must stay") } }) t.Run("the data directory is not writable", func(t *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { t.Skip("directory permissions are not enforced here") } h := newRestartHTTP(t) if err := os.Chmod(h.dataDir, 0o500); err != nil { t.Fatal(err) } defer os.Chmod(h.dataDir, 0o700) assertFailure(t, h) if _, found := h.markerBytes(); found { t.Error("no marker may exist") } }) }) t.Run("GET derives the state from the files and never changes them", func(t *testing.T) { finished := restartBaseTime.Add(-10 * time.Second) cases := []struct { name string files func(*restartHTTP, *testing.T) state string errKind any finished any }{ {"a marker is present", func(h *restartHTTP, t *testing.T) { _ = h.markers.Write(update.RestartRequest{Kind: "restart", RequestedAt: restartBaseTime.Add(-time.Second)}) }, "restarting", nil, nil}, {"result running written 30 s ago", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: "running", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-30 * time.Second)}) }, "restarting", nil, nil}, {"result running written 6 minutes ago, no marker", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: "running", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-6 * time.Minute), FinishedAt: &finished}) }, "failed", "install", "2026-09-30T09:49:50Z"}, {"result succeeded, kind update, tag equal to the running version", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "update", Tag: updateRunning, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished}) }, "succeeded", nil, "2026-09-30T09:49:50Z"}, {"result succeeded, kind update, tag older than the running one", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "update", Tag: "v2.1.0-beta", StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished}) }, "idle", nil, "2026-09-30T09:49:50Z"}, {"result succeeded, kind restart", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "restart", StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished}) }, "idle", nil, "2026-09-30T09:49:50Z"}, {"result rolled_back", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: "rolled_back", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished}) }, "failed", "rolled_back", "2026-09-30T09:49:50Z"}, {"result failed", func(h *restartHTTP, t *testing.T) { h.seedResult(t, update.Result{Outcome: "failed", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished}) }, "failed", "install", "2026-09-30T09:49:50Z"}, {"no marker, no result", func(*restartHTTP, *testing.T) {}, "idle", nil, nil}, {"a corrupt result file", func(h *restartHTTP, t *testing.T) { if err := os.WriteFile(h.resultPath, []byte("{not json"), 0o644); err != nil { t.Fatal(err) } }, "idle", nil, nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newRestartHTTP(t) tc.files(h, t) before := snapshotDirectory(t, h.dataDir) first := h.getAs(t, updateAdminId) second := h.getAs(t, updateAdminId) body := decodeUpdateBody(t, first) assertUpdateKeys(t, body) if first.Body.String() != second.Body.String() { t.Error("two GETs must answer identically") } if body["state"] != tc.state || body["error"] != tc.errKind || body["finished_at"] != tc.finished { t.Errorf("state = %v, error = %v, finished_at = %v", body["state"], body["error"], body["finished_at"]) } if after := snapshotDirectory(t, h.dataDir); !equalSnapshots(before, after) { t.Errorf("a GET changed the data directory: %v -> %v", before, after) } }) } }) t.Run("After a successful update the status is consistent", func(t *testing.T) { h := newRestartHTTP(t) h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "update", Tag: updateRunning, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: finishedAt(restartBaseTime)}) h.store.result.Latest = updateRunning body := decodeUpdateBody(t, h.getAs(t, updateOwnerId)) if body["state"] != "succeeded" || body["available"] != false || body["current"] != updateRunning || body["finished_at"] == nil { t.Errorf("body = %v", body) } }) } type directorySnapshot map[string]string func snapshotDirectory(t *testing.T, directory string) directorySnapshot { t.Helper() snapshot := directorySnapshot{} entries, err := os.ReadDir(directory) if err != nil { t.Fatal(err) } for _, entry := range entries { info, _ := entry.Info() content, _ := os.ReadFile(filepath.Join(directory, entry.Name())) snapshot[entry.Name()] = string(content) + "|" + info.ModTime().String() } return snapshot } func equalSnapshots(a, b directorySnapshot) bool { if len(a) != len(b) { return false } for name, value := range a { if b[name] != value { return false } } return true } func TestRestartResultLivesUntilTheNextJob(t *testing.T) { t.Run("The result lives until the next job", func(t *testing.T) { rig := newDownloadRig(t) h := newDownloadHTTP(t, rig) rig.results.put(update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate, Tag: downloadTag, StartedAt: restartBaseTime, FinishedAt: finishedAt(restartBaseTime)}) before := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId)) rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId) ready := waitForHTTPState(t, h, "ready", "failed") if before["state"] != "failed" || before["error"] != "rolled_back" || before["finished_at"] == nil { t.Errorf("before = %v", before) } if rec.Code != http.StatusAccepted || rig.results.deletes != 1 || rig.results.found { t.Errorf("status = %d, deletes = %d", rec.Code, rig.results.deletes) } assertUpdateKeys(t, ready) if ready["state"] != "ready" || ready["error"] != nil || ready["finished_at"] != nil { t.Errorf("ready = %v", ready) } }) t.Run("A download is refused while a restart is pending", func(t *testing.T) { rig := newDownloadRig(t) h := newDownloadHTTP(t, rig) rig.requests.present = true rig.results.put(update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime}) rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId) if rec.Code != http.StatusConflict || rig.results.deletes != 0 || rig.fetcher.totalCalls() != 0 { t.Errorf("status = %d", rec.Code) } }) } func TestHealthzIsUnchanged(t *testing.T) { t.Run("Healthz is unchanged", func(t *testing.T) { container := &api.UseCases{ Token: jwtauth.New("HS256", []byte("test-secret"), nil), OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())), } router := api.InitRoutes(container, config.Settings{Environment: "production"}) rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rec.Code != http.StatusOK || rec.Body.String() != "ok" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } for name := range rec.Header() { if strings.Contains(strings.ToLower(name), "version") { t.Errorf("unexpected header %s", name) } } }) } func TestRunningInfoFileAtStartup(t *testing.T) { t.Run("The server writes update-running.json at startup", func(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, update.RunningInfoFileName) store := infraUpdate.NewJSONRunningInfoStore(path) first := &fakeClock{now: restartBaseTime} if err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 111, first.Now).Record(); err != nil { t.Fatal(err) } content, _ := os.ReadFile(path) var written map[string]any _ = json.Unmarshal(content, &written) if written["version"] != "v2.3.0-beta" || written["pid"] != float64(111) || written["started_at"] != "2026-09-30T09:50:00Z" || len(written) != 3 { t.Errorf("file = %s", content) } later := &fakeClock{now: restartBaseTime.Add(time.Minute)} if err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 222, later.Now).Record(); err != nil { t.Fatal(err) } info, found := store.Read() if !found || info.Pid != 222 || !info.StartedAt.Equal(later.now) { t.Errorf("a second start must replace the file: %+v", info) } if entries, _ := os.ReadDir(directory); len(entries) != 1 { t.Errorf("the temporary file must be renamed away, got %d entries", len(entries)) } }) t.Run("a write error is returned and nothing panics", func(t *testing.T) { store := infraUpdate.NewJSONRunningInfoStore(filepath.Join(t.TempDir(), "missing", update.RunningInfoFileName)) err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 1, time.Now).Record() if err == nil { t.Error("the error must reach the startup code") } }) t.Run("the file is not served by any route", func(t *testing.T) { container := &api.UseCases{ Token: jwtauth.New("HS256", []byte("test-secret"), nil), OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())), } router := api.InitRoutes(container, config.Settings{Environment: "production"}) for _, target := range []string{"/update-running.json", "/v1/update-running.json", "/static/uploadedImages/update-running.json"} { rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "pid") { t.Errorf("%s served the file", target) } } }) }