package test import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/infrastructure/persistence/database" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "encoding/json" "fmt" "reflect" "sort" "strings" "sync/atomic" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) var activityDatabaseCounter atomic.Int64 func openActivityDatabase(t *testing.T) *gorm.DB { t.Helper() name := fmt.Sprintf("file:activity%d?mode=memory&cache=shared", activityDatabaseCounter.Add(1)) db, err := gorm.Open(sqlite.Open(name), &gorm.Config{Logger: logger.Discard}) if err != nil { t.Fatalf("failed to open the in-memory database: %v", err) } if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatalf("failed to migrate: %v", err) } t.Cleanup(func() { if sqlDb, err := db.DB(); err == nil { _ = sqlDb.Close() } }) return db } func activityPtr[T any](value T) *T { return &value } func activityUserEvent(userID uint32, name string, at time.Time) audit.Event { event := audit.NewEvent("article.publish", audit.CategoryArticles) event.CreatedAt = at event.Actor = audit.Actor{Type: audit.ActorUser, ID: activityPtr(userID), Name: name} event.Source = audit.SourceWeb return event } func activityMustAppend(t *testing.T, repo audit.Repository, event audit.Event) audit.Event { t.Helper() stored, err := repo.Append(event) if err != nil { t.Fatalf("append failed: %v", err) } return stored } func activityIDs(events []audit.Event) []uint64 { ids := make([]uint64, 0, len(events)) for _, event := range events { ids = append(ids, event.ID) } return ids } func activityDay(day int, hour int) time.Time { return time.Date(2026, time.October, day, hour, 0, 0, 0, time.UTC) } func TestActivityPersistence(t *testing.T) { t.Run("The model is migrated with its indexes", func(t *testing.T) { db := openActivityDatabase(t) if !db.Migrator().HasTable("audit_events") { t.Fatal("the audit events table does not exist") } var names []string if err := db.Raw("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'audit_events'").Scan(&names).Error; err != nil { t.Fatal(err) } found := map[string]bool{} for _, name := range names { var columns []struct { Name string } if err := db.Raw("SELECT name FROM pragma_index_info(?) ORDER BY seqno", name).Scan(&columns).Error; err != nil { t.Fatal(err) } parts := make([]string, 0, len(columns)) for _, column := range columns { parts = append(parts, column.Name) } found[strings.Join(parts, ",")] = true } for _, wanted := range []string{"created_at", "actor_type,actor_id,created_at", "category,created_at"} { if !found[wanted] { t.Errorf("missing index on (%s), have %v", wanted, found) } } }) t.Run("The persistence implementation exposes no other exported method than the contract", func(t *testing.T) { repoType := reflect.TypeOf(persistence.NewAuditEventRepository(nil)) var names []string for i := 0; i < repoType.NumMethod(); i++ { names = append(names, repoType.Method(i).Name) } sort.Strings(names) wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"} if !reflect.DeepEqual(names, wanted) { t.Errorf("expected %v, got %v", wanted, names) } }) t.Run("An appended event round-trips", func(t *testing.T) { repo := persistence.NewAuditEventRepository(openActivityDatabase(t)) event := audit.NewEvent("media.purge", audit.CategoryMedia) event.CreatedAt = time.Date(2026, time.October, 6, 10, 42, 18, 0, time.UTC) event.Actor = audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(3)), Name: "Marta Alves"} event.Target = audit.Target{Type: "article", ID: "8", Name: "Spring release"} event.Source = audit.SourceWeb event.IP = activityPtr("203.0.113.12") event.UserAgent = activityPtr("Chrome 129 on Windows") event.Security = true event.Params = map[string]any{"merged": 3} event.Changes = []audit.Change{ {Field: "online", Kind: audit.ChangeText, Before: activityPtr("false"), After: activityPtr("true")}, {Field: "body", Kind: audit.ChangeBody, Delta: activityPtr(312)}, } stored := activityMustAppend(t, repo, event) page, err := repo.List(audit.Filter{Page: 1, Size: 25}) if err != nil || page.Total != 1 || len(page.Items) != 1 { t.Fatalf("unexpected list: %+v, %v", page, err) } event.ID = stored.ID want, _ := json.Marshal(event) got, _ := json.Marshal(page.Items[0]) if string(want) != string(got) { t.Errorf("round trip mismatch\nwant %s\ngot %s", want, got) } if page.Items[0].FailureReason != nil { t.Errorf("failure_reason must stay null, got %v", *page.Items[0].FailureReason) } }) t.Run("A system event keeps its null actor id, ip and user agent", func(t *testing.T) { repo := persistence.NewAuditEventRepository(openActivityDatabase(t)) event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem) event.CreatedAt = activityDay(6, 3) event.Actor = audit.SystemActor() event.Source = audit.SourceSystem activityMustAppend(t, repo, event) page, _ := repo.List(audit.Filter{Page: 1, Size: 25}) item := page.Items[0] if item.Actor.ID != nil || item.IP != nil || item.UserAgent != nil || item.Actor.Name != "System" { t.Errorf("unexpected system event %+v", item) } }) t.Run("List sorts by created_at descending then id descending", func(t *testing.T) { repo := persistence.NewAuditEventRepository(openActivityDatabase(t)) older := activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(1, 9))) var same []uint64 for i := 0; i < 3; i++ { same = append(same, activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(2, 9))).ID) } page, err := repo.List(audit.Filter{Page: 1, Size: 25}) if err != nil { t.Fatal(err) } want := []uint64{same[2], same[1], same[0], older.ID} if !reflect.DeepEqual(activityIDs(page.Items), want) { t.Errorf("expected %v, got %v", want, activityIDs(page.Items)) } }) t.Run("Filters narrow the list and the total", func(t *testing.T) { repo := persistence.NewAuditEventRepository(openActivityDatabase(t)) add := func(mutate func(*audit.Event)) uint64 { event := activityUserEvent(1, "Someone", activityDay(2, 9)) mutate(&event) return activityMustAppend(t, repo, event).ID } user3 := add(func(e *audit.Event) { e.Actor = audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(3)), Name: "Marta"} }) user3Id7 := add(func(e *audit.Event) { e.Actor = audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(7)), Name: "Seven"} }) key7 := add(func(e *audit.Event) { e.Actor = audit.Actor{Type: audit.ActorAPIKey, ID: activityPtr(uint32(7)), Name: "Deploy bot"} e.Source = audit.SourceAPIKey }) system := add(func(e *audit.Event) { e.Actor = audit.SystemActor() e.Source = audit.SourceSystem e.Category = audit.CategorySystem }) authFailed := add(func(e *audit.Event) { e.Actor = audit.AnonymousActor("mallory") e.Category = audit.CategoryAuth e.Outcome = audit.OutcomeFailed e.Security = true }) spring := add(func(e *audit.Event) { e.Target = audit.Target{Type: "article", ID: "8", Name: "Spring release"} }) percent := add(func(e *audit.Event) { e.Target = audit.Target{Type: "article", ID: "9", Name: "Sale 50%_ off"} }) plain := add(func(e *audit.Event) { e.Target = audit.Target{Type: "article", ID: "10", Name: "Sale 505 off"} }) oct1 := add(func(e *audit.Event) { e.CreatedAt = time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC) }) oct3end := add(func(e *audit.Event) { e.CreatedAt = time.Date(2026, time.October, 3, 23, 59, 59, 0, time.UTC) }) oct4 := add(func(e *audit.Event) { e.CreatedAt = time.Date(2026, time.October, 4, 0, 0, 0, 0, time.UTC) }) sep30 := add(func(e *audit.Event) { e.CreatedAt = time.Date(2026, time.September, 30, 23, 59, 59, 0, time.UTC) }) base := add(func(e *audit.Event) {}) everyone := []uint64{user3, user3Id7, key7, system, authFailed, spring, percent, plain, oct1, oct3end, oct4, sep30, base} sort.Slice(everyone, func(i, j int) bool { return everyone[i] < everyone[j] }) from := time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, time.October, 4, 0, 0, 0, 0, time.UTC) cases := []struct { name string filter audit.Filter want []uint64 }{ {"actor user:3", audit.Filter{Actor: &audit.ActorRef{Type: audit.ActorUser, ID: activityPtr(uint32(3))}}, []uint64{user3}}, {"actor key:7", audit.Filter{Actor: &audit.ActorRef{Type: audit.ActorAPIKey, ID: activityPtr(uint32(7))}}, []uint64{key7}}, {"actor system", audit.Filter{Actor: &audit.ActorRef{Type: audit.ActorSystem}}, []uint64{system}}, {"category auth", audit.Filter{Category: audit.CategoryAuth}, []uint64{authFailed}}, {"outcome failed", audit.Filter{Outcome: audit.OutcomeFailed}, []uint64{authFailed}}, {"outcome success", audit.Filter{Outcome: audit.OutcomeSuccess}, withoutIDs(everyone, authFailed)}, {"security true", audit.Filter{Security: true}, []uint64{authFailed}}, {"q SPRING", audit.Filter{Query: "SPRING"}, []uint64{spring}}, {"q actor name", audit.Filter{Query: "marta"}, []uint64{user3}}, {"q 50%_", audit.Filter{Query: "50%_"}, []uint64{percent}}, {"date range", audit.Filter{From: &from, To: &to}, []uint64{user3, user3Id7, key7, system, authFailed, spring, percent, plain, oct1, oct3end, base}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { filter := c.filter filter.Page, filter.Size = 1, 100 page, err := repo.List(filter) if err != nil { t.Fatal(err) } got := activityIDs(page.Items) sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) want := append([]uint64(nil), c.want...) sort.Slice(want, func(i, j int) bool { return want[i] < want[j] }) if !reflect.DeepEqual(got, want) || page.Total != len(want) { t.Errorf("expected %v (total %d), got %v (total %d)", want, len(want), got, page.Total) } }) } }) t.Run("Paging slices the sorted list and total is the unpaged count", func(t *testing.T) { repo := persistence.NewAuditEventRepository(openActivityDatabase(t)) var ids []uint64 for i := 0; i < 60; i++ { ids = append(ids, activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(2, 9).Add(time.Duration(i)*time.Minute))).ID) } page, err := repo.List(audit.Filter{Page: 2, Size: 25}) if err != nil || len(page.Items) != 25 || page.Total != 60 { t.Fatalf("unexpected page: %d items, total %d, %v", len(page.Items), page.Total, err) } if page.Items[0].ID != ids[34] || page.Items[24].ID != ids[10] { t.Errorf("expected the 26th to 50th of the sorted list, got %d..%d", page.Items[0].ID, page.Items[24].ID) } past, _ := repo.List(audit.Filter{Page: 9, Size: 25}) if len(past.Items) != 0 || past.Total != 60 { t.Errorf("expected an empty page with total 60, got %d items, total %d", len(past.Items), past.Total) } }) t.Run("The summary counts the range and the previous period", func(t *testing.T) { repo := persistence.NewAuditEventRepository(openActivityDatabase(t)) now := activityDay(8, 12) at := func(daysAgo int, extra time.Duration) time.Time { return now.Add(-time.Duration(daysAgo)*24*time.Hour + extra) } add := func(when time.Time, mutate func(*audit.Event)) { event := activityUserEvent(1, "one", when) mutate(&event) activityMustAppend(t, repo, event) } none := func(*audit.Event) {} add(at(0, -time.Hour), none) add(at(0, -2*time.Hour), func(e *audit.Event) { e.Outcome = audit.OutcomeFailed }) add(at(1, -time.Hour), func(e *audit.Event) { e.Actor = audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(2)), Name: "two"} e.Security = true }) add(at(3, -time.Hour), func(e *audit.Event) { e.Actor = audit.Actor{Type: audit.ActorAPIKey, ID: activityPtr(uint32(9)), Name: "bot"} }) add(at(6, -time.Hour), func(e *audit.Event) { e.Actor = audit.AnonymousActor("x") e.Outcome = audit.OutcomeFailed e.Security = true }) add(at(6, -2*time.Hour), func(e *audit.Event) { e.Actor = audit.SystemActor() }) for i := 0; i < 4; i++ { add(at(8+i, 0), none) } summary, err := repo.Summary(audit.SummaryQuery{From: now.Add(-7 * 24 * time.Hour), To: now}) if err != nil { t.Fatal(err) } if summary.Total != 6 || summary.PreviousTotal != 4 || summary.Failed != 2 || summary.Security != 2 { t.Errorf("unexpected counts %+v", summary) } if summary.ActivePeople.Users != 2 || summary.ActivePeople.APIKeys != 1 { t.Errorf("unexpected active people %+v", summary.ActivePeople) } wantTotal := []int{2, 0, 0, 1, 0, 1, 2} if !reflect.DeepEqual(summary.Series.Total, wantTotal) { t.Errorf("expected total series %v, got %v", wantTotal, summary.Series.Total) } if !reflect.DeepEqual(summary.Series.Failed, []int{1, 0, 0, 0, 0, 0, 1}) || !reflect.DeepEqual(summary.Series.Security, []int{1, 0, 0, 0, 0, 1, 0}) { t.Errorf("unexpected failed %v or security %v series", summary.Series.Failed, summary.Series.Security) } if !reflect.DeepEqual(summary.Series.Active, []int{0, 0, 0, 1, 0, 1, 1}) { t.Errorf("unexpected active series %v", summary.Series.Active) } }) t.Run("DeleteOlderThan removes only events before the cutoff", func(t *testing.T) { repo := persistence.NewAuditEventRepository(openActivityDatabase(t)) now := activityDay(20, 12) for _, days := range []int{100, 40, 31, 29, 1} { activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(days)*24*time.Hour))) } removed, err := repo.DeleteOlderThan(now.Add(-30 * 24 * time.Hour)) if err != nil || removed != 3 { t.Fatalf("expected 3 removed, got %d, %v", removed, err) } page, _ := repo.List(audit.Filter{Page: 1, Size: 25}) if page.Total != 2 { t.Errorf("expected 2 events left, got %d", page.Total) } }) } func TestActivitySettingsPersistence(t *testing.T) { t.Run("No row means the default", func(t *testing.T) { repo := persistence.NewAuditSettingsRepository(openActivityDatabase(t)) settings, err := repo.Get() if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 { t.Errorf("unexpected default %+v, %v", settings, err) } }) t.Run("The retention and the last cleanup are stored independently", func(t *testing.T) { repo := persistence.NewAuditSettingsRepository(openActivityDatabase(t)) at := time.Date(2026, time.October, 6, 3, 0, 0, 0, time.UTC) if err := repo.SaveLastCleanup(at, 142); err != nil { t.Fatal(err) } settings, _ := repo.Get() 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, 0); err != nil { t.Fatal(err) } settings, _ = repo.Get() if settings.RetentionDays != 0 || settings.LastCleanupRemoved != 142 { t.Errorf("forever must be stored as 0 and keep the last cleanup, got %+v", settings) } }) } func withoutIDs(ids []uint64, excluded ...uint64) []uint64 { var kept []uint64 for _, id := range ids { skip := false for _, other := range excluded { skip = skip || id == other } if !skip { kept = append(kept, id) } } return kept }