package test import ( "RenewCMS/api" "RenewCMS/api/middleware" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/useCases" "encoding/csv" "encoding/json" "fmt" "net/http" "net/http/httptest" "reflect" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" ) const ( activityOwnerID = 1 activityAdminID = 2 activityEditorID = 3 activityUnverifiedID = 4 activityPassword = "oldpassword1" activityUserAgent = "Mozilla/5.0 test" activityPeer = "203.0.113.12:5050" activityMismatch = apperr.BodyErrorMessage ) type activityHTTPEnv struct { *activityHarness users *mockUserRepo keys *mockAPIKeyRepo articles *mockArticleRepo mail *mockMailRepo token *jwtauth.JWTAuth generator *hashing.SHA256KeyGenerator router http.Handler } func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv { t.Helper() validator.InitValidator() h := newActivityHarness(t) e := &activityHTTPEnv{ activityHarness: h, users: newMockUserRepo(), keys: newMockAPIKeyRepo(), articles: newMockArticleRepo(), mail: newMockMailRepo(), token: jwtauth.New("HS256", []byte("test-secret"), nil), generator: hashing.NewSHA256KeyGenerator(), } e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner} e.users.users[activityAdminID] = user.User{ID: activityAdminID, Username: "dev", Email: "dev@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleAdmin} e.users.users[activityEditorID] = user.User{ID: activityEditorID, Username: "ed", Email: "ed@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: "editor"} e.users.users[activityUnverifiedID] = user.User{ID: activityUnverifiedID, Username: "quinn", Email: "quinn@example.com", IsVerified: false, Role: user.RoleOwner} e.users.nextID = 4 hasher := &stubHasher{} resetClock := time.Now container := &api.UseCases{ Token: e.token, GetUserUseCase: useCases.NewGetUserUseCase(e.users), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(e.keys, e.generator, h.clock, h.recorder), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(e.keys, e.users, h.clock), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock), GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock), UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock), ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock, plainSites(), plainAuthors()), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock), ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock), } settings := config.Settings{AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: attempts, Window: time.Minute}} router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(container, settings)) e.router = router return e } type activityRequest func(*http.Request) func asUser(e *activityHTTPEnv, id uint32) activityRequest { return func(r *http.Request) { _, tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0}) r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) } } func withBearer(raw string) activityRequest { return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+raw) } } func (e *activityHTTPEnv) send(method string, path string, body string, options ...activityRequest) *httptest.ResponseRecorder { var req *http.Request if body == "" { req = httptest.NewRequest(method, path, nil) } else { req = httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") } req.RemoteAddr = activityPeer req.Header.Set("User-Agent", activityUserAgent) for _, option := range options { option(req) } rec := httptest.NewRecorder() e.router.ServeHTTP(rec, req) return rec } func (e *activityHTTPEnv) get(path string, options ...activityRequest) *httptest.ResponseRecorder { return e.send(http.MethodGet, path, "", options...) } func (e *activityHTTPEnv) issueKey(t *testing.T, owner uint32, name string) (string, uint32) { t.Helper() raw, prefix, hashed := e.generator.Generate() stored, err := e.keys.Create(apikey.APIKey{UserID: owner, Name: name, Prefix: prefix, HashedKey: hashed, CreatedAt: e.now}) if err != nil { t.Fatal(err) } return raw, stored.ID } func (e *activityHTTPEnv) seedBy(t *testing.T, actor audit.Actor, ago time.Duration, mutate ...func(*audit.Event)) audit.Event { t.Helper() event := activityUserEvent(0, "", e.now.Add(-ago)) event.Actor = actor if actor.Type == audit.ActorSystem { event.Source = audit.SourceSystem } if actor.Type == audit.ActorAPIKey { event.Source = audit.SourceAPIKey } for _, apply := range mutate { apply(&event) } return e.seed(t, event) } func userActor(id uint32, name string) audit.Actor { return audit.Actor{Type: audit.ActorUser, ID: activityPtr(id), Name: name} } type activityList struct { Items []map[string]any `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` } func decodeActivityList(t *testing.T, rec *httptest.ResponseRecorder) activityList { t.Helper() if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var list activityList if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { t.Fatalf("unexpected body %q: %v", rec.Body.String(), err) } return list } func (l activityList) ids() []uint64 { ids := make([]uint64, 0, len(l.Items)) for _, item := range l.Items { ids = append(ids, uint64(item["id"].(float64))) } return ids } func assertPlain(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { t.Helper() if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body { t.Errorf("expected %d %q, got %d %q", status, body, rec.Code, strings.TrimSpace(rec.Body.String())) } } func TestActivityLogBackendAudienceGates(t *testing.T) { t.Run("The credentials gate answers before any data", func(t *testing.T) { paths := []string{"/v1/activity", "/v1/activity/summary", "/v1/activity/export"} for _, path := range paths { t.Run("no cookie "+path, func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) assertPlain(t, e.get(path), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) if e.probe.reads != 0 { t.Error("the audit repository was read") } }) t.Run("API key "+path, func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) raw, _ := e.issueKey(t, activityOwnerID, "Deploy bot") assertPlain(t, e.get(path, withBearer(raw)), http.StatusForbidden, apperr.SessionRequiredErrorMessage) if e.probe.reads != 0 { t.Error("the audit repository was read") } }) } e := newActivityHTTPEnv(t, 1000) assertPlain(t, e.get("/v1/activity", asUser(e, activityUnverifiedID)), http.StatusForbidden, apperr.VerificationRequiredErrorMessage) if e.probe.reads != 0 { t.Error("the audit repository was read") } }) t.Run("Owner and admin see every event", func(t *testing.T) { for _, role := range []struct { name string id uint32 }{{"owner", activityOwnerID}, {"admin", activityAdminID}} { t.Run(role.name, func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.seedBy(t, userActor(1, "a"), time.Hour) e.seedBy(t, userActor(2, "b"), time.Hour) e.seedBy(t, userActor(3, "c"), time.Hour) e.seedBy(t, audit.APIKeyActor(7, "bot"), time.Hour) e.seedBy(t, audit.SystemActor(), time.Hour) list := decodeActivityList(t, e.get("/v1/activity", asUser(e, role.id))) if list.Total != 5 { t.Errorf("expected 5 events, got %d", list.Total) } }) } }) t.Run("Another role is scoped to its own events", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.seedBy(t, userActor(1, "a"), time.Hour) e.seedBy(t, userActor(2, "b"), time.Hour) e.seedBy(t, userActor(3, "c"), time.Hour) e.seedBy(t, userActor(3, "c"), 2*time.Hour) list := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityEditorID))) if list.Total != 2 { t.Fatalf("expected user 3's 2 events, got %d", list.Total) } for _, item := range list.Items { actor := item["actor"].(map[string]any) if actor["type"] != "user" || actor["id"] != float64(3) { t.Errorf("unexpected actor %v", actor) } } }) t.Run("A scoped caller cannot ask for another actor", func(t *testing.T) { for actor, status := range map[string]int{"user:3": 200, "user:2": 403, "key:7": 403, "system": 403} { e := newActivityHTTPEnv(t, 1000) rec := e.get("/v1/activity?actor="+actor, asUser(e, activityEditorID)) if rec.Code != status { t.Errorf("%s: expected %d, got %d", actor, status, rec.Code) } } }) t.Run("A scoped caller cannot read the summary or the export", func(t *testing.T) { for _, path := range []string{"/v1/activity/summary", "/v1/activity/export"} { e := newActivityHTTPEnv(t, 1000) assertPlain(t, e.get(path, asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage) if e.probe.reads != 0 { t.Error("the audit repository was read") } } }) t.Run("The repository exposes no way to edit or delete one event", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { for _, path := range []string{"/v1/activity", "/v1/activity/1", "/v1/activity/summary", "/v1/activity/export"} { rec := e.send(method, path, `{}`, asUser(e, activityOwnerID)) if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound { t.Errorf("%s %s: expected 404 or 405, got %d", method, path, rec.Code) } } } }) } func TestActivityLogBackendList(t *testing.T) { t.Run("The default request", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) for i := 0; i < 30; i++ { e.seedBy(t, userActor(1, "a"), time.Duration(i+1)*time.Hour) } rec := e.get("/v1/activity", asUser(e, activityOwnerID)) list := decodeActivityList(t, rec) if list.Total != 30 || list.Page != 1 || list.Size != 25 || len(list.Items) != 25 { t.Fatalf("unexpected list %d/%d/%d with %d items", list.Total, list.Page, list.Size, len(list.Items)) } for i := 1; i < len(list.Items); i++ { if list.Items[i]["created_at"].(string) > list.Items[i-1]["created_at"].(string) { t.Errorf("expected newest first, got %v then %v", list.Items[i-1]["created_at"], list.Items[i]["created_at"]) break } } }) t.Run("An item has the documented shape", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.articles.articles[8] = article.Article{ID: 8, Title: "Spring release"} stored := e.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 }()) rec := e.get("/v1/activity", asUser(e, activityOwnerID)) var got struct { Items []json.RawMessage `json:"items"` } _ = json.Unmarshal(rec.Body.Bytes(), &got) want := fmt.Sprintf(`{"id": %d, "created_at": "2026-10-06T10:42:18Z", "action": "article.publish", "category": "articles", "actor": {"type": "user", "id": 3, "name": "Marta Alves"}, "target": {"type": "article", "id": "8", "name": "Spring release", "exists": true}, "outcome": "success", "failure_reason": null, "security": false, "source": "web", "ip": "203.0.113.12", "user_agent": "Chrome 129 on Windows", "params": {}, "changes": [{"field": "online", "kind": "text", "before": "false", "after": "true"}]}`, stored.ID) assertJSONEqual(t, want, string(got.Items[0])) }) t.Run("A system event has no ip or user agent", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.seedBy(t, audit.SystemActor(), time.Hour) item := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityOwnerID))).Items[0] assertJSONValue(t, item["actor"], `{"type": "system", "id": null, "name": "System"}`) if item["source"] != "system" || item["ip"] != nil || item["user_agent"] != nil { t.Errorf("unexpected item %v", item) } }) t.Run("A renamed or deleted user still reads correctly", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.seedBy(t, userActor(9, "Marta Alves"), time.Hour) e.users.users[9] = user.User{ID: 9, Username: "marta2", DisplayName: "M. Alves", IsVerified: true} delete(e.users.users, 9) item := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityOwnerID))).Items[0] if item["actor"].(map[string]any)["name"] != "Marta Alves" { t.Errorf("unexpected actor %v", item["actor"]) } }) t.Run("target.exists is resolved when the list is read", func(t *testing.T) { cases := []struct { name string target audit.Target setup func(*activityHTTPEnv) exists bool }{ {"the article still exists", audit.Target{Type: "article", ID: "8", Name: "Spring release"}, func(e *activityHTTPEnv) { e.articles.articles[8] = article.Article{ID: 8} }, true}, {"the article was deleted", audit.Target{Type: "article", ID: "8", Name: "Spring release"}, func(e *activityHTTPEnv) {}, false}, {"the user was deleted", audit.Target{Type: "user", ID: "9", Name: "Marta Alves"}, func(e *activityHTTPEnv) {}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) c.setup(e) e.seedBy(t, userActor(1, "a"), time.Hour, func(ev *audit.Event) { ev.Target = c.target }) target := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityOwnerID))).Items[0]["target"].(map[string]any) if target["name"] != c.target.Name || target["exists"] != c.exists { t.Errorf("unexpected target %v", target) } }) } }) t.Run("A body change carries a delta and never text", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.seedBy(t, userActor(1, "a"), time.Hour, func(ev *audit.Event) { ev.Changes = []audit.Change{{Field: "body", Kind: audit.ChangeBody, Delta: activityPtr(312)}} }) item := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityOwnerID))).Items[0] assertJSONValue(t, item["changes"], `[{"field": "body", "kind": "body", "delta": 312}]`) }) t.Run("The filters are applied through the query", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) hour := time.Hour spring := e.seedBy(t, userActor(1, "a"), hour, func(ev *audit.Event) { ev.Target = audit.Target{Type: "article", ID: "1", Name: "Spring release"} }) user3 := e.seedBy(t, userActor(3, "c"), 14*hour) key7 := e.seedBy(t, audit.APIKeyActor(7, "bot"), 15*hour) system := e.seedBy(t, audit.SystemActor(), 16*hour, func(ev *audit.Event) { ev.Category = audit.CategorySystem }) failedAuth := e.seedBy(t, audit.AnonymousActor("mallory"), 17*hour, func(ev *audit.Event) { ev.Category = audit.CategoryAuth ev.Outcome = audit.OutcomeFailed ev.Security = true }) old := e.seedBy(t, userActor(1, "a"), 20*24*hour) september := e.seedBy(t, userActor(1, "a"), 35*24*hour+12*hour) everyRecent := []uint64{spring.ID, user3.ID, key7.ID, system.ID, failedAuth.ID} cases := []struct { query string want []uint64 }{ {"q=spring", []uint64{spring.ID}}, {"actor=user:3", []uint64{user3.ID}}, {"actor=key:7", []uint64{key7.ID}}, {"actor=system", []uint64{system.ID}}, {"category=auth", []uint64{failedAuth.ID}}, {"outcome=failed", []uint64{failedAuth.ID}}, {"outcome=success", []uint64{spring.ID, user3.ID, key7.ID, system.ID}}, {"outcome=all", everyRecent}, {"security=true", []uint64{failedAuth.ID}}, {"range=today", []uint64{spring.ID}}, {"range=7d", everyRecent}, {"range=30d", append(append([]uint64{}, everyRecent...), old.ID)}, {"range=custom&from=2026-09-01&to=2026-09-01", []uint64{september.ID}}, {"page=2&size=25", nil}, } for _, c := range cases { t.Run(c.query, func(t *testing.T) { got := decodeActivityList(t, e.get("/v1/activity?"+c.query, asUser(e, activityOwnerID))).ids() if !sameIDs(got, c.want) { t.Errorf("expected %v, got %v", c.want, got) } }) } page := decodeActivityList(t, e.get("/v1/activity?page=2&size=50", asUser(e, activityOwnerID))) if page.Page != 2 || page.Size != 50 { t.Errorf("unexpected paging %d/%d", page.Page, page.Size) } }) t.Run("Omitting range means 7 days", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) recent := e.seedBy(t, userActor(1, "a"), 2*24*time.Hour) e.seedBy(t, userActor(1, "a"), 10*24*time.Hour) got := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityOwnerID))).ids() if !reflect.DeepEqual(got, []uint64{recent.ID}) { t.Errorf("expected only the recent event, got %v", got) } }) t.Run("Invalid values answer 400", func(t *testing.T) { queries := []string{ "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=0", "page=abc", "size=10", "size=200", } for _, query := range queries { e := newActivityHTTPEnv(t, 1000) assertPlain(t, e.get("/v1/activity?"+query, asUser(e, activityOwnerID)), http.StatusBadRequest, activityMismatch) if e.probe.reads != 0 { t.Errorf("%s: the audit repository was read", query) } } }) } func sameIDs(got []uint64, want []uint64) bool { if len(got) != len(want) { return false } seen := map[uint64]bool{} for _, id := range got { seen[id] = true } for _, id := range want { if !seen[id] { return false } } return true } func assertJSONEqual(t *testing.T, want string, got string) { t.Helper() var a, b any if err := json.Unmarshal([]byte(want), &a); err != nil { t.Fatalf("bad expectation %q: %v", want, err) } if err := json.Unmarshal([]byte(got), &b); err != nil { t.Fatalf("bad body %q: %v", got, err) } if !reflect.DeepEqual(a, b) { t.Errorf("expected %s, got %s", want, got) } } func assertJSONValue(t *testing.T, value any, want string) { t.Helper() encoded, _ := json.Marshal(value) assertJSONEqual(t, want, string(encoded)) } func TestActivityLogBackendSummary(t *testing.T) { t.Run("The summary of the last seven days", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) for i := 0; i < 12; i++ { i := i actor := userActor(uint32(1+i%2), "u") if i == 11 { actor = audit.APIKeyActor(7, "bot") } e.seedBy(t, actor, time.Duration(i)*12*time.Hour+time.Hour, func(ev *audit.Event) { ev.Outcome = map[bool]audit.Outcome{true: audit.OutcomeFailed, false: audit.OutcomeSuccess}[i < 3] ev.Security = i < 2 }) } for i := 0; i < 10; i++ { e.seedBy(t, userActor(1, "u"), 7*24*time.Hour+time.Duration(i+1)*time.Hour) } rec := e.get("/v1/activity/summary?range=7d", asUser(e, activityOwnerID)) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var got map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &got) if got["total"] != float64(12) || got["previous_total"] != float64(10) || got["failed"] != float64(3) || got["security"] != float64(2) { t.Errorf("unexpected counts %v", got) } assertJSONValue(t, got["active_people"], `{"users": 2, "api_keys": 1}`) series := got["series"].(map[string]any) sum := 0.0 for _, name := range []string{"total", "active", "failed", "security"} { values := series[name].([]any) if len(values) != 7 { t.Errorf("%s: expected 7 entries, got %d", name, len(values)) } if name == "total" { for _, v := range values { sum += v.(float64) } } } if sum != 12 { t.Errorf("expected the total series to sum to 12, got %v", sum) } }) t.Run("An empty log answers zeros", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.get("/v1/activity/summary", asUser(e, activityOwnerID)) assertJSONEqual(t, `{"total":0,"previous_total":0,"active_people":{"users":0,"api_keys":0},"failed":0,"security":0,"series":{"total":[0,0,0,0,0,0,0],"active":[0,0,0,0,0,0,0],"failed":[0,0,0,0,0,0,0],"security":[0,0,0,0,0,0,0]}}`, rec.Body.String()) }) t.Run("The summary uses the same filters as the range", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) at := func(month time.Month, day int) { e.seed(t, activityUserEvent(1, "a", time.Date(2026, month, day, 12, 0, 0, 0, time.UTC))) } at(time.September, 2) at(time.September, 7) at(time.September, 9) at(time.August, 28) rec := e.get("/v1/activity/summary?range=custom&from=2026-09-01&to=2026-09-07", asUser(e, activityOwnerID)) var got map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &got) if got["total"] != float64(2) || got["previous_total"] != float64(1) { t.Errorf("unexpected counts %v", got) } }) t.Run("An invalid range answers 400", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) assertPlain(t, e.get("/v1/activity/summary?range=year", asUser(e, activityOwnerID)), http.StatusBadRequest, activityMismatch) }) } func TestActivityLogBackendExport(t *testing.T) { t.Run("The response is a CSV attachment", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) for i := 1; i <= 3; i++ { e.seedBy(t, userActor(1, "a"), time.Duration(i)*time.Hour) } rec := e.get("/v1/activity/export", asUser(e, activityOwnerID)) if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/csv; charset=utf-8" || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), "attachment") { t.Fatalf("unexpected response %d %v", rec.Code, rec.Header()) } rows, err := csv.NewReader(strings.NewReader(rec.Body.String())).ReadAll() if err != nil || len(rows) != 4 || strings.Join(rows[0], ",") != "when,actor,action,target,outcome,source,ip" { t.Errorf("unexpected file %q, %v", rec.Body.String(), err) } }) t.Run("A row holds the documented columns only", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.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 }()) body := e.get("/v1/activity/export", asUser(e, activityOwnerID)).Body.String() if !strings.HasSuffix(body, "2026-10-06T10:42:18Z,Marta Alves,article.publish,Spring release,success,web,203.0.113.12\n") || strings.Contains(body, "Chrome") { t.Errorf("unexpected file %q", body) } }) t.Run("The export uses the list filters without paging", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) for i := 0; i < 60; i++ { failed := i < 8 e.seedBy(t, userActor(1, "a"), time.Duration(i+1)*time.Minute, func(ev *audit.Event) { if failed { ev.Outcome = audit.OutcomeFailed } }) } rows, _ := csv.NewReader(strings.NewReader(e.get("/v1/activity/export?outcome=failed&page=2&size=25", asUser(e, activityOwnerID)).Body.String())).ReadAll() if len(rows) != 9 { t.Errorf("expected the header and 8 failed rows, got %d lines", len(rows)) } }) t.Run("An invalid filter answers 400 before any byte is streamed", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.get("/v1/activity/export?outcome=maybe", asUser(e, activityOwnerID)) assertPlain(t, rec, http.StatusBadRequest, activityMismatch) if strings.Contains(rec.Header().Get("Content-Type"), "csv") || strings.Contains(rec.Body.String(), "when,actor") { t.Errorf("no CSV header expected, got %v %q", rec.Header(), rec.Body.String()) } }) } func TestActivityLogBackendRetentionSetting(t *testing.T) { settingsBodyOf := func(rec *httptest.ResponseRecorder) map[string]any { var body map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &body) return body } t.Run("The default is 90 days", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.get("/v1/settings", asUser(e, activityEditorID)) assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String()) }) t.Run("The last cleanup is reported", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 3, 0, 0, 0, time.UTC)) e.settings.settings.LastCleanupRemoved = 142 body := settingsBodyOf(e.get("/v1/settings", asUser(e, activityEditorID))) if body["activity_last_cleanup_at"] != "2026-10-06T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) { t.Errorf("unexpected body %v", body) } }) t.Run("An API key can still read the settings", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) raw, _ := e.issueKey(t, activityOwnerID, "bot") if rec := e.get("/v1/settings", withBearer(raw)); rec.Code != http.StatusOK { t.Errorf("expected 200, got %d", rec.Code) } }) t.Run("The owner sets a valid period", func(t *testing.T) { for _, days := range []int{30, 90, 365, 0} { e := newActivityHTTPEnv(t, 1000) rec := e.send(http.MethodPatch, "/v1/settings", fmt.Sprintf(`{"activity_retention_days": %d}`, days), asUser(e, activityOwnerID)) if rec.Code != http.StatusOK || settingsBodyOf(rec)["activity_retention_days"] != float64(days) { t.Fatalf("%d: unexpected response %d %s", days, rec.Code, rec.Body.String()) } if settingsBodyOf(e.get("/v1/settings", asUser(e, activityOwnerID)))["activity_retention_days"] != float64(days) { t.Errorf("%d: the following GET disagrees", days) } } }) t.Run("Any other value answers 400 and changes nothing", func(t *testing.T) { bodies := []string{ `{"activity_retention_days": 7}`, `{"activity_retention_days": -1}`, `{"activity_retention_days": 366}`, `{"activity_retention_days": 30.5}`, `{"activity_retention_days": "30"}`, `{"activity_retention_days": null}`, `{}`, `not json`, } for _, body := range bodies { e := newActivityHTTPEnv(t, 1000) assertPlain(t, e.send(http.MethodPatch, "/v1/settings", body, asUser(e, activityOwnerID)), http.StatusBadRequest, activityMismatch) if e.settings.settings.RetentionDays != 90 { t.Errorf("%s: the retention changed to %d", body, e.settings.settings.RetentionDays) } } }) t.Run("An admin cannot change it", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityAdminID)) assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage) if e.settings.settings.RetentionDays != 90 { t.Error("the retention changed") } }) t.Run("The gate answers before the body is read", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) raw, _ := e.issueKey(t, activityOwnerID, "bot") body := `{"activity_retention_days": 30}` assertPlain(t, e.send(http.MethodPatch, "/v1/settings", body), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage) assertPlain(t, e.send(http.MethodPatch, "/v1/settings", body, withBearer(raw)), http.StatusForbidden, apperr.SessionRequiredErrorMessage) assertPlain(t, e.send(http.MethodPatch, "/v1/settings", body, asUser(e, activityUnverifiedID)), http.StatusForbidden, apperr.VerificationRequiredErrorMessage) }) t.Run("A change is recorded as a system event by the owner", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityOwnerID)) events := e.all(t) if len(events) != 1 { t.Fatalf("expected one event, got %d", len(events)) } event := events[0] if event.Action != "system.activity_retention_changed" || event.Category != audit.CategorySystem || event.Actor.Type != audit.ActorUser || *event.Actor.ID != activityOwnerID || event.Source != audit.SourceWeb || event.Outcome != audit.OutcomeSuccess || event.Security { t.Errorf("unexpected event %+v", event) } if !reflect.DeepEqual(event.Params, map[string]any{"from": float64(90), "to": float64(30)}) { t.Errorf("unexpected params %v", event.Params) } }) t.Run("Setting the same value records nothing", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 90}`, asUser(e, activityOwnerID)) if rec.Code != http.StatusOK || len(e.all(t)) != 0 { t.Errorf("expected 200 and no event, got %d and %d events", rec.Code, len(e.all(t))) } }) } func (e *activityHTTPEnv) events(t *testing.T, action string) []audit.Event { t.Helper() var matching []audit.Event for _, event := range e.all(t) { if event.Action == action { matching = append(matching, event) } } return matching } func assertAuthEvent(t *testing.T, events []audit.Event, actorType audit.ActorType, name string, outcome audit.Outcome, security bool, reason *string) audit.Event { t.Helper() if len(events) != 1 { t.Fatalf("expected exactly one event, got %d", len(events)) } event := events[0] if event.Category != audit.CategoryAuth || event.Source != audit.SourceWeb || event.Actor.Type != actorType || event.Actor.Name != name || event.Outcome != outcome || event.Security != security { t.Errorf("unexpected event %+v", event) } if (reason == nil) != (event.FailureReason == nil) || (reason != nil && *reason != *event.FailureReason) { t.Errorf("unexpected failure reason %v", event.FailureReason) } if event.IP == nil || *event.IP != "203.0.113.12" || event.UserAgent == nil || *event.UserAgent != activityUserAgent { t.Errorf("expected the request ip and user agent, got %v %v", event.IP, event.UserAgent) } return event } func TestActivityLogBackendProducers(t *testing.T) { login := func(e *activityHTTPEnv, username string, password string) *httptest.ResponseRecorder { return e.send(http.MethodPost, "/v1/auth/login", fmt.Sprintf(`{"username":%q,"password":%q}`, username, password)) } t.Run("Authentication events", func(t *testing.T) { wrong := "Wrong username or password." t.Run("login with the right username and password", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) login(e, "marta", activityPassword) assertAuthEvent(t, e.events(t, "auth.login"), audit.ActorUser, "Marta Alves", audit.OutcomeSuccess, false, nil) }) t.Run("login with a wrong password", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) login(e, "marta", "wrongpassword1") assertAuthEvent(t, e.events(t, "auth.login_failed"), audit.ActorAnonymous, "marta", audit.OutcomeFailed, true, &wrong) }) t.Run("login with an unknown username", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) login(e, "ghost", "wrongpassword1") assertAuthEvent(t, e.events(t, "auth.login_failed"), audit.ActorAnonymous, "ghost", audit.OutcomeFailed, true, &wrong) }) t.Run("logout with a valid jwt cookie", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.send(http.MethodPost, "/v1/auth/logout", "", asUser(e, activityOwnerID)) assertAuthEvent(t, e.events(t, "auth.logout"), audit.ActorUser, "Marta Alves", audit.OutcomeSuccess, false, nil) }) t.Run("password reset for an existing account", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.send(http.MethodPost, "/v1/auth/password-reset", `{"email":"marta@example.com"}`) assertAuthEvent(t, e.events(t, "auth.password_reset_requested"), audit.ActorUser, "Marta Alves", audit.OutcomeSuccess, true, nil) }) t.Run("password reset with a valid code", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.send(http.MethodPost, "/v1/auth/password-reset", `{"email":"marta@example.com"}`) code := e.mail.SentEmails[0].Data.(useCases.MailPasswordResetData).VerificationCode rec := e.send(http.MethodPost, "/v1/auth/password-reset/confirm", fmt.Sprintf(`{"email":"marta@example.com","code":%q,"password":"brandnewpass1"}`, code)) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } assertAuthEvent(t, e.events(t, "auth.password_reset_completed"), audit.ActorUser, "Marta Alves", audit.OutcomeSuccess, true, nil) }) }) t.Run("A failed login reveals no more than the response does", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) login(e, "marta", "wrongpassword1") login(e, "ghost", "wrongpassword1") events := e.events(t, "auth.login_failed") if len(events) != 2 || *events[0].FailureReason != *events[1].FailureReason || events[0].Actor.Type != audit.ActorAnonymous || events[1].Actor.Type != audit.ActorAnonymous || events[0].Target.Type != "" || events[1].Target.Type != "" { t.Fatalf("unexpected events %+v", events) } stored, _ := json.Marshal(events) if strings.Contains(string(stored), "wrongpassword1") || strings.Contains(e.logs.String(), "wrongpassword1") { t.Error("the password reached the log") } }) t.Run("Nothing is recorded when nothing happened", func(t *testing.T) { cases := map[string]func(*activityHTTPEnv) *httptest.ResponseRecorder{ "POST /v1/auth/logout without a jwt cookie": func(e *activityHTTPEnv) *httptest.ResponseRecorder { return e.send(http.MethodPost, "/v1/auth/logout", "") }, "POST /v1/auth/password-reset for an unknown email": func(e *activityHTTPEnv) *httptest.ResponseRecorder { return e.send(http.MethodPost, "/v1/auth/password-reset", `{"email":"nobody@example.com"}`) }, "POST /v1/auth/password-reset/confirm with an invalid code": func(e *activityHTTPEnv) *httptest.ResponseRecorder { return e.send(http.MethodPost, "/v1/auth/password-reset/confirm", `{"email":"marta@example.com","code":"nope","password":"brandnewpass1"}`) }, "POST /v1/auth/login with a body that fails validation": func(e *activityHTTPEnv) *httptest.ResponseRecorder { return login(e, "x", "short") }, } for name, trigger := range cases { t.Run(name, func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) trigger(e) if got := e.all(t); len(got) != 0 { t.Errorf("expected no event, got %+v", got) } }) } t.Run("POST /v1/auth/login answered 429 by the rate limiter", func(t *testing.T) { e := newActivityHTTPEnv(t, 1) login(e, "marta", "wrongpassword1") rec := login(e, "marta", "wrongpassword1") if rec.Code != http.StatusTooManyRequests || len(e.all(t)) != 1 { t.Errorf("expected 429 with only the first event, got %d and %d events", rec.Code, len(e.all(t))) } }) }) t.Run("A reset event never holds the code", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.send(http.MethodPost, "/v1/auth/password-reset", `{"email":"marta@example.com"}`) code := e.mail.SentEmails[0].Data.(useCases.MailPasswordResetData).VerificationCode e.send(http.MethodPost, "/v1/auth/password-reset/confirm", fmt.Sprintf(`{"email":"marta@example.com","code":%q,"password":"brandnewpass1"}`, code)) stored, _ := json.Marshal(e.all(t)) everything := string(stored) + e.logs.String() for _, secret := range []string{code, "hashed:" + code, "brandnewpass1", "hashed:brandnewpass1"} { if strings.Contains(everything, secret) { t.Errorf("%q reached the log or an event", secret) } } }) t.Run("Creating an API key is recorded", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.send(http.MethodPost, "/v1/api-key", `{"name":"Deploy bot"}`, asUser(e, activityOwnerID)) var created struct { ID uint32 `json:"id"` Key string `json:"key"` } _ = json.Unmarshal(rec.Body.Bytes(), &created) events := e.events(t, "api_key.create") if rec.Code != http.StatusCreated || len(events) != 1 { t.Fatalf("unexpected result %d with %d events", rec.Code, len(events)) } event := events[0] if event.Category != audit.CategoryAPIKeys || *event.Actor.ID != activityOwnerID || event.Source != audit.SourceWeb || event.Outcome != audit.OutcomeSuccess || event.Security { t.Errorf("unexpected event %+v", event) } if event.Target != (audit.Target{Type: "api_key", ID: strconv.Itoa(int(created.ID)), Name: "Deploy bot"}) { t.Errorf("unexpected target %+v", event.Target) } stored, _ := json.Marshal(e.all(t)) listed := e.get("/v1/activity", asUser(e, activityOwnerID)).Body.String() for _, secret := range []string{created.Key, e.generator.Hash(created.Key)} { if strings.Contains(string(stored), secret) || strings.Contains(listed, secret) || strings.Contains(e.logs.String(), secret) { t.Errorf("the key material %q reached the log or the activity API", secret) } } }) t.Run("Revoking an API key is recorded", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) _, id := e.issueKey(t, activityOwnerID, "Deploy bot") rec := e.send(http.MethodDelete, "/v1/api-key/"+strconv.Itoa(int(id)), "", asUser(e, activityOwnerID)) events := e.events(t, "api_key.revoke") if rec.Code != http.StatusNoContent || len(events) != 1 { t.Fatalf("unexpected result %d with %d events", rec.Code, len(events)) } if events[0].Target != (audit.Target{Type: "api_key", ID: strconv.Itoa(int(id)), Name: "Deploy bot"}) || *events[0].Actor.ID != activityOwnerID || events[0].Outcome != audit.OutcomeSuccess { t.Errorf("unexpected event %+v", events[0]) } }) t.Run("A revoke of an unknown key records nothing", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) rec := e.send(http.MethodDelete, "/v1/api-key/99", "", asUser(e, activityOwnerID)) if rec.Code != http.StatusNotFound || len(e.all(t)) != 0 { t.Errorf("expected 404 and no event, got %d and %d events", rec.Code, len(e.all(t))) } }) t.Run("The recorded events are readable through the API", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) login(e, "marta", "wrongpassword1") e.send(http.MethodPost, "/v1/api-key", `{"name":"Deploy bot"}`, asUser(e, activityOwnerID)) e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityOwnerID)) list := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityOwnerID))) var actions []string for _, item := range list.Items { actions = append(actions, item["action"].(string)) } if !reflect.DeepEqual(actions, []string{"system.activity_retention_changed", "api_key.create", "auth.login_failed"}) { t.Errorf("unexpected actions %v", actions) } secure := decodeActivityList(t, e.get("/v1/activity?security=true", asUser(e, activityOwnerID))) if len(secure.Items) != 1 || secure.Items[0]["action"] != "auth.login_failed" { t.Errorf("expected only the failed login, got %v", secure.Items) } }) } func TestActivityLogBackendRecorderResilience(t *testing.T) { t.Run("A failing repository does not change the caller's result", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.probe.appendErr = errDiskFull rec := e.send(http.MethodPost, "/v1/auth/login", fmt.Sprintf(`{"username":"marta","password":%q}`, activityPassword)) if rec.Code != http.StatusOK || !strings.Contains(strings.Join(rec.Header().Values("Set-Cookie"), ";"), "jwt=") { t.Fatalf("expected 200 with a jwt cookie, got %d %v", rec.Code, rec.Header()) } if !strings.Contains(rec.Body.String(), "User logged in!") { t.Errorf("unexpected body %q", rec.Body.String()) } if !strings.Contains(e.logs.String(), "auth.login") || !strings.Contains(e.logs.String(), "disk full") { t.Errorf("expected a log line naming the failed write, got %q", e.logs.String()) } }) t.Run("A panicking repository does not crash the request", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) e.probe.appendPanic = true rec := e.send(http.MethodPost, "/v1/auth/logout", "", asUser(e, activityOwnerID)) if rec.Code != http.StatusOK { t.Errorf("expected 200, got %d", rec.Code) } if again := e.get("/v1/activity", asUser(e, activityOwnerID)); again.Code != http.StatusOK { t.Errorf("the process must keep serving, got %d", again.Code) } }) } type auditOriginProbe struct { origin audit.Origin seen bool } func TestActivityLogBackendAuditContext(t *testing.T) { newProbeRouter := func(t *testing.T, e *activityHTTPEnv, trusted []string) (http.Handler, *auditOriginProbe) { t.Helper() probe := &auditOriginProbe{} r := chi.NewRouter() r.Use(jwtauth.Verifier(e.token)) r.Use(middleware.APIKeyAuth(useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), 100, time.Minute)) r.Use(middleware.AuditContext(useCases.NewGetUserUseCase(e.users), trusted)) r.Get("/probe", func(w http.ResponseWriter, req *http.Request) { probe.origin, probe.seen = audit.OriginFrom(req.Context()) }) return r, probe } run := func(router http.Handler, mutate func(*http.Request)) { req := httptest.NewRequest(http.MethodGet, "/probe", nil) req.RemoteAddr = "198.51.100.9:443" mutate(req) router.ServeHTTP(httptest.NewRecorder(), req) } t.Run("A session sets a user actor with source web", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) router, probe := newProbeRouter(t, e, nil) run(router, func(r *http.Request) { asUser(e, activityOwnerID)(r) }) if !probe.seen || probe.origin.Actor.Type != audit.ActorUser || *probe.origin.Actor.ID != activityOwnerID || probe.origin.Actor.Name != "Marta Alves" || probe.origin.Source != audit.SourceWeb { t.Errorf("unexpected origin %+v", probe.origin) } }) t.Run("An API key sets an api_key actor with source api_key", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) raw, id := e.issueKey(t, activityOwnerID, "Deploy bot") router, probe := newProbeRouter(t, e, nil) run(router, func(r *http.Request) { withBearer(raw)(r) }) if probe.origin.Actor.Type != audit.ActorAPIKey || *probe.origin.Actor.ID != id || probe.origin.Actor.Name != "Deploy bot" || probe.origin.Source != audit.SourceAPIKey { t.Errorf("unexpected origin %+v", probe.origin) } }) t.Run("No credentials sets an anonymous actor", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) router, probe := newProbeRouter(t, e, nil) run(router, func(r *http.Request) {}) if probe.origin.Actor.Type != audit.ActorAnonymous || probe.origin.Source != audit.SourceWeb { t.Errorf("unexpected origin %+v", probe.origin) } }) t.Run("A stale session sets an anonymous actor", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) u := e.users.users[activityOwnerID] u.TokenVersion = 5 e.users.users[activityOwnerID] = u router, probe := newProbeRouter(t, e, nil) run(router, func(r *http.Request) { asUser(e, activityOwnerID)(r) }) if probe.origin.Actor.Type != audit.ActorAnonymous { t.Errorf("unexpected origin %+v", probe.origin) } }) t.Run("The ip comes from the trusted-proxy real IP only", func(t *testing.T) { cases := []struct{ peer, header, ip string }{ {"10.0.0.5:443", "203.0.113.12", "203.0.113.12"}, {"198.51.100.9:443", "203.0.113.12", "198.51.100.9"}, {"198.51.100.9:443", "", "198.51.100.9"}, } for _, c := range cases { e := newActivityHTTPEnv(t, 1000) router, probe := newProbeRouter(t, e, []string{"10.0.0.5"}) run(router, func(r *http.Request) { r.RemoteAddr = c.peer if c.header != "" { r.Header.Set("X-Real-IP", c.header) } }) if probe.origin.IP != c.ip { t.Errorf("peer %s header %q: expected %s, got %s", c.peer, c.header, c.ip, probe.origin.IP) } } }) t.Run("The user agent is stored as sent and bounded", func(t *testing.T) { e := newActivityHTTPEnv(t, 1000) router, probe := newProbeRouter(t, e, nil) long := strings.Repeat("a", 400) run(router, func(r *http.Request) { r.Header.Set("User-Agent", long) }) if probe.origin.UserAgent != long[:255] { t.Errorf("expected the first 255 characters, got %d", len(probe.origin.UserAgent)) } }) }