401 lines
15 KiB
Go
401 lines
15 KiB
Go
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
|
|
}
|