Files
summercms/modules/cabana/reorder_hook_test.go
Jakub Zych 055f51c94f test(19.3-05): surf CORS, profiles, api-docs and store lookup; cabana reorder hook
- cors_profile_test.go: credentials with * refused (global and profile),
  pattern errors, anchored wildcard origins, Vary: Origin on dynamic
  answers, api/www credentialed case, unchanged * answers, profile
  preflight routes and boot errors, turnstile factory params
- apidocs_test.go: off by default, pinned 5.33.1 page with the RESEARCH
  SRI hashes and nonce CSP, ordered compact JSON, provider count, path
  validation, escaped title
- store_lookup_test.go: MemoryStore fallback, published Store, no dial
- reorder_hook_test.go (Postgres): hook sees the new order in the tx,
  its error rolls back, no hook answers unchanged
- gate: surf names in the go stage, cabana names in the docker stage
2026-10-15 19:30:52 +02:00

240 lines
7.8 KiB
Go

package cabana_test
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
// Unit tests (plan 05 of phase 19.3) for pact.ReorderAfterSave (D-11): the
// hook runs inside the reorder transaction after the new order is written,
// receives the partition, and its error rolls the reorder back. They reuse
// the partitioned flat acme.shelf fixture of catalog_smoke_test.go.
// hookShelfPlugin is acme.shelf with a pins controller that implements
// pact.ReorderAfterSave through hook.
type hookShelfPlugin struct {
hook func(ctx context.Context, partition *uint64) error
}
func (hookShelfPlugin) ID() string { return "acme.shelf" }
func (hookShelfPlugin) Requires() []string { return nil }
func (hookShelfPlugin) Register(*backpack.App) error { return nil }
func (hookShelfPlugin) Boot(*backpack.App) error { return nil }
func (hookShelfPlugin) AdminFS() fs.FS { return shelfFS }
func (p hookShelfPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{hookedPins{hook: p.hook}}
}
func (hookShelfPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.shelf.access", Roles: []string{"developer"}}}
}
// hookedPins is shelfPins with the reorder hook.
type hookedPins struct {
shelfPins
hook func(ctx context.Context, partition *uint64) error
}
func (h hookedPins) ReorderAfterSave(ctx context.Context, partition *uint64) error {
return h.hook(ctx, partition)
}
var _ pact.ReorderAfterSave = hookedPins{}
// shelfHookEnv is a logged-in admin over plugin with fresh acme.shelf
// tables, two shelves and three pins (A=1 and B=2 on top, X=7 on low).
type shelfHookEnv struct {
*conformEnv
top, low shelfShelf
a, b, x uint
}
func newShelfHookEnv(t *testing.T, plugin party.Plugin) *shelfHookEnv {
t.Helper()
gdb := adminGorm(t)
models := []any{&shelfPin{}, &shelfShelf{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(models...); err != nil {
t.Fatal(err)
}
login := fmt.Sprintf("shelf-hook-%d", time.Now().UnixNano())
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
h, err := paymentsAssemble(t, gdb, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
env := &shelfHookEnv{conformEnv: &conformEnv{h: h, db: gdb, login: login}, top: shelfShelf{Title: "Top"}, low: shelfShelf{Title: "Low"}}
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
for _, s := range []*shelfShelf{&env.top, &env.low} {
if err := gdb.Create(s).Error; err != nil {
t.Fatal(err)
}
}
pins := []shelfPin{{Name: "A", ShelfID: &env.top.ID, SortOrder: 1}, {Name: "B", ShelfID: &env.top.ID, SortOrder: 2}, {Name: "X", ShelfID: &env.low.ID, SortOrder: 7}}
for i := range pins {
if err := gdb.Create(&pins[i]).Error; err != nil {
t.Fatal(err)
}
}
env.a, env.b, env.x = pins[0].ID, pins[1].ID, pins[2].ID
return env
}
// swap posts B=1, A=2 for the top shelf and returns the status and body.
func (e *shelfHookEnv) swap(t *testing.T) (int, string) {
t.Helper()
rec := e.send(t, http.MethodPost, "/acme/shelf/pins/reorder", map[string]any{
"partition": e.top.ID,
"nodes": []map[string]any{
{"id": e.b, "parent_id": nil, "sort_order": 1},
{"id": e.a, "parent_id": nil, "sort_order": 2},
},
}, true)
return rec.Code, rec.Body.String()
}
// sorts reads the sort values of every pin through db.
func sorts(t *testing.T, db *gorm.DB) map[uint]int {
t.Helper()
var all []shelfPin
if err := db.Order("id").Find(&all).Error; err != nil {
t.Fatal(err)
}
out := map[uint]int{}
for _, p := range all {
out[p.ID] = p.SortOrder
}
return out
}
func TestReorderHookRunsInTx(t *testing.T) {
var (
mu sync.Mutex
calls int
gotPart *uint64
inTx map[uint]int
outside map[uint]int
hasTx bool
afterCommit map[uint]int
)
var env *shelfHookEnv
env = newShelfHookEnv(t, hookShelfPlugin{hook: func(ctx context.Context, partition *uint64) error {
mu.Lock()
defer mu.Unlock()
calls++
gotPart = partition
tx, ok := cabana.TxFromContext(ctx)
hasTx = ok
if !ok {
return errors.New("no transaction in the hook context")
}
inTx = sorts(t, tx.WithContext(ctx))
// Another connection still sees the committed order.
outside = sorts(t, env.db)
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) {
mu.Lock()
defer mu.Unlock()
afterCommit = sorts(t, db.WithContext(ctx))
})
return nil
}})
code, body := env.swap(t)
if code != http.StatusOK {
t.Fatalf("POST status=%d body=%s", code, body)
}
mu.Lock()
defer mu.Unlock()
if calls != 1 || !hasTx {
t.Fatalf("hook calls=%d tx=%t", calls, hasTx)
}
if gotPart == nil || *gotPart != uint64(env.top.ID) {
t.Fatalf("partition = %v, want %d", gotPart, env.top.ID)
}
if inTx[env.b] != 1 || inTx[env.a] != 2 || inTx[env.x] != 7 {
t.Fatalf("hook saw %v through the transaction, want the new order", inTx)
}
if outside[env.a] != 1 || outside[env.b] != 2 {
t.Fatalf("outside the transaction %v, want the old order before commit", outside)
}
if afterCommit == nil || afterCommit[env.b] != 1 || afterCommit[env.a] != 2 {
t.Fatalf("AfterCommit work saw %v, want the committed new order", afterCommit)
}
if got := sorts(t, env.db); got[env.b] != 1 || got[env.a] != 2 || got[env.x] != 7 {
t.Fatalf("stored = %v", got)
}
}
func TestReorderHookErrorRollsBack(t *testing.T) {
for _, tc := range []struct {
name string
err error
status int
}{
{"plain error", errors.New("purge registry unavailable"), http.StatusInternalServerError},
{"validation error", &cabana.ValidationError{Details: map[string]any{"nodes": []string{"Refused."}}}, http.StatusUnprocessableEntity},
{"forbidden", &cabana.ForbiddenError{Message: "Locked."}, http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
var afterCommitRan bool
env := newShelfHookEnv(t, hookShelfPlugin{hook: func(ctx context.Context, _ *uint64) error {
tx, _ := cabana.TxFromContext(ctx)
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { afterCommitRan = true })
return tc.err
}})
before := sorts(t, env.db)
code, body := env.swap(t)
if code != tc.status {
t.Fatalf("status=%d want %d body=%s", code, tc.status, body)
}
if after := sorts(t, env.db); fmt.Sprint(after) != fmt.Sprint(before) {
t.Fatalf("sort values changed: before %v after %v", before, after)
}
if afterCommitRan {
t.Fatal("AfterCommit work ran after a rolled-back reorder")
}
if tc.status == http.StatusInternalServerError && (strings.Contains(body, "purge registry") || !strings.Contains(body, `"error"`)) {
t.Fatalf("500 body = %s", body)
}
})
}
}
func TestReorderWithoutHookUnchanged(t *testing.T) {
plain := newShelfHookEnv(t, shelfPlugin{})
code, plainBody := plain.swap(t)
if code != http.StatusOK {
t.Fatalf("POST status=%d body=%s", code, plainBody)
}
if got := sorts(t, plain.db); got[plain.b] != 1 || got[plain.a] != 2 || got[plain.x] != 7 {
t.Fatalf("stored = %v", got)
}
// A hook that does nothing answers the same tree.
hooked := newShelfHookEnv(t, hookShelfPlugin{hook: func(context.Context, *uint64) error { return nil }})
code, hookedBody := hooked.swap(t)
if code != http.StatusOK {
t.Fatalf("hooked POST status=%d body=%s", code, hookedBody)
}
if plainBody != hookedBody {
t.Fatalf("answers differ:\nwithout hook %s\nwith hook %s", plainBody, hookedBody)
}
}