package test import ( "RenewCMS/api/pkg/apperr" "encoding/json" "net/http" "reflect" "sort" "testing" ) func sizeSettingsBody(t *testing.T, body []byte) map[string]any { t.Helper() var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("invalid json %q: %v", body, err) } return decoded } func TestSettingsRetentionSizeHttp(t *testing.T) { owner := func(e *activityHTTPEnv) activityRequest { return asUser(e, activityOwnerID) } t.Run("Settings API, GET reports the size limit and the measured size", func(t *testing.T) { cases := []struct { days, gb int bytes int64 next any }{ {90, 0, 0, "2026-10-07T03:00:00+02:00"}, {90, 5, 3435973837, "2026-10-07T03:00:00+02:00"}, {0, 5, 3435973837, "2026-10-07T03:00:00+02:00"}, {0, 0, 1024, nil}, } keys := []string{"version", "activity_retention_days", "activity_retention_max_gb", "activity_log_size_bytes", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"} sort.Strings(keys) for _, c := range cases { e := newActivityHTTPEnv(t, 1000) e.now = settingsTestClock() e.settings.settings.RetentionDays = c.days e.settings.settings.RetentionMaxGB = c.gb e.probe.sizeBytes = &c.bytes rec := e.get("/v1/settings", asUser(e, activityEditorID)) body := sizeSettingsBody(t, rec.Body.Bytes()) var got []string for key := range body { got = append(got, key) } sort.Strings(got) if rec.Code != http.StatusOK || !reflect.DeepEqual(got, keys) { t.Fatalf("unexpected %d %v", rec.Code, got) } if body["activity_retention_max_gb"] != float64(c.gb) || body["activity_log_size_bytes"] != float64(c.bytes) || body["activity_next_cleanup_at"] != c.next { t.Errorf("%+v: unexpected body %v", c, body) } } }) t.Run("Settings API, a fresh install defaults to no size limit", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityEditorID)).Body.Bytes()) if body["activity_retention_days"] != float64(90) || body["activity_retention_max_gb"] != float64(0) || body["activity_log_size_bytes"] != float64(0) { t.Errorf("unexpected body %v", body) } }) t.Run("Settings API, PATCH saves a size limit, an age, or both", func(t *testing.T) { cases := []struct { body string days, gb int null bool }{ {`{"activity_retention_max_gb": 5}`, 90, 5, false}, {`{"activity_retention_days": 0, "activity_retention_max_gb": 5}`, 0, 5, false}, {`{"activity_retention_days": 30}`, 30, 0, false}, {`{"activity_retention_days": 0}`, 0, 0, true}, } for _, c := range cases { e := newActivityHTTPEnv(t, 1000) rec := e.send(http.MethodPatch, "/v1/settings", c.body, owner(e)) body := sizeSettingsBody(t, rec.Body.Bytes()) next, present := body["activity_next_cleanup_at"] if rec.Code != http.StatusOK || body["activity_retention_days"] != float64(c.days) || body["activity_retention_max_gb"] != float64(c.gb) || !present || (next == nil) != c.null { t.Errorf("%s: unexpected %d %v", c.body, rec.Code, body) } if e.settings.settings.RetentionDays != c.days || e.settings.settings.RetentionMaxGB != c.gb { t.Errorf("%s: the repository holds %+v", c.body, e.settings.settings) } } }) t.Run("Settings API, leaving the size limit while Forever clears the schedule", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.settings.settings.RetentionDays = 0 e.settings.settings.RetentionMaxGB = 5 rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 0}`, owner(e)) body := sizeSettingsBody(t, rec.Body.Bytes()) next, present := body["activity_next_cleanup_at"] if rec.Code != http.StatusOK || !present || next != nil { t.Errorf("unexpected %d %v", rec.Code, body) } }) t.Run("Settings API, PATCH rejects invalid size input", func(t *testing.T) { bodies := []string{ `{"activity_retention_max_gb": 2}`, `{"activity_retention_max_gb": -1}`, `{"activity_retention_max_gb": 100}`, `{"activity_retention_max_gb": 5.5}`, `{"activity_retention_max_gb": "5"}`, `{"activity_retention_max_gb": null}`, `{"activity_retention_days": 30, "activity_retention_max_gb": 2}`, `{"activity_retention_days": null}`, `{"activity_retention_days": 30, "activity_retention_max_gb": null}`, `{"activity_retention_days": null, "activity_retention_max_gb": 5}`, `[1]`, `{}`, `not json`, } for _, body := range bodies { e := newActivityHTTPEnv(t, 1000) e.settings.settings.RetentionMaxGB = 5 assertPlain(t, e.send(http.MethodPatch, "/v1/settings", body, owner(e)), http.StatusBadRequest, activityMismatch) if e.settings.retentionOK != 0 || e.settings.settings.RetentionDays != 90 || e.settings.settings.RetentionMaxGB != 5 || len(e.all(t)) != 0 { t.Errorf("%s: something was saved", body) } } }) t.Run("Settings API, a PATCH changing nothing is a no-op", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.settings.settings.RetentionMaxGB = 5 rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 90, "activity_retention_max_gb": 5}`, owner(e)) if rec.Code != http.StatusOK || e.settings.retentionOK != 0 || len(e.all(t)) != 0 { t.Errorf("unexpected %d, saves %d, events %d", rec.Code, e.settings.retentionOK, len(e.all(t))) } }) t.Run("Settings API, a non-owner cannot set the size limit", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`, asUser(e, activityAdminID)) assertPlain(t, rec, http.StatusForbidden, "Only the owner can do this.") if e.settings.retentionOK != 0 { t.Error("the admin changed something") } assertPlain(t, e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) }) t.Run("Settings API, a failing size measurement answers 500", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.probe.sizeErr = errRepositoryFailure assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusInternalServerError, "Internal server error") }) t.Run("Settings API, the change is audited", func(t *testing.T) { cases := []struct { body string params map[string]any }{ {`{"activity_retention_days": 30}`, map[string]any{"from": float64(90), "to": float64(30)}}, {`{"activity_retention_max_gb": 5}`, map[string]any{"max_gb_from": float64(0), "max_gb_to": float64(5)}}, {`{"activity_retention_days": 30, "activity_retention_max_gb": 5}`, map[string]any{"from": float64(90), "to": float64(30), "max_gb_from": float64(0), "max_gb_to": float64(5)}}, } for _, c := range cases { e := newActivityHTTPEnv(t, 1000) e.send(http.MethodPatch, "/v1/settings", c.body, owner(e)) events := e.all(t) if len(events) != 1 || events[0].Action != "system.activity_retention_changed" || !reflect.DeepEqual(events[0].Params, c.params) { t.Errorf("%s: unexpected events %+v", c.body, events) } } }) }