944 lines
40 KiB
Go
944 lines
40 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|