- config_form.yaml preview block (optional headerPartial), reported in the form schema as preview
- fields with context: preview show only on the preview screen and are never written
- form messages preview and edit; recordActions without a preview block stops boot
- SPA route {id}/preview, PreviewView and PreviewField, record actions in the footer
- mapWinterUrl maps preview/:id; the update form returns to the preview
- summer-callout partial style classes for status hints
- README, docs, OpenAPI document, TS types and the embedded build updated
473 lines
16 KiB
Go
473 lines
16 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// rosterDir is the neutral acme.roster fixture plugin tree of the Phase 12.1
|
|
// framework features: declared bulk actions, record actions, row state and
|
|
// the forbidden error.
|
|
const rosterDir = "testdata/roster"
|
|
|
|
// rosterPerson is the fixture model: a person of one tenant who can be
|
|
// active, banned and soft-deleted.
|
|
type rosterPerson struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Tenant string `gorm:"column:tenant"`
|
|
Name string `gorm:"column:name"`
|
|
Email string `gorm:"column:email"`
|
|
Active bool `gorm:"column:active"`
|
|
Banned bool `gorm:"column:banned"`
|
|
// JoinedIP is shown on the preview screen only (context: preview).
|
|
JoinedIP *string `gorm:"column:joined_ip"`
|
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
|
}
|
|
|
|
func (rosterPerson) TableName() string { return "roster_people" }
|
|
func (rosterPerson) Fillable() []string { return []string{"name", "email"} }
|
|
func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required"} }
|
|
|
|
// rosterSpy records what each registered action's Run receives.
|
|
type rosterSpy struct {
|
|
mu sync.Mutex
|
|
bulk []pact.AdminBulkActionInput
|
|
record []pact.AdminRecordActionInput
|
|
// states counts ListRowStates calls and keeps the size of each page.
|
|
states []int
|
|
}
|
|
|
|
func (s *rosterSpy) recordStates(n int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.states = append(s.states, n)
|
|
}
|
|
|
|
func (s *rosterSpy) takeStates() []int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := s.states
|
|
s.states = nil
|
|
return out
|
|
}
|
|
|
|
func (s *rosterSpy) recordOne(in pact.AdminRecordActionInput) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.record = append(s.record, in)
|
|
}
|
|
|
|
func (s *rosterSpy) takeRecord() []pact.AdminRecordActionInput {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := s.record
|
|
s.record = nil
|
|
return out
|
|
}
|
|
|
|
func (s *rosterSpy) recordBulk(in pact.AdminBulkActionInput) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.bulk = append(s.bulk, in)
|
|
}
|
|
|
|
func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := s.bulk
|
|
s.bulk = nil
|
|
return out
|
|
}
|
|
|
|
// rosterPlugin is the acme.roster fixture plugin. fsys, when set, replaces
|
|
// the fixture tree (boot-error tests).
|
|
type rosterPlugin struct {
|
|
spy *rosterSpy
|
|
fsys fs.FS
|
|
}
|
|
|
|
func (rosterPlugin) ID() string { return "acme.roster" }
|
|
func (rosterPlugin) Requires() []string { return nil }
|
|
func (rosterPlugin) Register(*backpack.App) error { return nil }
|
|
func (rosterPlugin) Boot(*backpack.App) error { return nil }
|
|
func (p rosterPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{rosterController{spy: p.spy}}
|
|
}
|
|
func (rosterPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
|
|
}
|
|
func (p rosterPlugin) AdminFS() fs.FS {
|
|
if p.fsys != nil {
|
|
return p.fsys
|
|
}
|
|
return os.DirFS(rosterDir)
|
|
}
|
|
|
|
// LangFS serves only the fixture's lang/ tree.
|
|
func (rosterPlugin) LangFS() fs.FS {
|
|
out := fstest.MapFS{}
|
|
for _, name := range []string{"lang/en/lang.yaml", "lang/pl/lang.yaml"} {
|
|
data, err := os.ReadFile(filepath.Join(rosterDir, name))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
out[name] = &fstest.MapFile{Data: data}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type rosterController struct{ spy *rosterSpy }
|
|
|
|
func (rosterController) ID() string { return "acme.roster.people" }
|
|
func (rosterController) ModelName() string { return "Person" }
|
|
func (rosterController) ConfigDir() string { return "controllers/people" }
|
|
func (rosterController) RequiredPermissions() []string { return []string{"acme.roster.access"} }
|
|
func (rosterController) NewRecord() any { return &rosterPerson{} }
|
|
|
|
// ListExtendQuery and FormExtendQuery scope every lookup to the acme tenant,
|
|
// so a person of another tenant is out of scope. Both include soft-deleted
|
|
// people, as a WinterCMS controller with withTrashed does.
|
|
func (rosterController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
|
return db.Unscoped().Where("tenant = ?", "acme")
|
|
}
|
|
func (rosterController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
|
return db.Unscoped().Where("tenant = ?", "acme")
|
|
}
|
|
|
|
// ListRowStates marks a page of people: deleted when soft-deleted, negative
|
|
// when banned, disabled when not active. It answers out of order and with a
|
|
// duplicate and, for a person named Odd, a value outside the fixed set, so
|
|
// the framework's reduction is visible.
|
|
func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
|
|
c.spy.recordStates(len(records))
|
|
if _, inTx := cabana.TxFromContext(ctx); inTx || db == nil {
|
|
return nil, fmt.Errorf("a list hook gets the list handle, not a transaction")
|
|
}
|
|
out := make([][]pact.RowState, len(records))
|
|
for i, record := range records {
|
|
person := record.(*rosterPerson)
|
|
if !person.Active {
|
|
out[i] = append(out[i], pact.RowStateDisabled, pact.RowStateDisabled)
|
|
}
|
|
if person.Banned {
|
|
out[i] = append(out[i], pact.RowStateNegative)
|
|
}
|
|
if person.DeletedAt.Valid {
|
|
out[i] = append(out[i], pact.RowStateDeleted)
|
|
}
|
|
if person.Name == "Odd" {
|
|
out[i] = append(out[i], pact.RowState("starred"))
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// rosterStatus is the curated view model of the preview status hint: the
|
|
// callout tone and the phrase keys of its title and text. It is empty when
|
|
// no state applies, and the template then renders nothing.
|
|
type rosterStatus struct {
|
|
Tone, Title, Text string
|
|
}
|
|
|
|
// PartialData serves the preview header partial `status`: one callout by
|
|
// precedence banned, archived, not active.
|
|
func (rosterController) PartialData(_ context.Context, name string, record any) (any, error) {
|
|
if name != "status" {
|
|
return nil, fmt.Errorf("unknown partial %s", name)
|
|
}
|
|
person, ok := record.(*rosterPerson)
|
|
if !ok || person == nil {
|
|
return rosterStatus{}, nil
|
|
}
|
|
const keys = "acme.roster::lang.people."
|
|
switch {
|
|
case person.Banned:
|
|
return rosterStatus{Tone: "danger", Title: keys + "banned_title", Text: keys + "banned_text"}, nil
|
|
case person.DeletedAt.Valid:
|
|
return rosterStatus{Tone: "danger", Title: keys + "deleted_title", Text: keys + "deleted_text"}, nil
|
|
case !person.Active:
|
|
return rosterStatus{Tone: "warning", Title: keys + "inactive_title", Text: keys + "inactive_text"}, nil
|
|
}
|
|
return rosterStatus{}, nil
|
|
}
|
|
|
|
// rosterLocked is the sentinel name of a person the roster's actions refuse.
|
|
const rosterLocked = "Locked"
|
|
|
|
// rosterRefused is a shared refusal value: the framework must localize a
|
|
// copy and never write into it.
|
|
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
|
|
|
|
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming
|
|
// the field, and fails with a plain error for the name Boom.
|
|
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error {
|
|
switch model.(*rosterPerson).Name {
|
|
case "Reserved":
|
|
return &cabana.ForbiddenError{
|
|
Message: "acme.roster::lang.people.refused",
|
|
Details: map[string]any{"name": []string{"acme.roster::lang.people.refused_name"}},
|
|
}
|
|
case "Silent":
|
|
return &cabana.ForbiddenError{}
|
|
case "Boom":
|
|
return fmt.Errorf("the roster database said hunter2")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FormAfterDelete removes the person for good inside the delete's
|
|
// transaction: the list keeps soft-deleted people, so deleting one there is
|
|
// permanent.
|
|
func (rosterController) FormAfterDelete(ctx context.Context, model any) error {
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return fmt.Errorf("no transaction on the context")
|
|
}
|
|
return tx.Unscoped().Delete(model).Error
|
|
}
|
|
|
|
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
|
|
// rows that are not active yet, reporting how many it changed; archive needs
|
|
// only the controller permission and soft-deletes the rows.
|
|
func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
|
|
return []pact.AdminBulkAction{{
|
|
Name: "activate", Label: "acme.roster::lang.people.activate", Confirm: "acme.roster::lang.people.activate_confirm",
|
|
Permissions: []string{"acme.roster.manage"},
|
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
|
c.spy.recordBulk(in)
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
|
}
|
|
changed := 0
|
|
for _, record := range in.Records {
|
|
person := record.(*rosterPerson)
|
|
if person.Active {
|
|
continue
|
|
}
|
|
// Unscoped: the list scope includes soft-deleted people.
|
|
if err := tx.Unscoped().Model(person).Update("active", true).Error; err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
changed++
|
|
}
|
|
return pact.AdminBulkActionResult{Affected: changed}, nil
|
|
},
|
|
}, {
|
|
Name: "archive", Label: "acme.roster::lang.people.archive",
|
|
Permissions: []string{"acme.roster.access"},
|
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
|
c.spy.recordBulk(in)
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
|
}
|
|
for _, record := range in.Records {
|
|
// A refusal after earlier rows were written: the whole
|
|
// selection must roll back.
|
|
if record.(*rosterPerson).Name == rosterLocked {
|
|
return pact.AdminBulkActionResult{}, rosterRefused
|
|
}
|
|
if err := tx.Delete(record).Error; err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
}
|
|
return pact.AdminBulkActionResult{Message: "acme.roster::lang.people.archived", Affected: len(in.Records)}, nil
|
|
},
|
|
}}
|
|
}
|
|
|
|
// AdminRecordActions: activate needs acme.roster.manage and applies to a
|
|
// person who is not active; reinstate applies to a banned person and lifts
|
|
// the ban.
|
|
func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
|
|
return []pact.AdminRecordAction{{
|
|
Name: "activate", Label: "acme.roster::lang.people.activate",
|
|
Permissions: []string{"acme.roster.manage"},
|
|
Applies: func(_ context.Context, record any) (bool, error) {
|
|
return !record.(*rosterPerson).Active, nil
|
|
},
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
c.spy.recordOne(in)
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
|
|
}
|
|
if err := tx.Unscoped().Model(in.Record).Update("active", true).Error; err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil
|
|
},
|
|
}, {
|
|
Name: "reinstate", Label: "acme.roster::lang.people.reinstate", Confirm: "acme.roster::lang.people.reinstate_confirm",
|
|
Applies: func(_ context.Context, record any) (bool, error) {
|
|
return record.(*rosterPerson).Banned, nil
|
|
},
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
c.spy.recordOne(in)
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
|
|
}
|
|
if err := tx.Unscoped().Model(in.Record).Update("banned", false).Error; err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
// Refused after the write: the transaction must roll it back.
|
|
if in.Record.(*rosterPerson).Name == rosterLocked {
|
|
return pact.AdminRecordActionResult{}, rosterRefused
|
|
}
|
|
return pact.AdminRecordActionResult{}, nil
|
|
},
|
|
}}
|
|
}
|
|
|
|
// rosterEnv is the assembled admin API over the roster fixture. The embedded
|
|
// actEnv supplies call and expect with the four auth modes: bearer (developer
|
|
// token), limited (acme.roster.access only), cookie and cookie-only.
|
|
type rosterEnv struct {
|
|
*actEnv
|
|
spy *rosterSpy
|
|
}
|
|
|
|
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
|
|
t.Helper()
|
|
gdb := adminGorm(t)
|
|
models := []any{&rosterPerson{}}
|
|
if err := gdb.Migrator().DropTable(models...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gdb.AutoMigrate(models...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stamp := fmt.Sprintf("r%d", time.Now().UnixNano())
|
|
login := "roster-" + stamp
|
|
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
|
var roleID uint
|
|
if err := gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at)
|
|
VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Roster limited "+stamp, "roster-limited-"+stamp, `{"acme.roster.access":1}`).Scan(&roleID).Error; err != nil || roleID == 0 {
|
|
t.Fatalf("limited role: id=%d err=%v", roleID, err)
|
|
}
|
|
limitedLogin := "roster-limited-" + stamp
|
|
limited := insertAdmin(t, gdb, limitedLogin, limitedLogin+"@example.test", adminTestPassword, true, false)
|
|
if err := gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, limited.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-roster\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
|
|
if err := cfg.Set(key, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
spy := &rosterSpy{}
|
|
plugins := []party.Plugin{rosterPlugin{spy: spy}}
|
|
if err := phrasebook.Activate(app, plugins); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := &rosterEnv{actEnv: &actEnv{h: h}, spy: spy}
|
|
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
env.token = accessToken(t, rec.Body.Bytes())
|
|
env.cookie = &http.Cookie{Name: cabana.AdminCookieName, Value: env.token}
|
|
rec = postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": limitedLogin, "password": adminTestPassword})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("limited login status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
env.limited = accessToken(t, rec.Body.Bytes())
|
|
return env, gdb
|
|
}
|
|
|
|
// rosterTree is the roster fixture tree as an in-memory file system with the
|
|
// given files replaced or added (boot-error tests).
|
|
func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS {
|
|
t.Helper()
|
|
out := fstest.MapFS{}
|
|
err := filepath.WalkDir(rosterDir, func(name string, entry fs.DirEntry, err error) error {
|
|
if err != nil || entry.IsDir() {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(rosterDir, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out[filepath.ToSlash(rel)] = &fstest.MapFile{Data: data}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, body := range replace {
|
|
out[name] = &fstest.MapFile{Data: []byte(body)}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rosterBoot activates the roster plugin over fsys and returns the boot error.
|
|
func rosterBoot(t *testing.T, fsys fs.FS) error {
|
|
t.Helper()
|
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
|
|
return err
|
|
}
|
|
|
|
// rosterInsert stores one person and returns its id.
|
|
func rosterInsert(t *testing.T, gdb *gorm.DB, person rosterPerson) uint {
|
|
t.Helper()
|
|
if err := gdb.Create(&person).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return person.ID
|
|
}
|
|
|
|
// rosterLoad reads one person, soft-deleted or not.
|
|
func rosterLoad(t *testing.T, gdb *gorm.DB, id uint) rosterPerson {
|
|
t.Helper()
|
|
var person rosterPerson
|
|
if err := gdb.Unscoped().First(&person, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return person
|
|
}
|