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
This commit is contained in:
438
admin_harness_test.go
Normal file
438
admin_harness_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
384
admin_users_test.go
Normal file
384
admin_users_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user