492 lines
17 KiB
Go
492 lines
17 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/internal/domain/apikey"
|
|
"RenewCMS/internal/domain/article"
|
|
"RenewCMS/internal/domain/audit"
|
|
"RenewCMS/internal/domain/user"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type activityWorld struct {
|
|
*activityHarness
|
|
users *mockUserRepo
|
|
articles *mockArticleRepo
|
|
images *mockImageRepo
|
|
keys *mockAPIKeyRepo
|
|
list *useCases.ListActivityUseCase
|
|
summary *useCases.GetActivitySummaryUseCase
|
|
export *useCases.ExportActivityUseCase
|
|
}
|
|
|
|
func newActivityWorld(t *testing.T) *activityWorld {
|
|
t.Helper()
|
|
h := newActivityHarness(t)
|
|
w := &activityWorld{
|
|
activityHarness: h,
|
|
users: newMockUserRepo(),
|
|
articles: newMockArticleRepo(),
|
|
images: newMockImageRepo(),
|
|
keys: newMockAPIKeyRepo(),
|
|
}
|
|
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner}
|
|
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
|
|
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"}
|
|
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites())
|
|
w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock)
|
|
w.export = useCases.NewExportActivityUseCase(h.probe, h.clock)
|
|
return w
|
|
}
|
|
|
|
func (w *activityWorld) seedAt(t *testing.T, ago time.Duration, mutate func(*audit.Event)) audit.Event {
|
|
t.Helper()
|
|
event := activityUserEvent(3, "Marta Alves", w.now.Add(-ago))
|
|
if mutate != nil {
|
|
mutate(&event)
|
|
}
|
|
return w.seed(t, event)
|
|
}
|
|
|
|
func TestActivityListUseCase(t *testing.T) {
|
|
const day = 24 * time.Hour
|
|
|
|
t.Run("Omitting the range means seven days", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
recent := w.seedAt(t, 2*day, nil)
|
|
w.seedAt(t, 10*day, nil)
|
|
|
|
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
|
|
|
|
if err != nil || len(page.Items) != 1 || page.Items[0].Event.ID != recent.ID {
|
|
t.Fatalf("expected only the recent event, got %+v, %v", page, err)
|
|
}
|
|
if page.Page != 1 || page.Size != 25 || page.Total != 1 {
|
|
t.Errorf("unexpected paging %d/%d/%d", page.Page, page.Size, page.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("The ranges map to the documented windows", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
query useCases.ActivityQuery
|
|
ago time.Duration
|
|
found bool
|
|
}{
|
|
{"today keeps this morning", useCases.ActivityQuery{Range: "today"}, 11 * time.Hour, true},
|
|
{"today drops yesterday", useCases.ActivityQuery{Range: "today"}, 13 * time.Hour, false},
|
|
{"7d keeps six days", useCases.ActivityQuery{Range: "7d"}, 6 * day, true},
|
|
{"7d drops eight days", useCases.ActivityQuery{Range: "7d"}, 8 * day, false},
|
|
{"30d keeps twenty nine days", useCases.ActivityQuery{Range: "30d"}, 29 * day, true},
|
|
{"30d drops thirty one days", useCases.ActivityQuery{Range: "30d"}, 31 * day, false},
|
|
{"custom keeps the first day", useCases.ActivityQuery{Range: "custom", From: "2026-09-01", To: "2026-09-30"}, 35*day + 12*time.Hour, true},
|
|
{"custom keeps the end of the last day", useCases.ActivityQuery{Range: "custom", From: "2026-09-01", To: "2026-09-30"}, 5*day + 12*time.Hour + time.Second, true},
|
|
{"custom drops after the last day", useCases.ActivityQuery{Range: "custom", From: "2026-09-01", To: "2026-09-30"}, 5 * day, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
w.seedAt(t, c.ago, nil)
|
|
|
|
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: c.query})
|
|
|
|
if err != nil || (page.Total == 1) != c.found {
|
|
t.Errorf("expected found=%v, got total %d, %v", c.found, page.Total, err)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Invalid values are refused before the repository is read", func(t *testing.T) {
|
|
queries := []useCases.ActivityQuery{
|
|
{Actor: "user:abc"}, {Actor: "user:-1"}, {Actor: "group:3"},
|
|
{Category: "billing"}, {Outcome: "maybe"}, {Security: "false"},
|
|
{Range: "year"}, {Range: "custom"},
|
|
{Range: "custom", From: "2026-09-30", To: "2026-09-01"},
|
|
{Range: "custom", From: "yesterday", To: "today"},
|
|
{Page: -1}, {Size: 10}, {Size: 200},
|
|
}
|
|
for i, query := range queries {
|
|
w := newActivityWorld(t)
|
|
|
|
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: query})
|
|
|
|
if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
|
|
t.Errorf("query %d (%+v): expected ErrInvalidFilter without reading, got %v after %d reads", i, query, err, w.probe.reads)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The outcome all lists both outcomes", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
w.seedAt(t, day, nil)
|
|
w.seedAt(t, day, func(e *audit.Event) { e.Outcome = audit.OutcomeFailed })
|
|
|
|
page, _ := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1, Query: useCases.ActivityQuery{Outcome: "all"}})
|
|
|
|
if page.Total != 2 {
|
|
t.Errorf("expected both outcomes, got %d", page.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("A caller who is not owner or admin is scoped to its own events", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
w.seedAt(t, day, nil)
|
|
w.seedAt(t, day, func(e *audit.Event) {
|
|
e.Actor = audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(2)), Name: "admin"}
|
|
})
|
|
w.seedAt(t, day, func(e *audit.Event) { e.Actor = audit.SystemActor() })
|
|
|
|
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3})
|
|
|
|
if err != nil || page.Total != 1 || *page.Items[0].Event.Actor.ID != 3 {
|
|
t.Fatalf("expected only user 3's events, got %+v, %v", page, err)
|
|
}
|
|
})
|
|
|
|
t.Run("A scoped caller cannot ask for another actor", func(t *testing.T) {
|
|
cases := map[string]bool{"user:3": true, "user:2": false, "key:7": false, "system": false}
|
|
for actor, allowed := range cases {
|
|
w := newActivityWorld(t)
|
|
|
|
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 3, Query: useCases.ActivityQuery{Actor: actor}})
|
|
|
|
if allowed && err != nil {
|
|
t.Errorf("%s: expected success, got %v", actor, err)
|
|
}
|
|
if !allowed && !errors.Is(err, audit.ErrActorNotAllowed) {
|
|
t.Errorf("%s: expected ErrActorNotAllowed, got %v", actor, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("An unknown viewer is reported", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
|
|
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 99})
|
|
|
|
if !errors.Is(err, user.ErrUserNotFound) {
|
|
t.Errorf("expected ErrUserNotFound, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("target.exists is resolved when the list is read", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
target audit.Target
|
|
seed func(*activityWorld)
|
|
exists bool
|
|
}{
|
|
{"the article still exists", audit.Target{Type: "article", ID: "8", Name: "Spring release"}, func(w *activityWorld) { w.articles.articles[8] = article.Article{ID: 8} }, true},
|
|
{"the article was deleted", audit.Target{Type: "article", ID: "8", Name: "Spring release"}, func(w *activityWorld) {}, false},
|
|
{"the user was deleted", audit.Target{Type: "user", ID: "3", Name: "Marta Alves"}, func(w *activityWorld) { delete(w.users.users, 3) }, false},
|
|
{"the api key still exists", audit.Target{Type: "api_key", ID: "7", Name: "Deploy bot"}, func(w *activityWorld) { w.keys.keys[7] = apikey.APIKey{ID: 7} }, true},
|
|
{"the api key is unknown", audit.Target{Type: "api_key", ID: "7", Name: "Deploy bot"}, func(w *activityWorld) {}, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
c.seed(w)
|
|
w.seedAt(t, day, func(e *audit.Event) { e.Target = c.target })
|
|
|
|
page, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
|
|
|
|
if err != nil || len(page.Items) != 1 {
|
|
t.Fatalf("unexpected result %+v, %v", page, err)
|
|
}
|
|
if page.Items[0].TargetExists != c.exists || page.Items[0].Event.Target.Name != c.target.Name {
|
|
t.Errorf("expected exists=%v with the name kept, got %+v", c.exists, page.Items[0])
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A repository failure is returned", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
w.users.fail.failNext("Get")
|
|
|
|
_, err := w.list.ListActivity(useCases.ListActivityCommand{ViewerID: 1})
|
|
|
|
if !errors.Is(err, errRepositoryFailure) {
|
|
t.Errorf("expected the repository failure, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestActivitySummaryUseCase(t *testing.T) {
|
|
const day = 24 * time.Hour
|
|
|
|
t.Run("The summary of the last seven days compares with the previous period", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
for i := 0; i < 12; i++ {
|
|
i := i
|
|
w.seedAt(t, time.Duration(i)*12*time.Hour+time.Hour, func(e *audit.Event) {
|
|
if i < 3 {
|
|
e.Outcome = audit.OutcomeFailed
|
|
}
|
|
e.Security = i < 2
|
|
if i%2 == 1 {
|
|
e.Actor = audit.Actor{Type: audit.ActorAPIKey, ID: activityPtr(uint32(7)), Name: "bot"}
|
|
}
|
|
})
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
w.seedAt(t, 7*day+time.Duration(i)*time.Hour+time.Hour, nil)
|
|
}
|
|
|
|
summary, err := w.summary.GetActivitySummary(useCases.ActivityQuery{Range: "7d"})
|
|
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if summary.Total != 12 || summary.PreviousTotal != 10 || summary.Failed != 3 || summary.Security != 2 {
|
|
t.Errorf("unexpected counts %+v", summary)
|
|
}
|
|
if summary.ActivePeople.Users != 1 || summary.ActivePeople.APIKeys != 1 {
|
|
t.Errorf("unexpected people %+v", summary.ActivePeople)
|
|
}
|
|
sum := 0
|
|
for _, n := range summary.Series.Total {
|
|
sum += n
|
|
}
|
|
if len(summary.Series.Total) != 7 || len(summary.Series.Active) != 7 || len(summary.Series.Failed) != 7 || len(summary.Series.Security) != 7 || sum != 12 {
|
|
t.Errorf("unexpected series %+v", summary.Series)
|
|
}
|
|
})
|
|
|
|
t.Run("An empty log answers zeros", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
|
|
summary, err := w.summary.GetActivitySummary(useCases.ActivityQuery{})
|
|
|
|
if err != nil || summary.Total != 0 || summary.PreviousTotal != 0 || len(summary.Series.Total) != 7 {
|
|
t.Errorf("unexpected summary %+v, %v", summary, err)
|
|
}
|
|
for _, n := range append(append(summary.Series.Total, summary.Series.Active...), append(summary.Series.Failed, summary.Series.Security...)...) {
|
|
if n != 0 {
|
|
t.Errorf("expected only zeros, got %+v", summary.Series)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("A custom range compares with the seven days immediately before it", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
at := func(day int, month time.Month) {
|
|
w.seed(t, activityUserEvent(3, "a", time.Date(2026, month, day, 12, 0, 0, 0, time.UTC)))
|
|
}
|
|
at(1, time.September)
|
|
at(7, time.September)
|
|
at(8, time.September)
|
|
at(25, time.August)
|
|
at(31, time.August)
|
|
at(24, time.August)
|
|
|
|
summary, err := w.summary.GetActivitySummary(useCases.ActivityQuery{Range: "custom", From: "2026-09-01", To: "2026-09-07"})
|
|
|
|
if err != nil || summary.Total != 2 || summary.PreviousTotal != 2 {
|
|
t.Errorf("expected 2 and 2, got %+v, %v", summary, err)
|
|
}
|
|
})
|
|
|
|
t.Run("An invalid range is refused", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
|
|
_, err := w.summary.GetActivitySummary(useCases.ActivityQuery{Range: "year"})
|
|
|
|
if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
|
|
t.Errorf("expected ErrInvalidFilter without reading, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func exportToString(t *testing.T, w *activityWorld, query useCases.ActivityQuery) string {
|
|
t.Helper()
|
|
prepared, err := w.export.Prepare(query)
|
|
if err != nil {
|
|
t.Fatalf("prepare failed: %v", err)
|
|
}
|
|
var out bytes.Buffer
|
|
if err := prepared.WriteCSV(&out); err != nil {
|
|
t.Fatalf("write failed: %v", err)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func TestActivityExportUseCase(t *testing.T) {
|
|
t.Run("The file has the header and one row per event, newest first", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
for i := 1; i <= 3; i++ {
|
|
w.seedAt(t, time.Duration(i)*time.Hour, func(e *audit.Event) { e.Action = fmt.Sprintf("article.edit%d", i) })
|
|
}
|
|
|
|
rows, err := csv.NewReader(strings.NewReader(exportToString(t, w, useCases.ActivityQuery{}))).ReadAll()
|
|
|
|
if err != nil || len(rows) != 4 {
|
|
t.Fatalf("unexpected rows %v, %v", rows, err)
|
|
}
|
|
if strings.Join(rows[0], ",") != "when,actor,action,target,outcome,source,ip" {
|
|
t.Errorf("unexpected header %v", rows[0])
|
|
}
|
|
if rows[1][2] != "article.edit1" || rows[3][2] != "article.edit3" {
|
|
t.Errorf("expected newest first, got %v", rows)
|
|
}
|
|
})
|
|
|
|
t.Run("A row holds the documented columns only", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
w.seed(t, func() audit.Event {
|
|
event := activityUserEvent(3, "Marta Alves", time.Date(2026, time.October, 6, 10, 42, 18, 0, time.UTC))
|
|
event.Target = audit.Target{Type: "article", ID: "8", Name: "Spring release"}
|
|
event.IP = activityPtr("203.0.113.12")
|
|
event.UserAgent = activityPtr("Chrome 129 on Windows")
|
|
event.Changes = []audit.Change{{Field: "online", Kind: audit.ChangeText, Before: activityPtr("false"), After: activityPtr("true")}}
|
|
return event
|
|
}())
|
|
|
|
file := exportToString(t, w, useCases.ActivityQuery{})
|
|
|
|
want := "2026-10-06T10:42:18Z,Marta Alves,article.publish,Spring release,success,web,203.0.113.12\n"
|
|
if !strings.HasSuffix(file, want) {
|
|
t.Errorf("expected the row %q, got %q", want, file)
|
|
}
|
|
if strings.Contains(file, "Chrome") || strings.Contains(file, "false") {
|
|
t.Errorf("the file must hold no user agent and no changes: %q", file)
|
|
}
|
|
})
|
|
|
|
t.Run("Cells that a spreadsheet would evaluate are neutralised", func(t *testing.T) {
|
|
cases := []struct {
|
|
column string
|
|
value string
|
|
cell string
|
|
}{
|
|
{"actor", "=cmd", "'=cmd"},
|
|
{"target", "+1+1", "'+1+1"},
|
|
{"target", "-2", "'-2"},
|
|
{"actor", "@SUM(A1)", "'@SUM(A1)"},
|
|
{"target", "Spring release", "Spring release"},
|
|
{"target", "a=b", "a=b"},
|
|
}
|
|
for _, c := range cases {
|
|
w := newActivityWorld(t)
|
|
w.seedAt(t, time.Hour, func(e *audit.Event) {
|
|
if c.column == "actor" {
|
|
e.Actor.Name = c.value
|
|
} else {
|
|
e.Target = audit.Target{Type: "article", ID: "1", Name: c.value}
|
|
}
|
|
})
|
|
|
|
rows, _ := csv.NewReader(strings.NewReader(exportToString(t, w, useCases.ActivityQuery{}))).ReadAll()
|
|
|
|
index := 1
|
|
if c.column == "target" {
|
|
index = 3
|
|
}
|
|
if rows[1][index] != c.cell {
|
|
t.Errorf("%s %q: expected %q, got %q", c.column, c.value, c.cell, rows[1][index])
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Commas, quotes and newlines are quoted and keep one row", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
name := "Hello, \"world\"\nsecond line"
|
|
w.seedAt(t, time.Hour, func(e *audit.Event) { e.Target = audit.Target{Type: "article", ID: "1", Name: name} })
|
|
|
|
file := exportToString(t, w, useCases.ActivityQuery{})
|
|
|
|
if !strings.Contains(file, "\"Hello, \"\"world\"\"\nsecond line\"") {
|
|
t.Errorf("the cell is not quoted with doubled quotes: %q", file)
|
|
}
|
|
rows, err := csv.NewReader(strings.NewReader(file)).ReadAll()
|
|
if err != nil || len(rows) != 2 || rows[1][3] != name {
|
|
t.Errorf("expected one data row holding the name, got %v, %v", rows, err)
|
|
}
|
|
})
|
|
|
|
t.Run("The filters apply and paging is ignored", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
for i := 0; i < 60; i++ {
|
|
failed := i < 8
|
|
w.seedAt(t, time.Duration(i+1)*time.Minute, func(e *audit.Event) {
|
|
if failed {
|
|
e.Outcome = audit.OutcomeFailed
|
|
}
|
|
})
|
|
}
|
|
|
|
rows, _ := csv.NewReader(strings.NewReader(exportToString(t, w, useCases.ActivityQuery{Outcome: "failed", Page: 2, Size: 25}))).ReadAll()
|
|
|
|
if len(rows) != 9 {
|
|
t.Errorf("expected the header and 8 failed rows, got %d lines", len(rows))
|
|
}
|
|
})
|
|
|
|
t.Run("A system event has an empty ip cell", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
w.seedAt(t, time.Hour, func(e *audit.Event) { e.Actor = audit.SystemActor(); e.Source = audit.SourceSystem })
|
|
|
|
rows, _ := csv.NewReader(strings.NewReader(exportToString(t, w, useCases.ActivityQuery{}))).ReadAll()
|
|
|
|
if rows[1][6] != "" || rows[1][1] != "System" {
|
|
t.Errorf("unexpected row %v", rows[1])
|
|
}
|
|
})
|
|
|
|
t.Run("An invalid filter is refused before anything is prepared", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
|
|
_, err := w.export.Prepare(useCases.ActivityQuery{Outcome: "maybe"})
|
|
|
|
if !errors.Is(err, audit.ErrInvalidFilter) || w.probe.reads != 0 {
|
|
t.Errorf("expected ErrInvalidFilter without reading, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("A large export is read in batches", func(t *testing.T) {
|
|
w := newActivityWorld(t)
|
|
for i := 0; i < 1200; i++ {
|
|
w.seedAt(t, time.Duration(i+1)*time.Second, nil)
|
|
}
|
|
|
|
rows, err := csv.NewReader(strings.NewReader(exportToString(t, w, useCases.ActivityQuery{}))).ReadAll()
|
|
|
|
if err != nil || len(rows) != 1201 {
|
|
t.Fatalf("expected 1200 rows plus the header, got %d, %v", len(rows), err)
|
|
}
|
|
if len(w.probe.listCalls) < 2 {
|
|
t.Errorf("expected several paged reads, got %d", len(w.probe.listCalls))
|
|
}
|
|
for _, call := range w.probe.listCalls {
|
|
if call.Size == 0 || call.Size >= 1200 {
|
|
t.Errorf("every read must be a bounded page, got size %d", call.Size)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("An export that fills whole batches stops without an extra read", func(t *testing.T) {
|
|
for _, c := range []struct{ events, reads int }{{500, 1}, {1000, 2}} {
|
|
w := newActivityWorld(t)
|
|
for i := 0; i < c.events; i++ {
|
|
w.seedAt(t, time.Duration(i+1)*time.Second, nil)
|
|
}
|
|
|
|
rows, err := csv.NewReader(strings.NewReader(exportToString(t, w, useCases.ActivityQuery{}))).ReadAll()
|
|
|
|
if err != nil || len(rows) != c.events+1 {
|
|
t.Fatalf("%d events: expected %d rows, got %d, %v", c.events, c.events+1, len(rows), err)
|
|
}
|
|
if len(w.probe.listCalls) != c.reads {
|
|
t.Errorf("%d events: expected %d reads, got %d", c.events, c.reads, len(w.probe.listCalls))
|
|
}
|
|
}
|
|
})
|
|
}
|