Files
summercms/modules/cabana/phase121_fixture_test.go
Jakub Zych 2e94cbf9f8 test(12.1-05): unit tests for bulk and record actions, row state, forbidden, preview and the form seams
- bulk action: empty, duplicate, unordered, absent, partial, out-of-scope, rollback, concurrent runs, permissions, CSRF, body cap
- record action: scope, Applies, strict body, offered order, rollback, Applies error
- ForbiddenError from every Form hook, the bulk delete and the relation link and child hooks
- permission editor modes, locked codes and provider errors; relation locks on create, update and belongsTo
- TestPhase121BootErrors: every boot error of plans 01 and 02 with plugin, controller and file
- pact: the action, row state and filter contracts on a sample controller
2026-10-05 14:48:34 +02:00

968 lines
34 KiB
Go

package cabana_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"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"`
// Password is a stored hash. The form's password field is virtual: the
// controller's hooks derive this column from the submitted value.
Password string `gorm:"column:password" json:"-"`
Slug string `gorm:"column:slug"`
// Permissions is the permission editor's storage: a JSON object of code
// to value, or NULL.
Permissions *string `gorm:"column:permissions"`
// OrganisationID is a protected fill key: only the team relation field,
// whose contract sets WritableForeignKey, writes it.
OrganisationID *uint `gorm:"column:organisation_id"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (rosterPerson) TableName() string { return "roster_people" }
func (rosterPerson) Fillable() []string { return []string{"name", "email", "slug"} }
// Rules are the model's own (sign-up) rules: every save needs a confirmed
// password. The admin form replaces them through the controller's FormRules.
func (rosterPerson) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// FilterScopes and FilterScope: the tagged filter keeps the people who carry
// one tag. Its choices come from the controller, which can read the tags.
func (rosterPerson) FilterScopes() []string { return []string{"tagged"} }
func (rosterPerson) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != "tagged" {
return db
}
return db.Where("roster_people.id IN (SELECT person_id FROM roster_person_tags WHERE tag_id = ?)", value)
}
// rosterTeam is the belongsTo target of the team field.
type rosterTeam struct {
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
}
func (rosterTeam) TableName() string { return "roster_teams" }
// rosterTag is the belongsToMany target of the tags field. The tag named
// staff is locked for an administrator without acme.roster.manage.
type rosterTag struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (rosterTag) TableName() string { return "roster_tags" }
type rosterPersonTag struct {
PersonID uint `gorm:"column:person_id;primaryKey"`
TagID uint `gorm:"column:tag_id;primaryKey"`
}
func (rosterPersonTag) TableName() string { return "roster_person_tags" }
// rosterHash is the fixture's stand-in for a password hash.
func rosterHash(plain string) string {
sum := sha256.Sum256([]byte(plain))
return "sha256:" + hex.EncodeToString(sum[:])
}
// rosterVirtual is what one Form hook read from VirtualFieldsFromContext.
type rosterVirtual struct {
Hook string
Values map[string]any
Found bool
}
// 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
// virtual keeps what each Form hook read from the context.
virtual []rosterVirtual
}
func (s *rosterSpy) recordVirtual(hook string, ctx context.Context) map[string]any {
values, found := cabana.VirtualFieldsFromContext(ctx)
if s == nil {
return values
}
s.mu.Lock()
defer s.mu.Unlock()
kept := make(map[string]any, len(values))
for name, value := range values {
kept[name] = value
}
s.virtual = append(s.virtual, rosterVirtual{Hook: hook, Values: kept, Found: found})
return values
}
func (s *rosterSpy) takeVirtual() []rosterVirtual {
s.mu.Lock()
defer s.mu.Unlock()
out := s.virtual
s.virtual = nil
return out
}
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
}
// rosterKnobs switch on failures of the controller's providers, which get no
// record to carry a sentinel name. A nil pointer switches nothing on.
type rosterKnobs struct {
// permissionOptions makes AdminPermissionOptions fail.
permissionOptions atomic.Bool
// permissionValues makes AdminPermissionValues fail.
permissionValues atomic.Bool
// relationLocks makes AdminRelationLocks fail.
relationLocks atomic.Bool
// slowBulk, when set, runs inside each bulk action after the rows were
// locked (concurrency tests).
slowBulk atomic.Pointer[func()]
}
// The sentinel names below make one hook of the roster controller misbehave
// for the person who carries the name.
const (
// rosterKeep: FormBeforeDelete refuses with a ForbiddenError.
rosterKeep = "Keep"
// rosterKeepAfter: FormAfterUpdate and FormAfterDelete refuse after the
// row was written or removed.
rosterKeepAfter = "KeepAfter"
// rosterShort: ListRowStates answers one entry too few.
rosterShort = "Short"
// rosterStateErr: ListRowStates fails with a plain error.
rosterStateErr = "StateErr"
// rosterAppliesErr: the activate record action's Applies fails.
rosterAppliesErr = "AppliesErr"
// rosterCrash: the archive bulk action fails with a plain error.
rosterCrash = "Crash"
// rosterRunErr: the reinstate record action fails with a plain error
// after its write.
rosterRunErr = "RunErr"
// rosterDenyCreate and rosterDenyAfterCreate: the create hooks refuse.
rosterDenyCreate = "DenyCreate"
rosterDenyAfterCreate = "DenyAfterCreate"
)
// rosterPlugin is the acme.roster fixture plugin. fsys, when set, replaces
// the fixture tree (boot-error tests).
type rosterPlugin struct {
spy *rosterSpy
knobs *rosterKnobs
fsys fs.FS
// db is the handle the controller reads filter choices and locked tags
// with outside a transaction.
db *gorm.DB
// relations, when set, rewrites the controller's relation contracts
// (boot tests).
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
// wrap, when set, replaces the controller the plugin registers.
wrap func(rosterController) pact.AdminController
}
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 {
ctl := rosterController{spy: p.spy, knobs: p.knobs, db: p.db, relations: p.relations}
if p.wrap != nil {
return []pact.AdminController{p.wrap(ctl)}
}
return []pact.AdminController{ctl}
}
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
knobs *rosterKnobs
db *gorm.DB
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
}
// AdminFieldRelations: team writes the protected organisation_id through an
// explicit opt-in; tags is a plain belongsToMany.
func (c rosterController) AdminFieldRelations() []cabana.FieldRelationContract {
out := []cabana.FieldRelationContract{{
Field: "team", Kind: "belongsTo", NewRelated: func() any { return &rosterTeam{} },
ForeignKey: "organisation_id", WritableForeignKey: true,
}, {
Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &rosterTag{} },
NewPivot: func() any { return &rosterPersonTag{} }, ParentForeignKey: "person_id", RelatedForeignKey: "tag_id",
}}
if c.relations != nil {
out = c.relations(out)
}
return out
}
// RelationExtendOptionsQuery offers only the acme tenant's teams.
func (rosterController) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
if field == "team" {
return db.Where("tenant = ?", "acme")
}
return db
}
// handle is the save's transaction when there is one, else the plugin's
// database handle.
func (c rosterController) handle(ctx context.Context) *gorm.DB {
if tx, ok := cabana.TxFromContext(ctx); ok {
return tx
}
return c.db.WithContext(ctx)
}
// AdminRelationLocks locks the staff tag and the vault team for an
// administrator without acme.roster.manage.
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
if c.knobs != nil && c.knobs.relationLocks.Load() {
return cabana.RelationLock{}, fmt.Errorf("the lock table said hunter2")
}
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
if field == "team" {
// The team named vault is locked, without a message of its own.
var ids []uint
if err := c.handle(ctx).Model(&rosterTeam{}).Where("name = ?", "vault").Pluck("id", &ids).Error; err != nil {
return cabana.RelationLock{}, err
}
return cabana.RelationLock{IDs: ids}, nil
}
if field != "tags" {
return cabana.RelationLock{}, nil
}
var ids []uint
if err := c.handle(ctx).Model(&rosterTag{}).Where("name = ?", "staff").Pluck("id", &ids).Error; err != nil {
return cabana.RelationLock{}, err
}
return cabana.RelationLock{IDs: ids, Message: "acme.roster::lang.people.tag_locked"}, nil
}
// FilterOptions serves the tagged filter's choices from the database.
func (c rosterController) FilterOptions(scope string) []pact.Option {
if scope != "tagged" || c.db == nil {
return nil
}
var tags []rosterTag
if err := c.db.Order("name").Find(&tags).Error; err != nil {
return nil
}
out := make([]pact.Option, len(tags))
for i, tag := range tags {
out[i] = pact.Option{Value: fmt.Sprint(tag.ID), Label: tag.Name}
}
return out
}
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)
}
switch person.Name {
case "Odd":
out[i] = append(out[i], pact.RowState("starred"))
case rosterShort:
return out[:len(out)-1], nil
case rosterStateErr:
return nil, fmt.Errorf("the state table said hunter2")
}
}
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
}
// rosterPermissionCodes are the permissions the people form offers: two tabs
// and one permission without a tab. reports.export is locked for an
// administrator without acme.roster.manage.
var rosterPermissionCodes = []cabana.PermissionOption{
{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content", Comment: "acme.roster::lang.permissions.posts_edit_comment"},
{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports"},
{Code: "misc.beta", Label: "acme.roster::lang.permissions.misc_beta"},
}
// AdminPermissionOptions serves the permission editor's options per
// administrator.
func (c rosterController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
if c.knobs != nil && c.knobs.permissionOptions.Load() {
return nil, fmt.Errorf("the permission table said hunter2")
}
if field != "permissions" {
return nil, fmt.Errorf("unknown permission field %s", field)
}
principal, _ := bouncer.User(ctx)
out := append([]cabana.PermissionOption(nil), rosterPermissionCodes...)
for i := range out {
if out[i].Code == "reports.export" {
out[i].Locked = !cabana.Allows(principal, []string{"acme.roster.manage"})
}
}
return out, nil
}
// AdminPermissionValues reads the stored JSON object.
func (c rosterController) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
if c.knobs != nil && c.knobs.permissionValues.Load() {
return nil, fmt.Errorf("the permission column said hunter2")
}
person := record.(*rosterPerson)
out := map[string]int{}
if person.Permissions == nil || *person.Permissions == "" {
return out, nil
}
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
return nil, err
}
return out, nil
}
// AdminSetPermissionValues writes the JSON object onto the model; the save
// writes the row.
func (rosterController) AdminSetPermissionValues(ctx context.Context, _ string, record any, values map[string]int) error {
if _, ok := cabana.TxFromContext(ctx); !ok {
return fmt.Errorf("no transaction on the context")
}
raw, err := json.Marshal(values)
if err != nil {
return err
}
text := string(raw)
record.(*rosterPerson).Permissions = &text
return 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"}
// FormVirtualFields lists the form fields that are not columns of the form:
// the password pair and the create-only notify checkbox.
func (rosterController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "notify"}
}
// FormRules are the admin form's rules: a create needs a confirmed password,
// an update takes one only when it is submitted.
func (rosterController) FormRules(_ context.Context, op string) map[string]string {
if op == "create" {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
return map[string]string{"name": "required", "password": "nullable|between:8,255|confirmed"}
}
// storePassword derives the stored hash from a submitted password.
func storePassword(person *rosterPerson, values map[string]any) {
if plain, ok := values["password"].(string); ok && plain != "" {
person.Password = rosterHash(plain)
}
}
// FormBeforeCreate stamps the tenant and stores the hash of the submitted
// password. It also drops notify from its own copy of the virtual values: the
// after hook must still see it.
func (c rosterController) FormBeforeCreate(ctx context.Context, model any) error {
values := c.spy.recordVirtual("before-create", ctx)
model.(*rosterPerson).Tenant = "acme"
storePassword(model.(*rosterPerson), values)
delete(values, "notify")
if model.(*rosterPerson).Name == rosterDenyCreate {
return rosterRefused
}
return nil
}
func (c rosterController) FormAfterCreate(ctx context.Context, model any) error {
c.spy.recordVirtual("after-create", ctx)
if model.(*rosterPerson).Name == rosterDenyAfterCreate {
return rosterRefused
}
return nil
}
// FormAfterUpdate refuses the name KeepAfter after the row was written.
func (rosterController) FormAfterUpdate(_ context.Context, model any) error {
if model.(*rosterPerson).Name == rosterKeepAfter {
return rosterRefused
}
return nil
}
// FormBeforeDelete refuses the person named Keep.
func (rosterController) FormBeforeDelete(_ context.Context, model any) error {
if model.(*rosterPerson).Name == rosterKeep {
return rosterRefused
}
return nil
}
// FormBeforeUpdate stores a submitted password, refuses the reserved name
// with a ForbiddenError naming the field, and fails with a plain error for
// the name Boom.
func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error {
storePassword(model.(*rosterPerson), c.spy.recordVirtual("before-update", ctx))
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")
}
if err := tx.Unscoped().Delete(model).Error; err != nil {
return err
}
// Refused after the row was removed: the transaction must bring it back.
if model.(*rosterPerson).Name == rosterKeepAfter {
return rosterRefused
}
return nil
}
// slow runs the slowBulk knob, when one is set.
func (c rosterController) slow() {
if c.knobs == nil {
return
}
if wait := c.knobs.slowBulk.Load(); wait != nil {
(*wait)()
}
}
// 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")
}
c.slow()
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")
}
c.slow()
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 record.(*rosterPerson).Name == rosterCrash {
return pact.AdminBulkActionResult{}, fmt.Errorf("the archive said hunter2")
}
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) {
if record.(*rosterPerson).Name == rosterAppliesErr {
return false, fmt.Errorf("the applies check said hunter2")
}
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
}
if in.Record.(*rosterPerson).Name == rosterRunErr {
return pact.AdminRecordActionResult{}, fmt.Errorf("the reinstate said hunter2")
}
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
knobs *rosterKnobs
}
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Helper()
return newRosterEnvWith(t, nil)
}
// newRosterEnvWith is newRosterEnv with the plugin adjusted by configure
// before it is assembled.
func newRosterEnvWith(t *testing.T, configure func(*rosterPlugin)) (*rosterEnv, *gorm.DB) {
t.Helper()
gdb := adminGorm(t)
models := []any{&rosterPerson{}, &rosterTeam{}, &rosterTag{}, &rosterPersonTag{}}
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{}
knobs := &rosterKnobs{}
plugin := rosterPlugin{spy: spy, knobs: knobs, db: gdb}
if configure != nil {
configure(&plugin)
}
plugins := []party.Plugin{plugin}
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, knobs: knobs}
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()
return rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, fsys: fsys})
}
// rosterBootWith activates one roster plugin value and returns the boot error.
func rosterBootWith(t *testing.T, plugin rosterPlugin) 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{plugin})
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
}
// rosterBare is the roster controller with an explicit method set: it has no
// relation lock provider, no FormRules, no FilterOptions and no row states,
// so the framework's behaviour without those seams is observable. newRecord,
// when set, replaces the model.
type rosterBare struct {
inner rosterController
newRecord func() any
}
func (b rosterBare) ID() string { return b.inner.ID() }
func (b rosterBare) ModelName() string { return b.inner.ModelName() }
func (b rosterBare) ConfigDir() string { return b.inner.ConfigDir() }
func (b rosterBare) RequiredPermissions() []string { return b.inner.RequiredPermissions() }
func (b rosterBare) NewRecord() any {
if b.newRecord != nil {
return b.newRecord()
}
return b.inner.NewRecord()
}
func (b rosterBare) ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return b.inner.ListExtendQuery(ctx, db)
}
func (b rosterBare) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
return b.inner.FormExtendQuery(ctx, db)
}
func (b rosterBare) AdminFieldRelations() []cabana.FieldRelationContract {
return b.inner.AdminFieldRelations()
}
func (b rosterBare) RelationExtendOptionsQuery(ctx context.Context, field string, db *gorm.DB) *gorm.DB {
return b.inner.RelationExtendOptionsQuery(ctx, field, db)
}
func (b rosterBare) FormVirtualFields() []string { return b.inner.FormVirtualFields() }
func (b rosterBare) FormBeforeCreate(ctx context.Context, model any) error {
return b.inner.FormBeforeCreate(ctx, model)
}
func (b rosterBare) FormBeforeUpdate(ctx context.Context, model any) error {
return b.inner.FormBeforeUpdate(ctx, model)
}
func (b rosterBare) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
return b.inner.AdminPermissionOptions(ctx, field)
}
func (b rosterBare) AdminPermissionValues(ctx context.Context, field string, record any) (map[string]int, error) {
return b.inner.AdminPermissionValues(ctx, field, record)
}
func (b rosterBare) AdminSetPermissionValues(ctx context.Context, field string, record any, values map[string]int) error {
return b.inner.AdminSetPermissionValues(ctx, field, record, values)
}
func (b rosterBare) AdminBulkActions() []pact.AdminBulkAction { return b.inner.AdminBulkActions() }
func (b rosterBare) AdminRecordActions() []pact.AdminRecordAction {
return b.inner.AdminRecordActions()
}
func (b rosterBare) PartialData(ctx context.Context, name string, record any) (any, error) {
return b.inner.PartialData(ctx, name, record)
}
// rosterOptionsPerson is the roster model that serves its tagged filter's
// choices itself (the model fallback of pact.FilterOptions).
type rosterOptionsPerson struct {
rosterPerson
}
func (rosterOptionsPerson) FilterOptions(scope string) []pact.Option {
if scope != "tagged" {
return nil
}
return []pact.Option{{Value: "1", Label: "acme.roster::lang.people.tags"}, {Value: "2", Label: "Plain label"}}
}
// rosterListNoFilter is the fixture's config_list.yaml without its filter.
func rosterListNoFilter(t *testing.T) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(rosterDir, "controllers/people/config_list.yaml"))
if err != nil {
t.Fatal(err)
}
const line = "filter: config_filter.yaml\n"
if !strings.Contains(string(raw), line) {
t.Fatal("the fixture list has no filter line")
}
return strings.Replace(string(raw), line, "", 1)
}
// newRosterBareEnv assembles the roster fixture around rosterBare, without
// the list filter (which needs FilterOptions).
func newRosterBareEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Helper()
list := rosterListNoFilter(t)
return newRosterEnvWith(t, func(p *rosterPlugin) {
p.fsys = rosterTree(t, map[string]string{"controllers/people/config_list.yaml": list})
p.wrap = func(inner rosterController) pact.AdminController { return rosterBare{inner: inner} }
})
}