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) } } }