200 lines
6.9 KiB
Go
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", "authors", "media", "sites", "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 }
|