1137 lines
45 KiB
Go
1137 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),
|
|
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),
|
|
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))
|
|
}
|
|
})
|
|
}
|