325 lines
12 KiB
Go
325 lines
12 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", "current", "error", "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),
|
|
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
|
|
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
|
|
}
|
|
return &updateHarness{router: api.NewSystemRouter(container), 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 {
|
|
t.Errorf("progress, error and started_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"} {
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("restart is not mounted", func(t *testing.T) {
|
|
h := newUpdateHarness(t, updateRunning)
|
|
for _, path := range []string{"/update/restart"} {
|
|
if rec := h.sendAs(t, http.MethodPost, path, updateOwnerId); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s status = %d", path, rec.Code)
|
|
}
|
|
}
|
|
})
|
|
}
|