package cabana_test import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io/fs" "net/http" "os" "path/filepath" "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 // slowArchive, when set, runs inside the archive bulk action after the // rows were locked (concurrency tests). slowArchive 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 } 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, knobs: p.knobs, db: p.db, relations: p.relations}} } 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 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"}) { 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 } // 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") } if c.knobs != nil { if wait := c.knobs.slowArchive.Load(); wait != nil { (*wait)() } } 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 }