package test import ( "RenewCMS/api" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/update" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" "net/http" "net/http/httptest" "regexp" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) var producersTimestamps = regexp.MustCompile(`"(created_at|expires_at)":"[^"]*",?`) const ( producersUserAgent = "producers-test/1.0" producersClientIP = "192.0.2.1" producersOwnerName = "Marta Alves" producersSpringName = "Spring release" producersBannerPath = "/static/uploadedImages/" ) type producersEnv struct { *apiKeyEnv audit *activityHarness } func newProducersEnv(t *testing.T) *producersEnv { t.Helper() h := newActivityHarness(t) env := newAPIKeyEnvRecording(t, h.recorder) owner := env.users.users[apiKeyOwnerId] owner.DisplayName = producersOwnerName env.users.users[apiKeyOwnerId] = owner env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock, plainSites(), plainAuthors()) router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings)) env.router = router return &producersEnv{apiKeyEnv: env, audit: h} } func (p *producersEnv) request(t *testing.T, req *http.Request, userId uint32) *httptest.ResponseRecorder { t.Helper() req.Header.Set("User-Agent", producersUserAgent) if userId != 0 { req.AddCookie(p.cookie(t, userId, 0)) } rec := httptest.NewRecorder() p.router.ServeHTTP(rec, req) return rec } func (p *producersEnv) as(t *testing.T, userId uint32, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } return p.request(t, req, userId) } func (p *producersEnv) asKey(t *testing.T, raw string, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Authorization", "Bearer "+raw) return p.request(t, req, 0) } func (p *producersEnv) upload(t *testing.T, userId uint32, filename string, contentType string, content []byte) *httptest.ResponseRecorder { t.Helper() return p.request(t, newMultipartFileRequest(t, http.MethodPost, "/v1/media", filename, contentType, content), userId) } func (p *producersEnv) events(t *testing.T) []audit.Event { t.Helper() return p.audit.all(t) } func (p *producersEnv) single(t *testing.T) audit.Event { t.Helper() events := p.events(t) if len(events) != 1 { t.Fatalf("expected exactly one event, got %d: %+v", len(events), events) } return events[0] } func (p *producersEnv) noEvent(t *testing.T) { t.Helper() if events := p.events(t); len(events) != 0 { t.Fatalf("expected no event, got %d: %+v", len(events), events) } } func (p *producersEnv) seedSpring(id uint32, body string, online bool) { p.articles.articles[id] = article.Article{ID: id, Title: producersSpringName, Body: body, IsOnline: online} } func (p *producersEnv) seedBanner(id uint32, filename string, hash string) { p.images.images[id] = image.Image{ID: id, Filename: filename, ContentType: "image/png", Path: producersBannerPath + strconv.Itoa(int(id)) + ".png", SHA256: hash, UploadedByID: apiKeyOwnerId} } func (p *producersEnv) seedPendingInvite(email string) invite.Invite { return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()}) } func paramsJSON(t *testing.T, event audit.Event) string { t.Helper() encoded, err := json.Marshal(event.Params) if err != nil { t.Fatal(err) } return string(encoded) } func changesJSON(t *testing.T, event audit.Event) string { t.Helper() encoded, err := json.Marshal(event.Changes) if err != nil { t.Fatal(err) } return string(encoded) } func assertProducerEvent(t *testing.T, event audit.Event, action string, category audit.Category, outcome audit.Outcome) { t.Helper() if event.Action != action || event.Category != category || event.Outcome != outcome { t.Fatalf("event = %s/%s/%s, expected %s/%s/%s", event.Action, event.Category, event.Outcome, action, category, outcome) } } func assertProducerTarget(t *testing.T, event audit.Event, targetType string, id string, name string) { t.Helper() if event.Target != (audit.Target{Type: targetType, ID: id, Name: name}) { t.Errorf("target = %+v, expected {%s %s %s}", event.Target, targetType, id, name) } } func assertProducerFailure(t *testing.T, event audit.Event, reason string) { t.Helper() if event.Outcome != audit.OutcomeFailed || event.FailureReason == nil || *event.FailureReason != reason { t.Errorf("outcome = %s, failure_reason = %v, expected failed with %q", event.Outcome, event.FailureReason, reason) } } func assertProducerOwnerOrigin(t *testing.T, event audit.Event) { t.Helper() if event.Actor.Type != audit.ActorUser || event.Actor.ID == nil || *event.Actor.ID != apiKeyOwnerId || event.Actor.Name != producersOwnerName { t.Errorf("actor = %+v", event.Actor) } if event.Source != audit.SourceWeb { t.Errorf("source = %s", event.Source) } if event.IP == nil || *event.IP != producersClientIP || event.UserAgent == nil || *event.UserAgent != producersUserAgent { t.Errorf("ip = %v, user agent = %v", event.IP, event.UserAgent) } } func TestActivityLogProducersRouteContext(t *testing.T) { t.Run("A producing route carries the origin of the request", func(t *testing.T) { t.Run("POST /v1/article with the owner's jwt cookie", func(t *testing.T) { p := newProducersEnv(t) rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`) assertAPIKeyStatus(t, rec, http.StatusCreated) assertProducerOwnerOrigin(t, p.single(t)) }) t.Run("POST /v1/article with an API key Deploy bot", func(t *testing.T) { p := newProducersEnv(t) raw, keyId := p.issueKey(apiKeyDevId, "Deploy bot") rec := p.asKey(t, raw, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`) assertAPIKeyStatus(t, rec, http.StatusCreated) event := p.single(t) if event.Actor.Type != audit.ActorAPIKey || event.Actor.ID == nil || *event.Actor.ID != keyId || event.Actor.Name != "Deploy bot" || event.Source != audit.SourceAPIKey { t.Errorf("actor = %+v, source = %s", event.Actor, event.Source) } if event.IP == nil || *event.IP != producersClientIP || event.UserAgent == nil || *event.UserAgent != producersUserAgent { t.Errorf("ip = %v, user agent = %v", event.IP, event.UserAgent) } }) t.Run("POST /v1/media with an API key Deploy bot", func(t *testing.T) { p := newProducersEnv(t) raw, keyId := p.issueKey(apiKeyDevId, "Deploy bot") req := newMultipartFileRequest(t, http.MethodPost, "/v1/media", "spring-banner.png", "image/png", pngBytes(t, 3, 2)) req.Header.Set("Authorization", "Bearer "+raw) rec := p.request(t, req, 0) assertAPIKeyStatus(t, rec, http.StatusCreated) event := p.single(t) if event.Actor.Type != audit.ActorAPIKey || event.Actor.ID == nil || *event.Actor.ID != keyId || event.Actor.Name != "Deploy bot" || event.Source != audit.SourceAPIKey { t.Errorf("actor = %+v, source = %s", event.Actor, event.Source) } }) t.Run("POST /v1/system/update/check with the owner's jwt cookie", func(t *testing.T) { p := newProducersEnv(t) rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", "") assertAPIKeyStatus(t, rec, http.StatusOK) assertProducerOwnerOrigin(t, p.single(t)) }) }) } func TestActivityLogProducersFailedAuditWrite(t *testing.T) { type row struct { name string panics bool setup func(p *producersEnv) send func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder applied func(t *testing.T, p *producersEnv) } owner := func(method string, path string, body string) func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder { return func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder { return p.as(t, apiKeyOwnerId, method, path, body) } } seedArticle := func(p *producersEnv) { p.seedSpring(8, "Hello", false) } seedCopies := func(p *producersEnv) { p.seedBanner(5, "banner.png", "same") p.seedBanner(6, "banner-copy.png", "same") p.articles.articles[1] = article.Article{ID: 1, Title: "Uses copy", Body: "see 6.png"} } rows := []row{ {name: "fails POST /v1/article", setup: func(*producersEnv) {}, send: owner(http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`), applied: func(t *testing.T, p *producersEnv) { if len(p.articles.articles) != 1 { t.Errorf("the article was not created") } }}, {name: "fails DELETE /v1/article/8", setup: seedArticle, send: owner(http.MethodDelete, "/v1/article/8", ""), applied: func(t *testing.T, p *producersEnv) { if _, found := p.articles.articles[8]; found { t.Errorf("the article was not deleted") } }}, {name: "panics PATCH /v1/article/8/publish", panics: true, setup: seedArticle, send: owner(http.MethodPatch, "/v1/article/8/publish", ""), applied: func(t *testing.T, p *producersEnv) { if !p.articles.articles[8].IsOnline { t.Errorf("the article was not published") } }}, {name: "fails POST /v1/media", setup: func(*producersEnv) {}, send: func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder { return p.upload(t, apiKeyOwnerId, "spring-banner.png", "image/png", pngBytes(t, 3, 2)) }, applied: func(t *testing.T, p *producersEnv) { if len(p.images.images) != 1 { t.Errorf("the image was not stored") } }}, {name: "fails POST /v1/media/merge", setup: seedCopies, send: owner(http.MethodPost, "/v1/media/merge", `{"keep_id":5,"merge_ids":[6]}`), applied: func(t *testing.T, p *producersEnv) { if _, found := p.images.images[6]; found { t.Errorf("the copy was not merged") } }}, {name: "fails POST /v1/user/invite", setup: func(*producersEnv) {}, send: owner(http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`), applied: func(t *testing.T, p *producersEnv) { if len(p.invites.invites) != 1 { t.Errorf("the invite was not created") } }}, {name: "fails POST /v1/system/update/restart", setup: func(*producersEnv) {}, send: owner(http.MethodPost, "/v1/system/update/restart", ""), applied: func(*testing.T, *producersEnv) {}}, } t.Run("A failing or panicking repository leaves the response untouched", func(t *testing.T) { for _, example := range rows { t.Run(example.name, func(t *testing.T) { healthy := newProducersEnv(t) example.setup(healthy) expected := example.send(t, healthy) p := newProducersEnv(t) example.setup(p) if example.panics { p.audit.probe.appendPanic = true } else { p.audit.probe.appendErr = errDiskFull } rec := example.send(t, p) if rec.Code != expected.Code || producersTimestamps.ReplaceAllString(rec.Body.String(), "") != producersTimestamps.ReplaceAllString(expected.Body.String(), "") { t.Errorf("response = %d %q, healthy response = %d %q", rec.Code, rec.Body.String(), expected.Code, expected.Body.String()) } if rec.Code >= http.StatusBadRequest { t.Fatalf("the request was expected to succeed, got %d: %s", rec.Code, rec.Body.String()) } example.applied(t, p) if !strings.Contains(p.audit.logs.String(), "audit:") { t.Errorf("the process log does not name the failed audit write: %q", p.audit.logs.String()) } }) } }) } func TestActivityLogProducersArticles(t *testing.T) { t.Run("Creating an article is recorded", func(t *testing.T) { p := newProducersEnv(t) rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`) assertAPIKeyStatus(t, rec, http.StatusCreated) event := p.single(t) assertProducerEvent(t, event, "article.create", audit.CategoryArticles, audit.OutcomeSuccess) assertProducerOwnerOrigin(t, event) assertProducerTarget(t, event, "article", "1", producersSpringName) if event.FailureReason != nil || len(event.Changes) != 0 { t.Errorf("failure reason = %v, changes = %+v", event.FailureReason, event.Changes) } if encoded, _ := json.Marshal(event); strings.Contains(string(encoded), "Hello") { t.Errorf("the stored event holds the article body: %s", encoded) } }) t.Run("Editing the body records a size delta and never the text", func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, strings.Repeat("a", 100), false) newBody := strings.Repeat("secretbody", 41) + "xx" rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"body":"`+newBody+`"}`) assertAPIKeyStatus(t, rec, http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "article.edit", audit.CategoryArticles, audit.OutcomeSuccess) assertProducerTarget(t, event, "article", "8", producersSpringName) if got := changesJSON(t, event); got != `[{"field":"body","kind":"body","delta":312}]` { t.Errorf("changes = %s", got) } listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity", "") assertAPIKeyStatus(t, listing, http.StatusOK) if strings.Contains(listing.Body.String(), "secretbody") || strings.Contains(listing.Body.String(), "aaaa") { t.Errorf("the activity response holds article text: %s", listing.Body.String()) } }) t.Run("Saving an identical body records nothing", func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, "Same text", false) rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"body":"Same text"}`) assertAPIKeyStatus(t, rec, http.StatusOK) p.noEvent(t) }) t.Run("Publishing is recorded with the publish state", func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, "Hello", false) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/publish", ""), http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "article.publish", audit.CategoryArticles, audit.OutcomeSuccess) if got := changesJSON(t, event); got != `[{"field":"online","kind":"text","before":"false","after":"true"}]` { t.Errorf("changes = %s", got) } }) t.Run("Unpublishing is recorded with the publish state", func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, "Hello", true) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/unpublish", ""), http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "article.unpublish", audit.CategoryArticles, audit.OutcomeSuccess) if got := changesJSON(t, event); got != `[{"field":"online","kind":"text","before":"true","after":"false"}]` { t.Errorf("changes = %s", got) } }) t.Run("A publish state that does not change records nothing", func(t *testing.T) { for _, example := range []struct { state bool path string status int }{{true, "publish", http.StatusOK}, {false, "unpublish", http.StatusOK}} { t.Run(strconv.FormatBool(example.state)+" "+example.path, func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, "Hello", example.state) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/"+example.path, ""), example.status) p.noEvent(t) }) } }) t.Run("Deleting an article is recorded with its name snapshot", func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, "Hello", false) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/article/8", ""), http.StatusNoContent) event := p.single(t) assertProducerEvent(t, event, "article.delete", audit.CategoryArticles, audit.OutcomeSuccess) assertProducerTarget(t, event, "article", "8", producersSpringName) listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity", "") var page struct { Items []struct { Target struct { Exists bool `json:"exists"` } `json:"target"` } `json:"items"` } if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil || len(page.Items) != 1 || page.Items[0].Target.Exists { t.Errorf("listing = %s, err = %v", listing.Body.String(), err) } }) t.Run("A refused article action is recorded as failed with a plain reason", func(t *testing.T) { t.Run("the repository fails with db is locked on create", func(t *testing.T) { p := newProducersEnv(t) p.articles.fail.failNextWith("Create", errors.New("db is locked")) rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`) assertAPIKeyStatus(t, rec, http.StatusInternalServerError) event := p.single(t) assertProducerEvent(t, event, "article.create", audit.CategoryArticles, audit.OutcomeFailed) assertProducerFailure(t, event, useCases.GenericFailureReason) if strings.Contains(paramsJSON(t, event), "locked") || strings.Contains(*event.FailureReason, "locked") { t.Errorf("the raw error leaked: %+v", event) } }) t.Run("the repository fails with db is locked on publish", func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, "Hello", false) p.articles.fail.failNextWith("UpdateIsOnline", errors.New("db is locked")) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/publish", ""), http.StatusInternalServerError) event := p.single(t) assertProducerEvent(t, event, "article.publish", audit.CategoryArticles, audit.OutcomeFailed) assertProducerFailure(t, event, useCases.GenericFailureReason) assertProducerTarget(t, event, "article", "8", producersSpringName) }) }) t.Run("An article that does not exist records nothing", func(t *testing.T) { p := newProducersEnv(t) for _, call := range []struct{ method, path, body string }{ {http.MethodDelete, "/v1/article/99", ""}, {http.MethodPatch, "/v1/article/99", `{"body":"x"}`}, {http.MethodPatch, "/v1/article/99/publish", ""}, {http.MethodPatch, "/v1/article/99/unpublish", ""}, } { assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, call.method, call.path, call.body), http.StatusNotFound) } p.noEvent(t) }) t.Run("A request that fails validation records nothing", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{}`), http.StatusBadRequest) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/abc", `{"body":"x"}`), http.StatusBadRequest) p.noEvent(t) }) t.Run("Reads are never recorded", func(t *testing.T) { p := newProducersEnv(t) p.seedSpring(8, "Hello", true) for _, path := range []string{"/v1/article", "/v1/article/8"} { assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, path, ""), http.StatusOK) } assertAPIKeyStatus(t, p.as(t, 0, http.MethodGet, "/v1/article/8", ""), http.StatusOK) p.noEvent(t) }) } func TestActivityLogProducersMedia(t *testing.T) { t.Run("Uploading an image is recorded", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.upload(t, apiKeyOwnerId, "spring-banner.png", "image/png", pngBytes(t, 3, 2)), http.StatusCreated) event := p.single(t) assertProducerEvent(t, event, "media.upload", audit.CategoryMedia, audit.OutcomeSuccess) assertProducerOwnerOrigin(t, event) assertProducerTarget(t, event, "image", "1", "spring-banner.png") if encoded, _ := json.Marshal(event); strings.Contains(string(encoded), "/static/") { t.Errorf("the stored event holds a storage path: %s", encoded) } }) t.Run("A refused upload is recorded as failed", func(t *testing.T) { for _, example := range []struct { name string contentType string content func(t *testing.T) []byte reason string }{ {"a GIF", "image/gif", func(t *testing.T) []byte { return []byte("GIF89a") }, "The file must be a PNG, JPEG, WEBP or SVG image."}, {"a PNG larger than the limit", "image/png", func(t *testing.T) []byte { return paddedPngBytes(t, int(image.MaxImageSize)+10) }, "The file is too large to upload."}, {"a corrupt PNG", "image/png", func(t *testing.T) []byte { return []byte("not a png") }, "The image could not be read."}, } { t.Run(example.name, func(t *testing.T) { p := newProducersEnv(t) rec := p.upload(t, apiKeyOwnerId, "spring-banner.png", example.contentType, example.content(t)) if rec.Code < http.StatusBadRequest { t.Fatalf("expected a refusal, got %d", rec.Code) } event := p.single(t) assertProducerEvent(t, event, "media.upload", audit.CategoryMedia, audit.OutcomeFailed) assertProducerFailure(t, event, example.reason) if event.Target.Name != "spring-banner.png" { t.Errorf("target = %+v", event.Target) } }) } }) t.Run("Renaming an image records the filename diff", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "h") assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"spring-banner"}`), http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "media.rename", audit.CategoryMedia, audit.OutcomeSuccess) assertProducerTarget(t, event, "image", "5", "spring-banner.png") if got := changesJSON(t, event); got != `[{"field":"filename","kind":"text","before":"banner.png","after":"spring-banner.png"}]` { t.Errorf("changes = %s", got) } }) t.Run("Renaming to the same name records nothing", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "h") assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"banner"}`), http.StatusOK) p.noEvent(t) }) t.Run("A refused rename is recorded as failed", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "h") rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"not/valid"}`) assertAPIKeyStatus(t, rec, http.StatusBadRequest) event := p.single(t) assertProducerEvent(t, event, "media.rename", audit.CategoryMedia, audit.OutcomeFailed) assertProducerFailure(t, event, "That file name is not valid.") assertProducerTarget(t, event, "image", "5", "banner.png") }) t.Run("Deleting an image is recorded with its name snapshot", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "h") assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/media/5", ""), http.StatusNoContent) event := p.single(t) assertProducerEvent(t, event, "media.delete", audit.CategoryMedia, audit.OutcomeSuccess) assertProducerTarget(t, event, "image", "5", "banner.png") }) t.Run("Merging images is recorded once with its counts", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "same") p.seedBanner(6, "banner-copy.png", "same") p.seedBanner(7, "banner-copy-2.png", "same") p.articles.articles[1] = article.Article{ID: 1, Title: "One", Body: "see 6.png"} p.articles.articles[2] = article.Article{ID: 2, Title: "Two", Body: "see 7.png"} rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", `{"keep_id":5,"merge_ids":[6,7]}`) assertAPIKeyStatus(t, rec, http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "media.merge", audit.CategoryMedia, audit.OutcomeSuccess) assertProducerTarget(t, event, "image", "5", "banner.png") if got := paramsJSON(t, event); got != `{"articles_updated":2,"merged":2}` { t.Errorf("params = %s", got) } }) t.Run("A refused merge is recorded as failed", func(t *testing.T) { for _, example := range []struct { name string setup func(p *producersEnv) body string reason string }{ {"ErrInvalidMergeRequest", func(p *producersEnv) { p.seedBanner(5, "banner.png", "same") }, `{"keep_id":5,"merge_ids":[5]}`, "The merge request is not valid."}, {"ErrMergeCopyMissing", func(p *producersEnv) { p.seedBanner(5, "banner.png", "same") }, `{"keep_id":5,"merge_ids":[6]}`, "One of the copies no longer exists."}, {"ErrMergeHashMismatch", func(p *producersEnv) { p.seedBanner(5, "banner.png", "same") p.seedBanner(6, "other.png", "different") }, `{"keep_id":5,"merge_ids":[6]}`, "The images are not identical copies."}, } { t.Run(example.name, func(t *testing.T) { p := newProducersEnv(t) example.setup(p) rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", example.body) if rec.Code < http.StatusBadRequest { t.Fatalf("expected a refusal, got %d", rec.Code) } event := p.single(t) assertProducerEvent(t, event, "media.merge", audit.CategoryMedia, audit.OutcomeFailed) assertProducerFailure(t, event, example.reason) }) } }) t.Run("A manual purge is recorded once and not as separate merges", func(t *testing.T) { p := newProducersEnv(t) seedTwoGroupPurgePlan(p) rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", "") assertAPIKeyStatus(t, rec, http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "media.purge", audit.CategoryMedia, audit.OutcomeSuccess) assertProducerOwnerOrigin(t, event) if got := paramsJSON(t, event); got != `{"articles_updated":5,"merged":3}` { t.Errorf("params = %s", got) } }) t.Run("A partly failed manual purge is a success with the failed group count", func(t *testing.T) { p := newProducersEnv(t) seedThreeGroupPurgePlan(p, 5) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", ""), http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "media.purge", audit.CategoryMedia, audit.OutcomeSuccess) if !strings.Contains(paramsJSON(t, event), `"failed_groups":1`) { t.Errorf("params = %s", paramsJSON(t, event)) } }) t.Run("A refused manual purge is recorded as failed", func(t *testing.T) { p := newProducersEnv(t) seedTwoGroupPurgePlan(p) rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{"fingerprint":"stale"}`) assertAPIKeyStatus(t, rec, http.StatusConflict) event := p.single(t) assertProducerEvent(t, event, "media.purge", audit.CategoryMedia, audit.OutcomeFailed) assertProducerFailure(t, event, "The library changed since the preview. Review it again.") }) t.Run("Browsing and the media usage lookup are never recorded", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "h") assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media", ""), http.StatusOK) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media/5/usage", ""), http.StatusOK) p.noEvent(t) }) } func (p *producersEnv) seedArticleUsing(id uint32, body string) { p.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: body} } func seedTwoGroupPurgePlan(p *producersEnv) { p.seedBanner(1, "kept-a.png", "hash-a") p.seedBanner(2, "copy-a1.png", "hash-a") p.seedBanner(3, "copy-a2.png", "hash-a") p.seedBanner(4, "kept-b.png", "hash-b") p.seedBanner(5, "copy-b1.png", "hash-b") for id, body := range map[uint32]string{1: "uses 2.png", 2: "uses 3.png", 3: "uses 5.png", 4: "uses 2.png", 5: "uses 3.png", 6: "1.png 4.png", 7: "1.png 4.png", 8: "1.png 4.png"} { p.seedArticleUsing(id, body) } } func seedThreeGroupPurgePlan(p *producersEnv, failingCopy uint32) { p.seedBanner(1, "kept-a.png", "hash-a") p.seedBanner(2, "copy-a.png", "hash-a") p.seedBanner(4, "kept-b.png", "hash-b") p.seedBanner(5, "copy-b.png", "hash-b") p.seedBanner(7, "kept-c.png", "hash-c") p.seedBanner(8, "copy-c.png", "hash-c") for id, body := range map[uint32]string{1: "uses 2.png", 2: "uses 5.png", 3: "uses 8.png", 4: "1.png 4.png 7.png", 5: "1.png 4.png 7.png"} { p.seedArticleUsing(id, body) } p.images.failDeleteFor = map[uint32]error{failingCopy: errors.New("storage offline")} } func TestActivityLogProducersUsers(t *testing.T) { t.Run("Changing the display name records a before and after", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"Marta Alves 2"}`), http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "user.update_profile", audit.CategoryUsers, audit.OutcomeSuccess) assertProducerTarget(t, event, "user", "1", "Marta Alves 2") if got := changesJSON(t, event); got != `[{"field":"display_name","kind":"text","before":"Marta Alves","after":"Marta Alves 2"}]` { t.Errorf("changes = %s", got) } if event.Actor.Name != producersOwnerName { t.Errorf("the actor snapshot name = %q, expected the name held when the request began", event.Actor.Name) } }) t.Run("Setting a display name that does not change records nothing", func(t *testing.T) { for _, sent := range []string{"Marta Alves", " Marta Alves "} { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"`+sent+`"}`), http.StatusOK) p.noEvent(t) } }) t.Run("A display name that is too long is recorded as failed", func(t *testing.T) { p := newProducersEnv(t) rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"`+strings.Repeat("a", 61)+`"}`) assertAPIKeyStatus(t, rec, http.StatusBadRequest) event := p.single(t) assertProducerEvent(t, event, "user.update_profile", audit.CategoryUsers, audit.OutcomeFailed) assertProducerFailure(t, event, "That display name is too long.") if len(event.Changes) != 0 { t.Errorf("changes = %+v", event.Changes) } }) t.Run("Creating an invite is recorded", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`), http.StatusCreated) event := p.single(t) assertProducerEvent(t, event, "invite.create", audit.CategoryUsers, audit.OutcomeSuccess) assertProducerOwnerOrigin(t, event) assertProducerTarget(t, event, "invite", "1", "new@example.com") if encoded, _ := json.Marshal(event); strings.Contains(string(encoded), "hashed:") { t.Errorf("the stored event holds a code hash: %s", encoded) } }) t.Run("A refused invite action is recorded as failed", func(t *testing.T) { for _, example := range []struct { name string setup func(p *producersEnv) method string path string body string action string reason string }{ {"ErrEmailHasAccount", func(*producersEnv) {}, http.MethodPost, "/v1/user/invite", `{"email":"dev@example.com"}`, "invite.create", "That email already has an account."}, {"ErrPendingInvite", func(p *producersEnv) { p.seedPendingInvite("new@example.com") }, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, "invite.create", "An invitation is already pending for that email."}, {"ErrInviteEmailNotSent on create", func(p *producersEnv) { p.mail.fail.failNext("Send") }, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, "invite.create", "The invitation email could not be sent."}, {"ErrInviteEmailNotSent on renew", func(p *producersEnv) { p.seedPendingInvite("new@example.com"); p.mail.fail.failNext("Send") }, http.MethodPost, "/v1/user/invite/1/renew", "", "invite.renew", "The invitation email could not be sent."}, {"ErrInviteAlreadyAccepted on renew", func(p *producersEnv) { p.seedAcceptedInvite("new@example.com") }, http.MethodPost, "/v1/user/invite/1/renew", "", "invite.renew", "That invitation was already accepted."}, {"ErrInviteAlreadyAccepted on cancel", func(p *producersEnv) { p.seedAcceptedInvite("new@example.com") }, http.MethodDelete, "/v1/user/invite/1", "", "invite.cancel", "That invitation was already accepted."}, } { t.Run(example.name, func(t *testing.T) { p := newProducersEnv(t) example.setup(p) rec := p.as(t, apiKeyOwnerId, example.method, example.path, example.body) if rec.Code < http.StatusBadRequest { t.Fatalf("expected a refusal, got %d", rec.Code) } event := p.single(t) assertProducerEvent(t, event, example.action, audit.CategoryUsers, audit.OutcomeFailed) assertProducerFailure(t, event, example.reason) }) } }) t.Run("Cancelling an invite is recorded", func(t *testing.T) { p := newProducersEnv(t) p.seedPendingInvite("new@example.com") assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/user/invite/1", ""), http.StatusNoContent) event := p.single(t) assertProducerEvent(t, event, "invite.cancel", audit.CategoryUsers, audit.OutcomeSuccess) assertProducerTarget(t, event, "invite", "1", "new@example.com") }) t.Run("Renewing an invite is recorded", func(t *testing.T) { p := newProducersEnv(t) p.seedPendingInvite("new@example.com") assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite/1/renew", ""), http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "invite.renew", audit.CategoryUsers, audit.OutcomeSuccess) assertProducerTarget(t, event, "invite", "1", "new@example.com") }) t.Run("An invite or user that does not exist records nothing", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/user/invite/99", ""), http.StatusNotFound) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite/99/renew", ""), http.StatusNotFound) p.noEvent(t) }) t.Run("Reads and checks are never recorded", func(t *testing.T) { p := newProducersEnv(t) for _, path := range []string{"/v1/user", "/v1/auth/me", "/v1/auth/registration"} { rec := p.as(t, apiKeyOwnerId, http.MethodGet, path, "") assertAPIKeyStatus(t, rec, http.StatusOK) } p.noEvent(t) }) } func (p *producersEnv) seedAcceptedInvite(email string) { accepted := time.Now().Add(-time.Hour) p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)}) } func TestActivityLogProducersSystem(t *testing.T) { t.Run("Checking for an update is recorded", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", ""), http.StatusOK) event := p.single(t) assertProducerEvent(t, event, "system.update_checked", audit.CategorySystem, audit.OutcomeSuccess) assertProducerTarget(t, event, "release", "v2.3.0-beta", "v2.3.0-beta") if got := paramsJSON(t, event); got != `{"update_available":true}` { t.Errorf("params = %s", got) } }) t.Run("A failed update check is recorded with a plain reason", func(t *testing.T) { for _, example := range []struct { name string err error reason string }{ {"ErrFeedUnavailable", update.ErrFeedUnavailable, "The release feed could not be reached."}, {"ErrNoRelease", update.ErrNoRelease, "No release was found."}, } { t.Run(example.name, func(t *testing.T) { p := newProducersEnv(t) p.feed.err = example.err assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", ""), http.StatusInternalServerError) event := p.single(t) assertProducerEvent(t, event, "system.update_checked", audit.CategorySystem, audit.OutcomeFailed) assertProducerFailure(t, event, example.reason) if event.Target != (audit.Target{}) { t.Errorf("target = %+v", event.Target) } }) } }) t.Run("The status poll is never recorded", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/system/update", ""), http.StatusOK) p.noEvent(t) }) t.Run("A restart request is recorded", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/restart", ""), http.StatusAccepted) event := p.single(t) assertProducerEvent(t, event, "system.restart_requested", audit.CategorySystem, audit.OutcomeSuccess) assertProducerOwnerOrigin(t, event) if got := paramsJSON(t, event); got != `{"kind":"restart"}` { t.Errorf("params = %s", got) } if event.Target != (audit.Target{}) { t.Errorf("target = %+v", event.Target) } }) } func (p *producersEnv) listedActions(t *testing.T, category string) []string { t.Helper() listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity?category="+category, "") assertAPIKeyStatus(t, listing, http.StatusOK) var page struct { Items []struct { Action string `json:"action"` Category string `json:"category"` } `json:"items"` } if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil { t.Fatal(err) } actions := []string{} for _, item := range page.Items { if item.Category != category { t.Errorf("item category = %q, expected %q", item.Category, category) } actions = append(actions, item.Action) } return actions } func TestActivityLogProducersReadBackOtherCategories(t *testing.T) { t.Run("Media events are listed under their category", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "same") assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"spring"}`), http.StatusOK) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/media/5", ""), http.StatusNoContent) if got := strings.Join(p.listedActions(t, "media"), ","); got != "media.delete,media.rename" { t.Errorf("actions = %s", got) } if got := p.listedActions(t, "articles"); len(got) != 0 { t.Errorf("articles actions = %v", got) } }) t.Run("User events are listed under their category", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"Marta Alves 2"}`), http.StatusOK) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`), http.StatusCreated) if got := strings.Join(p.listedActions(t, "users"), ","); got != "invite.create,user.update_profile" { t.Errorf("actions = %s", got) } }) t.Run("System events are listed under their category", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", ""), http.StatusOK) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/restart", ""), http.StatusAccepted) if got := strings.Join(p.listedActions(t, "system"), ","); got != "system.restart_requested,system.update_checked" { t.Errorf("actions = %s", got) } }) } func TestActivityLogProducersReadBack(t *testing.T) { t.Run("Producer events are listed with their documented shape", func(t *testing.T) { p := newProducersEnv(t) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`), http.StatusCreated) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/1", `{"body":"Hello world"}`), http.StatusOK) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/1/publish", ""), http.StatusOK) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/article/1", ""), http.StatusNoContent) listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity?category=articles", "") assertAPIKeyStatus(t, listing, http.StatusOK) var page struct { Items []struct { Action string `json:"action"` Category string `json:"category"` Target struct { Exists bool `json:"exists"` } `json:"target"` } `json:"items"` } if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil { t.Fatal(err) } var actions []string for _, item := range page.Items { actions = append(actions, item.Action) if item.Category != "articles" || item.Target.Exists { t.Errorf("item = %+v", item) } } if strings.Join(actions, ",") != "article.delete,article.publish,article.edit,article.create" { t.Errorf("actions = %v", actions) } }) t.Run("A failed producer event is filterable", func(t *testing.T) { p := newProducersEnv(t) p.seedBanner(5, "banner.png", "same") p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", `{"keep_id":5,"merge_ids":[6]}`) assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"spring"}`), http.StatusOK) listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity?outcome=failed", "") assertAPIKeyStatus(t, listing, http.StatusOK) var page struct { Items []struct { Action string `json:"action"` FailureReason *string `json:"failure_reason"` } `json:"items"` } if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil { t.Fatal(err) } if len(page.Items) != 1 || page.Items[0].Action != "media.merge" || page.Items[0].FailureReason == nil || *page.Items[0].FailureReason != "One of the copies no longer exists." { t.Errorf("items = %+v", page.Items) } }) }