Files
RenewCMS/test/activity_retention_size_test.go

624 lines
20 KiB
Go

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
}