Files
sm-journal-plugin/site_cache_admin_test.go
Jakub Zych 4405feeaa7 test(16.2-07): full coverage of the site cache purge
- TestSiteCachePurgeConfig: every refused configuration names its key and
  never the token; https anywhere and http to loopback are accepted
- TestSiteCachePurgeRequest: the contract request, Info and Warn lines,
  the 2 s timeout, a cancelled parent context, no token in any log line
- Postgres: category create, update and delete and post create, untouched
  update (D-03 bytes kept), failing and unreachable purges and delete each
  purge once after the commit; failed saves send none
2026-10-10 18:35:36 +02:00

332 lines
12 KiB
Go

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: "<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()...)
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))
}
})
}