package test import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/author" "encoding/json" "errors" "net/http" "reflect" "strings" "testing" ) func TestAuthorsActivityLog(t *testing.T) { t.Run("A successful write is recorded once after the change", func(t *testing.T) { cases := []struct { request string action string id string name string actor uint32 prepare func(e *authorEnv) method string path string body string status int }{ {"POST /v1/author/", "author.create", "1", "Camille Roche", apiKeyOwnerId, func(e *authorEnv) {}, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`, http.StatusCreated}, {"POST /v1/author/me", "author.create", "1", "Edith Moreau", authorEditorID, func(e *authorEnv) {}, http.MethodPost, "/v1/author/me", "", http.StatusCreated}, {"PATCH renaming", "author.edit", "2", "Camille R.", apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "Camille Roche", "camille-roche") }, http.MethodPatch, "/v1/author/2", `{"name": "Camille R."}`, http.StatusOK}, {"PATCH linking", "author.edit", "2", "Camille Roche", apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "Camille Roche", "camille-roche") }, http.MethodPatch, "/v1/author/2", `{"user_id": 5}`, http.StatusOK}, {"DELETE", "author.delete", "2", "Camille Roche", apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "Camille Roche", "camille-roche") }, http.MethodDelete, "/v1/author/2", "", http.StatusNoContent}, } for _, c := range cases { t.Run(c.request, func(t *testing.T) { e := newAuthorEnv(t) c.prepare(e) rec := e.as(t, c.actor, c.method, c.path, c.body) if rec.Code != c.status { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } event := e.single(t) assertProducerEvent(t, event, c.action, audit.CategoryAuthors, audit.OutcomeSuccess) assertProducerTarget(t, event, "author", c.id, c.name) if event.Category != "authors" || event.Source != audit.SourceWeb || event.Actor.ID == nil || *event.Actor.ID != c.actor { t.Errorf("event = %+v", event) } }) } }) t.Run("The change fields are limited and carry no email", func(t *testing.T) { e := newAuthorEnv(t) e.seedBanner(5, "banner.png", "hash") e.authors.seed(2, "Camille Roche", "camille-roche") rec := e.owner(t, http.MethodPatch, "/v1/author/2", `{"name": "Camille R.", "slug": "camille-r", "bio": "New.", "website": "https://c.example", "social_links": [{"type": "x", "url": "https://x.com/c"}], "avatar_id": 5, "user_id": 5}`) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } event := e.single(t) fields := []string{} for _, change := range event.Changes { fields = append(fields, change.Field) } if !reflect.DeepEqual(fields, []string{"name", "slug", "bio", "website", "social_links", "avatar", "user"}) { t.Errorf("fields = %v", fields) } encoded, _ := json.Marshal(event) if strings.Contains(string(encoded), "@") { t.Errorf("event holds an email: %s", encoded) } if userChange := event.Changes[6]; *userChange.Before != "" || *userChange.After != "edith" { t.Errorf("user change = %+v", userChange) } }) t.Run("An own-author edit by a role without author rights is recorded", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seedLinked(4, "Edith", "edith", authorEditorID) e.as(t, authorEditorID, http.MethodPatch, "/v1/author/4", `{"bio": "Hi."}`) event := e.single(t) assertProducerEvent(t, event, "author.edit", audit.CategoryAuthors, audit.OutcomeSuccess) if event.Actor.ID == nil || *event.Actor.ID != authorEditorID { t.Errorf("actor = %+v", event.Actor) } }) t.Run("A refused write is recorded as failed with a plain reason", func(t *testing.T) { cases := []struct { request string action string reason string status int actor uint32 prepare func(e *authorEnv) method string path string body string }{ {"POST taken slug", "author.create", "That slug is already used by another author.", 409, apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(1, "A", "camille-roche") }, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`}, {"POST /me linked caller", "author.create", "That user already has an author.", 409, authorEditorID, func(e *authorEnv) { e.authors.seedLinked(1, "Edith", "edith", authorEditorID) }, http.MethodPost, "/v1/author/me", ""}, {"PATCH taken slug", "author.edit", "That slug is already used by another author.", 409, apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "B", "b") e.authors.seed(3, "C", "c") }, http.MethodPatch, "/v1/author/2", `{"slug": "c"}`}, {"PATCH linking a linked user", "author.edit", "That user already has an author.", 409, apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "B", "b") e.authors.seedLinked(3, "C", "c", authorEditorID) }, http.MethodPatch, "/v1/author/2", `{"user_id": 5}`}, {"PATCH by a non-admin", "author.edit", "Only an owner or admin can change this author.", 403, authorEditorID, func(e *authorEnv) { e.authors.seed(2, "B", "b") }, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`}, {"DELETE with articles", "author.delete", "The author is still credited on articles.", 409, apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "B", "b") e.seedOnlineArticle(1, "One", 2) }, http.MethodDelete, "/v1/author/2", ""}, {"DELETE reassigned to itself", "author.delete", "The destination author is not valid.", 400, apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "B", "b") e.seedOnlineArticle(1, "One", 2) }, http.MethodDelete, "/v1/author/2?reassign_to=2", ""}, {"POST when the repository fails", "author.create", genericFailureSentence, 500, apiKeyOwnerId, func(e *authorEnv) { e.authors.fail.failNextWith("Create", errors.New("db is locked")) }, http.MethodPost, "/v1/author/", `{"name": "X"}`}, {"DELETE when the repository fails", "author.delete", genericFailureSentence, 500, apiKeyOwnerId, func(e *authorEnv) { e.authors.seed(2, "B", "b") e.authors.fail.failNext("DeleteMovingCredits") }, http.MethodDelete, "/v1/author/2", ""}, } for _, c := range cases { t.Run(c.request, func(t *testing.T) { e := newAuthorEnv(t) c.prepare(e) rec := e.as(t, c.actor, c.method, c.path, c.body) if rec.Code != c.status { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } event := e.single(t) assertProducerEvent(t, event, c.action, audit.CategoryAuthors, audit.OutcomeFailed) assertProducerFailure(t, event, c.reason) }) } }) t.Run("Nothing is recorded when there is nothing to attribute", func(t *testing.T) { cases := []struct { request string as uint32 method string path string body string }{ {"PATCH /v1/author/999", apiKeyOwnerId, http.MethodPatch, "/v1/author/999", `{"name": "X"}`}, {"DELETE /v1/author/999", apiKeyOwnerId, http.MethodDelete, "/v1/author/999", ""}, {"unknown avatar", apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "X", "avatar_id": 999}`}, {"bio too long", apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "X", "bio": "` + strings.Repeat("a", 281) + `"}`}, {"403", authorEditorID, http.MethodPost, "/v1/author/", `{"name": "X"}`}, {"401", 0, http.MethodPost, "/v1/author/", `{"name": "X"}`}, {"PATCH /v1/author/abc", apiKeyOwnerId, http.MethodPatch, "/v1/author/abc", `{"name": "X"}`}, {"malformed body", apiKeyOwnerId, http.MethodPost, "/v1/author/", "{not json"}, {"unknown user", apiKeyOwnerId, http.MethodPost, "/v1/author/", `{"name": "X", "user_id": 999}`}, {"read", apiKeyOwnerId, http.MethodGet, "/v1/author/", ""}, } for _, c := range cases { t.Run(c.request, func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(2, "B", "b") e.as(t, c.as, c.method, c.path, c.body) e.noEvent(t) }) } }) t.Run("A failing audit repository never changes the response", func(t *testing.T) { body := `{"name": "Camille Roche"}` healthy := newAuthorEnv(t) healthyRec := healthy.owner(t, http.MethodPost, "/v1/author/", body) broken := newAuthorEnv(t) broken.audit.probe.appendErr = errDiskFull rec := broken.owner(t, http.MethodPost, "/v1/author/", body) if rec.Code != healthyRec.Code || rec.Body.String() != healthyRec.Body.String() || len(broken.authors.authors) != 1 { t.Errorf("broken %d %s, healthy %d %s", rec.Code, rec.Body.String(), healthyRec.Code, healthyRec.Body.String()) } }) t.Run("An article's authors change is recorded in article.edit", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(5, "Inès Duarte", "ines-duarte") e.authors.seed(6, "Marta Alves", "marta-alves") e.authors.seed(7, "Zoe Lambert", "zoe-lambert") e.seedOnlineArticle(8, "Spring release", 5, 6) rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"author_ids": [6, 5, 7]}`) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } event := e.single(t) assertProducerEvent(t, event, "article.edit", audit.CategoryArticles, audit.OutcomeSuccess) if changes := changesJSON(t, event); changes != `[{"field":"authors","kind":"text","before":"Inès Duarte, Marta Alves","after":"Marta Alves, Inès Duarte, Zoe Lambert"}]` { t.Errorf("changes = %s", changes) } }) t.Run("Deleting an author with reassignment records only the author event", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(2, "Camille R.", "camille-r") e.authors.seed(3, "Zoe", "zoe") for id := uint32(10); id < 13; id++ { e.seedOnlineArticle(id, "Article", 2) } e.owner(t, http.MethodDelete, "/v1/author/2?reassign_to=3", "") event := e.single(t) assertProducerEvent(t, event, "author.delete", audit.CategoryAuthors, audit.OutcomeSuccess) }) t.Run("The activity API lists author events", func(t *testing.T) { e := newAuthorEnv(t) e.owner(t, http.MethodPost, "/v1/author/", `{"name": "Camille Roche"}`) e.owner(t, http.MethodPatch, "/v1/author/1", `{"bio": "Hi."}`) e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`) rec := e.owner(t, http.MethodGet, "/v1/activity/?category=authors", "") var page struct { Items []struct { Action string `json:"action"` Category string `json:"category"` } `json:"items"` } if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil || rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } if len(page.Items) != 2 { t.Fatalf("items = %+v", page.Items) } for _, item := range page.Items { if item.Category != "authors" { t.Errorf("category = %s", item.Category) } } }) } func TestAuthorFailureReasons(t *testing.T) { t.Run("The author sentinels are not reported as a generic failure", func(t *testing.T) { e := newAuthorEnv(t) e.authors.seed(2, "B", "b") e.authors.fail.failNextWith("Update", author.ErrAuthorAlreadyLinked) e.owner(t, http.MethodPatch, "/v1/author/2", `{"bio": "x"}`) assertProducerFailure(t, e.single(t), "That author is already linked to a user.") }) }