package test import ( "RenewCMS/api" "RenewCMS/api/middleware" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/update" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "net/http" "net/http/httptest" "sort" "strconv" "testing" "time" "github.com/go-chi/jwtauth/v5" ) const ( updateOwnerId = 1 updateAdminId = 2 updateUnverifiedId = 3 updateSecondOwner = 4 updateUnverifiedOwner = 5 updateRunning = "v2.2.0-beta" ) var updateStatusKeys = []string{"available", "can_apply", "checked_at", "container", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"} type updateHarness struct { router http.Handler token *jwtauth.JWTAuth feed *fakeReleaseFeed store *fakeUpdateStore } func newUpdateUserRepo() *mockUserRepo { users := newMockUserRepo() users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2} users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin} users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, Role: user.RoleAdmin} users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, Role: user.RoleOwner} users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, Role: user.RoleOwner} return users } func newUpdateHarness(t *testing.T, running string) *updateHarness { t.Helper() validator.InitValidator() users := newUpdateUserRepo() feed := &fakeReleaseFeed{release: update.Release{ Tag: "v2.3.0-beta", URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta", PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC), Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}}, }} store := &fakeUpdateStore{} token := jwtauth.New("HS256", []byte("test-secret"), nil) now := func() time.Time { return updateTestCheckedAt } container := &api.UseCases{ Token: token, GetUserUseCase: useCases.NewGetUserUseCase(users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder), } return &updateHarness{router: api.NewSystemRouter(container, nil), token: token, feed: feed, store: store} } func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, tokenVersion uint32) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, nil) 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 *updateHarness) sendAs(t *testing.T, method, path string, userId uint32) *httptest.ResponseRecorder { tokenVersion := uint32(0) if userId == updateOwnerId { tokenVersion = 2 } return h.send(t, method, path, userId, tokenVersion) } func decodeUpdateBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("body is not json: %v: %s", err, rec.Body.String()) } return body } func assertUpdateKeys(t *testing.T, body map[string]any) { t.Helper() keys := make([]string, 0, len(body)) for key := range body { keys = append(keys, key) } sort.Strings(keys) if len(keys) != len(updateStatusKeys) { t.Fatalf("keys = %v, want %v", keys, updateStatusKeys) } for i := range keys { if keys[i] != updateStatusKeys[i] { t.Fatalf("keys = %v, want %v", keys, updateStatusKeys) } } } func TestUpdateStatusHTTP(t *testing.T) { t.Run("GET returns the idle shape, never applicable", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) h.store.result = newStoredUpdateResult() h.store.found = true rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } body := decodeUpdateBody(t, rec) assertUpdateKeys(t, body) if body["current"] != updateRunning || body["latest"] != "v2.3.0-beta" || body["available"] != true || body["can_apply"] != false || body["state"] != "idle" { t.Errorf("body = %v", body) } if body["release_url"] != h.store.result.ReleaseURL || body["checked_at"] != "2026-09-30T09:41:00Z" { t.Errorf("release_url = %v, checked_at = %v", body["release_url"], body["checked_at"]) } if body["progress"] != nil || body["error"] != nil || body["started_at"] != nil || body["finished_at"] != nil { t.Errorf("progress, error, started_at and finished_at must be null: %v", body) } notes := body["notes"].(map[string]any) if notes["version"] != "v2.3.0-beta" || len(notes["items"].([]any)) != 1 || notes["released_at"] != "2026-09-24T08:00:00Z" { t.Errorf("notes = %v", notes) } if h.feed.calls != 0 { t.Errorf("feed calls = %d", h.feed.calls) } }) t.Run("GET before any check", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId) body := decodeUpdateBody(t, rec) assertUpdateKeys(t, body) if rec.Code != http.StatusOK || body["available"] != false || body["latest"] != "" || body["state"] != "idle" { t.Errorf("status = %d body = %v", rec.Code, body) } for _, key := range []string{"release_url", "notes", "checked_at", "progress", "error", "started_at", "finished_at"} { if body[key] != nil { t.Errorf("%s = %v, want null", key, body[key]) } } }) for name, userId := range map[string]uint32{"owner": updateOwnerId, "admin": updateAdminId} { t.Run("GET is open to any verified user: "+name, func(t *testing.T) { rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", userId) if rec.Code != http.StatusOK { t.Errorf("status = %d", rec.Code) } }) } t.Run("GET without a cookie", func(t *testing.T) { rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", 0, 0) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d", rec.Code) } }) t.Run("GET with a stale token", func(t *testing.T) { rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", updateOwnerId, 1) if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } }) t.Run("GET by an unverified account", func(t *testing.T) { rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", updateUnverifiedId) if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } }) } func TestUpdateCheckHTTP(t *testing.T) { t.Run("The owner checks and the result is stored", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } body := decodeUpdateBody(t, rec) assertUpdateKeys(t, body) if body["available"] != true || body["latest"] != "v2.3.0-beta" || body["checked_at"] != "2026-09-30T09:41:00Z" { t.Errorf("body = %v", body) } if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) { t.Errorf("stored = %+v", h.store.result) } later := h.sendAs(t, http.MethodGet, "/update", updateOwnerId) if later.Body.String() != rec.Body.String() { t.Errorf("later GET = %s, check = %s", later.Body.String(), rec.Body.String()) } if h.feed.calls != 1 { t.Errorf("feed calls = %d", h.feed.calls) } }) t.Run("A check with nothing newer", func(t *testing.T) { h := newUpdateHarness(t, "v2.3.0-beta") rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId) if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false { t.Errorf("status = %d body = %s", rec.Code, rec.Body.String()) } }) t.Run("A dev build never reports an update", func(t *testing.T) { h := newUpdateHarness(t, "dev") h.feed.release.Tag = "v9.9.9" rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId) if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false { t.Errorf("status = %d body = %s", rec.Code, rec.Body.String()) } }) t.Run("An admin cannot check", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) rec := h.sendAs(t, http.MethodPost, "/update/check", updateAdminId) if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" { t.Errorf("status = %d body = %q", rec.Code, rec.Body.String()) } if h.feed.calls != 0 { t.Errorf("feed calls = %d", h.feed.calls) } }) t.Run("A check without a cookie is unauthorized", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) if rec := h.send(t, http.MethodPost, "/update/check", 0, 0); rec.Code != http.StatusUnauthorized { t.Errorf("status = %d", rec.Code) } }) t.Run("Feed failure keeps the last result", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) h.store.result = newStoredUpdateResult() h.store.found = true h.feed.err = update.ErrFeedUnavailable rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId) if rec.Code != http.StatusInternalServerError { t.Errorf("status = %d", rec.Code) } if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) || h.store.saves != 0 { t.Errorf("store changed: %+v", h.store.result) } if body := rec.Body.String(); body != "Internal server error\n" { t.Errorf("body leaks detail: %q", body) } }) t.Run("Check is rate limited per user", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) loginLimited := middleware.NewRateLimiter(5, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) var codes []int var last *httptest.ResponseRecorder for i := 0; i < 6; i++ { last = h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId) codes = append(codes, last.Code) } for i, code := range codes[:5] { if code != http.StatusOK { t.Errorf("check %d status = %d", i+1, code) } } if codes[5] != http.StatusTooManyRequests || last.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" { t.Fatalf("6th status = %d body = %q", codes[5], last.Body.String()) } if seconds, err := strconv.Atoi(last.Header().Get("Retry-After")); err != nil || seconds < 1 { t.Errorf("Retry-After = %q", last.Header().Get("Retry-After")) } if h.feed.calls != 5 { t.Errorf("feed calls = %d", h.feed.calls) } if other := h.sendAs(t, http.MethodPost, "/update/check", updateSecondOwner); other.Code != http.StatusOK { t.Errorf("another owner status = %d", other.Code) } for i := 0; i < 5; i++ { rec := httptest.NewRecorder() loginLimited.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/login", nil)) if rec.Code != http.StatusOK { t.Errorf("auth limiter request %d status = %d", i+1, rec.Code) } } }) }