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 }