- every read of the purge receiver first drains the app's purger through cooler.SiteCachePurger.Wait, since cooler now queues purges after commit
390 lines
14 KiB
Go
390 lines
14 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
)
|
|
|
|
// 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
|
|
// drain waits for the queued purges of the app under test; see
|
|
// drainFrom.
|
|
drain func()
|
|
}
|
|
|
|
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, after
|
|
// draining the queued purges (see drainFrom).
|
|
func (r *purgeReceiver) take() []purgeRequest {
|
|
r.mu.Lock()
|
|
drain := r.drain
|
|
r.mu.Unlock()
|
|
if drain != nil {
|
|
drain()
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
got := r.got
|
|
r.got = nil
|
|
return got
|
|
}
|
|
|
|
// drainFrom makes every read of r first wait for the purges queued on app's
|
|
// site-cache purger: cooler queues them after commit and sends them in the
|
|
// background, so a save's purge is asserted only once it was sent.
|
|
func (r *purgeReceiver) drainFrom(t *testing.T, app *backpack.App) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.drain = func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := cooler.SiteCacheFromApp(app).Wait(ctx); err != nil {
|
|
t.Errorf("waiting for the queued site-cache purge: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()...)
|
|
recv.drainFrom(t, env.app)
|
|
token := env.admin(t, controllers.PermissionAccessCategories)
|
|
|
|
// The probe reads the committed row through the pool while the purge
|
|
// request is in flight: "<content>" 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": "<p>Before</p>\n",
|
|
})
|
|
id := adminCreatedID(t, rec)
|
|
itemPath := adminAPI(fmt.Sprintf("%s/%d", adminCategoriesPath, id))
|
|
if req := recv.expectOnePurge(t, "create", "category", "purge-news"); req.Seen != "<p>Before</p>\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 = "<p>After the edit</p>\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"] = "<p>Refused</p>\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()...)
|
|
recv.drainFrom(t, env.app)
|
|
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": "<p>Before</p>"},
|
|
"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 != "<p>Before</p>" {
|
|
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 = "<p>After the edit</p>"
|
|
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<!-- imported -->\n<p onclick=\"x()\">Kept as stored</p>\n"
|
|
const importedHTML = "<p>Rendered elsewhere</p>\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 = "<p>Saved while the site answers 500</p>"
|
|
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": "<p>Spare</p>"},
|
|
})
|
|
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 = "<p>Saved while nothing listens</p>"
|
|
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))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCategoriesAdminFrameworkKeysWin proves the framework keys site_cache.* take
|
|
// precedence over the Journal's deprecated keys: with both set, a category
|
|
// save purges through the framework target only.
|
|
func TestCategoriesAdminFrameworkKeysWin(t *testing.T) {
|
|
framework := newPurgeReceiver(t)
|
|
alias := newPurgeReceiver(t)
|
|
env := newPostsEnv(t, append(alias.env(),
|
|
"SUMMER_SITE_CACHE__PURGE_URL="+framework.srv.URL+"/api/__site__/cache/purge",
|
|
"SUMMER_SITE_CACHE__PURGE_TOKEN="+purgeTestToken,
|
|
)...)
|
|
framework.drainFrom(t, env.app)
|
|
alias.drainFrom(t, env.app)
|
|
token := env.admin(t, controllers.PermissionAccessCategories)
|
|
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
|
"name": map[string]any{"en": "Framework keys"},
|
|
"slug": map[string]any{"en": "framework-keys"},
|
|
})
|
|
got := framework.take()
|
|
if len(got) != 1 || got[0].Path != "/api/__site__/cache/purge" || got[0].Body["kind"] != "category" || got[0].Body["slug"] != "framework-keys" ||
|
|
got[0].Authorization != "Bearer "+purgeTestToken {
|
|
t.Fatalf("framework receiver got %+v, want one category purge", got)
|
|
}
|
|
if got := alias.take(); len(got) != 0 {
|
|
t.Fatalf("the deprecated Journal target got %d requests: %+v", len(got), got)
|
|
}
|
|
}
|