- password with confirmation on create, optional reset on update that ends the user's sessions; the hash is never returned - send_invite sends one invitation with an activation link after the create commits - organisation picker, avatar upload shared with the user API and the frontend permission editor on the user form - an email held by a deactivated user answers 422 instead of failing on the unique constraint - the privileged-member guard now also refuses a submitted password
663 lines
20 KiB
Go
663 lines
20 KiB
Go
package user
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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
|
|
mail *mailCapture
|
|
}
|
|
|
|
// 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,
|
|
// The log driver renders every mail and hands it to the app logger,
|
|
// which mailCapture reads.
|
|
"mail.driver": "log",
|
|
"mail.from": "noreply@example.test",
|
|
} {
|
|
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)
|
|
}
|
|
mail := &mailCapture{next: slog.Default().Handler()}
|
|
if err := app.Publish(slog.New(mail)); 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, mail: mail}
|
|
}
|
|
|
|
// 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 raw []byte
|
|
if body != nil {
|
|
var err error
|
|
if raw, err = json.Marshal(body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return e.send(t, method, path, token, raw, "application/json", nil)
|
|
}
|
|
|
|
// send is call with a raw body, its content type and extra headers (for
|
|
// example the form session key of a file upload).
|
|
func (e *adminEnv) send(t *testing.T, method, path, token string, body []byte, contentType string, headers map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
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", contentType)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// sentMail is one message the application sent, as the log mail driver
|
|
// reports it: rendered, with the text part.
|
|
type sentMail struct {
|
|
To []string
|
|
Subject string
|
|
Text string
|
|
}
|
|
|
|
// mailCapture is the slog handler behind the app logger: it keeps the
|
|
// records of the log mail driver and passes everything else on.
|
|
type mailCapture struct {
|
|
mu sync.Mutex
|
|
sent []sentMail
|
|
next slog.Handler
|
|
}
|
|
|
|
func (m *mailCapture) Enabled(context.Context, slog.Level) bool { return true }
|
|
|
|
func (m *mailCapture) Handle(ctx context.Context, record slog.Record) error {
|
|
if record.Message != "postcard mail" {
|
|
if m.next != nil && m.next.Enabled(ctx, record.Level) {
|
|
return m.next.Handle(ctx, record)
|
|
}
|
|
return nil
|
|
}
|
|
var mail sentMail
|
|
record.Attrs(func(attr slog.Attr) bool {
|
|
switch attr.Key {
|
|
case "to":
|
|
mail.To, _ = attr.Value.Any().([]string)
|
|
case "subject":
|
|
mail.Subject = attr.Value.String()
|
|
case "text":
|
|
mail.Text = attr.Value.String()
|
|
}
|
|
return true
|
|
})
|
|
m.mu.Lock()
|
|
m.sent = append(m.sent, mail)
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (m *mailCapture) WithAttrs([]slog.Attr) slog.Handler { return m }
|
|
func (m *mailCapture) WithGroup(string) slog.Handler { return m }
|
|
|
|
// mails returns the messages sent to the address since the env booted.
|
|
func (e *adminEnv) mails(to string) []sentMail {
|
|
e.mail.mu.Lock()
|
|
defer e.mail.mu.Unlock()
|
|
var out []sentMail
|
|
for _, mail := range e.mail.sent {
|
|
for _, recipient := range mail.To {
|
|
if recipient == to {
|
|
out = append(out, mail)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// adminSessionKey returns a fresh form session key, as the admin SPA makes
|
|
// one when it opens a form.
|
|
func adminSessionKey(t *testing.T) string {
|
|
t.Helper()
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
|
}
|
|
|
|
// upload posts one file to a fileupload field of a user's form under a form
|
|
// session key; the next save sent with the same key attaches it.
|
|
func (e *adminEnv) upload(t *testing.T, token string, id uint, field, name string, data []byte, sessionKey string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, err := mw.CreateFormFile("file_data", name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return e.send(t, http.MethodPost, userPath(id, "/files/"+field), token, buf.Bytes(), mw.FormDataContentType(),
|
|
map[string]string{cabana.SessionKeyHeader: sessionKey})
|
|
}
|
|
|
|
// 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 users []models.User
|
|
if err := e.gdb.Unscoped().Where("id = ?", id).Limit(1).Find(&users).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(users) == 0 {
|
|
return nil
|
|
}
|
|
return &users[0]
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// seedAvatar stores an avatar for the user the way the user API does: one
|
|
// system_files row (field avatar) and its blob. It returns the blob key.
|
|
func (e *adminEnv) seedAvatar(t *testing.T, user *models.User) string {
|
|
t.Helper()
|
|
disk := "avatar" + adminStamp() + ".jpg"
|
|
key := attach.BlobKey(disk)
|
|
if err := e.bucket.WriteAll(context.Background(), key, []byte("avatar-bytes"), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
row := attach.File{
|
|
DiskName: disk,
|
|
FileName: "avatar.jpg",
|
|
FileSize: 12,
|
|
ContentType: "image/jpeg",
|
|
Field: "avatar",
|
|
AttachmentID: strconv.FormatUint(uint64(user.ID), 10),
|
|
AttachmentType: user.MorphName(),
|
|
}
|
|
if err := e.gdb.Create(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return key
|
|
}
|
|
|
|
// blobExists reports whether the bucket still holds key.
|
|
func (e *adminEnv) blobExists(t *testing.T, key string) bool {
|
|
t.Helper()
|
|
ok, err := e.bucket.Exists(context.Background(), key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// seedThrottle stores one user_throttle row. ip may be empty for the row
|
|
// without an address.
|
|
func (e *adminEnv) seedThrottle(t *testing.T, user *models.User, ip string, banned bool, suspendedAt *time.Time) {
|
|
t.Helper()
|
|
row := models.Throttle{UserID: user.ID, IsBanned: banned}
|
|
if ip != "" {
|
|
row.IPAddress = &ip
|
|
}
|
|
if suspendedAt != nil {
|
|
row.IsSuspended, row.SuspendedAt, row.Attempts = true, suspendedAt, 5
|
|
}
|
|
if err := e.gdb.Create(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// hangers counts what hangs off a user: throttle rows, group memberships
|
|
// and attachment rows.
|
|
type hangers struct{ Throttle, Groups, Files int64 }
|
|
|
|
func (e *adminEnv) hangers(t *testing.T, user *models.User) hangers {
|
|
t.Helper()
|
|
var out hangers
|
|
count := func(into *int64, query string, args ...any) {
|
|
t.Helper()
|
|
if err := e.gdb.Raw(query, args...).Scan(into).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
count(&out.Throttle, `SELECT COUNT(*) FROM user_throttle WHERE user_id = ?`, user.ID)
|
|
count(&out.Groups, `SELECT COUNT(*) FROM users_groups WHERE user_id = ?`, user.ID)
|
|
count(&out.Files, `SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ?`,
|
|
user.MorphName(), strconv.FormatUint(uint64(user.ID), 10))
|
|
return out
|
|
}
|
|
|
|
// bulk posts a declared bulk action, or the built-in bulk delete when action
|
|
// is "delete", for the given users.
|
|
func (e *adminEnv) bulk(t *testing.T, token, action string, users ...*models.User) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
ids := make([]uint, len(users))
|
|
for i, user := range users {
|
|
ids[i] = user.ID
|
|
}
|
|
path := adminAPI(adminUsersPath + "/bulk/" + action)
|
|
if action == "delete" {
|
|
path = adminAPI(adminUsersPath + "/bulk-delete")
|
|
}
|
|
return e.call(t, http.MethodPost, path, token, map[string]any{"ids": ids})
|
|
}
|
|
|
|
// offered returns the record actions the show response of the user offers.
|
|
func (e *adminEnv) offered(t *testing.T, token string, id uint) []string {
|
|
t.Helper()
|
|
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil)
|
|
var meta struct {
|
|
Actions []struct {
|
|
Name string `json:"name"`
|
|
} `json:"actions"`
|
|
}
|
|
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names := []string{}
|
|
for _, action := range meta.Actions {
|
|
names = append(names, action.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// secretKey matches a JSON key that names a password or a one-time code.
|
|
var secretKey = regexp.MustCompile(`(?i)"[a-z_]*(password|activation_code|reset_password_code)[a-z_]*"\s*:`)
|
|
|
|
// 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()
|
|
if secretKey.MatchString(raw) || 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)
|
|
}
|
|
}
|
|
}
|