From 4e17c0d35bd7a90071c49a2874b7c1b4651d9f3c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 13:01:59 +0200 Subject: [PATCH] test: cover the Users admin list, update and the privileged-member guard - admin harness: boots the plugin with the admin API mounted and mints backend administrators with chosen permissions - TestAdminUsersTracer and TestAdminPrivilegedMember --- admin_harness_test.go | 438 ++++++++++++++++++++++++++++++++++++++++++ admin_users_test.go | 384 ++++++++++++++++++++++++++++++++++++ 2 files changed, 822 insertions(+) create mode 100644 admin_harness_test.go create mode 100644 admin_users_test.go diff --git a/admin_harness_test.go b/admin_harness_test.go new file mode 100644 index 0000000..1fc157b --- /dev/null +++ b/admin_harness_test.go @@ -0,0 +1,438 @@ +package user + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "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/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + "gocloud.dev/blob" + "gocloud.dev/blob/memblob" + "gorm.io/gorm" +) + +// The admin test harness boots the plugin the way an application does +// (party.Activate, lagoon.Migrate, surf.Assemble) with the admin API mounted, +// on its own database in the session container, and mints backend +// administrators with a chosen permission set. +// +// env := newAdminEnv(t) +// token := env.admin(t, permAccessUsers) +// rec := env.call(t, http.MethodGet, adminAPI("/golem15/user/users"), token, nil) + +const ( + // adminUsersPath is the Users controller under the admin API. + adminUsersPath = "/golem15/user/users" + + permAccessUsers = "golem15.users.access_users" + permManagePrivileged = "golem15.users.manage_privileged_groups" +) + +var ( + adminHarnessOnce sync.Once + adminHarnessGDB *gorm.DB + adminHarnessSQL *sql.DB + adminHarnessErr error + // adminSeq makes logins, emails and codes unique across the tests that + // share the harness database. + adminSeq atomic.Uint64 +) + +// adminEnv is one booted application: the assembled handler, its app, the +// shared harness database and the in-memory blob bucket. +type adminEnv struct { + h http.Handler + app *backpack.App + gdb *gorm.DB + bucket *blob.Bucket +} + +// adminAPI builds a path under the admin API prefix. +func adminAPI(rel string) string { + return cabana.DefaultAdminPrefix + "/api/v1" + rel +} + +// adminStamp returns a token unique within the test binary, for emails, +// logins and group codes. +func adminStamp() string { + return fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1)) +} + +// adminHarnessDB opens (once) the harness database: a second database in the +// session container, migrated by lagoon.Migrate on the first boot. +func adminHarnessDB(t *testing.T) (*gorm.DB, *sql.DB) { + t.Helper() + if testing.Short() { + t.Skip("requires testcontainers postgres") + } + _, admin := sessionDB(t) + adminHarnessOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + const name = "user_admin" + if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+name+` TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`); err != nil { + adminHarnessErr = err + return + } + u, err := url.Parse(sessionDSN) + if err != nil { + adminHarnessErr = err + return + } + u.Path = "/" + name + db, err := sql.Open("pgx", u.String()) + if err != nil { + adminHarnessErr = err + return + } + gdb, err := lagoon.Use(ctx, db) + if err != nil { + adminHarnessErr = err + return + } + adminHarnessSQL, adminHarnessGDB = db, gdb + }) + if adminHarnessErr != nil { + t.Fatal(adminHarnessErr) + } + return adminHarnessGDB, adminHarnessSQL +} + +// newAdminEnv boots the plugin alone with the admin API mounted. configure +// functions adjust the config before the plugin is activated. +func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv { + t.Helper() + gdb, sqlDB := adminHarnessDB(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-admin\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: []string{ + "SUMMER_ENV=development", + "SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret, + "SUMMER_ADMIN__JWT__SECRET=" + adminHarnessSecret, + }, + }) + if err != nil { + t.Fatal(err) + } + for key, value := range map[string]any{ + "http.body_limits.default_bytes": 1048576, + "http.body_limits.upload_bytes": 8388608, + } { + if err := cfg.Set(key, value); err != nil { + t.Fatal(err) + } + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + t.Fatal(err) + } + bucket := memblob.OpenBucket(nil) + t.Cleanup(func() { _ = bucket.Close() }) + if err := attach.Publish(app, bucket); err != nil { + t.Fatal(err) + } + // The registered plugin value is shared by every boot in this binary; + // its blacklist sweep is started at most once and is harmless here. + plugins, err := party.Activate(app, []string{"golem15.user"}) + if err != nil { + t.Fatal(err) + } + // Overrides go in after the plugin's own config defaults were merged. + for _, fn := range configure { + fn(app.Config) + } + if err := lagoon.Migrate(gdb, plugins); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket} +} + +// admin inserts a backend role holding exactly permissions and a backend +// user in it, logs the user in and returns the bearer token. No permission +// at all gives an administrator who may sign in and open nothing. +func (e *adminEnv) admin(t *testing.T, permissions ...string) string { + t.Helper() + stamp := adminStamp() + login := "admin-" + stamp + password := "admin-secret-" + stamp + hash, err := bouncer.HashPassword(10, password) + if err != nil { + t.Fatal(err) + } + grants := map[string]int{} + for _, code := range permissions { + grants[code] = 1 + } + raw, err := json.Marshal(grants) + if err != nil { + t.Fatal(err) + } + 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 "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("backend role: id=%d err=%v", roleID, err) + } + if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, + login, login+"@example.test", hash, roleID).Error; err != nil { + t.Fatal(err) + } + rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password}) + if rec.Code != http.StatusOK { + t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + AccessToken string `json:"access_token"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" { + t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err) + } + return body.Data.AccessToken +} + +// call sends one request through the assembled handler. body is JSON-encoded +// unless it is nil; token, when set, is sent as a bearer token. The request +// locale is English and each call comes from its own address, so the login +// throttle never trips inside one test. +func (e *adminEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, path, reader) + req.Host = "example.com" + req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1) + req.Header.Set("Accept-Language", "en") + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +// expect is call plus a status assertion. +func (e *adminEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + rec := e.call(t, method, path, token, body) + if rec.Code != status { + t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String()) + } + return rec +} + +// adminEnvelope is the admin API answer: data and meta on success, error +// otherwise. +type adminEnvelope struct { + Data json.RawMessage `json:"data"` + Meta json.RawMessage `json:"meta"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string][]string `json:"details"` + } `json:"error"` +} + +// adminDecode parses an admin API answer. +func adminDecode(t *testing.T, rec *httptest.ResponseRecorder) adminEnvelope { + t.Helper() + var env adminEnvelope + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("admin body %q: %v", rec.Body.String(), err) + } + return env +} + +// adminInto decodes the data of an admin API answer into out. +func adminInto(t *testing.T, rec *httptest.ResponseRecorder, out any) adminEnvelope { + t.Helper() + env := adminDecode(t, rec) + if err := json.Unmarshal(env.Data, out); err != nil { + t.Fatalf("admin data %q: %v", string(env.Data), err) + } + return env +} + +// adminUserList is a decoded Users list page. +type adminUserList struct { + Rows []map[string]any + RowStates map[string][]string + Total int + Raw string +} + +// has reports whether the page holds the user. +func (l adminUserList) has(id uint) bool { return l.row(id) != nil } + +// row returns the list row of the user, or nil. +func (l adminUserList) row(id uint) map[string]any { + for _, row := range l.Rows { + if n, ok := row["id"].(float64); ok && uint(n) == id { + return row + } + } + return nil +} + +// states returns the row states of the user. +func (l adminUserList) states(id uint) []string { + return l.RowStates[strconv.FormatUint(uint64(id), 10)] +} + +// users lists the Users screen with a raw query string (without the leading +// question mark) and expects 200. +func (e *adminEnv) users(t *testing.T, token, query string) adminUserList { + t.Helper() + path := adminAPI(adminUsersPath) + if query != "" { + path += "?" + query + } + rec := e.expect(t, http.StatusOK, http.MethodGet, path, token, nil) + out := adminUserList{Raw: rec.Body.String()} + env := adminInto(t, rec, &out.Rows) + var meta struct { + Total int `json:"total"` + RowStates map[string][]string `json:"row_states"` + } + if err := json.Unmarshal(env.Meta, &meta); err != nil { + t.Fatalf("list meta %q: %v", string(env.Meta), err) + } + out.RowStates, out.Total = meta.RowStates, meta.Total + return out +} + +// userPath is the admin API path of one user, with an optional suffix such +// as "/actions/activate". +func userPath(id uint, suffix string) string { + return adminAPI(adminUsersPath) + "/" + strconv.FormatUint(uint64(id), 10) + suffix +} + +// seedUser stores a frontend user with the password correct-horse. The email +// is unique; mutate adjusts the row before it is inserted. +func (e *adminEnv) seedUser(t *testing.T, label string, mutate func(*models.User)) *models.User { + t.Helper() + hash, err := bouncer.HashPassword(10, "correct-horse") + if err != nil { + t.Fatal(err) + } + name := label + user := &models.User{Email: label + "-" + adminStamp() + "@example.test", Password: hash, Name: &name, IsActivated: true} + if mutate != nil { + mutate(user) + } + if err := e.gdb.Omit("Groups").Create(user).Error; err != nil { + t.Fatal(err) + } + return user +} + +// seedGroup stores a user group; an empty code stores a group without one. +func (e *adminEnv) seedGroup(t *testing.T, name, code string) *models.UserGroup { + t.Helper() + group := &models.UserGroup{Name: name} + if code != "" { + group.Code = &code + } + if err := e.gdb.Create(group).Error; err != nil { + t.Fatal(err) + } + return group +} + +// join puts the user into the groups by writing users_groups directly. +func (e *adminEnv) join(t *testing.T, user *models.User, groups ...*models.UserGroup) { + t.Helper() + for _, group := range groups { + if err := e.gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, user.ID, group.ID).Error; err != nil { + t.Fatal(err) + } + } +} + +// reload reads a user row, deactivated or not; nil when it is gone. +func (e *adminEnv) reload(t *testing.T, id uint) *models.User { + t.Helper() + var user models.User + err := e.gdb.Unscoped().Where("id = ?", id).Take(&user).Error + if err == gorm.ErrRecordNotFound { + return nil + } + if err != nil { + t.Fatal(err) + } + return &user +} + +// groupIDs returns the user's users_groups rows, ordered. +func (e *adminEnv) groupIDs(t *testing.T, userID uint) []uint { + t.Helper() + ids := []uint{} + if err := e.gdb.Raw(`SELECT user_group_id FROM users_groups WHERE user_id = ? ORDER BY user_group_id`, userID).Scan(&ids).Error; err != nil { + t.Fatal(err) + } + return ids +} + +// userLogin signs a frontend user in through the user API route and returns +// the status and the token. +func (e *adminEnv) userLogin(t *testing.T, email, password string) (int, string) { + t.Helper() + rec := e.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": email, "password": password}) + var body struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &body) + return rec.Code, body.Token +} + +// assertNoSecret fails when an admin response carries a password key, a +// stored hash or the given plain text. +func assertNoSecret(t *testing.T, raw string, plain ...string) { + t.Helper() + lower := strings.ToLower(raw) + if strings.Contains(lower, `"password`) || strings.Contains(raw, "$2a$") || strings.Contains(raw, "$2y$") { + t.Fatalf("admin response carries a password: %s", raw) + } + for _, secret := range plain { + if secret != "" && strings.Contains(raw, secret) { + t.Fatalf("admin response carries a secret value: %s", raw) + } + } +} diff --git a/admin_users_test.go b/admin_users_test.go new file mode 100644 index 0000000..199dedb --- /dev/null +++ b/admin_users_test.go @@ -0,0 +1,384 @@ +package user + +import ( + "net/http" + "reflect" + "strconv" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/compass" +) + +const ( + privilegedMemberForbidden = "This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved." + privilegedMemberDeleteForbidden = "Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted." +) + +// TestAdminUsersTracer drives the Users screen end to end: navigation and +// permission gating, the list schema, row states, search, the three filters +// and an update. +func TestAdminUsersTracer(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stranger := env.admin(t) + + // Navigation lists the item only for an administrator who may open it. + var nav []struct { + Code string `json:"code"` + Order int `json:"order"` + Controller string `json:"controller"` + SideMenu []struct { + Code string `json:"code"` + } `json:"sideMenu"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), token, nil), &nav) + if len(nav) != 1 || nav[0].Code != "user" || nav[0].Order != 555 || nav[0].Controller != "golem15.user.users" || + len(nav[0].SideMenu) != 1 || nav[0].SideMenu[0].Code != "users" { + t.Fatalf("navigation = %+v", nav) + } + nav = nil + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), stranger, nil), &nav) + if len(nav) != 0 { + t.Fatalf("navigation without the permission = %+v", nav) + } + for _, path := range []string{adminAPI(adminUsersPath), adminAPI(adminUsersPath + "/schema/list"), adminAPI(adminUsersPath + "/schema/form")} { + rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, stranger, nil) + if code := adminDecode(t, rec).Error.Code; code != "forbidden" { + t.Fatalf("%s code = %q", path, code) + } + } + env.expect(t, http.StatusUnauthorized, http.MethodGet, adminAPI(adminUsersPath), "", nil) + + // The list schema: exactly the ported columns and filters. There is no + // login-name column (D-18) and no guest column (D-03). + var schema struct { + RecordURL string `json:"recordUrl"` + Columns []struct { + Key string `json:"key"` + Invisible bool `json:"invisible"` + Searchable bool `json:"searchable"` + Type string `json:"type"` + } `json:"columns"` + Filters []struct { + Name string `json:"name"` + Type string `json:"type"` + Scope string `json:"scope"` + Column string `json:"column"` + } `json:"filters"` + Messages map[string]map[string]string `json:"messages"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/list"), token, nil), &schema) + var columns, invisible, searchable, filters []string + for _, column := range schema.Columns { + columns = append(columns, column.Key) + if column.Invisible { + invisible = append(invisible, column.Key) + } + if column.Searchable { + searchable = append(searchable, column.Key) + } + } + for _, filter := range schema.Filters { + filters = append(filters, filter.Name+":"+filter.Type+":"+filter.Scope+filter.Column) + } + if want := []string{"id", "name", "surname", "email", "created_at", "last_seen", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(columns, want) { + t.Fatalf("columns = %v, want %v", columns, want) + } + if want := []string{"id", "surname", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(invisible, want) { + t.Fatalf("invisible columns = %v, want %v", invisible, want) + } + if want := []string{"name", "surname", "email", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(searchable, want) { + t.Fatalf("searchable columns = %v, want %v", searchable, want) + } + if want := []string{"groups:scope:filterByGroup", "created_date:daterange:created_at", "activated:switch:is_activated"}; !reflect.DeepEqual(filters, want) { + t.Fatalf("filters = %v, want %v", filters, want) + } + for key, want := range map[string]string{ + "empty": "No users match this view. Clear the filters, or add one with New User.", + "rowStateDeleted": "Deactivated", + "rowStateNegative": "Banned", + "rowStateDisabled": "Not activated", + } { + if got := schema.Messages[key]["other"]; got != want { + t.Fatalf("list message %s = %q", key, got) + } + } + + // Seed: every user of this test shares one marker in the surname, which + // is searchable and not shown. + marker := "tracer" + adminStamp() + seed := func(label string, mutate func(*models.User)) *models.User { + return env.seedUser(t, label, func(u *models.User) { + u.Surname = &marker + if mutate != nil { + mutate(u) + } + }) + } + active := seed("active", nil) + deactivated := seed("deactivated", nil) + banned := seed("banned", nil) + inactive := seed("inactive", func(u *models.User) { u.IsActivated = false }) + old := seed("old", nil) + if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, deactivated.ID).Error; err != nil { + t.Fatal(err) + } + if err := env.gdb.Exec(`INSERT INTO user_throttle (user_id, ip_address, is_banned) VALUES (?, NULL, TRUE)`, banned.ID).Error; err != nil { + t.Fatal(err) + } + if err := env.gdb.Exec(`UPDATE users SET created_at = '2020-06-15 12:00:00+00' WHERE id = ?`, old.ID).Error; err != nil { + t.Fatal(err) + } + group := env.seedGroup(t, "Tracer group "+marker, "tracer-"+marker) + env.join(t, active, group) + env.join(t, deactivated, group) + + // Search by the invisible surname finds all five, deactivated included, + // with their row states; the rows carry no surname and no secret. + page := env.users(t, token, "search="+marker) + if page.Total != 5 { + t.Fatalf("search total = %d: %s", page.Total, page.Raw) + } + for _, user := range []*models.User{active, deactivated, banned, inactive, old} { + row := page.row(user.ID) + if row == nil { + t.Fatalf("user %d missing from the list: %s", user.ID, page.Raw) + } + for _, hidden := range []string{"surname", "created_ip_address", "last_ip_address", "permissions", "is_activated"} { + if _, ok := row[hidden]; ok { + t.Fatalf("list row carries %s: %v", hidden, row) + } + } + for _, shown := range []string{"id", "name", "email", "created_at", "last_seen"} { + if _, ok := row[shown]; !ok { + t.Fatalf("list row lacks %s: %v", shown, row) + } + } + } + assertNoSecret(t, page.Raw) + for id, want := range map[uint][]string{ + active.ID: nil, + deactivated.ID: {"deleted"}, + banned.ID: {"negative"}, + inactive.ID: {"disabled"}, + } { + if got := page.states(id); !reflect.DeepEqual(got, want) { + t.Fatalf("row states of %d = %v, want %v", id, got, want) + } + } + + // The groups filter keeps the group's members; its choices are the groups. + gid := strconv.FormatUint(uint64(group.ID), 10) + members := env.users(t, token, "search="+marker+"&filter[groups]="+gid) + if members.Total != 2 || !members.has(active.ID) || !members.has(deactivated.ID) { + t.Fatalf("group filter = %s", members.Raw) + } + var options []struct { + Value string `json:"value"` + Label string `json:"label"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/filters/groups/options"), token, nil), &options) + found := false + for _, option := range options { + if option.Value == gid && option.Label == group.Name { + found = true + } + } + if !found { + t.Fatalf("group %s missing from the filter options: %+v", gid, options) + } + env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath+"/filters/groups/options"), stranger, nil) + + // The date range and the activated switch. + ranged := env.users(t, token, "search="+marker+"&filter[created_date]=2020-06-01..2020-06-30") + if ranged.Total != 1 || !ranged.has(old.ID) { + t.Fatalf("date range filter = %s", ranged.Raw) + } + notActivated := env.users(t, token, "search="+marker+"&filter[activated]=false") + if notActivated.Total != 1 || !notActivated.has(inactive.ID) { + t.Fatalf("activated=false filter = %s", notActivated.Raw) + } + activated := env.users(t, token, "search="+marker+"&filter[activated]=true") + if activated.Total != 4 || activated.has(inactive.ID) { + t.Fatalf("activated=true filter = %s", activated.Raw) + } + + // An update of name and email persists and returns no secret. + renamed := "renamed-" + marker + "@example.test" + rec := env.expect(t, http.StatusOK, http.MethodPut, userPath(active.ID, ""), token, map[string]any{"name": "Renamed", "email": renamed}) + assertNoSecret(t, rec.Body.String()) + if got := env.reload(t, active.ID); got.Email != renamed || got.Name == nil || *got.Name != "Renamed" { + t.Fatalf("update did not persist: %+v", got) + } + // The stored hash survives an update that does not name a password. + if got := env.reload(t, active.ID); got.Password != active.Password { + t.Fatal("an update without a password changed the stored hash") + } + // A name-only update needs no password (the admin rules replace the + // register rules). + env.expect(t, http.StatusOK, http.MethodPut, userPath(active.ID, ""), token, map[string]any{"name": "Renamed again", "email": renamed}) + + // A deactivated user can be shown and updated and stays deactivated. + show := env.expect(t, http.StatusOK, http.MethodGet, userPath(deactivated.ID, ""), token, nil) + assertNoSecret(t, show.Body.String()) + before := env.reload(t, deactivated.ID) + env.expect(t, http.StatusOK, http.MethodPut, userPath(deactivated.ID, ""), token, map[string]any{"name": "Still gone", "email": deactivated.Email}) + after := env.reload(t, deactivated.ID) + if !after.DeletedAt.Valid || !after.DeletedAt.Time.Equal(before.DeletedAt.Time) || after.Name == nil || *after.Name != "Still gone" { + t.Fatalf("deactivated user after update = %+v", after) + } + + // Validation failures answer 422 and mark the field. + dup := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(banned.ID, ""), token, map[string]any{"name": "Dup", "email": renamed}) + if details := adminDecode(t, dup).Error.Details; len(details["email"]) == 0 { + t.Fatalf("duplicate email details = %v", details) + } + bad := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(banned.ID, ""), token, map[string]any{"name": "Bad", "email": "nope"}) + if details := adminDecode(t, bad).Error.Details; len(details["email"]) == 0 { + t.Fatalf("invalid email details = %v", details) + } + if got := env.reload(t, banned.ID); got.Email != banned.Email { + t.Fatal("a refused update changed the email") + } + + // Protected columns are never written from a body. + env.expect(t, http.StatusOK, http.MethodPut, userPath(inactive.ID, ""), token, map[string]any{ + "name": "Still inactive", "email": inactive.Email, "is_activated": true, "password": "forged-password-1", "permissions": map[string]int{"x": 1}, + }) + if got := env.reload(t, inactive.ID); got.IsActivated || got.Password != inactive.Password { + t.Fatalf("a body wrote a protected column: %+v", got) + } + + // Writes need the permission too. + env.expect(t, http.StatusForbidden, http.MethodPut, userPath(active.ID, ""), stranger, map[string]any{"name": "Nope", "email": renamed}) +} + +// TestAdminPrivilegedMember covers the takeover guard (D-30): the email of a +// member of a privileged group, and the delete of such a user, need +// golem15.users.manage_privileged_groups on top of +// golem15.users.access_users. +func TestAdminPrivilegedMember(t *testing.T) { + env := newAdminEnv(t) + limited := env.admin(t, permAccessUsers) + manager := env.admin(t, permAccessUsers, permManagePrivileged) + + stamp := adminStamp() + // The default privileged list is [admin]; several groups may carry the code. + adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") + ordinaryCode := "ordinary-" + stamp + ordinary := env.seedGroup(t, "Ordinary "+stamp, ordinaryCode) + uncoded := env.seedGroup(t, "Uncoded "+stamp, "") + + member := env.seedUser(t, "member", nil) + env.join(t, member, adminGroup, ordinary) + plain := env.seedUser(t, "plain", nil) + env.join(t, plain, ordinary, uncoded) + gone := env.seedUser(t, "gone", nil) + env.join(t, gone, adminGroup) + if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil { + t.Fatal(err) + } + + assertRefused := func(body map[string]any, id uint, fields ...string) { + t.Helper() + rec := env.expect(t, http.StatusForbidden, http.MethodPut, userPath(id, ""), limited, body) + answer := adminDecode(t, rec) + if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberForbidden { + t.Fatalf("refusal = %s", rec.Body.String()) + } + if len(answer.Error.Details) != len(fields) { + t.Fatalf("refusal details = %v, want fields %v", answer.Error.Details, fields) + } + for _, field := range fields { + if got := answer.Error.Details[field]; len(got) != 1 || got[0] != privilegedMemberForbidden { + t.Fatalf("refusal details[%s] = %v", field, got) + } + } + } + unchanged := func(original *models.User) { + t.Helper() + got := env.reload(t, original.ID) + if got == nil { + t.Fatalf("user %d is gone", original.ID) + } + if got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) || got.Password != original.Password || + got.DeletedAt.Valid != original.DeletedAt.Valid { + t.Fatalf("user %d changed: %+v", original.ID, got) + } + } + gone = env.reload(t, gone.ID) + + // An email change on a privileged member is refused as a whole: the name + // in the same body is not saved either. + assertRefused(map[string]any{"name": "Taken over", "email": "takeover-" + stamp + "@example.test"}, member.ID, "email") + unchanged(member) + // A deactivated privileged member is protected the same way. + assertRefused(map[string]any{"name": "Taken over", "email": "takeover-gone-" + stamp + "@example.test"}, gone.ID, "email") + unchanged(gone) + + // The stored email sent back unchanged with a new name is an ordinary update. + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "New name", "email": member.Email}) + if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "New name" || got.Email != member.Email { + t.Fatalf("name-only update of a privileged member = %+v", got) + } + member = env.reload(t, member.ID) + + // The delete of a privileged member is refused and nothing changes. + for _, target := range []*models.User{member, gone} { + rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(target.ID, ""), limited, nil) + answer := adminDecode(t, rec) + if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberDeleteForbidden || len(answer.Error.Details) != 0 { + t.Fatalf("delete refusal = %s", rec.Body.String()) + } + unchanged(target) + if got := env.groupIDs(t, target.ID); len(got) == 0 { + t.Fatalf("a refused delete removed the memberships of %d", target.ID) + } + } + + // A member of ordinary groups only, and of a group without a code, is + // not protected. + plainEmail := "plain-new-" + stamp + "@example.test" + env.expect(t, http.StatusOK, http.MethodPut, userPath(plain.ID, ""), limited, map[string]any{"name": "Plain", "email": plainEmail}) + if got := env.reload(t, plain.ID); got.Email != plainEmail { + t.Fatalf("ordinary member email = %q", got.Email) + } + + // With the permission both refused requests succeed. + memberEmail := "member-new-" + stamp + "@example.test" + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Managed", "email": memberEmail}) + if got := env.reload(t, member.ID); got.Email != memberEmail { + t.Fatalf("manager email change = %q", got.Email) + } + env.expect(t, http.StatusOK, http.MethodDelete, userPath(member.ID, ""), manager, nil) + if got := env.reload(t, member.ID); got != nil && !got.DeletedAt.Valid { + t.Fatalf("manager delete left the user active: %+v", got) + } + + // The privileged list is config, read per request: with the ordinary + // group's code in it the protection moves to that group's member, and the + // admin group's member is no longer protected. + moved := newAdminEnv(t, func(cfg *compass.Config) { + if err := cfg.Set("golem15.user.privileged_groups", []string{ordinaryCode}); err != nil { + t.Fatal(err) + } + }) + movedLimited := moved.admin(t, permAccessUsers) + rec := moved.expect(t, http.StatusForbidden, http.MethodPut, userPath(plain.ID, ""), movedLimited, map[string]any{"name": "Plain", "email": "plain-moved-" + stamp + "@example.test"}) + if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 { + t.Fatalf("moved refusal = %s", rec.Body.String()) + } + if got := moved.reload(t, plain.ID); got.Email != plainEmail { + t.Fatalf("a refused update changed the email to %q", got.Email) + } + goneEmail := "gone-moved-" + stamp + "@example.test" + moved.expect(t, http.StatusOK, http.MethodPut, userPath(gone.ID, ""), movedLimited, map[string]any{"name": "Gone", "email": goneEmail}) + if got := moved.reload(t, gone.ID); got.Email != goneEmail || !got.DeletedAt.Valid { + t.Fatalf("admin-group member under the moved list = %+v", got) + } + if strings.Contains(rec.Body.String(), adminHarnessSecret) { + t.Fatal("a refusal carries the admin secret") + } +}