Files
RenewCMS/test/activity_domain_test.go
T

200 lines
6.9 KiB
Go

package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"errors"
"reflect"
"sort"
"testing"
"time"
)
func activityStrings[T ~string](values []T) []string {
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, string(value))
}
sort.Strings(result)
return result
}
func TestActivityDomain(t *testing.T) {
t.Run("An event carries everything the log needs", func(t *testing.T) {
event := audit.NewEvent("article.publish", audit.CategoryArticles)
if event.Action != "article.publish" || event.Category != audit.CategoryArticles {
t.Fatalf("unexpected action or category: %+v", event)
}
if event.Outcome != audit.OutcomeSuccess {
t.Errorf("expected a successful outcome by default, got %q", event.Outcome)
}
wanted := []string{"ID", "CreatedAt", "Action", "Params", "Category", "Actor", "Target", "Outcome", "FailureReason", "Security", "Source", "IP", "UserAgent", "Changes"}
eventType := reflect.TypeOf(event)
for _, name := range wanted {
if _, ok := eventType.FieldByName(name); !ok {
t.Errorf("the event has no %s field", name)
}
}
actorType := reflect.TypeOf(event.Actor)
for _, name := range []string{"Type", "ID", "Name"} {
if _, ok := actorType.FieldByName(name); !ok {
t.Errorf("the actor snapshot has no %s field", name)
}
}
targetType := reflect.TypeOf(event.Target)
for _, name := range []string{"Type", "ID", "Name"} {
if _, ok := targetType.FieldByName(name); !ok {
t.Errorf("the target snapshot has no %s field", name)
}
}
})
vocabulary := []struct {
field string
got []string
wanted []string
}{
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "media", "system", "users"}},
{"outcome", activityStrings(audit.Outcomes()), []string{"failed", "success"}},
{"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}},
{"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}},
}
for _, c := range vocabulary {
t.Run("The vocabulary is closed: "+c.field, func(t *testing.T) {
if !reflect.DeepEqual(c.got, c.wanted) {
t.Errorf("expected %v, got %v", c.wanted, c.got)
}
})
}
t.Run("The repository exposes no way to edit or delete one event", func(t *testing.T) {
repoType := reflect.TypeOf((*audit.Repository)(nil)).Elem()
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 methods %v, got %v", wanted, names)
}
})
t.Run("The sentinel errors are matchable", func(t *testing.T) {
if !errors.Is(audit.ErrInvalidFilter, audit.ErrInvalidFilter) || !errors.Is(audit.ErrInvalidRetention, audit.ErrInvalidRetention) {
t.Fatal("sentinels must match themselves")
}
if errors.Is(audit.ErrInvalidFilter, audit.ErrInvalidRetention) {
t.Error("the sentinels must be distinct")
}
if _, err := audit.ParseActor("group:3"); !errors.Is(err, audit.ErrInvalidFilter) {
t.Errorf("expected ErrInvalidFilter, got %v", err)
}
if err := audit.ValidateRetentionDays(7); !errors.Is(err, audit.ErrInvalidRetention) {
t.Errorf("expected ErrInvalidRetention, got %v", err)
}
})
t.Run("An actor reference is parsed from its query form", func(t *testing.T) {
cases := []struct {
raw string
wantType audit.ActorType
wantID uint32
wantErr bool
}{
{"user:3", audit.ActorUser, 3, false},
{"key:7", audit.ActorAPIKey, 7, false},
{"system", audit.ActorSystem, 0, false},
{"user:abc", "", 0, true},
{"user:-1", "", 0, true},
{"user:", "", 0, true},
{"group:3", "", 0, true},
{"system:1", "", 0, true},
}
for _, c := range cases {
ref, err := audit.ParseActor(c.raw)
if c.wantErr {
if !errors.Is(err, audit.ErrInvalidFilter) {
t.Errorf("%q: expected ErrInvalidFilter, got %v", c.raw, err)
}
continue
}
if err != nil || ref.Type != c.wantType {
t.Fatalf("%q: unexpected result %+v, %v", c.raw, ref, err)
}
if c.wantID != 0 && (ref.ID == nil || *ref.ID != c.wantID) {
t.Errorf("%q: expected id %d, got %v", c.raw, c.wantID, ref.ID)
}
}
})
t.Run("A user actor reads the display name and falls back to the username", func(t *testing.T) {
named := audit.UserActor(user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves"})
if named.Type != audit.ActorUser || named.ID == nil || *named.ID != 3 || named.Name != "Marta Alves" {
t.Errorf("unexpected actor %+v", named)
}
plain := audit.UserActor(user.User{ID: 4, Username: "dev"})
if plain.Name != "dev" {
t.Errorf("expected the username, got %q", plain.Name)
}
})
t.Run("Only the documented retention periods are valid", func(t *testing.T) {
for _, days := range []int{30, 90, 365, 0} {
if err := audit.ValidateRetentionDays(days); err != nil {
t.Errorf("%d should be valid: %v", days, err)
}
}
for _, days := range []int{7, -1, 366, 1} {
if err := audit.ValidateRetentionDays(days); !errors.Is(err, audit.ErrInvalidRetention) {
t.Errorf("%d should be invalid, got %v", days, err)
}
}
if audit.DefaultSettings().RetentionDays != 90 {
t.Errorf("the default retention must be 90 days")
}
})
t.Run("The cleanup is due once a day at or after 03:00", func(t *testing.T) {
at := func(day, hour, minute int) time.Time {
return time.Date(2026, time.October, day, hour, minute, 0, 0, time.UTC)
}
cases := []struct {
name string
last *time.Time
now time.Time
due bool
}{
{"never before 03:00", nil, at(6, 2, 59), false},
{"never at 03:00", nil, at(6, 3, 0), true},
{"never at 14:00", nil, at(6, 14, 0), true},
{"yesterday before 03:00", ptrTime(at(5, 3, 0)), at(6, 2, 59), false},
{"yesterday at 03:00", ptrTime(at(5, 3, 0)), at(6, 3, 0), true},
{"today already", ptrTime(at(6, 3, 0)), at(6, 4, 0), false},
{"tomorrow at 03:00", ptrTime(at(6, 3, 0)), at(7, 3, 0), true},
}
for _, c := range cases {
settings := audit.Settings{RetentionDays: 90, LastCleanupAt: c.last}
if got := settings.CleanupDue(c.now); got != c.due {
t.Errorf("%s: expected %v, got %v", c.name, c.due, got)
}
}
})
t.Run("The last 24 hours are never removed", func(t *testing.T) {
now := time.Date(2026, time.October, 6, 3, 0, 0, 0, time.UTC)
tooRecent := now.Add(-time.Hour)
if got := audit.ClampCutoff(now, tooRecent); !got.Equal(now.Add(-24 * time.Hour)) {
t.Errorf("expected the 24 hour floor, got %v", got)
}
older := now.Add(-48 * time.Hour)
if got := audit.ClampCutoff(now, older); !got.Equal(older) {
t.Errorf("expected the older cutoff to stay, got %v", got)
}
})
}
func ptrTime(t time.Time) *time.Time { return &t }