Files
RenewCMS/test/activity_log_backend_http_test.go

1138 lines
45 KiB
Go

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))
}
})
}