feat: setup logs retention by max file size
This commit is contained in:
1 parent
70c8492f4f
commit
f914eae3c5
44 files changed
+2316
-161
No files matched your search
@@ -76,7 +76,7 @@ func TestActivityDomain(t *testing.T) {
|
||||
names = append(names, repoType.Method(i).Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
wanted := []string{"Append", "DeleteOlderThan", "List", "Summary"}
|
||||
wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
|
||||
if !reflect.DeepEqual(names, wanted) {
|
||||
t.Errorf("expected methods %v, got %v", wanted, names)
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.clock),
|
||||
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock),
|
||||
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock),
|
||||
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock),
|
||||
ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock),
|
||||
@@ -670,7 +670,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
|
||||
|
||||
rec := e.get("/v1/settings", asUser(e, activityEditorID))
|
||||
|
||||
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
|
||||
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("The last cleanup is reported", func(t *testing.T) {
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestSettingsNextCleanupDomain(t *testing.T) {
|
||||
func TestSettingsNextCleanupUseCases(t *testing.T) {
|
||||
t.Run("Get returns the next cleanup from the clock or nil for Forever", func(t *testing.T) {
|
||||
fake := newActivitySettingsFake()
|
||||
get := useCases.NewGetActivityRetentionUseCase(fake, settingsTestClock)
|
||||
get := useCases.NewGetActivityRetentionUseCase(fake, newSizeEvents(t), settingsTestClock)
|
||||
|
||||
current, err := get.GetActivityRetention()
|
||||
if err != nil || current.NextCleanupAt == nil || current.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
|
||||
@@ -66,17 +66,17 @@ func TestSettingsNextCleanupUseCases(t *testing.T) {
|
||||
|
||||
t.Run("Update returns the next cleanup of the new period", func(t *testing.T) {
|
||||
fake := newActivitySettingsFake()
|
||||
update := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock)
|
||||
update := useCases.NewUpdateActivityRetentionUseCase(fake, newSizeEvents(t), discardRecorder, settingsTestClock)
|
||||
|
||||
forever, err := update.UpdateActivityRetention(testCtx, 0)
|
||||
forever, err := update.UpdateActivityRetention(testCtx, retentionDays(0))
|
||||
if err != nil || forever.RetentionDays != 0 || forever.NextCleanupAt != nil {
|
||||
t.Fatalf("unexpected %+v, %v", forever, err)
|
||||
}
|
||||
back, err := update.UpdateActivityRetention(testCtx, 365)
|
||||
back, err := update.UpdateActivityRetention(testCtx, retentionDays(365))
|
||||
if err != nil || back.NextCleanupAt == nil || back.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
|
||||
t.Fatalf("unexpected %+v, %v", back, err)
|
||||
}
|
||||
same, err := update.UpdateActivityRetention(testCtx, 365)
|
||||
same, err := update.UpdateActivityRetention(testCtx, retentionDays(365))
|
||||
if err != nil || same.NextCleanupAt == nil {
|
||||
t.Fatalf("unexpected %+v, %v", same, err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestSettingsNextCleanupUseCases(t *testing.T) {
|
||||
|
||||
t.Run("Update refuses an invalid period and saves nothing", func(t *testing.T) {
|
||||
fake := newActivitySettingsFake()
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, 7)
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(fake, newSizeEvents(t), discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, retentionDays(7))
|
||||
if !errors.Is(err, audit.ErrInvalidRetention) || fake.retentionOK != 0 {
|
||||
t.Fatalf("expected a refusal, got %v", err)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
|
||||
return decoded
|
||||
}
|
||||
|
||||
t.Run("GET keeps the existing fields and adds exactly one", func(t *testing.T) {
|
||||
t.Run("GET keeps the existing fields and adds exactly one next cleanup and the size fields", func(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
|
||||
e.settings.settings.LastCleanupRemoved = 142
|
||||
@@ -109,7 +109,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
|
||||
rec := e.get("/v1/settings", asUser(e, activityEditorID))
|
||||
|
||||
body := keys(t, rec.Body.String())
|
||||
if rec.Code != http.StatusOK || len(body) != 5 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
|
||||
if rec.Code != http.StatusOK || len(body) != 7 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
|
||||
t.Errorf("unexpected %d %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestActivityPersistence(t *testing.T) {
|
||||
names = append(names, repoType.Method(i).Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
wanted := []string{"Append", "DeleteOlderThan", "List", "Summary"}
|
||||
wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
|
||||
if !reflect.DeepEqual(names, wanted) {
|
||||
t.Errorf("expected %v, got %v", wanted, names)
|
||||
}
|
||||
@@ -375,7 +375,7 @@ func TestActivitySettingsPersistence(t *testing.T) {
|
||||
if settings.RetentionDays != 90 || settings.LastCleanupAt == nil || !settings.LastCleanupAt.Equal(at) || settings.LastCleanupRemoved != 142 {
|
||||
t.Fatalf("unexpected settings %+v", settings)
|
||||
}
|
||||
if err := repo.SaveRetention(0); err != nil {
|
||||
if err := repo.SaveRetention(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, _ = repo.Get()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const sizeTestGB = audit.BytesPerGB
|
||||
|
||||
type sizedEvent struct {
|
||||
id uint64
|
||||
at time.Time
|
||||
bytes int64
|
||||
}
|
||||
|
||||
type sizedEventsFake struct {
|
||||
events []sizedEvent
|
||||
ageCalls int
|
||||
oldestCalls int
|
||||
sizeCalls int
|
||||
sizeErr error
|
||||
oldestErr error
|
||||
}
|
||||
|
||||
var _ audit.Repository = &sizedEventsFake{}
|
||||
|
||||
func (f *sizedEventsFake) add(at time.Time, bytes int64) {
|
||||
f.events = append(f.events, sizedEvent{id: uint64(len(f.events) + 1), at: at, bytes: bytes})
|
||||
}
|
||||
|
||||
func (f *sizedEventsFake) Append(event audit.Event) (audit.Event, error) { return event, nil }
|
||||
|
||||
func (f *sizedEventsFake) List(audit.Filter) (audit.Page, error) { return audit.Page{}, nil }
|
||||
|
||||
func (f *sizedEventsFake) Summary(audit.SummaryQuery) (audit.Summary, error) {
|
||||
return audit.Summary{}, nil
|
||||
}
|
||||
|
||||
func (f *sizedEventsFake) DeleteOlderThan(cutoff time.Time) (int, error) {
|
||||
f.ageCalls++
|
||||
kept := f.events[:0:0]
|
||||
removed := 0
|
||||
for _, event := range f.events {
|
||||
if event.at.Before(cutoff) {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, event)
|
||||
}
|
||||
f.events = kept
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func (f *sizedEventsFake) DeleteOldest(limit int, before time.Time) (int, error) {
|
||||
f.oldestCalls++
|
||||
if f.oldestErr != nil {
|
||||
return 0, f.oldestErr
|
||||
}
|
||||
sort.SliceStable(f.events, func(i, j int) bool {
|
||||
if f.events[i].at.Equal(f.events[j].at) {
|
||||
return f.events[i].id < f.events[j].id
|
||||
}
|
||||
return f.events[i].at.Before(f.events[j].at)
|
||||
})
|
||||
removed := 0
|
||||
for removed < limit && removed < len(f.events) && f.events[removed].at.Before(before) {
|
||||
removed++
|
||||
}
|
||||
f.events = f.events[removed:]
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func (f *sizedEventsFake) SizeBytes() (int64, error) {
|
||||
f.sizeCalls++
|
||||
if f.sizeErr != nil {
|
||||
return 0, f.sizeErr
|
||||
}
|
||||
var total int64
|
||||
for _, event := range f.events {
|
||||
total += event.bytes
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
type recordingRecorder struct {
|
||||
events []audit.Event
|
||||
}
|
||||
|
||||
func (r *recordingRecorder) Record(_ context.Context, event audit.Event) {
|
||||
r.events = append(r.events, event)
|
||||
}
|
||||
|
||||
type sizeCleanupEnv struct {
|
||||
events *sizedEventsFake
|
||||
settings *activitySettingsFake
|
||||
recorder *recordingRecorder
|
||||
cleanup *useCases.CleanupActivityUseCase
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newSizeCleanupEnv(days int, maxGB int) *sizeCleanupEnv {
|
||||
e := &sizeCleanupEnv{
|
||||
events: &sizedEventsFake{},
|
||||
settings: newActivitySettingsFake(),
|
||||
recorder: &recordingRecorder{},
|
||||
now: cleanupAt(6, 3, 0),
|
||||
}
|
||||
e.settings.settings.RetentionDays = days
|
||||
e.settings.settings.RetentionMaxGB = maxGB
|
||||
e.cleanup = useCases.NewCleanupActivityUseCase(e.events, e.settings, e.recorder).WithBatchSize(1)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *sizeCleanupEnv) daysAgo(days int) time.Time {
|
||||
return e.now.Add(-time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
func TestActivityRetentionSizeDomain(t *testing.T) {
|
||||
t.Run("The size options are 0, 1, 5, 10, 25 and 50", func(t *testing.T) {
|
||||
if !reflect.DeepEqual(audit.SizeOptions(), []int{0, 1, 5, 10, 25, 50}) {
|
||||
t.Errorf("unexpected options %v", audit.SizeOptions())
|
||||
}
|
||||
for _, gb := range []int{0, 1, 5, 10, 25, 50} {
|
||||
if err := audit.ValidateRetentionMaxGB(gb); err != nil {
|
||||
t.Errorf("%d should be valid: %v", gb, err)
|
||||
}
|
||||
}
|
||||
for _, gb := range []int{-1, 2, 3, 100, 51} {
|
||||
if err := audit.ValidateRetentionMaxGB(gb); !errors.Is(err, audit.ErrInvalidRetention) {
|
||||
t.Errorf("%d should be invalid, got %v", gb, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A fresh install has no size limit", func(t *testing.T) {
|
||||
if audit.DefaultSettings().RetentionMaxGB != 0 || audit.BytesPerGB != 1024*1024*1024 {
|
||||
t.Errorf("unexpected defaults %+v", audit.DefaultSettings())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The next cleanup is empty only when both limits are 0", func(t *testing.T) {
|
||||
now := time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone)
|
||||
cases := []struct {
|
||||
days, gb int
|
||||
scheduled bool
|
||||
}{{90, 0, true}, {90, 5, true}, {0, 5, true}, {0, 0, false}}
|
||||
for _, c := range cases {
|
||||
next := audit.Settings{RetentionDays: c.days, RetentionMaxGB: c.gb}.WithNextCleanup(now).NextCleanupAt
|
||||
if (next != nil) != c.scheduled {
|
||||
t.Errorf("days %d gb %d: unexpected %v", c.days, c.gb, next)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The limit in bytes is gigabytes times 1024 cubed", func(t *testing.T) {
|
||||
if got := (audit.Settings{RetentionMaxGB: 5}).MaxLogSizeBytes(); got != 5368709120 {
|
||||
t.Errorf("unexpected %d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityRetentionSizeCleanup(t *testing.T) {
|
||||
t.Run("The size limit removes the oldest events", func(t *testing.T) {
|
||||
for _, days := range []int{90, 0} {
|
||||
e := newSizeCleanupEnv(days, 5)
|
||||
for age := 20; age > 13; age-- {
|
||||
e.events.add(e.daysAgo(age), sizeTestGB)
|
||||
}
|
||||
|
||||
result, ran, err := e.cleanup.Tick(e.now)
|
||||
|
||||
size, _ := e.events.SizeBytes()
|
||||
if err != nil || !ran || result.Removed != 2 || size != 5*sizeTestGB {
|
||||
t.Fatalf("days %d: unexpected %+v ran=%v size=%d err=%v", days, result, ran, size, err)
|
||||
}
|
||||
if e.events.events[0].id != 3 {
|
||||
t.Errorf("days %d: the oldest events must go first, first left is %d", days, e.events.events[0].id)
|
||||
}
|
||||
last := e.settings.settings
|
||||
if last.LastCleanupAt == nil || !last.LastCleanupAt.Equal(e.now) || last.LastCleanupRemoved != 2 {
|
||||
t.Errorf("days %d: unexpected last cleanup %+v", days, last)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Age first then size, and one event with the total", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(30, 5)
|
||||
for i := 0; i < 10; i++ {
|
||||
e.events.add(e.daysAgo(40), 10)
|
||||
}
|
||||
for age := 20; age > 13; age-- {
|
||||
e.events.add(e.daysAgo(age), sizeTestGB)
|
||||
}
|
||||
|
||||
result, _, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if err != nil || result.Removed != 12 || e.settings.settings.LastCleanupRemoved != 12 {
|
||||
t.Fatalf("expected 12 removed, got %+v, %v", result, err)
|
||||
}
|
||||
if len(e.recorder.events) != 1 || e.recorder.events[0].Action != "system.activity_cleanup" || e.recorder.events[0].Params[audit.ParamRemoved] != 12 {
|
||||
t.Errorf("unexpected events %+v", e.recorder.events)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The age limit alone is enough", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(30, 5)
|
||||
e.events.add(e.daysAgo(40), 10*sizeTestGB)
|
||||
e.events.add(e.daysAgo(2), sizeTestGB)
|
||||
|
||||
result, _, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if err != nil || result.Removed != 1 || e.events.oldestCalls != 0 {
|
||||
t.Errorf("unexpected %+v, %d size removals, %v", result, e.events.oldestCalls, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The size is already met", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(0, 5)
|
||||
e.events.add(e.daysAgo(10), sizeTestGB)
|
||||
|
||||
result, ran, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if err != nil || !ran || result.Removed != 0 || e.events.oldestCalls != 0 || len(e.recorder.events) != 0 {
|
||||
t.Errorf("unexpected %+v ran=%v %v", result, ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Forever with no size limit never runs", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(0, 0)
|
||||
e.events.add(e.daysAgo(10), 100*sizeTestGB)
|
||||
|
||||
_, ran, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if err != nil || ran || e.events.ageCalls != 0 || e.events.oldestCalls != 0 || e.events.sizeCalls != 0 {
|
||||
t.Errorf("expected nothing, got ran=%v %v", ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Forever with a size limit runs and does no age removal", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(0, 1)
|
||||
e.events.add(e.daysAgo(400), 2*sizeTestGB)
|
||||
|
||||
_, ran, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if err != nil || !ran || e.events.ageCalls != 0 || len(e.events.events) != 0 {
|
||||
t.Errorf("unexpected ran=%v %v", ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The last 24 hours are protected even when the size cannot be met", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(0, 1)
|
||||
for i := 0; i < 4; i++ {
|
||||
e.events.add(e.now.Add(-time.Duration(i+1)*time.Hour), sizeTestGB)
|
||||
}
|
||||
|
||||
result, ran, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if err != nil || !ran || result.Removed != 0 || len(e.events.events) != 4 {
|
||||
t.Fatalf("unexpected %+v ran=%v %v", result, ran, err)
|
||||
}
|
||||
if e.settings.settings.LastCleanupAt == nil || e.settings.settings.LastCleanupRemoved != 0 || len(e.recorder.events) != 0 {
|
||||
t.Errorf("the run must be recorded with 0 removed, got %+v", e.settings.settings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing size removal is retried", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(0, 1)
|
||||
e.events.add(e.daysAgo(10), 4*sizeTestGB)
|
||||
e.events.oldestErr = errRepositoryFailure
|
||||
|
||||
_, ran, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || ran || e.settings.settings.LastCleanupAt != nil {
|
||||
t.Fatalf("unexpected ran=%v %v", ran, err)
|
||||
}
|
||||
e.events.oldestErr = nil
|
||||
if _, ran, err = e.cleanup.Tick(e.now.Add(time.Hour)); err != nil || !ran {
|
||||
t.Errorf("expected the next tick to run, got ran=%v %v", ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing size measurement is returned", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(0, 1)
|
||||
e.events.sizeErr = errRepositoryFailure
|
||||
|
||||
_, ran, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || ran {
|
||||
t.Errorf("unexpected ran=%v %v", ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The batch loop ends when a batch removes nothing", func(t *testing.T) {
|
||||
e := newSizeCleanupEnv(0, 1)
|
||||
e.events.add(e.daysAgo(10), 3*sizeTestGB)
|
||||
e.events.add(e.now.Add(-time.Hour), 3*sizeTestGB)
|
||||
|
||||
_, _, err := e.cleanup.Tick(e.now)
|
||||
|
||||
if err != nil || e.events.oldestCalls != 2 || len(e.events.events) != 1 {
|
||||
t.Errorf("unexpected %d calls, %d left, %v", e.events.oldestCalls, len(e.events.events), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityRetentionSizeUseCases(t *testing.T) {
|
||||
newEnv := func() (*activitySettingsFake, *sizedEventsFake, *recordingRecorder, *useCases.UpdateActivityRetentionUseCase) {
|
||||
settings := newActivitySettingsFake()
|
||||
events := &sizedEventsFake{}
|
||||
recorder := &recordingRecorder{}
|
||||
return settings, events, recorder, useCases.NewUpdateActivityRetentionUseCase(settings, events, recorder, settingsTestClock)
|
||||
}
|
||||
|
||||
t.Run("Get carries the measured size", func(t *testing.T) {
|
||||
settings := newActivitySettingsFake()
|
||||
settings.settings.RetentionMaxGB = 5
|
||||
events := &sizedEventsFake{}
|
||||
events.add(settingsTestClock(), 3435973837)
|
||||
|
||||
current, err := useCases.NewGetActivityRetentionUseCase(settings, events, settingsTestClock).GetActivityRetention()
|
||||
|
||||
if err != nil || current.LogSizeBytes != 3435973837 || current.RetentionMaxGB != 5 {
|
||||
t.Errorf("unexpected %+v, %v", current, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A size failure propagates from Get and Update", func(t *testing.T) {
|
||||
settings, events, _, update := newEnv()
|
||||
events.sizeErr = errRepositoryFailure
|
||||
|
||||
_, getErr := useCases.NewGetActivityRetentionUseCase(settings, events, settingsTestClock).GetActivityRetention()
|
||||
_, updateErr := update.UpdateActivityRetention(testCtx, retentionMaxGB(5))
|
||||
|
||||
if !errors.Is(getErr, errRepositoryFailure) || !errors.Is(updateErr, errRepositoryFailure) || settings.retentionOK != 0 {
|
||||
t.Errorf("unexpected %v, %v", getErr, updateErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An empty command or an invalid size is refused and saves nothing", func(t *testing.T) {
|
||||
settings, _, recorder, update := newEnv()
|
||||
two := 2
|
||||
for _, command := range []useCases.UpdateActivityRetentionCommand{{}, {MaxGB: &two}, {Days: activityPtr(30), MaxGB: &two}} {
|
||||
if _, err := update.UpdateActivityRetention(testCtx, command); !errors.Is(err, audit.ErrInvalidRetention) {
|
||||
t.Errorf("expected a refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
if settings.retentionOK != 0 || len(recorder.events) != 0 {
|
||||
t.Error("something was saved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Each key leaves the other unchanged", func(t *testing.T) {
|
||||
settings, _, _, update := newEnv()
|
||||
|
||||
sized, err := update.UpdateActivityRetention(testCtx, retentionMaxGB(5))
|
||||
if err != nil || sized.RetentionDays != 90 || sized.RetentionMaxGB != 5 {
|
||||
t.Fatalf("unexpected %+v, %v", sized, err)
|
||||
}
|
||||
aged, err := update.UpdateActivityRetention(testCtx, retentionDays(30))
|
||||
if err != nil || aged.RetentionDays != 30 || aged.RetentionMaxGB != 5 || settings.settings.RetentionMaxGB != 5 {
|
||||
t.Fatalf("unexpected %+v, %v", aged, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The audit params follow what changed", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
command useCases.UpdateActivityRetentionCommand
|
||||
params map[string]any
|
||||
}{
|
||||
{"age", retentionDays(30), map[string]any{"from": 90, "to": 30}},
|
||||
{"size", retentionMaxGB(5), map[string]any{"max_gb_from": 0, "max_gb_to": 5}},
|
||||
{"both", useCases.UpdateActivityRetentionCommand{Days: activityPtr(30), MaxGB: activityPtr(5)}, map[string]any{"from": 90, "to": 30, "max_gb_from": 0, "max_gb_to": 5}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _, recorder, update := newEnv()
|
||||
if _, err := update.UpdateActivityRetention(testCtx, c.command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recorder.events) != 1 || recorder.events[0].Action != "system.activity_retention_changed" || !reflect.DeepEqual(recorder.events[0].Params, c.params) {
|
||||
t.Errorf("%s: unexpected events %+v", c.name, recorder.events)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Saving the same pair is a no-op", func(t *testing.T) {
|
||||
settings, _, recorder, update := newEnv()
|
||||
settings.settings.RetentionMaxGB = 5
|
||||
|
||||
current, err := update.UpdateActivityRetention(testCtx, useCases.UpdateActivityRetentionCommand{Days: activityPtr(90), MaxGB: activityPtr(5)})
|
||||
|
||||
if err != nil || current.RetentionMaxGB != 5 || settings.retentionOK != 0 || len(recorder.events) != 0 {
|
||||
t.Errorf("unexpected %+v, %v", current, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityRetentionSizePersistence(t *testing.T) {
|
||||
text := func(event audit.Event) int64 {
|
||||
var total int64
|
||||
for _, field := range []string{event.Action, event.Actor.Name, event.Target.Type, event.Target.ID, event.Target.Name} {
|
||||
total += int64(len([]byte(field)))
|
||||
}
|
||||
if event.FailureReason != nil {
|
||||
total += int64(len(*event.FailureReason))
|
||||
}
|
||||
if event.IP != nil {
|
||||
total += int64(len(*event.IP))
|
||||
}
|
||||
if event.UserAgent != nil {
|
||||
total += int64(len(*event.UserAgent))
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
t.Run("SizeBytes is 0 for an empty table", func(t *testing.T) {
|
||||
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
|
||||
size, err := repo.SizeBytes()
|
||||
|
||||
if err != nil || size != 0 {
|
||||
t.Errorf("unexpected %d, %v", size, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SizeBytes sums the byte lengths of the text columns plus the row overhead", func(t *testing.T) {
|
||||
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
plain := activityUserEvent(1, "marta", activityDay(1, 10))
|
||||
plain.Params = map[string]any{"a": 1}
|
||||
accented := activityUserEvent(2, "Zoë Çelik 日本", activityDay(2, 10))
|
||||
accented.IP = activityPtr("203.0.113.12")
|
||||
accented.UserAgent = activityPtr("Mozilla/5.0 é")
|
||||
accented.FailureReason = activityPtr("Wrong username or password.")
|
||||
accented.Changes = []audit.Change{{Field: "title", Kind: audit.ChangeText, Before: activityPtr("é"), After: activityPtr("e")}}
|
||||
var wanted int64
|
||||
for _, event := range []audit.Event{plain, accented} {
|
||||
stored := activityMustAppend(t, repo, event)
|
||||
wanted += text(stored) + int64(len(mustMarshalParams(t, stored))) + int64(len(mustMarshalChanges(t, stored))) + audit.RowOverheadBytes
|
||||
}
|
||||
|
||||
size, err := repo.SizeBytes()
|
||||
|
||||
if err != nil || size != wanted {
|
||||
t.Errorf("expected %d, got %d, %v", wanted, size, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting events lowers the size", func(t *testing.T) {
|
||||
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(1, 10)))
|
||||
activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(20, 10)))
|
||||
before, _ := repo.SizeBytes()
|
||||
|
||||
if _, err := repo.DeleteOlderThan(activityDay(10, 10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after, err := repo.SizeBytes()
|
||||
if err != nil || after >= before || after <= 0 {
|
||||
t.Errorf("expected a lower size than %d, got %d, %v", before, after, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteOldest removes the oldest events up to a limit and a guard", func(t *testing.T) {
|
||||
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
now := activityDay(20, 12)
|
||||
var ids []uint64
|
||||
for i := 0; i < 7; i++ {
|
||||
ids = append(ids, activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(10-i)*24*time.Hour))).ID)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
ids = append(ids, activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(i+1)*time.Hour))).ID)
|
||||
}
|
||||
guard := audit.GuardCutoff(now)
|
||||
|
||||
removed, err := repo.DeleteOldest(5, guard)
|
||||
if err != nil || removed != 5 {
|
||||
t.Fatalf("expected 5 removed, got %d, %v", removed, err)
|
||||
}
|
||||
page, _ := repo.List(audit.Filter{Page: 1, Size: 100})
|
||||
left := activityIDs(page.Items)
|
||||
sort.Slice(left, func(i, j int) bool { return left[i] < left[j] })
|
||||
if !reflect.DeepEqual(left, ids[5:]) {
|
||||
t.Errorf("expected %v left, got %v", ids[5:], left)
|
||||
}
|
||||
|
||||
removed, err = repo.DeleteOldest(20, guard)
|
||||
page, _ = repo.List(audit.Filter{Page: 1, Size: 100})
|
||||
if err != nil || removed != 2 || page.Total != 3 {
|
||||
t.Errorf("expected only the 2 older events removed, got %d with %d left, %v", removed, page.Total, err)
|
||||
}
|
||||
if removed, err = repo.DeleteOldest(0, guard); err != nil || removed != 0 {
|
||||
t.Errorf("a zero limit removes nothing, got %d, %v", removed, err)
|
||||
}
|
||||
if removed, err = repo.DeleteOldest(-1, guard); err != nil || removed != 0 {
|
||||
t.Errorf("a negative limit removes nothing, got %d, %v", removed, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteOldest honours a limit of 1", func(t *testing.T) {
|
||||
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
now := activityDay(20, 12)
|
||||
for i := 0; i < 3; i++ {
|
||||
activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(10-i)*24*time.Hour)))
|
||||
}
|
||||
|
||||
removed, err := repo.DeleteOldest(1, audit.GuardCutoff(now))
|
||||
|
||||
page, _ := repo.List(audit.Filter{Page: 1, Size: 100})
|
||||
if err != nil || removed != 1 || page.Total != 2 {
|
||||
t.Errorf("expected 1 removed and 2 left, got %d and %d, %v", removed, page.Total, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteOldest removes more events than one delete chunk", func(t *testing.T) {
|
||||
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
now := activityDay(20, 12)
|
||||
for i := 0; i < 1001; i++ {
|
||||
activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(2000-i)*time.Hour)))
|
||||
}
|
||||
|
||||
removed, err := repo.DeleteOldest(1001, audit.GuardCutoff(now))
|
||||
|
||||
page, _ := repo.List(audit.Filter{Page: 1, Size: 10})
|
||||
if err != nil || removed != 1001 || page.Total != 0 {
|
||||
t.Errorf("expected 1001 removed, got %d with %d left, %v", removed, page.Total, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The size limit column defaults to 0 and is stored", func(t *testing.T) {
|
||||
repo := persistence.NewAuditSettingsRepository(openActivityDatabase(t))
|
||||
settings, err := repo.Get()
|
||||
if err != nil || settings.RetentionMaxGB != 0 {
|
||||
t.Fatalf("unexpected %+v, %v", settings, err)
|
||||
}
|
||||
|
||||
if err := repo.SaveRetention(0, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, _ = repo.Get()
|
||||
if settings.RetentionDays != 0 || settings.RetentionMaxGB != 5 {
|
||||
t.Errorf("unexpected %+v", settings)
|
||||
}
|
||||
if err := repo.SaveLastCleanup(activityDay(6, 3), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, _ = repo.Get()
|
||||
if settings.RetentionMaxGB != 5 {
|
||||
t.Errorf("the size limit must survive a cleanup record, got %+v", settings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityRetentionSizePersistenceErrors(t *testing.T) {
|
||||
closedDatabase := func(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := openActivityDatabase(t)
|
||||
sqlDb, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sqlDb.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
t.Run("SizeBytes returns the database error", func(t *testing.T) {
|
||||
size, err := persistence.NewAuditEventRepository(closedDatabase(t)).SizeBytes()
|
||||
|
||||
if err == nil || size != 0 {
|
||||
t.Errorf("expected an error and 0, got %d, %v", size, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteOldest returns the database error", func(t *testing.T) {
|
||||
removed, err := persistence.NewAuditEventRepository(closedDatabase(t)).DeleteOldest(5, activityDay(20, 12))
|
||||
|
||||
if err == nil || removed != 0 {
|
||||
t.Errorf("expected an error and 0, got %d, %v", removed, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SaveRetention returns the database error", func(t *testing.T) {
|
||||
if err := persistence.NewAuditSettingsRepository(closedDatabase(t)).SaveRetention(30, 5); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get returns the database error", func(t *testing.T) {
|
||||
if _, err := persistence.NewAuditSettingsRepository(closedDatabase(t)).Get(); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustMarshalParams(t *testing.T, event audit.Event) []byte {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(event.Params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func mustMarshalChanges(t *testing.T, event audit.Event) []byte {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(event.Changes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
|
||||
t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.clock).GetActivityRetention()
|
||||
settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock).GetActivityRetention()
|
||||
|
||||
if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 {
|
||||
t.Errorf("unexpected default %+v, %v", settings, err)
|
||||
@@ -29,7 +29,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
|
||||
for _, days := range []int{30, 90, 365, 0} {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days)
|
||||
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days))
|
||||
|
||||
if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days {
|
||||
t.Errorf("%d: unexpected result %+v, %v", days, settings, err)
|
||||
@@ -41,7 +41,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
|
||||
for _, days := range []int{7, -1, 366} {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days)
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days))
|
||||
|
||||
if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 {
|
||||
t.Errorf("%d: expected a refusal, got %v", days, err)
|
||||
@@ -52,7 +52,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
|
||||
t.Run("A change is recorded as a system event by the owner", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30))
|
||||
|
||||
events := h.all(t)
|
||||
if err != nil || len(events) != 1 {
|
||||
@@ -70,7 +70,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
|
||||
t.Run("Setting the same value records nothing", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 90)
|
||||
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(90))
|
||||
|
||||
if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 {
|
||||
t.Errorf("expected no event and no write, got %v", err)
|
||||
@@ -81,7 +81,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.settings.saveErr = errRepositoryFailure
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30))
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 {
|
||||
t.Errorf("expected the failure and no event, got %v", err)
|
||||
@@ -254,7 +254,7 @@ func TestActivityCleanupUseCase(t *testing.T) {
|
||||
h.now = now
|
||||
h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour)))
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), 30)
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), retentionDays(30))
|
||||
if err != nil || len(h.all(t)) != 2 {
|
||||
t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/infrastructure/auditing"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -25,6 +26,10 @@ type activityProbeRepo struct {
|
||||
deleteErr error
|
||||
deleteCalls int
|
||||
readErr error
|
||||
sizeErr error
|
||||
sizeBytes *int64
|
||||
oldestErr error
|
||||
oldestCalls int
|
||||
}
|
||||
|
||||
var _ audit.Repository = &activityProbeRepo{}
|
||||
@@ -64,6 +69,24 @@ func (p *activityProbeRepo) DeleteOlderThan(cutoff time.Time) (int, error) {
|
||||
return p.inner.DeleteOlderThan(cutoff)
|
||||
}
|
||||
|
||||
func (p *activityProbeRepo) DeleteOldest(limit int, before time.Time) (int, error) {
|
||||
p.oldestCalls++
|
||||
if p.oldestErr != nil {
|
||||
return 0, p.oldestErr
|
||||
}
|
||||
return p.inner.DeleteOldest(limit, before)
|
||||
}
|
||||
|
||||
func (p *activityProbeRepo) SizeBytes() (int64, error) {
|
||||
if p.sizeErr != nil {
|
||||
return 0, p.sizeErr
|
||||
}
|
||||
if p.sizeBytes != nil {
|
||||
return *p.sizeBytes, nil
|
||||
}
|
||||
return p.inner.SizeBytes()
|
||||
}
|
||||
|
||||
type activitySettingsFake struct {
|
||||
settings audit.Settings
|
||||
saveErr error
|
||||
@@ -85,12 +108,13 @@ func (f *activitySettingsFake) Get() (audit.Settings, error) {
|
||||
return f.settings, nil
|
||||
}
|
||||
|
||||
func (f *activitySettingsFake) SaveRetention(days int) error {
|
||||
func (f *activitySettingsFake) SaveRetention(days int, maxGB int) error {
|
||||
if f.saveErr != nil {
|
||||
return f.saveErr
|
||||
}
|
||||
f.retentionOK++
|
||||
f.settings.RetentionDays = days
|
||||
f.settings.RetentionMaxGB = maxGB
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -144,3 +168,16 @@ func (h *activityHarness) seed(t *testing.T, event audit.Event) audit.Event {
|
||||
}
|
||||
|
||||
var errDiskFull = errors.New("disk full")
|
||||
|
||||
func newSizeEvents(t *testing.T) audit.Repository {
|
||||
t.Helper()
|
||||
return persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
}
|
||||
|
||||
func retentionDays(days int) useCases.UpdateActivityRetentionCommand {
|
||||
return useCases.UpdateActivityRetentionCommand{Days: &days}
|
||||
}
|
||||
|
||||
func retentionMaxGB(maxGB int) useCases.UpdateActivityRetentionCommand {
|
||||
return useCases.UpdateActivityRetentionCommand{MaxGB: &maxGB}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), time.Now),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
|
||||
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
|
||||
func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
settingsHandler := settings.NewHandler(
|
||||
useCases.NewGetSettingsUseCase(version),
|
||||
useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), settingsTestClock),
|
||||
useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder, settingsTestClock),
|
||||
useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), settingsTestClock),
|
||||
useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), discardRecorder, settingsTestClock),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -35,7 +35,7 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
|
||||
}
|
||||
|
||||
func settingsBody(version string) string {
|
||||
return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}`
|
||||
return `{"version":"` + version + `","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}`
|
||||
}
|
||||
|
||||
func TestSettingsHttp(t *testing.T) {
|
||||
@@ -46,7 +46,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newSettingsRouter(userRepo, token, version)
|
||||
router := newSettingsRouter(t, userRepo, token, version)
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
|
||||
@@ -64,7 +64,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
|
||||
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
|
||||
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
t.Setenv("ENVIRONMENT", "development")
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
|
||||
t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
router := newSettingsRouter(newMockUserRepo(), token, "v2.2.0-beta")
|
||||
router := newSettingsRouter(t, newMockUserRepo(), token, "v2.2.0-beta")
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
|
||||
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
|
||||
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
|
||||
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
|
||||
@@ -113,7 +113,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
|
||||
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
|
||||
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
|
||||
cookie := mintGetCurrentUserCookie(t, token, 8, 0)
|
||||
|
||||
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
|
||||
|
||||
Reference in new issue
Block a user