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
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -200,9 +201,9 @@ type rosterKnobs struct {
|
||||
permissionValues atomic.Bool
|
||||
// relationLocks makes AdminRelationLocks fail.
|
||||
relationLocks atomic.Bool
|
||||
// slowArchive, when set, runs inside the archive bulk action after the
|
||||
// rows were locked (concurrency tests).
|
||||
slowArchive atomic.Pointer[func()]
|
||||
// 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
|
||||
@@ -241,6 +242,8 @@ type rosterPlugin struct {
|
||||
// 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" }
|
||||
@@ -248,7 +251,11 @@ 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, knobs: p.knobs, db: p.db, relations: p.relations}}
|
||||
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"}}}
|
||||
@@ -313,14 +320,25 @@ func (c rosterController) handle(ctx context.Context) *gorm.DB {
|
||||
return c.db.WithContext(ctx)
|
||||
}
|
||||
|
||||
// AdminRelationLocks locks the staff tag for an administrator without
|
||||
// acme.roster.manage.
|
||||
// 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 field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
|
||||
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
|
||||
@@ -588,6 +606,16 @@ func (rosterController) FormAfterDelete(ctx context.Context, model any) error {
|
||||
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.
|
||||
@@ -601,6 +629,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
|
||||
if !ok {
|
||||
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
||||
}
|
||||
c.slow()
|
||||
changed := 0
|
||||
for _, record := range in.Records {
|
||||
person := record.(*rosterPerson)
|
||||
@@ -624,11 +653,7 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
|
||||
if !ok {
|
||||
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
||||
}
|
||||
if c.knobs != nil {
|
||||
if wait := c.knobs.slowArchive.Load(); wait != nil {
|
||||
(*wait)()
|
||||
}
|
||||
}
|
||||
c.slow()
|
||||
for _, record := range in.Records {
|
||||
// A refusal after earlier rows were written: the whole
|
||||
// selection must roll back.
|
||||
@@ -847,3 +872,96 @@ func rosterLoad(t *testing.T, gdb *gorm.DB, id uint) rosterPerson {
|
||||
}
|
||||
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} }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user