632 lines
23 KiB
Go
632 lines
23 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|