Files
RenewCMS/test/update_http_test.go

318 lines
11 KiB
Go

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)
}
}
})
}