Files
RenewCMS/test/authors_backend_activity_http_test.go
T

263 lines
11 KiB
Go

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