344 lines
11 KiB
Go
344 lines
11 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/internal/domain/audit"
|
|
"RenewCMS/internal/infrastructure/scheduler"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestActivityRetentionUseCases(t *testing.T) {
|
|
ownerCtx := activityOwnerCtx
|
|
|
|
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.probe, h.clock).GetActivityRetention()
|
|
|
|
if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 {
|
|
t.Errorf("unexpected default %+v, %v", settings, err)
|
|
}
|
|
})
|
|
|
|
t.Run("The owner sets a valid period", func(t *testing.T) {
|
|
for _, days := range []int{30, 90, 365, 0} {
|
|
h := newActivityHarness(t)
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Any other value is refused and changes nothing", func(t *testing.T) {
|
|
for _, days := range []int{7, -1, 366} {
|
|
h := newActivityHarness(t)
|
|
|
|
_, 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)
|
|
}
|
|
}
|
|
})
|
|
|
|
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.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30))
|
|
|
|
events := h.all(t)
|
|
if err != nil || len(events) != 1 {
|
|
t.Fatalf("expected one event, got %d, %v", len(events), err)
|
|
}
|
|
event := events[0]
|
|
if event.Action != "system.activity_retention_changed" || event.Category != audit.CategorySystem || event.Actor.Name != "owner" || event.Source != audit.SourceWeb || event.Outcome != audit.OutcomeSuccess || event.Security {
|
|
t.Errorf("unexpected event %+v", event)
|
|
}
|
|
if event.Params["from"] != float64(90) || event.Params["to"] != float64(30) {
|
|
t.Errorf("unexpected params %v", event.Params)
|
|
}
|
|
})
|
|
|
|
t.Run("Setting the same value records nothing", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
|
|
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)
|
|
}
|
|
})
|
|
|
|
t.Run("A failing save is returned and records nothing", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
h.settings.saveErr = errRepositoryFailure
|
|
|
|
_, 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)
|
|
}
|
|
})
|
|
}
|
|
|
|
func cleanupAt(day int, hour int, minute int) time.Time {
|
|
return time.Date(2026, time.October, day, hour, minute, 0, 0, time.UTC)
|
|
}
|
|
|
|
func activityOwnerCtx() context.Context {
|
|
return audit.WithOrigin(testCtx, audit.Origin{
|
|
Actor: audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(1)), Name: "owner"},
|
|
Source: audit.SourceWeb,
|
|
})
|
|
}
|
|
|
|
func newCleanup(h *activityHarness) *useCases.CleanupActivityUseCase {
|
|
return useCases.NewCleanupActivityUseCase(h.probe, h.settings, h.recorder)
|
|
}
|
|
|
|
func TestActivityCleanupUseCase(t *testing.T) {
|
|
t.Run("The cleanup is due once a day at or after 03:00", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
last *time.Time
|
|
now time.Time
|
|
ran bool
|
|
}{
|
|
{"never at 02:59", nil, cleanupAt(6, 2, 59), false},
|
|
{"never at 03:00", nil, cleanupAt(6, 3, 0), true},
|
|
{"never at 14:00", nil, cleanupAt(6, 14, 0), true},
|
|
{"yesterday at 02:59", ptrTime(cleanupAt(5, 3, 0)), cleanupAt(6, 2, 59), false},
|
|
{"yesterday at 03:00", ptrTime(cleanupAt(5, 3, 0)), cleanupAt(6, 3, 0), true},
|
|
{"today at 04:00", ptrTime(cleanupAt(6, 3, 0)), cleanupAt(6, 4, 0), false},
|
|
{"tomorrow at 03:00", ptrTime(cleanupAt(6, 3, 0)), cleanupAt(7, 3, 0), true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
h.settings.settings.LastCleanupAt = c.last
|
|
h.now = c.now
|
|
|
|
_, ran, err := newCleanup(h).Tick(c.now)
|
|
|
|
if err != nil || ran != c.ran {
|
|
t.Errorf("expected ran=%v, got %v, %v", c.ran, ran, err)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Forever never runs", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
h.settings.settings.RetentionDays = 0
|
|
|
|
_, ran, err := newCleanup(h).Tick(cleanupAt(6, 3, 0))
|
|
|
|
if err != nil || ran || h.probe.deleteCalls != 0 {
|
|
t.Errorf("expected nothing to run, got ran=%v, %v, %d deletes", ran, err, h.probe.deleteCalls)
|
|
}
|
|
})
|
|
|
|
t.Run("The cutoff is now minus the retention days", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
h.settings.settings.RetentionDays = 30
|
|
now := cleanupAt(6, 3, 0)
|
|
h.now = now
|
|
var kept []uint64
|
|
for _, days := range []int{40, 31, 29, 1} {
|
|
id := h.seed(t, activityUserEvent(1, "a", now.Add(-time.Duration(days)*24*time.Hour))).ID
|
|
if days < 30 {
|
|
kept = append(kept, id)
|
|
}
|
|
}
|
|
|
|
result, ran, err := newCleanup(h).Tick(now)
|
|
|
|
if err != nil || !ran || result.Removed != 2 {
|
|
t.Fatalf("expected 2 removed, got %+v ran=%v %v", result, ran, err)
|
|
}
|
|
remaining := map[uint64]bool{}
|
|
for _, event := range h.all(t) {
|
|
remaining[event.ID] = true
|
|
}
|
|
for _, id := range kept {
|
|
if !remaining[id] {
|
|
t.Errorf("event %d should remain", id)
|
|
}
|
|
}
|
|
last := h.settings.settings
|
|
if last.LastCleanupAt == nil || !last.LastCleanupAt.Equal(now) || last.LastCleanupRemoved != 2 {
|
|
t.Errorf("unexpected last cleanup %+v", last)
|
|
}
|
|
})
|
|
|
|
t.Run("A cleanup that removes nothing still records the run and no event", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
now := cleanupAt(6, 3, 0)
|
|
h.now = now
|
|
h.seed(t, activityUserEvent(1, "a", now.Add(-24*time.Hour)))
|
|
|
|
result, ran, err := newCleanup(h).Tick(now)
|
|
|
|
if err != nil || !ran || result.Removed != 0 {
|
|
t.Fatalf("unexpected result %+v ran=%v %v", result, ran, err)
|
|
}
|
|
if h.settings.cleanupOK != 1 || h.settings.settings.LastCleanupAt == nil || !h.settings.settings.LastCleanupAt.Equal(now) || len(h.all(t)) != 1 {
|
|
t.Errorf("expected the run recorded without event, got %+v with %d events", h.settings.settings, len(h.all(t)))
|
|
}
|
|
})
|
|
|
|
t.Run("A cleanup that removes events records a system event after deleting", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
now := cleanupAt(6, 3, 0)
|
|
h.now = now
|
|
for i := 0; i < 142; i++ {
|
|
h.seed(t, activityUserEvent(1, "a", now.Add(-100*24*time.Hour)))
|
|
}
|
|
|
|
result, _, err := newCleanup(h).Tick(now)
|
|
|
|
events := h.all(t)
|
|
if err != nil || result.Removed != 142 || len(events) != 1 {
|
|
t.Fatalf("expected 142 removed and one event, got %+v, %d events, %v", result, len(events), err)
|
|
}
|
|
event := events[0]
|
|
if event.Action != "system.activity_cleanup" || event.Category != audit.CategorySystem || event.Actor.Type != audit.ActorSystem || event.Source != audit.SourceSystem || event.IP != nil || event.UserAgent != nil || event.Outcome != audit.OutcomeSuccess {
|
|
t.Errorf("unexpected event %+v", event)
|
|
}
|
|
if event.Params["removed"] != float64(142) {
|
|
t.Errorf("unexpected params %v", event.Params)
|
|
}
|
|
})
|
|
|
|
t.Run("A failing delete is retried at the next tick", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
now := cleanupAt(6, 3, 0)
|
|
h.now = now
|
|
h.probe.deleteErr = errRepositoryFailure
|
|
cleanup := newCleanup(h)
|
|
|
|
_, ran, err := cleanup.Tick(now)
|
|
|
|
if !errors.Is(err, errRepositoryFailure) || ran || h.settings.settings.LastCleanupAt != nil {
|
|
t.Fatalf("expected the failure without a recorded run, got ran=%v %v", ran, err)
|
|
}
|
|
h.probe.deleteErr = nil
|
|
_, ran, err = cleanup.Tick(now.Add(time.Hour))
|
|
if err != nil || !ran {
|
|
t.Errorf("expected the next tick to run, got ran=%v %v", ran, err)
|
|
}
|
|
})
|
|
|
|
t.Run("A failing save of the run is returned", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
h.settings.saveErr = errRepositoryFailure
|
|
|
|
_, ran, err := newCleanup(h).Tick(cleanupAt(6, 3, 0))
|
|
|
|
if !errors.Is(err, errRepositoryFailure) || ran {
|
|
t.Errorf("expected the failure, got ran=%v %v", ran, err)
|
|
}
|
|
})
|
|
|
|
t.Run("A shortened period applies at the next cleanup, not at the change", func(t *testing.T) {
|
|
h := newActivityHarness(t)
|
|
now := cleanupAt(6, 3, 0)
|
|
h.now = now
|
|
h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour)))
|
|
|
|
_, 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)
|
|
}
|
|
|
|
result, _, _ := newCleanup(h).Tick(now)
|
|
|
|
if result.Removed != 1 {
|
|
t.Errorf("expected the old event removed at the next cleanup, got %+v", result)
|
|
}
|
|
})
|
|
}
|
|
|
|
type cleanupJobFake struct {
|
|
calls chan time.Time
|
|
removed int
|
|
err error
|
|
}
|
|
|
|
func (c *cleanupJobFake) Tick(now time.Time) (audit.CleanupResult, bool, error) {
|
|
c.calls <- now
|
|
return audit.CleanupResult{Removed: c.removed}, c.err == nil, c.err
|
|
}
|
|
|
|
func TestActivityCleanupScheduler(t *testing.T) {
|
|
start := func(job *cleanupJobFake) (chan time.Time, chan time.Time, *strings.Builder, context.CancelFunc, chan struct{}) {
|
|
ticks := make(chan time.Time, 4)
|
|
startup := make(chan time.Time, 1)
|
|
logs := &strings.Builder{}
|
|
runner := scheduler.NewCleanupRunner(job, func() time.Time { return cleanupAt(6, 3, 0) }, ticks, startup, log.New(logs, "", 0))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
runner.Run(ctx)
|
|
}()
|
|
return ticks, startup, logs, cancel, done
|
|
}
|
|
|
|
t.Run("The runner ticks the cleanup with the injected clock and logs the count", func(t *testing.T) {
|
|
job := &cleanupJobFake{calls: make(chan time.Time, 4), removed: 142}
|
|
ticks, _, logs, cancel, done := start(job)
|
|
defer func() { cancel(); <-done }()
|
|
|
|
ticks <- time.Time{}
|
|
select {
|
|
case got := <-job.calls:
|
|
if !got.Equal(cleanupAt(6, 3, 0)) {
|
|
t.Errorf("expected the injected clock, got %v", got)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("the job was never ticked")
|
|
}
|
|
cancel()
|
|
<-done
|
|
if !strings.Contains(logs.String(), "activity cleanup completed: removed=142") {
|
|
t.Errorf("unexpected log %q", logs.String())
|
|
}
|
|
})
|
|
|
|
t.Run("A failing tick is logged", func(t *testing.T) {
|
|
job := &cleanupJobFake{calls: make(chan time.Time, 4), err: errRepositoryFailure}
|
|
_, startup, logs, cancel, done := start(job)
|
|
|
|
startup <- time.Time{}
|
|
<-job.calls
|
|
cancel()
|
|
<-done
|
|
|
|
if !strings.Contains(logs.String(), "activity cleanup failed") || !strings.Contains(logs.String(), errRepositoryFailure.Error()) {
|
|
t.Errorf("unexpected log %q", logs.String())
|
|
}
|
|
})
|
|
|
|
t.Run("The scheduler stops with the process", func(t *testing.T) {
|
|
job := &cleanupJobFake{calls: make(chan time.Time, 4)}
|
|
_, _, _, cancel, done := start(job)
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("the runner did not return after the context was cancelled")
|
|
}
|
|
})
|
|
}
|