263 lines
11 KiB
Go
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.")
|
|
})
|
|
}
|