package journal_test import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" ) // purgeTestToken is a 40-character purge token for the receiver. const purgeTestToken = "purge-test-token-0123456789abcdefghijklm" const purgeTestPath = "/api/__journal__/cache/purge" // purgeRequest is one request the purge receiver got. type purgeRequest struct { Method string Path string Authorization string ContentType string Body map[string]string // Seen is what the receiver's probe read from the database while the // request was in flight. Seen string } // purgeReceiver stands in for the host site's purge route on 127.0.0.1. type purgeReceiver struct { srv *httptest.Server mu sync.Mutex got []purgeRequest status int probe func() string } func newPurgeReceiver(t *testing.T) *purgeReceiver { t.Helper() r := &purgeReceiver{status: http.StatusOK} r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { raw, _ := io.ReadAll(req.Body) var body map[string]string _ = json.Unmarshal(raw, &body) r.mu.Lock() probe, status := r.probe, r.status r.mu.Unlock() seen := "" if probe != nil { seen = probe() } r.mu.Lock() r.got = append(r.got, purgeRequest{ Method: req.Method, Path: req.URL.Path, Authorization: req.Header.Get("Authorization"), ContentType: req.Header.Get("Content-Type"), Body: body, Seen: seen, }) r.mu.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if status == http.StatusOK { _, _ = w.Write([]byte(`{"purged":3}`)) } })) t.Cleanup(r.srv.Close) return r } // env returns the extra environment that points the Journal plugin at r. func (r *purgeReceiver) env() []string { return []string{ "SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL=" + r.srv.URL + purgeTestPath, "SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN=" + purgeTestToken, } } // take returns the requests received so far and forgets them. func (r *purgeReceiver) take() []purgeRequest { r.mu.Lock() defer r.mu.Unlock() got := r.got r.got = nil return got } func (r *purgeReceiver) setStatus(status int) { r.mu.Lock() r.status = status r.mu.Unlock() } func (r *purgeReceiver) setProbe(probe func() string) { r.mu.Lock() r.probe = probe r.mu.Unlock() } // expectOnePurge asserts exactly one contract request for kind and slug was // received since the last take, and returns it. func (r *purgeReceiver) expectOnePurge(t *testing.T, stage, kind, slug string) purgeRequest { t.Helper() got := r.take() if len(got) != 1 { t.Fatalf("%s: %d purge requests, want 1: %+v", stage, len(got), got) } req := got[0] if req.Method != http.MethodPost || req.Path != purgeTestPath { t.Fatalf("%s: purge request %s %s, want POST %s", stage, req.Method, req.Path, purgeTestPath) } if req.Authorization != "Bearer "+purgeTestToken { t.Fatalf("%s: Authorization = %q, want the bearer token", stage, req.Authorization) } if !strings.HasPrefix(req.ContentType, "application/json") { t.Fatalf("%s: Content-Type = %q, want application/json", stage, req.ContentType) } if req.Body["kind"] != kind || req.Body["slug"] != slug || len(req.Body) != 2 { t.Fatalf("%s: purge body = %#v, want kind %q and slug %q", stage, req.Body, kind, slug) } return req } // TestCategoriesAdminPurgesSiteCache proves every admin write of a category // sends one authorized purge request with kind category after the save // transaction commits, and a failed save sends none (G-16.2-2, D-19). func TestCategoriesAdminPurgesSiteCache(t *testing.T) { recv := newPurgeReceiver(t) env := newPostsEnv(t, recv.env()...) token := env.admin(t, controllers.PermissionAccessCategories) // The probe reads the committed row through the pool while the purge // request is in flight: "" or "missing". recv.setProbe(func() string { var cat models.Category if err := env.gdb.Where("slug = ?", "purge-news").Limit(1).Find(&cat).Error; err != nil { return "error: " + err.Error() } if cat.ID == 0 { return "missing" } if cat.Content == nil { return "" } return *cat.Content }) rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{ "name": map[string]any{"en": "Purge news", "pl": "Wiadomości"}, "slug": map[string]any{"en": "purge-news", "pl": "wiadomosci"}, "content": "

Before

\n", }) id := adminCreatedID(t, rec) itemPath := adminAPI(fmt.Sprintf("%s/%d", adminCategoriesPath, id)) if req := recv.expectOnePurge(t, "create", "category", "purge-news"); req.Seen != "

Before

\n" { t.Fatalf("create: the purge saw %q, want the committed row", req.Seen) } served := func(t *testing.T) map[string]json.RawMessage { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, itemPath, token, nil) var data map[string]json.RawMessage if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil { t.Fatal(err) } return data } t.Run("update", func(t *testing.T) { const edited = "

After the edit

\n" body := servedBody(served(t), "name", "slug") body["content"] = edited env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, body) if req := recv.expectOnePurge(t, "update", "category", "purge-news"); req.Seen != edited { t.Fatalf("the purge saw content %q, want the committed %q (purge before commit)", req.Seen, edited) } }) t.Run("a failed update sends none", func(t *testing.T) { body := servedBody(served(t), "slug") body["name"] = map[string]any{"en": "", "pl": ""} body["content"] = "

Refused

