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