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 }