Files
RenewCMS/test/site_activity_acceptance_test.go

212 lines
8.9 KiB
Go

package test
import (
"RenewCMS/internal/domain/audit"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
)
const genericFailureSentence = "Something went wrong on our side. Please try again."
func TestSitesActivityLog(t *testing.T) {
t.Run("A successful write is recorded once after the change", func(t *testing.T) {
cases := []struct {
request string
action string
prepare func(e *siteEnv)
method string
path string
body string
status int
}{
{"POST /v1/site/ for \"Shop\"", "site.create", func(e *siteEnv) {}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`, http.StatusCreated},
{"PATCH /v1/site/2 renaming it", "site.edit", func(e *siteEnv) {
e.sites.seed(siteShopID, "Boutique", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"name": "Shop"}`, http.StatusOK},
{"PATCH /v1/site/2/default", "site.set_default", func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2/default", "", http.StatusOK},
{"DELETE /v1/site/2", "site.delete", func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodDelete, "/v1/site/2", "", http.StatusNoContent},
}
for _, c := range cases {
t.Run(c.request, func(t *testing.T) {
e := newSiteEnvWithMain(t)
c.prepare(e)
rec := e.owner(t, 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.CategorySites, audit.OutcomeSuccess)
assertProducerTarget(t, event, "site", "2", "Shop")
assertProducerOwnerOrigin(t, event)
if event.Category != "sites" {
t.Errorf("category = %s", event.Category)
}
})
}
})
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
prepare func(e *siteEnv)
method string
path string
body string
}{
{"POST /v1/site/ with a taken key", "site.create", "A site with that key already exists.", http.StatusConflict, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "shop", "base_url": "https://other.example.com"}`},
{"PATCH /v1/site/2 changing the key", "site.edit", "The key of a site cannot be changed.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"key": "store"}`},
{"DELETE /v1/site/1 on the default site", "site.delete", "The default site cannot be deleted.", http.StatusForbidden, func(e *siteEnv) {}, http.MethodDelete, "/v1/site/1", ""},
{"DELETE /v1/site/2 with articles", "site.delete", "The site still has articles.", http.StatusConflict, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
e.seedArticle(1, siteShopID, true)
}, http.MethodDelete, "/v1/site/2", ""},
{"POST /v1/site/ with an invalid base URL", "site.create", "The site address is not valid.", http.StatusBadRequest, func(e *siteEnv) {}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "http://shop.example.com"}`},
{"PATCH /v1/site/2 with an invalid locale", "site.edit", "The locale is not valid.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"locale": "not a locale"}`},
{"PATCH /v1/site/2 with is_default", "site.edit", "Use the default site action to change the default.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
}, http.MethodPatch, "/v1/site/2", `{"is_default": true}`},
{"DELETE /v1/site/2 reassigned to itself", "site.delete", "The destination site is not valid.", http.StatusBadRequest, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
e.seedArticle(1, siteShopID, true)
}, http.MethodDelete, "/v1/site/2?reassign_to=2", ""},
{"POST /v1/site/ when the repository fails with \"db is locked\"", "site.create", genericFailureSentence, http.StatusInternalServerError, func(e *siteEnv) {
e.sites.fail.failNextWith("Create", errors.New("db is locked"))
}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"PATCH /v1/site/2/default when the repository fails", "site.set_default", genericFailureSentence, http.StatusInternalServerError, func(e *siteEnv) {
e.sites.seed(siteShopID, "Shop", "shop", false)
e.sites.fail.failNext("SetDefault")
}, http.MethodPatch, "/v1/site/2/default", ""},
}
for _, c := range cases {
t.Run(c.request, func(t *testing.T) {
e := newSiteEnvWithMain(t)
c.prepare(e)
rec := e.owner(t, 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.CategorySites, 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/site/999 as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/999", `{"name": "X"}`},
{"DELETE /v1/site/999 as the owner", apiKeyOwnerId, http.MethodDelete, "/v1/site/999", ""},
{"POST /v1/site/ with an unknown image", apiKeyOwnerId, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "other", "base_url": "https://other.example.com", "default_social_image_id": 999}`},
{"POST /v1/site/ as an admin (403)", apiKeyDevId, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"POST /v1/site/ with no credentials (401)", 0, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
{"PATCH /v1/site/abc as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`},
{"POST /v1/site/ with a malformed body", apiKeyOwnerId, http.MethodPost, "/v1/site/", "{not json"},
}
for _, c := range cases {
t.Run(c.request, func(t *testing.T) {
e := newSiteEnvWithShop(t)
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": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`
healthy := newSiteEnvWithMain(t)
healthyRec := healthy.owner(t, http.MethodPost, "/v1/site/", body)
broken := newSiteEnvWithMain(t)
broken.audit.probe.appendErr = errDiskFull
rec := broken.owner(t, http.MethodPost, "/v1/site/", body)
if rec.Code != healthyRec.Code || rec.Body.String() != healthyRec.Body.String() {
t.Errorf("broken %d %s, healthy %d %s", rec.Code, rec.Body.String(), healthyRec.Code, healthyRec.Body.String())
}
if len(broken.sites.sites) != 2 {
t.Errorf("the site was not created")
}
})
t.Run("Reads are never recorded", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.owner(t, http.MethodGet, "/v1/site/", "")
e.noEvent(t)
})
t.Run("The key is never a change field", func(t *testing.T) {
e := newSiteEnvWithShop(t)
e.seedBanner(5, "banner.png", "hash")
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr", "default_social_image_id": 5}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
fields := []string{}
for _, change := range e.single(t).Changes {
fields = append(fields, change.Field)
}
if !reflect.DeepEqual(fields, []string{"name", "base_url", "default_robots", "locale", "default_social_image"}) {
t.Errorf("fields = %v", fields)
}
})
t.Run("The activity API lists site events", func(t *testing.T) {
e := newSiteEnvWithMain(t)
e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`)
e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
rec := e.owner(t, http.MethodGet, "/v1/activity/?category=sites", "")
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())
}
actions := []string{}
for _, item := range page.Items {
if item.Category != "sites" {
t.Errorf("item category = %s", item.Category)
}
actions = append(actions, item.Action)
}
if len(actions) != 2 || !strings.Contains(strings.Join(actions, ","), "site.create") || !strings.Contains(strings.Join(actions, ","), "site.set_default") {
t.Errorf("actions = %v", actions)
}
})
}