\n" if rec := env.call(t, http.MethodPut, itemPath, token, body); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("update without a name = %d, want 422: %s", rec.Code, rec.Body.String()) } if got := recv.take(); len(got) != 0 { t.Fatalf("a failed update sent %d purge requests: %+v", len(got), got) } }) t.Run("delete", func(t *testing.T) { env.expect(t, http.StatusOK, http.MethodDelete, itemPath, token, nil) if req := recv.expectOnePurge(t, "delete", "category", "purge-news"); req.Seen != "missing" { t.Fatalf("the purge saw %q, want the row already deleted", req.Seen) } }) } // TestPostsAdminPurgesSiteCache proves every admin write of a post sends one // authorized purge request with kind post after the commit, that an // untouched save still purges and keeps both content columns byte for byte // (D-03), that a refused, failing or unreachable purge never changes the // save response, and that a failed save sends none. func TestPostsAdminPurgesSiteCache(t *testing.T) { recv := newPurgeReceiver(t) env := newPostsEnv(t, recv.env()...) token := env.admin(t, controllers.PermissionAccessPosts) recv.setProbe(func() string { var post models.Post if err := env.gdb.Where("slug = ?", "purge-post").Limit(1).Find(&post).Error; err != nil { return "error: " + err.Error() } if post.ID == 0 { return "missing" } return post.ContentHTML }) rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ "title": map[string]any{"en": "Purge post", "pl": "Wpis"}, "slug": map[string]any{"en": "purge-post", "pl": "wpis"}, "content": map[string]any{"en": "

Before

"}, "excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"}, }) id := adminCreatedID(t, rec) itemPath := adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)) if req := recv.expectOnePurge(t, "create", "post", "purge-post"); req.Seen != "

Before

" { t.Fatalf("create: the purge saw %q, want the committed row", req.Seen) } put := func(t *testing.T, content string) *httptest.ResponseRecorder { t.Helper() body := servedBody(servedForm(t, env, token, id), "title", "slug", "excerpt") body["content"] = map[string]any{"en": content} return env.call(t, http.MethodPut, itemPath, token, body) } stored := func(t *testing.T) models.Post { t.Helper() var post models.Post if err := env.gdb.First(&post, id).Error; err != nil { t.Fatal(err) } return post } t.Run("update", func(t *testing.T) { const edited = "

After the edit

" if rec := put(t, edited); rec.Code != http.StatusOK { t.Fatalf("update = %d: %s", rec.Code, rec.Body.String()) } if req := recv.expectOnePurge(t, "update", "post", "purge-post"); req.Seen != edited { t.Fatalf("the purge saw content_html %q, want the committed %q (purge before commit)", req.Seen, edited) } }) t.Run("an untouched update purges and keeps both columns", func(t *testing.T) { const imported = "\n\n

Kept as stored

\n" const importedHTML = "

Rendered elsewhere

\n" if err := env.gdb.Model(&models.Post{}).Where("id = ?", id).Updates(map[string]any{"content": imported, "content_html": importedHTML}).Error; err != nil { t.Fatal(err) } served := servedForm(t, env, token, id) env.expect(t, http.StatusOK, http.MethodPut, itemPath, token, servedBody(served, "title", "slug", "excerpt", "content")) recv.expectOnePurge(t, "untouched update", "post", "purge-post") assertHostContent(t, env, id, "untouched update", imported, importedHTML) }) t.Run("a failed save sends none", func(t *testing.T) { // The admin lacks access_publish: FormBeforeUpdate refuses inside // the save transaction. body := servedBody(servedForm(t, env, token, id), "title", "slug", "excerpt") body["published"] = true if rec := env.call(t, http.MethodPut, itemPath, token, body); rec.Code != http.StatusForbidden { t.Fatalf("publish without access_publish = %d, want 403: %s", rec.Code, rec.Body.String()) } if got := recv.take(); len(got) != 0 { t.Fatalf("a failed save sent %d purge requests: %+v", len(got), got) } }) t.Run("a failing purge never changes the save", func(t *testing.T) { recv.setStatus(http.StatusInternalServerError) defer recv.setStatus(http.StatusOK) const edited = "

Saved while the site answers 500

" if rec := put(t, edited); rec.Code != http.StatusOK { t.Fatalf("update with a failing purge = %d: %s", rec.Code, rec.Body.String()) } recv.expectOnePurge(t, "failing purge", "post", "purge-post") if got := stored(t); got.Content != edited || got.ContentHTML != edited { t.Fatalf("stored = %q / %q, want %q", got.Content, got.ContentHTML, edited) } }) t.Run("delete", func(t *testing.T) { spare := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ "title": map[string]any{"en": "Spare"}, "slug": map[string]any{"en": "spare-post"}, "content": map[string]any{"en": "

Spare

"}, }) spareID := adminCreatedID(t, spare) recv.expectOnePurge(t, "spare create", "post", "spare-post") env.expect(t, http.StatusOK, http.MethodDelete, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, spareID)), token, nil) recv.expectOnePurge(t, "delete", "post", "spare-post") var n int64 if err := env.gdb.Model(&models.Post{}).Where("id = ?", spareID).Count(&n).Error; err != nil || n != 0 { t.Fatalf("spare post rows = %d (%v), want 0", n, err) } }) // Last: closing the receiver ends its use for this env. t.Run("an unreachable site never changes the save", func(t *testing.T) { recv.srv.Close() const edited = "

Saved while nothing listens

" if rec := put(t, edited); rec.Code != http.StatusOK { t.Fatalf("update with no receiver = %d: %s", rec.Code, rec.Body.String()) } if got := stored(t); got.Content != edited || got.ContentHTML != edited { t.Fatalf("stored = %q / %q, want %q", got.Content, got.ContentHTML, edited) } if got := recv.take(); len(got) != 0 { t.Fatalf("a closed receiver recorded %d requests", len(got)) } }) }