feat: activity log backend foundation
This commit is contained in:
1 parent
8907bb49fe
commit
50b1649965
74 files changed
+5949
-239
No files matched your search
@@ -70,7 +70,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
||||
registerHandler := auth.NewLoginHandler(
|
||||
f.token,
|
||||
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, f.hasher),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, f.hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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", "List", "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 }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,173 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActivityLogBackendQAResetResponseIsIndistinguishable(t *testing.T) {
|
||||
t.Run("unknown email answers the same bytes as an existing account", func(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
|
||||
known := e.send(http.MethodPost, "/v1/auth/password-reset", `{"email":"marta@example.com"}`)
|
||||
unknown := e.send(http.MethodPost, "/v1/auth/password-reset", `{"email":"nobody@example.com"}`)
|
||||
|
||||
if known.Code != unknown.Code || known.Body.String() != unknown.Body.String() {
|
||||
t.Errorf("responses differ: %d %q versus %d %q", known.Code, known.Body.String(), unknown.Code, unknown.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityLogBackendQAProducersSurviveRecorderFailure(t *testing.T) {
|
||||
type producer func(e *activityHTTPEnv) *httptest.ResponseRecorder
|
||||
producers := map[string]producer{
|
||||
"login failure": func(e *activityHTTPEnv) *httptest.ResponseRecorder {
|
||||
return e.send(http.MethodPost, "/v1/auth/login", `{"username":"marta","password":"wrongpassword1"}`)
|
||||
},
|
||||
"logout": func(e *activityHTTPEnv) *httptest.ResponseRecorder {
|
||||
return e.send(http.MethodPost, "/v1/auth/logout", "", asUser(e, activityOwnerID))
|
||||
},
|
||||
"password reset request": func(e *activityHTTPEnv) *httptest.ResponseRecorder {
|
||||
return e.send(http.MethodPost, "/v1/auth/password-reset", `{"email":"marta@example.com"}`)
|
||||
},
|
||||
"api key creation": func(e *activityHTTPEnv) *httptest.ResponseRecorder {
|
||||
return e.send(http.MethodPost, "/v1/api-key", `{"name":"Deploy bot"}`, asUser(e, activityOwnerID))
|
||||
},
|
||||
"api key revoke": func(e *activityHTTPEnv) *httptest.ResponseRecorder {
|
||||
_, id := e.issueKey(t, activityOwnerID, "Deploy bot")
|
||||
return e.send(http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", asUser(e, activityOwnerID))
|
||||
},
|
||||
"retention change": func(e *activityHTTPEnv) *httptest.ResponseRecorder {
|
||||
return e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityOwnerID))
|
||||
},
|
||||
}
|
||||
scrub := regexp.MustCompile(`"(id|key|prefix|created_at)":\s*("[^"]*"|[0-9]+)`)
|
||||
for name, run := range producers {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
healthy := run(newActivityHTTPEnv(t, 1000))
|
||||
broken := newActivityHTTPEnv(t, 1000)
|
||||
broken.probe.appendErr = errDiskFull
|
||||
|
||||
got := run(broken)
|
||||
|
||||
if got.Code != healthy.Code || scrub.ReplaceAllString(got.Body.String(), "") != scrub.ReplaceAllString(healthy.Body.String(), "") {
|
||||
t.Errorf("a failing recorder changed the answer: %d %q versus %d %q", got.Code, got.Body.String(), healthy.Code, healthy.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityLogBackendQAGuards(t *testing.T) {
|
||||
read := func(t *testing.T, root string, suffix string) map[string]string {
|
||||
t.Helper()
|
||||
files := map[string]string{}
|
||||
err := filepath.Walk(filepath.Join("..", root), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, suffix) {
|
||||
return err
|
||||
}
|
||||
content, readErr := os.ReadFile(path)
|
||||
files[path] = string(content)
|
||||
return readErr
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("no files under %s", root)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
t.Run("The audit repository source uses no dialect specific SQL", func(t *testing.T) {
|
||||
for path, source := range read(t, "internal/infrastructure/persistence/repositories", ".go") {
|
||||
if !strings.Contains(filepath.Base(path), "audit") {
|
||||
continue
|
||||
}
|
||||
for _, banned := range []string{"ILIKE", "strftime", "date_trunc"} {
|
||||
if strings.Contains(source, banned) {
|
||||
t.Errorf("%s contains %s", path, banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The audit domain imports only the stdlib and domain packages", func(t *testing.T) {
|
||||
importLine := regexp.MustCompile(`(?m)^\s*"([^"]+)"$`)
|
||||
for path, source := range read(t, "internal/domain/audit", ".go") {
|
||||
for _, match := range importLine.FindAllStringSubmatch(source, -1) {
|
||||
if strings.Contains(match[1], ".") && !strings.HasPrefix(match[1], "RenewCMS/internal/domain/") {
|
||||
t.Errorf("%s imports %s", path, match[1])
|
||||
}
|
||||
if strings.HasPrefix(match[1], "RenewCMS/") && !strings.HasPrefix(match[1], "RenewCMS/internal/domain/") {
|
||||
t.Errorf("%s imports %s", path, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The activity use cases and handlers keep their layering", func(t *testing.T) {
|
||||
banned := []string{"gorm.io", "infrastructure/persistence", "infrastructure/mailer", "net/http", "go-chi", "RenewCMS/api"}
|
||||
for path, source := range read(t, "internal/infrastructure/useCases", ".go") {
|
||||
base := filepath.Base(path)
|
||||
if !strings.Contains(base, "Activity") && !strings.Contains(base, "activity") {
|
||||
continue
|
||||
}
|
||||
for _, word := range banned {
|
||||
if strings.Contains(source, word+`"`) || strings.Contains(source, `"`+word) {
|
||||
t.Errorf("%s imports %s", path, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path, source := range read(t, "api/handlers/activity", ".go") {
|
||||
for _, word := range []string{"gorm.io", "persistence", "infrastructure/mailer"} {
|
||||
if strings.Contains(source, word) {
|
||||
t.Errorf("%s references %s", path, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The persistence repository asserts the audit contract", func(t *testing.T) {
|
||||
for path, source := range read(t, "internal/infrastructure/persistence/repositories", "auditEventRepository.go") {
|
||||
if !strings.Contains(source, "var _ audit.Repository = &") {
|
||||
t.Errorf("%s lacks the interface assertion", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The new Go files hold no comment", func(t *testing.T) {
|
||||
line := regexp.MustCompile(`(?m)^\s*//`)
|
||||
for _, root := range []string{"internal/domain/audit", "internal/infrastructure/auditing", "api/handlers/activity"} {
|
||||
for path, source := range read(t, root, ".go") {
|
||||
if line.MatchString(source) {
|
||||
t.Errorf("%s holds a comment", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The docs describe the activity surface", func(t *testing.T) {
|
||||
wants := map[string][]string{
|
||||
"docs/api.md": {"/v1/activity", "/v1/activity/summary", "/v1/activity/export", "activity_retention_days"},
|
||||
"docs/architecture.md": {"audit"},
|
||||
"docs/configuration.md": {"retention"},
|
||||
}
|
||||
for doc, needles := range wants {
|
||||
content, err := os.ReadFile(filepath.Join("..", doc))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if !strings.Contains(string(content), needle) {
|
||||
t.Errorf("%s does not mention %s", doc, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
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); 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", "List", "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); 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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestActivityRecorder(t *testing.T) {
|
||||
t.Run("A failing repository does not fail the caller and the failure is logged", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.probe.appendErr = errDiskFull
|
||||
|
||||
h.recorder.Record(testCtx, activityValidEvent())
|
||||
|
||||
logs := h.logs.String()
|
||||
if !strings.Contains(logs, "audit.test") || !strings.Contains(logs, "disk full") {
|
||||
t.Errorf("expected a log line naming the write and the error, got %q", logs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A panicking repository does not crash the caller", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.probe.appendPanic = true
|
||||
|
||||
h.recorder.Record(testCtx, activityValidEvent())
|
||||
|
||||
if !strings.Contains(h.logs.String(), "panicked") {
|
||||
t.Errorf("expected the panic to be logged, got %q", h.logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The recorder fills the event from the audit context", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.now = time.Date(2026, time.October, 6, 14, 0, 0, 0, time.FixedZone("CEST", 2*3600))
|
||||
ctx := audit.WithOrigin(testCtx, audit.Origin{
|
||||
Actor: audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(3)), Name: "marta"},
|
||||
Source: audit.SourceWeb,
|
||||
IP: "203.0.113.12",
|
||||
UserAgent: "Mozilla/5.0 test",
|
||||
})
|
||||
event := audit.NewEvent("audit.test", audit.CategorySystem)
|
||||
|
||||
h.recorder.Record(ctx, event)
|
||||
|
||||
stored := h.all(t)
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("expected one event, got %d", len(stored))
|
||||
}
|
||||
got := stored[0]
|
||||
if got.Actor.Type != audit.ActorUser || *got.Actor.ID != 3 || got.Actor.Name != "marta" || got.Source != audit.SourceWeb {
|
||||
t.Errorf("unexpected actor or source %+v", got)
|
||||
}
|
||||
if got.IP == nil || *got.IP != "203.0.113.12" || got.UserAgent == nil || *got.UserAgent != "Mozilla/5.0 test" {
|
||||
t.Errorf("unexpected ip or user agent %v %v", got.IP, got.UserAgent)
|
||||
}
|
||||
if !got.CreatedAt.Equal(h.now) || got.CreatedAt.Location() != time.UTC {
|
||||
t.Errorf("expected created_at %v in UTC, got %v", h.now.UTC(), got.CreatedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Values already on the event are kept", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
ctx := audit.WithOrigin(testCtx, audit.Origin{Actor: audit.AnonymousActor("x"), Source: audit.SourceWeb, IP: "203.0.113.12", UserAgent: "ua"})
|
||||
event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem)
|
||||
event.Actor = audit.SystemActor()
|
||||
event.Source = audit.SourceSystem
|
||||
|
||||
h.recorder.Record(ctx, event)
|
||||
|
||||
got := h.all(t)[0]
|
||||
if got.Actor.Type != audit.ActorSystem || got.Source != audit.SourceSystem {
|
||||
t.Errorf("the explicit actor and source must win, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An event outside the vocabulary is dropped and logged", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
event := activityValidEvent()
|
||||
event.Category = "billing"
|
||||
|
||||
h.recorder.Record(testCtx, event)
|
||||
|
||||
if len(h.all(t)) != 0 || !strings.Contains(h.logs.String(), "dropped") {
|
||||
t.Errorf("expected nothing stored and a log line, got %q", h.logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Long names are bounded", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
event := activityValidEvent()
|
||||
event.Target = audit.Target{Type: "article", ID: "1", Name: strings.Repeat("é", 400)}
|
||||
|
||||
h.recorder.Record(testCtx, event)
|
||||
|
||||
if got := h.all(t); len(got) != 1 || len([]rune(got[0].Target.Name)) != 255 {
|
||||
t.Errorf("expected one event with a 255 character target name, got %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func activityValidEvent() audit.Event {
|
||||
event := audit.NewEvent("audit.test", audit.CategorySystem)
|
||||
event.Actor = audit.SystemActor()
|
||||
event.Source = audit.SourceSystem
|
||||
return event
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/infrastructure/scheduler"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestActivityRetentionUseCases(t *testing.T) {
|
||||
ownerCtx := activityOwnerCtx
|
||||
|
||||
t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
settings, err := useCases.NewGetActivityRetentionUseCase(h.settings).GetActivityRetention()
|
||||
|
||||
if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 {
|
||||
t.Errorf("unexpected default %+v, %v", settings, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The owner sets a valid period", func(t *testing.T) {
|
||||
for _, days := range []int{30, 90, 365, 0} {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), days)
|
||||
|
||||
if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days {
|
||||
t.Errorf("%d: unexpected result %+v, %v", days, settings, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Any other value is refused and changes nothing", func(t *testing.T) {
|
||||
for _, days := range []int{7, -1, 366} {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), days)
|
||||
|
||||
if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 {
|
||||
t.Errorf("%d: expected a refusal, got %v", days, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A change is recorded as a system event by the owner", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 30)
|
||||
|
||||
events := h.all(t)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("expected one event, got %d, %v", len(events), err)
|
||||
}
|
||||
event := events[0]
|
||||
if event.Action != "system.activity_retention_changed" || event.Category != audit.CategorySystem || event.Actor.Name != "owner" || event.Source != audit.SourceWeb || event.Outcome != audit.OutcomeSuccess || event.Security {
|
||||
t.Errorf("unexpected event %+v", event)
|
||||
}
|
||||
if event.Params["from"] != float64(90) || event.Params["to"] != float64(30) {
|
||||
t.Errorf("unexpected params %v", event.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Setting the same value records nothing", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
|
||||
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 90)
|
||||
|
||||
if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 {
|
||||
t.Errorf("expected no event and no write, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing save is returned and records nothing", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.settings.saveErr = errRepositoryFailure
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 30)
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 {
|
||||
t.Errorf("expected the failure and no event, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func cleanupAt(day int, hour int, minute int) time.Time {
|
||||
return time.Date(2026, time.October, day, hour, minute, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func activityOwnerCtx() context.Context {
|
||||
return audit.WithOrigin(testCtx, audit.Origin{
|
||||
Actor: audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(1)), Name: "owner"},
|
||||
Source: audit.SourceWeb,
|
||||
})
|
||||
}
|
||||
|
||||
func newCleanup(h *activityHarness) *useCases.CleanupActivityUseCase {
|
||||
return useCases.NewCleanupActivityUseCase(h.probe, h.settings, h.recorder)
|
||||
}
|
||||
|
||||
func TestActivityCleanupUseCase(t *testing.T) {
|
||||
t.Run("The cleanup is due once a day at or after 03:00", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
last *time.Time
|
||||
now time.Time
|
||||
ran bool
|
||||
}{
|
||||
{"never at 02:59", nil, cleanupAt(6, 2, 59), false},
|
||||
{"never at 03:00", nil, cleanupAt(6, 3, 0), true},
|
||||
{"never at 14:00", nil, cleanupAt(6, 14, 0), true},
|
||||
{"yesterday at 02:59", ptrTime(cleanupAt(5, 3, 0)), cleanupAt(6, 2, 59), false},
|
||||
{"yesterday at 03:00", ptrTime(cleanupAt(5, 3, 0)), cleanupAt(6, 3, 0), true},
|
||||
{"today at 04:00", ptrTime(cleanupAt(6, 3, 0)), cleanupAt(6, 4, 0), false},
|
||||
{"tomorrow at 03:00", ptrTime(cleanupAt(6, 3, 0)), cleanupAt(7, 3, 0), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.settings.settings.LastCleanupAt = c.last
|
||||
h.now = c.now
|
||||
|
||||
_, ran, err := newCleanup(h).Tick(c.now)
|
||||
|
||||
if err != nil || ran != c.ran {
|
||||
t.Errorf("expected ran=%v, got %v, %v", c.ran, ran, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Forever never runs", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.settings.settings.RetentionDays = 0
|
||||
|
||||
_, ran, err := newCleanup(h).Tick(cleanupAt(6, 3, 0))
|
||||
|
||||
if err != nil || ran || h.probe.deleteCalls != 0 {
|
||||
t.Errorf("expected nothing to run, got ran=%v, %v, %d deletes", ran, err, h.probe.deleteCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The cutoff is now minus the retention days", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.settings.settings.RetentionDays = 30
|
||||
now := cleanupAt(6, 3, 0)
|
||||
h.now = now
|
||||
var kept []uint64
|
||||
for _, days := range []int{40, 31, 29, 1} {
|
||||
id := h.seed(t, activityUserEvent(1, "a", now.Add(-time.Duration(days)*24*time.Hour))).ID
|
||||
if days < 30 {
|
||||
kept = append(kept, id)
|
||||
}
|
||||
}
|
||||
|
||||
result, ran, err := newCleanup(h).Tick(now)
|
||||
|
||||
if err != nil || !ran || result.Removed != 2 {
|
||||
t.Fatalf("expected 2 removed, got %+v ran=%v %v", result, ran, err)
|
||||
}
|
||||
remaining := map[uint64]bool{}
|
||||
for _, event := range h.all(t) {
|
||||
remaining[event.ID] = true
|
||||
}
|
||||
for _, id := range kept {
|
||||
if !remaining[id] {
|
||||
t.Errorf("event %d should remain", id)
|
||||
}
|
||||
}
|
||||
last := h.settings.settings
|
||||
if last.LastCleanupAt == nil || !last.LastCleanupAt.Equal(now) || last.LastCleanupRemoved != 2 {
|
||||
t.Errorf("unexpected last cleanup %+v", last)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A cleanup that removes nothing still records the run and no event", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
now := cleanupAt(6, 3, 0)
|
||||
h.now = now
|
||||
h.seed(t, activityUserEvent(1, "a", now.Add(-24*time.Hour)))
|
||||
|
||||
result, ran, err := newCleanup(h).Tick(now)
|
||||
|
||||
if err != nil || !ran || result.Removed != 0 {
|
||||
t.Fatalf("unexpected result %+v ran=%v %v", result, ran, err)
|
||||
}
|
||||
if h.settings.cleanupOK != 1 || h.settings.settings.LastCleanupAt == nil || !h.settings.settings.LastCleanupAt.Equal(now) || len(h.all(t)) != 1 {
|
||||
t.Errorf("expected the run recorded without event, got %+v with %d events", h.settings.settings, len(h.all(t)))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A cleanup that removes events records a system event after deleting", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
now := cleanupAt(6, 3, 0)
|
||||
h.now = now
|
||||
for i := 0; i < 142; i++ {
|
||||
h.seed(t, activityUserEvent(1, "a", now.Add(-100*24*time.Hour)))
|
||||
}
|
||||
|
||||
result, _, err := newCleanup(h).Tick(now)
|
||||
|
||||
events := h.all(t)
|
||||
if err != nil || result.Removed != 142 || len(events) != 1 {
|
||||
t.Fatalf("expected 142 removed and one event, got %+v, %d events, %v", result, len(events), err)
|
||||
}
|
||||
event := events[0]
|
||||
if event.Action != "system.activity_cleanup" || event.Category != audit.CategorySystem || event.Actor.Type != audit.ActorSystem || event.Source != audit.SourceSystem || event.IP != nil || event.UserAgent != nil || event.Outcome != audit.OutcomeSuccess {
|
||||
t.Errorf("unexpected event %+v", event)
|
||||
}
|
||||
if event.Params["removed"] != float64(142) {
|
||||
t.Errorf("unexpected params %v", event.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing delete is retried at the next tick", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
now := cleanupAt(6, 3, 0)
|
||||
h.now = now
|
||||
h.probe.deleteErr = errRepositoryFailure
|
||||
cleanup := newCleanup(h)
|
||||
|
||||
_, ran, err := cleanup.Tick(now)
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || ran || h.settings.settings.LastCleanupAt != nil {
|
||||
t.Fatalf("expected the failure without a recorded run, got ran=%v %v", ran, err)
|
||||
}
|
||||
h.probe.deleteErr = nil
|
||||
_, ran, err = cleanup.Tick(now.Add(time.Hour))
|
||||
if err != nil || !ran {
|
||||
t.Errorf("expected the next tick to run, got ran=%v %v", ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing save of the run is returned", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
h.settings.saveErr = errRepositoryFailure
|
||||
|
||||
_, ran, err := newCleanup(h).Tick(cleanupAt(6, 3, 0))
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || ran {
|
||||
t.Errorf("expected the failure, got ran=%v %v", ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A shortened period applies at the next cleanup, not at the change", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
now := cleanupAt(6, 3, 0)
|
||||
h.now = now
|
||||
h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour)))
|
||||
|
||||
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(activityOwnerCtx(), 30)
|
||||
if err != nil || len(h.all(t)) != 2 {
|
||||
t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err)
|
||||
}
|
||||
|
||||
result, _, _ := newCleanup(h).Tick(now)
|
||||
|
||||
if result.Removed != 1 {
|
||||
t.Errorf("expected the old event removed at the next cleanup, got %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type cleanupJobFake struct {
|
||||
calls chan time.Time
|
||||
removed int
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *cleanupJobFake) Tick(now time.Time) (audit.CleanupResult, bool, error) {
|
||||
c.calls <- now
|
||||
return audit.CleanupResult{Removed: c.removed}, c.err == nil, c.err
|
||||
}
|
||||
|
||||
func TestActivityCleanupScheduler(t *testing.T) {
|
||||
start := func(job *cleanupJobFake) (chan time.Time, chan time.Time, *strings.Builder, context.CancelFunc, chan struct{}) {
|
||||
ticks := make(chan time.Time, 4)
|
||||
startup := make(chan time.Time, 1)
|
||||
logs := &strings.Builder{}
|
||||
runner := scheduler.NewCleanupRunner(job, func() time.Time { return cleanupAt(6, 3, 0) }, ticks, startup, log.New(logs, "", 0))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
return ticks, startup, logs, cancel, done
|
||||
}
|
||||
|
||||
t.Run("The runner ticks the cleanup with the injected clock and logs the count", func(t *testing.T) {
|
||||
job := &cleanupJobFake{calls: make(chan time.Time, 4), removed: 142}
|
||||
ticks, _, logs, cancel, done := start(job)
|
||||
defer func() { cancel(); <-done }()
|
||||
|
||||
ticks <- time.Time{}
|
||||
select {
|
||||
case got := <-job.calls:
|
||||
if !got.Equal(cleanupAt(6, 3, 0)) {
|
||||
t.Errorf("expected the injected clock, got %v", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the job was never ticked")
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
if !strings.Contains(logs.String(), "activity cleanup completed: removed=142") {
|
||||
t.Errorf("unexpected log %q", logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing tick is logged", func(t *testing.T) {
|
||||
job := &cleanupJobFake{calls: make(chan time.Time, 4), err: errRepositoryFailure}
|
||||
_, startup, logs, cancel, done := start(job)
|
||||
|
||||
startup <- time.Time{}
|
||||
<-job.calls
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
if !strings.Contains(logs.String(), "activity cleanup failed") || !strings.Contains(logs.String(), errRepositoryFailure.Error()) {
|
||||
t.Errorf("unexpected log %q", logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The scheduler stops with the process", func(t *testing.T) {
|
||||
job := &cleanupJobFake{calls: make(chan time.Time, 4)}
|
||||
_, _, _, cancel, done := start(job)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the runner did not return after the context was cancelled")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/infrastructure/auditing"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var testCtx = context.Background()
|
||||
|
||||
var discardRecorder audit.Recorder = audit.Discard
|
||||
|
||||
type activityProbeRepo struct {
|
||||
inner audit.Repository
|
||||
appendErr error
|
||||
appendPanic bool
|
||||
reads int
|
||||
listCalls []audit.Filter
|
||||
deleteErr error
|
||||
deleteCalls int
|
||||
readErr error
|
||||
}
|
||||
|
||||
var _ audit.Repository = &activityProbeRepo{}
|
||||
|
||||
func (p *activityProbeRepo) Append(event audit.Event) (audit.Event, error) {
|
||||
if p.appendPanic {
|
||||
panic("audit repository exploded")
|
||||
}
|
||||
if p.appendErr != nil {
|
||||
return audit.Event{}, p.appendErr
|
||||
}
|
||||
return p.inner.Append(event)
|
||||
}
|
||||
|
||||
func (p *activityProbeRepo) List(filter audit.Filter) (audit.Page, error) {
|
||||
p.reads++
|
||||
p.listCalls = append(p.listCalls, filter)
|
||||
if p.readErr != nil {
|
||||
return audit.Page{}, p.readErr
|
||||
}
|
||||
return p.inner.List(filter)
|
||||
}
|
||||
|
||||
func (p *activityProbeRepo) Summary(query audit.SummaryQuery) (audit.Summary, error) {
|
||||
p.reads++
|
||||
if p.readErr != nil {
|
||||
return audit.Summary{}, p.readErr
|
||||
}
|
||||
return p.inner.Summary(query)
|
||||
}
|
||||
|
||||
func (p *activityProbeRepo) DeleteOlderThan(cutoff time.Time) (int, error) {
|
||||
p.deleteCalls++
|
||||
if p.deleteErr != nil {
|
||||
return 0, p.deleteErr
|
||||
}
|
||||
return p.inner.DeleteOlderThan(cutoff)
|
||||
}
|
||||
|
||||
type activitySettingsFake struct {
|
||||
settings audit.Settings
|
||||
saveErr error
|
||||
retentionOK int
|
||||
cleanupOK int
|
||||
}
|
||||
|
||||
var _ audit.SettingsRepository = &activitySettingsFake{}
|
||||
|
||||
func newActivitySettingsFake() *activitySettingsFake {
|
||||
return &activitySettingsFake{settings: audit.DefaultSettings()}
|
||||
}
|
||||
|
||||
func (f *activitySettingsFake) Get() (audit.Settings, error) { return f.settings, nil }
|
||||
|
||||
func (f *activitySettingsFake) SaveRetention(days int) error {
|
||||
if f.saveErr != nil {
|
||||
return f.saveErr
|
||||
}
|
||||
f.retentionOK++
|
||||
f.settings.RetentionDays = days
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *activitySettingsFake) SaveLastCleanup(at time.Time, removed int) error {
|
||||
if f.saveErr != nil {
|
||||
return f.saveErr
|
||||
}
|
||||
f.cleanupOK++
|
||||
f.settings.LastCleanupAt = &at
|
||||
f.settings.LastCleanupRemoved = removed
|
||||
return nil
|
||||
}
|
||||
|
||||
type activityHarness struct {
|
||||
probe *activityProbeRepo
|
||||
store *persistence.AuditEventRepository
|
||||
settings *activitySettingsFake
|
||||
logs *bytes.Buffer
|
||||
now time.Time
|
||||
recorder *auditing.Recorder
|
||||
}
|
||||
|
||||
func newActivityHarness(t *testing.T) *activityHarness {
|
||||
t.Helper()
|
||||
store := persistence.NewAuditEventRepository(openActivityDatabase(t))
|
||||
h := &activityHarness{
|
||||
probe: &activityProbeRepo{inner: store},
|
||||
store: store,
|
||||
settings: newActivitySettingsFake(),
|
||||
logs: &bytes.Buffer{},
|
||||
now: time.Date(2026, time.October, 6, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
h.recorder = auditing.NewRecorder(h.probe, func() time.Time { return h.now }, log.New(h.logs, "", 0))
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *activityHarness) clock() time.Time { return h.now }
|
||||
|
||||
func (h *activityHarness) all(t *testing.T) []audit.Event {
|
||||
t.Helper()
|
||||
page, err := h.store.List(audit.Filter{Page: 1, Size: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("listing stored events failed: %v", err)
|
||||
}
|
||||
return page.Items
|
||||
}
|
||||
|
||||
func (h *activityHarness) seed(t *testing.T, event audit.Event) audit.Event {
|
||||
t.Helper()
|
||||
return activityMustAppend(t, h.store, event)
|
||||
}
|
||||
|
||||
var errDiskFull = errors.New("disk full")
|
||||
@@ -0,0 +1,491 @@
|
||||
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)
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validActivityEvent() audit.Event {
|
||||
event := audit.NewEvent("article.publish", audit.CategoryArticles)
|
||||
event.Source = audit.SourceWeb
|
||||
event.Actor = audit.Actor{Type: audit.ActorUser, Name: "marta"}
|
||||
return event
|
||||
}
|
||||
|
||||
func TestActivityEventValidation(t *testing.T) {
|
||||
long := func(n int) *string { value := strings.Repeat("x", n); return &value }
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*audit.Event)
|
||||
valid bool
|
||||
}{
|
||||
{"complete event", func(e *audit.Event) {}, true},
|
||||
{"empty action", func(e *audit.Event) { e.Action = "" }, false},
|
||||
{"action at the limit", func(e *audit.Event) { e.Action = strings.Repeat("a", 64) }, true},
|
||||
{"action over the limit", func(e *audit.Event) { e.Action = strings.Repeat("a", 65) }, false},
|
||||
{"unknown category", func(e *audit.Event) { e.Category = "nope" }, false},
|
||||
{"unknown outcome", func(e *audit.Event) { e.Outcome = "nope" }, false},
|
||||
{"unknown source", func(e *audit.Event) { e.Source = "nope" }, false},
|
||||
{"unknown actor type", func(e *audit.Event) { e.Actor.Type = "nope" }, false},
|
||||
{"failure reason at the limit", func(e *audit.Event) { e.FailureReason = long(255) }, true},
|
||||
{"failure reason over the limit", func(e *audit.Event) { e.FailureReason = long(256) }, false},
|
||||
{"unknown change kind", func(e *audit.Event) { e.Changes = []audit.Change{{Field: "a", Kind: "nope"}} }, false},
|
||||
{"body change", func(e *audit.Event) { e.Changes = []audit.Change{{Field: "a", Kind: audit.ChangeBody}} }, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
event := validActivityEvent()
|
||||
c.mutate(&event)
|
||||
|
||||
err := event.Validate()
|
||||
|
||||
if c.valid && err != nil {
|
||||
t.Errorf("%s: expected valid, got %v", c.name, err)
|
||||
}
|
||||
if !c.valid && !errors.Is(err, audit.ErrInvalidEvent) {
|
||||
t.Errorf("%s: expected ErrInvalidEvent, got %v", c.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Names are cut to 255 characters", func(t *testing.T) {
|
||||
event := validActivityEvent()
|
||||
event.Actor.Name = strings.Repeat("é", 256)
|
||||
event.Target.Name = strings.Repeat("é", 255)
|
||||
|
||||
cut := event.Truncated()
|
||||
|
||||
if len([]rune(cut.Actor.Name)) != 255 || len([]rune(cut.Target.Name)) != 255 {
|
||||
t.Errorf("unexpected lengths %d and %d", len([]rune(cut.Actor.Name)), len([]rune(cut.Target.Name)))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The discard recorder accepts events silently", func(t *testing.T) {
|
||||
audit.Discard.Record(testCtx, validActivityEvent())
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityHandlerErrorMapping(t *testing.T) {
|
||||
t.Run("A signed-in user who no longer exists is unauthorised", func(t *testing.T) {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
|
||||
rec := e.get("/v1/activity", asUser(e, 99))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A storage failure is a plain 500", func(t *testing.T) {
|
||||
for _, path := range []string{"/v1/activity", "/v1/activity/summary", "/v1/activity/export"} {
|
||||
e := newActivityHTTPEnv(t, 1000)
|
||||
e.probe.readErr = errors.New("storage down")
|
||||
|
||||
rec := e.get(path, asUser(e, activityOwnerID))
|
||||
|
||||
if path != "/v1/activity/export" && rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("%s: expected 500, got %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -99,11 +99,11 @@ func newAPIKeyEnv(t *testing.T) *apiKeyEnv {
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com"),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, discardRecorder),
|
||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users),
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com"),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock),
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", discardRecorder),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, discardRecorder),
|
||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com"),
|
||||
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites),
|
||||
@@ -118,11 +118,12 @@ func newAPIKeyEnv(t *testing.T) *apiKeyEnv {
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage()),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake()),
|
||||
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, discardRecorder),
|
||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, discardRecorder),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
|
||||
+28
-28
@@ -221,9 +221,9 @@ func TestSHA256KeyGenerator(t *testing.T) {
|
||||
func TestCreateAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("Use case: creating a key hashes it and stores no plaintext", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
created, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"})
|
||||
created, err := uc.CreateAPIKey(testCtx, useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -248,9 +248,9 @@ func TestCreateAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("An expiry that is not in the future is refused and nothing is stored", func(t *testing.T) {
|
||||
for name, expiry := range map[string]time.Time{"in the past": apiKeyUnitNow.Add(-time.Hour), "exactly now": apiKeyUnitNow} {
|
||||
repo := newMockAPIKeyRepo()
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
_, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci", ExpiresAt: &expiry})
|
||||
_, err := uc.CreateAPIKey(testCtx, useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci", ExpiresAt: &expiry})
|
||||
if !errors.Is(err, apikey.ErrExpiryNotInFuture) {
|
||||
t.Errorf("%s: expected ErrExpiryNotInFuture, got %v", name, err)
|
||||
}
|
||||
@@ -263,9 +263,9 @@ func TestCreateAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("A repository failure is returned", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
repo.fail.failNext("Create")
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if _, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"}); !errors.Is(err, errRepositoryFailure) {
|
||||
if _, err := uc.CreateAPIKey(testCtx, useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"}); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -486,9 +486,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) {
|
||||
@@ -499,9 +499,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("Another admin gets not found and the key stays active", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
if err := uc.RevokeAPIKey(testCtx, 3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||
}
|
||||
if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 {
|
||||
@@ -512,9 +512,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("The owner revokes any key", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 })
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(1, stored.ID); err != nil {
|
||||
if err := uc.RevokeAPIKey(testCtx, 1, stored.ID); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if repo.keys[stored.ID].RevokedAt == nil {
|
||||
@@ -526,9 +526,9 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
original := apiKeyUnitNow.Add(-time.Hour)
|
||||
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original })
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) {
|
||||
@@ -539,16 +539,16 @@ func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.RevokeAPIKey(2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||
}
|
||||
if err := uc.RevokeAPIKey(99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
|
||||
if err := uc.RevokeAPIKey(testCtx, 99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
repo.fail.failNext("Revoke")
|
||||
if err := uc.RevokeAPIKey(2, stored.ID); !errors.Is(err, errRepositoryFailure) {
|
||||
if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -566,9 +566,9 @@ func TestResetPasswordRevokesAPIKeys(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
seedStubKey(keys, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2 })
|
||||
other := seedStubKey(keys, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3 })
|
||||
uc := useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{}, keys, apiKeyUnitClock)
|
||||
uc := useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{}, keys, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); err != nil {
|
||||
if err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(keys.revokeAllCalls) != 1 || keys.revokeAllCalls[0].userId != 2 || !keys.revokeAllCalls[0].at.Equal(apiKeyUnitNow) {
|
||||
@@ -594,9 +594,9 @@ func TestResetPasswordRevokesAPIKeys(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
uc := useCases.NewResetPasswordUseCase(resetUser(tc.expiration), &stubHasher{}, keys, apiKeyUnitClock)
|
||||
uc := useCases.NewResetPasswordUseCase(resetUser(tc.expiration), &stubHasher{}, keys, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
if err := uc.ResetPassword(tc.email, tc.code, "newpassword1"); !errors.Is(err, tc.want) {
|
||||
if err := uc.ResetPassword(testCtx, tc.email, tc.code, "newpassword1"); !errors.Is(err, tc.want) {
|
||||
t.Errorf("expected %v, got %v", tc.want, err)
|
||||
}
|
||||
if len(keys.revokeAllCalls) != 0 {
|
||||
@@ -610,14 +610,14 @@ func TestResetPasswordRevokesAPIKeys(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
users := resetUser(apiKeyUnitNow.Add(time.Hour))
|
||||
users.fail.failNext("ResetPassword")
|
||||
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
|
||||
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, errRepositoryFailure) {
|
||||
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock, discardRecorder)
|
||||
if err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
|
||||
hashErr := errors.New("hash failed")
|
||||
uc = useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{hashErr: hashErr}, keys, apiKeyUnitClock)
|
||||
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, hashErr) {
|
||||
uc = useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{hashErr: hashErr}, keys, apiKeyUnitClock, discardRecorder)
|
||||
if err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, hashErr) {
|
||||
t.Errorf("expected the hash error, got %v", err)
|
||||
}
|
||||
if len(keys.revokeAllCalls) != 0 {
|
||||
@@ -629,9 +629,9 @@ func TestResetPasswordRevokesAPIKeys(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
keys.fail.failNext("RevokeAllByUser")
|
||||
users := resetUser(apiKeyUnitNow.Add(time.Hour))
|
||||
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
|
||||
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1")
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the revoke failure, got %v", err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
|
||||
+8
-8
@@ -85,9 +85,9 @@ func TestAuthenticateUserUseCase(t *testing.T) {
|
||||
|
||||
t.Run("correct password returns the user with no error", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{})
|
||||
uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{}, discardRecorder)
|
||||
|
||||
result, err := uc.Authenticate("alice", "hunter2example")
|
||||
result, err := uc.Authenticate(testCtx, "alice", "hunter2example")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
@@ -98,9 +98,9 @@ func TestAuthenticateUserUseCase(t *testing.T) {
|
||||
|
||||
t.Run("wrong password returns user.ErrInvalidCredentials", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{})
|
||||
uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{}, discardRecorder)
|
||||
|
||||
_, err := uc.Authenticate("alice", "wrongpassword12")
|
||||
_, err := uc.Authenticate(testCtx, "alice", "wrongpassword12")
|
||||
if !errors.Is(err, user.ErrInvalidCredentials) {
|
||||
t.Errorf("expected user.ErrInvalidCredentials, got %v", err)
|
||||
}
|
||||
@@ -109,9 +109,9 @@ func TestAuthenticateUserUseCase(t *testing.T) {
|
||||
t.Run("unknown username never calls the hasher", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
hasher := &spyHasher{}
|
||||
uc := useCases.NewAuthenticateUserUseCase(repo, hasher)
|
||||
uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder)
|
||||
|
||||
_, err := uc.Authenticate("bob", "hunter2example")
|
||||
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
|
||||
if !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected user.ErrUserNotFound, got %v", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -175,7 +175,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
|
||||
@@ -537,7 +537,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
|
||||
handler := auth.NewLoginHandler(
|
||||
jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL),
|
||||
useCases.NewAuthenticateUserUseCase(users, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -37,12 +37,12 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -20,7 +20,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, p
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo)
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
hasher := &stubHasher{}
|
||||
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com")
|
||||
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher), true)
|
||||
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/auth", func(r chi.Router) {
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
func newRequestPasswordResetRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux {
|
||||
hasher := &stubHasher{}
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL, discardRecorder),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -46,8 +46,8 @@ func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseR
|
||||
userRepo.nextID = 1
|
||||
hasher := resetMailFailureHasher{}
|
||||
handler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder),
|
||||
)
|
||||
router := chi.NewRouter()
|
||||
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
|
||||
|
||||
@@ -30,9 +30,9 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
|
||||
t.Run("The use case itself hashes the code, stores it with a one-hour expiration, and sends the email", func(t *testing.T) {
|
||||
repo := newRequestPasswordResetTestUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
err := uc.RequestPasswordReset("alice@example.com")
|
||||
err := uc.RequestPasswordReset(testCtx, "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
@@ -72,9 +72,9 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
|
||||
t.Run("The use case swallows an unknown email and returns nil without touching the repository or mailer", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
err := uc.RequestPasswordReset("nobody@example.com")
|
||||
err := uc.RequestPasswordReset(testCtx, "nobody@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
@@ -90,10 +90,10 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
|
||||
repo := newRequestPasswordResetTestUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
mailRepo.fail.failNext("Send")
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
logBuf := captureRequestPasswordResetLog(t)
|
||||
|
||||
err := uc.RequestPasswordReset("alice@example.com")
|
||||
err := uc.RequestPasswordReset(testCtx, "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
@@ -111,10 +111,10 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
|
||||
t.Run("The use case logs and swallows a hashing failure", func(t *testing.T) {
|
||||
repo := newRequestPasswordResetTestUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, mailRepo, "https://cms.example.com")
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
logBuf := captureRequestPasswordResetLog(t)
|
||||
|
||||
err := uc.RequestPasswordReset("alice@example.com")
|
||||
err := uc.RequestPasswordReset(testCtx, "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
@@ -136,10 +136,10 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
|
||||
repo := newRequestPasswordResetTestUserRepo()
|
||||
repo.fail.failNext("UpdatePasswordResetCode")
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
logBuf := captureRequestPasswordResetLog(t)
|
||||
|
||||
err := uc.RequestPasswordReset("alice@example.com")
|
||||
err := uc.RequestPasswordReset(testCtx, "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
@@ -160,9 +160,9 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
|
||||
repo.users[2] = user.User{ID: 2, Email: "bob@example.com", IsVerified: false}
|
||||
repo.nextID = 2
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
uc := useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
|
||||
|
||||
err := uc.RequestPasswordReset("alice@example.com")
|
||||
err := uc.RequestPasswordReset(testCtx, "alice@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
func newResetPasswordRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
||||
mailRepo := newMockMailRepo()
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
+18
-18
@@ -22,9 +22,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
|
||||
t.Run("The use case itself sets the password and clears the code in one write, independently of any handler", func(t *testing.T) {
|
||||
repo := newRepo(time.Now().Add(time.Hour))
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "reset-code-1", "newpassword1")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -49,9 +49,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
PasswordResetCode: "hashed:reset-code-1",
|
||||
PasswordResetExpiration: time.Now().Add(time.Hour),
|
||||
}
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "reset-code-1", "newpassword1")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -63,9 +63,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
|
||||
t.Run("The use case rejects a wrong code without touching the repository's ResetPassword method", func(t *testing.T) {
|
||||
repo := newRepo(time.Now().Add(time.Hour))
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "wrong-code", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "wrong-code", "newpassword1")
|
||||
|
||||
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
|
||||
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
|
||||
@@ -77,9 +77,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
|
||||
t.Run("The use case rejects an expired code without touching the repository's ResetPassword method", func(t *testing.T) {
|
||||
repo := newRepo(time.Now().Add(-time.Hour))
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "reset-code-1", "newpassword1")
|
||||
|
||||
if !errors.Is(err, user.ErrPasswordResetCodeExpired) {
|
||||
t.Errorf("expected user.ErrPasswordResetCodeExpired, got %v", err)
|
||||
@@ -91,9 +91,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
|
||||
t.Run("The use case treats an unknown email the same as a wrong code", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("nobody@example.com", "any-code", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "nobody@example.com", "any-code", "newpassword1")
|
||||
|
||||
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
|
||||
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
|
||||
@@ -107,9 +107,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[3] = user.User{ID: 3, Email: "alice@example.com", PasswordResetCode: ""}
|
||||
hasher := &stubHasher{}
|
||||
uc := useCases.NewResetPasswordUseCase(repo, hasher, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "anything", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "anything", "newpassword1")
|
||||
|
||||
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
|
||||
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
|
||||
@@ -125,9 +125,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
t.Run("The use case treats any GetByEmail failure, not just not-found, as an invalid code", func(t *testing.T) {
|
||||
repo := newRepo(time.Now().Add(time.Hour))
|
||||
repo.fail.failNextWith("GetByEmail", errors.New("db is down"))
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "reset-code-1", "newpassword1")
|
||||
|
||||
if !errors.Is(err, user.ErrInvalidPasswordResetCode) {
|
||||
t.Errorf("expected user.ErrInvalidPasswordResetCode, got %v", err)
|
||||
@@ -140,9 +140,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
t.Run("A hashing failure propagates and the repository's ResetPassword method is never called", func(t *testing.T) {
|
||||
repo := newRepo(time.Now().Add(time.Hour))
|
||||
hashErr := errors.New("hash blew up")
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{hashErr: hashErr}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{hashErr: hashErr}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "reset-code-1", "newpassword1")
|
||||
|
||||
if !errors.Is(err, hashErr) {
|
||||
t.Errorf("expected %v, got %v", hashErr, err)
|
||||
@@ -155,9 +155,9 @@ func TestResetPasswordUseCase(t *testing.T) {
|
||||
t.Run("A failure in the repository's ResetPassword method propagates", func(t *testing.T) {
|
||||
repo := newRepo(time.Now().Add(time.Hour))
|
||||
repo.fail.failNext("ResetPassword")
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now, discardRecorder)
|
||||
|
||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||
err := uc.ResetPassword(testCtx, "alice@example.com", "reset-code-1", "newpassword1")
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected %v, got %v", errRepositoryFailure, err)
|
||||
|
||||
@@ -36,7 +36,7 @@ func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mock
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
isProduction,
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
|
||||
|
||||
@@ -17,7 +17,11 @@ import (
|
||||
|
||||
func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
settingsHandler := settings.NewHandler(useCases.NewGetSettingsUseCase(version))
|
||||
settingsHandler := settings.NewHandler(
|
||||
useCases.NewGetSettingsUseCase(version),
|
||||
useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake()),
|
||||
useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/settings", func(r chi.Router) {
|
||||
@@ -30,6 +34,10 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
|
||||
return r
|
||||
}
|
||||
|
||||
func settingsBody(version string) string {
|
||||
return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0}`
|
||||
}
|
||||
|
||||
func TestSettingsHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
|
||||
@@ -46,7 +54,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != `{"version":"`+version+`"}` {
|
||||
if rec.Body.String() != settingsBody(version) {
|
||||
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||
}
|
||||
})
|
||||
@@ -65,7 +73,7 @@ func TestSettingsHttp(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != `{"version":"v2.2.0-beta"}` {
|
||||
if rec.Body.String() != settingsBody("v2.2.0-beta") {
|
||||
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -167,6 +168,11 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
}
|
||||
after := takeSqliteSnapshot(t, db)
|
||||
|
||||
for name := range after.schema {
|
||||
if strings.HasPrefix(name, "audit_") || strings.HasPrefix(name, "idx_audit_") {
|
||||
delete(after.schema, name)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(before.schema, after.schema) {
|
||||
t.Errorf("schema changed:\nbefore %v\nafter %v", before.schema, after.schema)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
@@ -42,8 +42,8 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
true,
|
||||
)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -24,12 +24,12 @@ func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -156,7 +156,7 @@ func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *jwtauth.JWTA
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
verificationHandler := auth.NewVerificationHandler(
|
||||
@@ -31,8 +31,8 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
|
||||
true,
|
||||
)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -51,7 +51,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
f.token,
|
||||
useCases.NewCreateUserUseCase(f.users, hasher, f.mail, invitePublicURL),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, hasher),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, hasher, discardRecorder),
|
||||
true,
|
||||
)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
|
||||
Reference in new issue
Block a user