- 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
240 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|