diff --git a/modules/cabana/administrators_harness_test.go b/modules/cabana/administrators_harness_test.go new file mode 100644 index 00000000..78fc6654 --- /dev/null +++ b/modules/cabana/administrators_harness_test.go @@ -0,0 +1,589 @@ +package cabana_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "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" +) + +// The screens' paths under the admin API. +const ( + usersPath = "/summercms/cabana/users" + rolesPath = "/summercms/cabana/roles" +) + +// The pinger's permission codes and its token scope. +const ( + pingPermission = "acme.pinger.ping" + pingScope = "pinger-admins" +) + +// The English refusal texts of backend::lang (UI-SPEC C8), compared verbatim. +const ( + enSuperuserForbidden = "Only a superuser can grant, remove or change superuser access." + enRoleCeiling = "You can only assign roles whose permissions you have yourself." + enSelfPrivileged = "You cannot change your own role, permissions, superuser access or activation. Ask another administrator." + enSelfDelete = "You cannot delete your own account." + enSelfDeactivate = "You cannot deactivate your own account." + enLastSuperuser = "This is the last active superuser, so it cannot be deactivated, deleted or lose superuser access. Make another administrator a superuser first." + enOutranked = "This administrator has permissions you do not have, so you cannot change, deactivate or delete their account." + enOwnPassword = "Change your own password in My account, where your current password is checked." + enLoginTaken = "This login is already used by another administrator." + enLoginTakenDeleted = "This login belongs to a deleted administrator. Restore that account instead of creating a new one." + enEmailTaken = "This email is already used by another administrator." + enEmailTakenDeleted = "This email belongs to a deleted administrator. Restore that account instead of creating a new one." + enPermissionLocked = "You cannot change this permission." + enLockedByCode = "Granted to this role by a plugin. It cannot be removed here." + enLockedSelf = "You cannot change your own permissions." + enCodeTaken = "This code is already used by another role." + enCodeLocked = "The code of a system role cannot be changed." + enDeleteSystem = "System roles cannot be deleted." +) + +// pingSpy records the runs of the contributed acme_ping record action and +// switches its Applies. +type pingSpy struct { + mu sync.Mutex + ids []uint64 + withTx []bool + // refuse makes Applies answer false while it is set. + refuse atomic.Bool +} + +func (s *pingSpy) record(id uint64, tx bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.ids = append(s.ids, id) + s.withTx = append(s.withTx, tx) +} + +func (s *pingSpy) runs() ([]uint64, []bool) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]uint64(nil), s.ids...), append([]bool(nil), s.withTx...) +} + +// pingPlugin owns no admin controller; it only adds the acme_ping record +// action to the Administrators screen (pact.HasAdminActionExtensions). It +// also declares a token scope over backend.manage_users, so a superuser's +// scoped token can reach the screen. +type pingPlugin struct{ spy *pingSpy } + +func (pingPlugin) ID() string { return "acme.pinger" } +func (pingPlugin) Requires() []string { return nil } +func (pingPlugin) Register(*backpack.App) error { return nil } +func (pingPlugin) Boot(*backpack.App) error { return nil } +func (pingPlugin) Permissions() []pact.Permission { + return []pact.Permission{{Code: pingPermission}} +} +func (pingPlugin) TokenScopes() []pact.TokenScope { + return []pact.TokenScope{{ + Name: pingScope, Label: "Administrators", Description: "Manage administrators through the API.", + Permissions: []string{cabana.PermissionManageUsers}, Methods: []string{"GET", "POST", "PUT", "DELETE"}, + }} +} +func (p pingPlugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: cabana.AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{{ + Name: "acme_ping", Label: "Ping", Permissions: []string{pingPermission}, + Applies: func(context.Context, any) (bool, error) { + return !p.spy.refuse.Load(), nil + }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + _, ok := cabana.TxFromContext(ctx) + if _, isUser := in.Record.(*cabana.BackendUser); !isUser { + return pact.AdminRecordActionResult{}, errors.New("acme_ping: record is not a backend user") + } + p.spy.record(in.RecordID, ok) + return pact.AdminRecordActionResult{Message: "Pinged"}, nil + }, + }}, + }} +} + +// grantPlugin declares permission codes granted in code to roles +// (pact.Permission.Roles), the code-declared grants of D-16. +type grantPlugin struct { + id string + grants []pact.Permission +} + +func (p grantPlugin) ID() string { return p.id } +func (grantPlugin) Requires() []string { return nil } +func (grantPlugin) Register(*backpack.App) error { return nil } +func (grantPlugin) Boot(*backpack.App) error { return nil } +func (p grantPlugin) Permissions() []pact.Permission { return p.grants } + +// screensEnv is one assembled admin with the demo plugin, the pinger, any +// extra plugins and the admin's own screens, on the shared Postgres. +type screensEnv struct { + h http.Handler + gdb *gorm.DB + stamp string + spy *pingSpy + seq atomic.Int64 +} + +// newScreensEnv assembles the admin like surf does in an application: +// compass config with the admin secret and body limits, lagoon.Publish, +// phrasebook.Activate and surf.Assemble. +func newScreensEnv(t *testing.T, extra ...party.Plugin) *screensEnv { + t.Helper() + gdb := adminGorm(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-screens\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, + "admin.password.bcrypt_cost": 4, "admin.login.max_attempts": 100000, + } { + 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 := &pingSpy{} + plugins := append([]party.Plugin{demoPlugin{fsys: demoFS()}, pingPlugin{spy: spy}}, extra...) + if err := phrasebook.Activate(app, plugins); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + return &screensEnv{h: h, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy} +} + +// unique is name plus the env stamp and a per-env sequence number. +func (e *screensEnv) unique(name string) string { + return fmt.Sprintf("%s-%s-%d", name, e.stamp, e.seq.Add(1)) +} + +// adminSeed describes one administrator seedAdmin inserts. +type adminSeed struct { + Name string + Superuser bool + Inactive bool + Deleted bool + // Role is the role id; when 0, RolePermissions (if not nil) seeds a + // role of its own, else the administrator gets an empty role of its + // own. Developer uses the developer system role. + Role uint + RolePermissions map[string]int + Developer bool + // Permissions are the administrator's own overrides. + Permissions map[string]int + Password string +} + +// roleSeed describes one role seedRole inserts. +type roleSeed struct { + Name string + Code string + Permissions map[string]int + System bool +} + +func permissionsJSON(t *testing.T, values map[string]int) string { + t.Helper() + if values == nil { + return "{}" + } + raw, err := json.Marshal(values) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// seedRole inserts a role with a unique name and code; t.Cleanup detaches +// the role from its holders and deletes it. +func (e *screensEnv) seedRole(t *testing.T, seed roleSeed) cabana.BackendUserRole { + t.Helper() + name := seed.Name + if name == "" { + name = "role" + } + name = e.unique(name) + code := seed.Code + if code == "" { + code = "code-" + name + } + role := cabana.BackendUserRole{Name: "Role " + name, Code: code, Permissions: permissionsJSON(t, seed.Permissions), IsSystem: seed.System} + if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW()) RETURNING id`, role.Name, role.Code, role.Permissions, role.IsSystem).Scan(&role.ID).Error; err != nil || role.ID == 0 { + t.Fatalf("seed role %s: id=%d err=%v", role.Code, role.ID, err) + } + t.Cleanup(func() { + e.gdb.Exec(`UPDATE backend_users SET role_id = NULL WHERE role_id = ?`, role.ID) + e.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, role.ID) + }) + return role +} + +// seedAdmin inserts an administrator with a unique login and email; t.Cleanup +// removes the superuser flag and soft-deletes the row. +func (e *screensEnv) seedAdmin(t *testing.T, seed adminSeed) cabana.BackendUser { + t.Helper() + name := seed.Name + if name == "" { + name = "admin" + } + login := e.unique(name) + password := seed.Password + if password == "" { + password = adminTestPassword + } + hash, err := bouncer.HashPassword(4, password) + if err != nil { + t.Fatal(err) + } + var roleID uint + switch { + case seed.Role != 0: + roleID = seed.Role + case seed.Developer: + if err := e.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("developer role: %v id=%d", err, roleID) + } + default: + roleID = e.seedRole(t, roleSeed{Name: name, Permissions: seed.RolePermissions}).ID + } + user := cabana.BackendUser{ + FirstName: "Ada", LastName: "Admin", Login: login, Email: login + "@example.test", + Password: hash, IsActivated: !seed.Inactive, IsSuperuser: seed.Superuser, RoleID: &roleID, + Permissions: permissionsJSON(t, seed.Permissions), + } + if err := e.gdb.Create(&user).Error; err != nil { + t.Fatal(err) + } + // GORM skips zero values with a default on Create: write the flags + // explicitly. + if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = ?, is_activated = ?, permissions = ? WHERE id = ?`, + seed.Superuser, !seed.Inactive, user.Permissions, user.ID).Error; err != nil { + t.Fatal(err) + } + if seed.Deleted { + if err := e.gdb.Exec(`UPDATE backend_users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { + e.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE, deleted_at = COALESCE(deleted_at, NOW()) WHERE id = ?`, user.ID) + }) + return e.storedUser(t, user.ID) +} + +// login signs login in with the seed password and returns the bearer token. +func (e *screensEnv) login(t *testing.T, login string) string { + t.Helper() + return e.loginWith(t, login, adminTestPassword) +} + +func (e *screensEnv) loginWith(t *testing.T, login, password string) string { + t.Helper() + rec := postJSON(t, e.h, adminAPI("/auth/login"), map[string]string{"login": login, "password": password}) + if rec.Code != http.StatusOK { + t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String()) + } + return accessToken(t, rec.Body.Bytes()) +} + +// admin seeds an activated administrator named name with an empty role of +// its own, or a role holding rolePermissions JSON when it is not empty, +// optionally a superuser, signs it in and returns it with its token. +func (e *screensEnv) admin(t *testing.T, name string, superuser bool, rolePermissions string) (cabana.BackendUser, string) { + t.Helper() + seed := adminSeed{Name: name, Superuser: superuser, Developer: rolePermissions == ""} + if rolePermissions != "" { + if err := json.Unmarshal([]byte(rolePermissions), &seed.RolePermissions); err != nil { + t.Fatal(err) + } + } + user := e.seedAdmin(t, seed) + return user, e.login(t, user.Login) +} + +// isolateSuperusers deactivates every other active superuser for the test, +// so keep are the only ones, and activates them again in t.Cleanup. +func (e *screensEnv) isolateSuperusers(t *testing.T, keep ...uint) { + t.Helper() + keep = append(keep, 0) + var ids []uint + if err := e.gdb.Raw(`UPDATE backend_users SET is_activated = FALSE + WHERE is_superuser AND is_activated AND deleted_at IS NULL AND id NOT IN ? + RETURNING id`, keep).Scan(&ids).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if len(ids) > 0 { + e.gdb.Exec(`UPDATE backend_users SET is_activated = TRUE WHERE id IN ?`, ids) + } + }) +} + +// request builds one admin API request with a Bearer token, JSON body, +// Accept-Language en and, on writes, X-Requested-With. +func (e *screensEnv) request(method, rel, token string, body any) (*http.Request, error) { + var raw []byte + if body != nil { + var err error + if raw, err = json.Marshal(body); err != nil { + return nil, err + } + } + req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(raw)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept-Language", "en") + if method != http.MethodGet && method != http.MethodHead { + req.Header.Set("X-Requested-With", "XMLHttpRequest") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req, nil +} + +// serve runs one request without a *testing.T, for goroutines. +func (e *screensEnv) serve(method, rel, token string, body any) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req, err := e.request(method, rel, token, body) + if err != nil { + rec.Code = http.StatusTeapot + rec.Body.WriteString(err.Error()) + return rec + } + e.h.ServeHTTP(rec, req) + return rec +} + +// call runs one admin API request with a Bearer token. +func (e *screensEnv) call(t *testing.T, method, rel, token string, body any) *httptest.ResponseRecorder { + t.Helper() + req, err := e.request(method, rel, token, body) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +func (e *screensEnv) expect(t *testing.T, status int, method, rel, token string, body any) map[string]any { + t.Helper() + rec := e.call(t, method, rel, token, body) + if rec.Code != status { + t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("%s %s body %s: %v", method, rel, rec.Body.String(), err) + } + return out +} + +// errorEnvelope decodes a failure envelope's code, message and details. +func errorEnvelope(t *testing.T, rec *httptest.ResponseRecorder) (string, string, map[string]any) { + t.Helper() + var body struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("error body %s: %v", rec.Body.String(), err) + } + return body.Error.Code, body.Error.Message, body.Error.Details +} + +// errorOf returns a failure envelope's message and details. +func errorOf(t *testing.T, rec *httptest.ResponseRecorder) (string, map[string]any) { + t.Helper() + _, message, details := errorEnvelope(t, rec) + return message, details +} + +// expectRefusal asserts a status and that the error message contains want. +func (e *screensEnv) expectRefusal(t *testing.T, status int, method, rel, token string, body any, want string) map[string]any { + t.Helper() + rec := e.call(t, method, rel, token, body) + if rec.Code != status { + t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) + } + message, details := errorOf(t, rec) + if want != "" && !strings.Contains(message, want) { + t.Fatalf("%s %s message=%q want %q (details %v)", method, rel, message, want, details) + } + return details +} + +// expectFieldRefusal asserts a status and that details[field] carries want. +// A non-empty message must carry it too when inMessage is set. +func (e *screensEnv) expectFieldRefusal(t *testing.T, status int, method, rel, token string, body any, field, want string, inMessage bool) { + t.Helper() + rec := e.call(t, method, rel, token, body) + if rec.Code != status { + t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) + } + _, message, details := errorEnvelope(t, rec) + if !detailHas(details, field, want) { + t.Fatalf("%s %s details[%s]=%v want %q (message %q)", method, rel, field, details[field], want, message) + } + if inMessage && message != want { + t.Fatalf("%s %s message=%q want %q", method, rel, message, want) + } +} + +// detailHas reports whether details[field] lists a text containing want. +func detailHas(details map[string]any, field, want string) bool { + list, _ := details[field].([]any) + for _, item := range list { + if text, ok := item.(string); ok && strings.Contains(text, want) { + return true + } + } + return false +} + +func (e *screensEnv) storedUser(t *testing.T, id uint) cabana.BackendUser { + t.Helper() + var user cabana.BackendUser + if err := e.gdb.Unscoped().First(&user, id).Error; err != nil { + t.Fatal(err) + } + return user +} + +// userRow is the whole backend_users row as JSON text, for byte-for-byte +// "unchanged" checks. +func (e *screensEnv) userRow(t *testing.T, id uint) string { + t.Helper() + var row string + if err := e.gdb.Raw(`SELECT row_to_json(u)::text FROM backend_users u WHERE id = ?`, id).Scan(&row).Error; err != nil || row == "" { + t.Fatalf("row %d: %q %v", id, row, err) + } + return row +} + +// roleRow is the whole backend_user_roles row as JSON text. +func (e *screensEnv) roleRow(t *testing.T, id uint) string { + t.Helper() + var row string + if err := e.gdb.Raw(`SELECT row_to_json(r)::text FROM backend_user_roles r WHERE id = ?`, id).Scan(&row).Error; err != nil || row == "" { + t.Fatalf("role row %d: %q %v", id, row, err) + } + return row +} + +// unchanged fails when the row of id differs from before. +func (e *screensEnv) unchanged(t *testing.T, id uint, before, what string) { + t.Helper() + if after := e.userRow(t, id); after != before { + t.Fatalf("%s changed the row:\nbefore %s\nafter %s", what, before, after) + } +} + +// userURL is the record path of an administrator. +func userURL(id uint, tail ...string) string { + path := fmt.Sprintf("%s/%d", usersPath, id) + for _, part := range tail { + path += "/" + part + } + return path +} + +// listIDs returns the ids of a list response's rows. +func listIDs(t *testing.T, body map[string]any) map[uint]bool { + t.Helper() + rows, _ := body["data"].([]any) + out := map[uint]bool{} + for _, row := range rows { + fields, _ := row.(map[string]any) + if id, ok := fields["id"].(float64); ok { + out[uint(id)] = true + } + } + return out +} + +// recordActionNames returns the names of a show response's meta.actions. +func recordActionNames(body map[string]any) []string { + meta, _ := body["meta"].(map[string]any) + actions, _ := meta["actions"].([]any) + out := []string{} + for _, action := range actions { + if entry, ok := action.(map[string]any); ok { + name, _ := entry["name"].(string) + out = append(out, name) + } + } + return out +} + +// dataOf is a data envelope's data object. +func dataOf(body map[string]any) map[string]any { + data, _ := body["data"].(map[string]any) + return data +} + +// schemaFields returns a form schema response's fields keyed by name. +func schemaFields(body map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + fields, _ := dataOf(body)["fields"].([]any) + for _, item := range fields { + if field, ok := item.(map[string]any); ok { + name, _ := field["name"].(string) + out[name] = field + } + } + return out +} + +// permissionLocked returns the permission editor field's options as code to +// Locked. +func permissionLocked(field map[string]any) map[string]bool { + out := map[string]bool{} + options, _ := field["permissionOptions"].([]any) + for _, item := range options { + option, _ := item.(map[string]any) + code, _ := option["code"].(string) + locked, _ := option["locked"].(bool) + out[code] = locked + } + return out +} diff --git a/modules/cabana/administrators_postgres_test.go b/modules/cabana/administrators_postgres_test.go new file mode 100644 index 00000000..e10b7faa --- /dev/null +++ b/modules/cabana/administrators_postgres_test.go @@ -0,0 +1,92 @@ +package cabana_test + +import ( + "net/http" + "testing" + + "git.golem15.com/golem15/summercms/modules/cabana" +) + +// manageUsers is the role grant of a delegated administrator. +var manageUsers = map[string]int{cabana.PermissionManageUsers: 1} + +// newAdminBody is a create body with a valid, confirmed password. +func newAdminBody(login string, extra map[string]any) map[string]any { + body := map[string]any{ + "login": login, "email": login + "@example.test", "first_name": "New", "last_name": "Admin", + "password": "pass-1234", "password_confirmation": "pass-1234", + } + for key, value := range extra { + body[key] = value + } + return body +} + +// cleanupLogin soft-deletes and demotes an administrator a test created +// through the API, by login. +func (e *screensEnv) cleanupLogin(t *testing.T, login string) { + t.Helper() + t.Cleanup(func() { + e.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE, deleted_at = COALESCE(deleted_at, NOW()) WHERE login = ?`, login) + }) +} + +// countLogin counts the administrators with login, deleted ones included. +func (e *screensEnv) countLogin(t *testing.T, login string) int64 { + t.Helper() + var n int64 + if err := e.gdb.Raw(`SELECT count(*) FROM backend_users WHERE login = ?`, login).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// TestAdministratorsSuperuserFlag (D-05): only an administrator acting as a +// superuser may send the superuser switch, on create and on update, whatever +// its value; a superuser grants and removes the flag. +func TestAdministratorsSuperuserFlag(t *testing.T) { + env := newScreensEnv(t) + super := env.seedAdmin(t, adminSeed{Name: "flag-super", Superuser: true}) + superToken := env.login(t, super.Login) + delegate := env.seedAdmin(t, adminSeed{Name: "flag-delegate", RolePermissions: manageUsers}) + delegateToken := env.login(t, delegate.Login) + target := env.seedAdmin(t, adminSeed{Name: "flag-target"}) + + for _, flag := range []bool{true, false} { + login := env.unique("flag-create") + env.cleanupLogin(t, login) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPost, usersPath, delegateToken, + newAdminBody(login, map[string]any{"is_superuser": flag}), "is_superuser", enSuperuserForbidden, true) + if n := env.countLogin(t, login); n != 0 { + t.Fatalf("a refused create (is_superuser=%v) left %d rows", flag, n) + } + + before := env.userRow(t, target.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken, + map[string]any{"login": target.Login, "email": target.Email, "first_name": "Changed", "is_superuser": flag}, + "is_superuser", enSuperuserForbidden, true) + env.unchanged(t, target.ID, before, "a refused is_superuser update") + } + + // A superuser grants the flag on create... + login := env.unique("flag-granted") + env.cleanupLogin(t, login) + created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, superToken, + newAdminBody(login, map[string]any{"is_superuser": true})) + id := uint(dataOf(created)["id"].(float64)) + if got := env.storedUser(t, id); !got.IsSuperuser { + t.Fatalf("created superuser = %+v", got) + } + // ...and removes it from that other superuser through update. + env.expect(t, http.StatusOK, http.MethodPut, userURL(id), superToken, + map[string]any{"login": login, "email": login + "@example.test", "is_superuser": false}) + if got := env.storedUser(t, id); got.IsSuperuser { + t.Fatal("the superuser flag was not removed") + } + // The same superuser grants it on update. + env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), superToken, + map[string]any{"login": target.Login, "email": target.Email, "is_superuser": true}) + if got := env.storedUser(t, target.ID); !got.IsSuperuser { + t.Fatal("the superuser flag was not granted on update") + } +} diff --git a/modules/cabana/backend_screens_smoke_test.go b/modules/cabana/backend_screens_smoke_test.go index 82a86a42..b447eca9 100644 --- a/modules/cabana/backend_screens_smoke_test.go +++ b/modules/cabana/backend_screens_smoke_test.go @@ -1,219 +1,15 @@ package cabana_test import ( - "bytes" - "context" - "encoding/json" - "errors" "fmt" "net/http" - "net/http/httptest" - "os" - "path/filepath" "strings" - "sync" "testing" - "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" ) -// pingSpy records the runs of the contributed acme_ping record action. -type pingSpy struct { - mu sync.Mutex - ids []uint64 - withTx []bool -} - -func (s *pingSpy) record(id uint64, tx bool) { - s.mu.Lock() - defer s.mu.Unlock() - s.ids = append(s.ids, id) - s.withTx = append(s.withTx, tx) -} - -func (s *pingSpy) runs() ([]uint64, []bool) { - s.mu.Lock() - defer s.mu.Unlock() - return append([]uint64(nil), s.ids...), append([]bool(nil), s.withTx...) -} - -// pingPlugin owns no admin controller; it only adds the acme_ping record -// action to the Administrators screen (pact.HasAdminActionExtensions). -type pingPlugin struct{ spy *pingSpy } - -func (pingPlugin) ID() string { return "acme.pinger" } -func (pingPlugin) Requires() []string { return nil } -func (pingPlugin) Register(*backpack.App) error { return nil } -func (pingPlugin) Boot(*backpack.App) error { return nil } -func (pingPlugin) Permissions() []pact.Permission { - return []pact.Permission{{Code: "acme.pinger.ping"}} -} -func (p pingPlugin) AdminActionExtensions() []pact.AdminActionExtension { - return []pact.AdminActionExtension{{ - Controller: cabana.AdministratorsControllerID, - RecordActions: []pact.AdminRecordAction{{ - Name: "acme_ping", Label: "Ping", Permissions: []string{"acme.pinger.ping"}, - Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { - _, ok := cabana.TxFromContext(ctx) - if _, isUser := in.Record.(*cabana.BackendUser); !isUser { - return pact.AdminRecordActionResult{}, errors.New("acme_ping: record is not a backend user") - } - p.spy.record(in.RecordID, ok) - return pact.AdminRecordActionResult{Message: "Pinged"}, nil - }, - }}, - }} -} - -// screensEnv is one assembled admin with the demo plugin, the pinger and the -// admin's own screens, on the shared Postgres. -type screensEnv struct { - h http.Handler - gdb *gorm.DB - stamp string - spy *pingSpy -} - -func newScreensEnv(t *testing.T) *screensEnv { - t.Helper() - gdb := adminGorm(t) - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-screens\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, "admin.password.bcrypt_cost": 4} { - 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 := &pingSpy{} - plugins := []party.Plugin{demoPlugin{fsys: demoFS()}, pingPlugin{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) - } - return &screensEnv{h: h, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy} -} - -// admin creates an activated administrator named name+stamp with the -// developer role (or a role holding rolePermissions JSON when it is not -// empty), optionally a superuser, signs it in and returns it with its token. -func (e *screensEnv) admin(t *testing.T, name string, superuser bool, rolePermissions string) (cabana.BackendUser, string) { - t.Helper() - login := name + "-" + e.stamp - user := insertAdmin(t, e.gdb, login, login+"@example.test", adminTestPassword, true, false) - if superuser { - if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, user.ID).Error; err != nil { - t.Fatal(err) - } - user.IsSuperuser = true - } - if rolePermissions != "" { - var roleID uint - if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) - VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Role "+login, "role-"+login, rolePermissions).Scan(&roleID).Error; err != nil || roleID == 0 { - t.Fatalf("role: id=%d err=%v", roleID, err) - } - if err := e.gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, user.ID).Error; err != nil { - t.Fatal(err) - } - user.RoleID = &roleID - } - rec := postJSON(t, e.h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword}) - if rec.Code != http.StatusOK { - t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String()) - } - return user, accessToken(t, rec.Body.Bytes()) -} - -// call runs one admin API request with a Bearer token. -func (e *screensEnv) call(t *testing.T, method, rel, token string, body any) *httptest.ResponseRecorder { - t.Helper() - var reader *bytes.Reader - if body != nil { - raw, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - reader = bytes.NewReader(raw) - } else { - reader = bytes.NewReader(nil) - } - req := httptest.NewRequest(method, adminAPI(rel), reader) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("Accept-Language", "en") - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - e.h.ServeHTTP(rec, req) - return rec -} - -func (e *screensEnv) expect(t *testing.T, status int, method, rel, token string, body any) map[string]any { - t.Helper() - rec := e.call(t, method, rel, token, body) - if rec.Code != status { - t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) - } - var out map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { - t.Fatalf("%s %s body %s: %v", method, rel, rec.Body.String(), err) - } - return out -} - -// listIDs returns the ids of a list response's rows. -func listIDs(t *testing.T, body map[string]any) map[uint]bool { - t.Helper() - rows, _ := body["data"].([]any) - out := map[uint]bool{} - for _, row := range rows { - fields, _ := row.(map[string]any) - if id, ok := fields["id"].(float64); ok { - out[uint(id)] = true - } - } - return out -} - -// recordActionNames returns the names of a show response's meta.actions. -func recordActionNames(body map[string]any) []string { - meta, _ := body["meta"].(map[string]any) - actions, _ := meta["actions"].([]any) - out := []string{} - for _, action := range actions { - if entry, ok := action.(map[string]any); ok { - name, _ := entry["name"].(string) - out = append(out, name) - } - } - return out -} - -const usersPath = "/summercms/cabana/users" - func TestBackendScreensPostgresSmoke(t *testing.T) { env := newScreensEnv(t) root, rootToken := env.admin(t, "screens-root", true, "") @@ -260,44 +56,6 @@ func TestBackendScreensPostgresSmoke(t *testing.T) { } } -// errorOf returns a failure envelope's message and details. -func errorOf(t *testing.T, rec *httptest.ResponseRecorder) (string, map[string]any) { - t.Helper() - var body struct { - Error struct { - Message string `json:"message"` - Details map[string]any `json:"details"` - } `json:"error"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("error body %s: %v", rec.Body.String(), err) - } - return body.Error.Message, body.Error.Details -} - -// expectRefusal asserts a status and that the error message contains want. -func (e *screensEnv) expectRefusal(t *testing.T, status int, method, rel, token string, body any, want string) map[string]any { - t.Helper() - rec := e.call(t, method, rel, token, body) - if rec.Code != status { - t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) - } - message, details := errorOf(t, rec) - if want != "" && !strings.Contains(message, want) { - t.Fatalf("%s %s message=%q want %q (details %v)", method, rel, message, want, details) - } - return details -} - -func (e *screensEnv) storedUser(t *testing.T, id uint) cabana.BackendUser { - t.Helper() - var user cabana.BackendUser - if err := e.gdb.Unscoped().First(&user, id).Error; err != nil { - t.Fatal(err) - } - return user -} - func TestAdministratorsFormSmoke(t *testing.T) { env := newScreensEnv(t) root, rootToken := env.admin(t, "form-root", true, "") @@ -478,8 +236,6 @@ func TestAdministratorsLifecycleSmoke(t *testing.T) { } } -const rolesPath = "/summercms/cabana/roles" - func TestRolesSmoke(t *testing.T) { env := newScreensEnv(t) _, rootToken := env.admin(t, "roles-root", true, "") diff --git a/modules/cabana/export_test.go b/modules/cabana/export_test.go index 48762800..8ab8214d 100644 --- a/modules/cabana/export_test.go +++ b/modules/cabana/export_test.go @@ -102,3 +102,8 @@ func IsReorderStale(err error) bool { var stale reorderStale return errors.As(err, &stale) } + +// SuperuserLockKey is the advisory lock key guardLastSuperuser takes. The +// last-superuser race tests hold it in a transaction of their own, so both +// racing requests queue on it before either counts the other superusers. +const SuperuserLockKey = superuserLockKey