feat: create users with a password and an invitation in the admin
- 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
This commit is contained in:
@@ -3,15 +3,20 @@ 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"
|
||||
@@ -67,6 +72,7 @@ type adminEnv struct {
|
||||
app *backpack.App
|
||||
gdb *gorm.DB
|
||||
bucket *blob.Bucket
|
||||
mail *mailCapture
|
||||
}
|
||||
|
||||
// adminAPI builds a path under the admin API prefix.
|
||||
@@ -143,6 +149,10 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
||||
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)
|
||||
@@ -152,6 +162,10 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
||||
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 {
|
||||
@@ -174,7 +188,7 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket}
|
||||
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail}
|
||||
}
|
||||
|
||||
// admin inserts a backend role holding exactly permissions and a backend
|
||||
@@ -227,27 +241,132 @@ func (e *adminEnv) admin(t *testing.T, permissions ...string) string {
|
||||
// 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
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
var err error
|
||||
if raw, err = json.Marshal(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
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", "application/json")
|
||||
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()
|
||||
@@ -525,12 +644,14 @@ func (e *adminEnv) userLogin(t *testing.T, email, password string) (int, string)
|
||||
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()
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.Contains(lower, `"password`) || strings.Contains(raw, "$2a$") || strings.Contains(raw, "$2y$") {
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -12,7 +17,10 @@ import (
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"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/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
@@ -271,9 +279,12 @@ func TestAdminUsersTracer(t *testing.T) {
|
||||
|
||||
// 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},
|
||||
"name": "Still inactive", "email": inactive.Email, "is_activated": true, "organisation_id": 987654,
|
||||
"activation_code": "forged", "deleted_at": "2020-01-01T00:00:00Z", "must_change_password": true,
|
||||
"has_self_set_password": false, "last_seen": "2020-01-01T00:00:00Z", "id": 1,
|
||||
})
|
||||
if got := env.reload(t, inactive.ID); got.IsActivated || got.Password != inactive.Password {
|
||||
if got := env.reload(t, inactive.ID); got.IsActivated || got.Password != inactive.Password || got.OrganisationID != nil ||
|
||||
got.ActivationCode != nil || got.DeletedAt.Valid || got.MustChangePassword || got.LastSeen != nil || got.ID != inactive.ID {
|
||||
t.Fatalf("a body wrote a protected column: %+v", got)
|
||||
}
|
||||
|
||||
@@ -351,6 +362,69 @@ func TestAdminPrivilegedMember(t *testing.T) {
|
||||
}
|
||||
member = env.reload(t, member.ID)
|
||||
|
||||
// A password submitted for a privileged member is refused before it is
|
||||
// hashed or any token is invalidated: the old password still signs in.
|
||||
const attempted = "takeover-password-1"
|
||||
assertRefused(map[string]any{"name": "New name", "email": member.Email, "password": attempted, "password_confirmation": attempted}, member.ID, "password")
|
||||
unchanged(member)
|
||||
if got := env.reload(t, member.ID); got.TokensValidAfter != nil {
|
||||
t.Fatalf("a refused password reset stamped tokens_valid_after: %v", got.TokensValidAfter)
|
||||
}
|
||||
if status, _ := env.userLogin(t, member.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatalf("old password after a refused reset = %d", status)
|
||||
}
|
||||
if status, _ := env.userLogin(t, member.Email, attempted); status != http.StatusUnauthorized {
|
||||
t.Fatalf("attempted password after a refused reset = %d", status)
|
||||
}
|
||||
// Email and password in one body: both fields are named.
|
||||
assertRefused(map[string]any{"name": "New name", "email": "both-" + stamp + "@example.test", "password": attempted, "password_confirmation": attempted}, member.ID, "email", "password")
|
||||
unchanged(member)
|
||||
// Validation runs before the hooks, so a mismatching confirmation is a
|
||||
// 422; it changes nothing either.
|
||||
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(member.ID, ""), limited,
|
||||
map[string]any{"name": "New name", "email": member.Email, "password": attempted, "password_confirmation": "something-else"})
|
||||
unchanged(member)
|
||||
// An empty password means "unchanged" and is an ordinary update.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited,
|
||||
map[string]any{"name": "Renamed again", "email": member.Email, "password": "", "password_confirmation": ""})
|
||||
if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "Renamed again" || got.Password != member.Password {
|
||||
t.Fatalf("empty-password update of a privileged member = %+v", got)
|
||||
}
|
||||
member = env.reload(t, member.ID)
|
||||
|
||||
// Everything else on a privileged member needs only the users
|
||||
// permission: organisation, frontend permissions and the avatar.
|
||||
organisation := models.Organisation{Slug: "guard-" + stamp, Name: "Guard " + stamp}
|
||||
if err := env.gdb.Create(&organisation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissionCode := "guard." + stamp
|
||||
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Guard', 'Guard')`, permissionCode).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionKey := adminSessionKey(t)
|
||||
if up := env.upload(t, limited, member.ID, "avatar", "member.jpg", jpegBytes(t), sessionKey); up.Code != http.StatusCreated && up.Code != http.StatusOK {
|
||||
t.Fatalf("avatar upload for a privileged member = %d %s", up.Code, up.Body.String())
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"name": "Renamed again", "email": member.Email, "organisation": organisation.ID, "permissions": map[string]int{permissionCode: -1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved := env.send(t, http.MethodPut, userPath(member.ID, ""), limited, body, "application/json", map[string]string{cabana.SessionKeyHeader: sessionKey}); saved.Code != http.StatusOK {
|
||||
t.Fatalf("ordinary changes on a privileged member = %d %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
got := env.reload(t, member.ID)
|
||||
if got.OrganisationID == nil || *got.OrganisationID != organisation.ID || got.Permissions[permissionCode] != -1 || env.hangers(t, member).Files != 1 {
|
||||
t.Fatalf("ordinary changes on a privileged member were not saved: %+v", got)
|
||||
}
|
||||
// Leave the member without an avatar for the delete scenarios below.
|
||||
if err := env.gdb.Exec(`DELETE FROM system_files WHERE attachment_type = ? AND attachment_id = ?`, member.MorphName(), strconv.FormatUint(uint64(member.ID), 10)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
@@ -378,6 +452,15 @@ func TestAdminPrivilegedMember(t *testing.T) {
|
||||
if got := env.reload(t, member.ID); got.Email != memberEmail {
|
||||
t.Fatalf("manager email change = %q", got.Email)
|
||||
}
|
||||
const managed = "managed-password-1"
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager,
|
||||
map[string]any{"name": "Managed", "email": memberEmail, "password": managed, "password_confirmation": managed})
|
||||
if got := env.reload(t, member.ID); !bouncer.CheckPassword(got.Password, managed) || got.TokensValidAfter == nil {
|
||||
t.Fatalf("manager password reset = %+v", got)
|
||||
}
|
||||
if status, _ := env.userLogin(t, memberEmail, managed); status != http.StatusOK {
|
||||
t.Fatalf("login with the password the manager set = %d", status)
|
||||
}
|
||||
|
||||
// The delete is permanent, so the refusal must leave everything that
|
||||
// hangs off the member in place: throttle rows, memberships, the avatar
|
||||
@@ -385,7 +468,7 @@ func TestAdminPrivilegedMember(t *testing.T) {
|
||||
env.seedThrottle(t, member, "203.0.113.7", false, nil)
|
||||
memberAvatar := env.seedAvatar(t, member)
|
||||
memberHangers := env.hangers(t, member)
|
||||
if memberHangers.Throttle != 1 || memberHangers.Groups != 2 || memberHangers.Files != 1 {
|
||||
if memberHangers.Throttle < 1 || memberHangers.Groups != 2 || memberHangers.Files != 1 {
|
||||
t.Fatalf("member seed = %+v", memberHangers)
|
||||
}
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(member.ID, ""), limited, nil)
|
||||
@@ -874,3 +957,545 @@ func TestAdminUserForceDelete(t *testing.T) {
|
||||
t.Fatalf("the delete removed the group: %d %v", groups, err)
|
||||
}
|
||||
}
|
||||
|
||||
// createdID reads the id of a create or update answer.
|
||||
func createdID(t *testing.T, rec *httptest.ResponseRecorder) uint {
|
||||
t.Helper()
|
||||
var data struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
adminInto(t, rec, &data)
|
||||
if data.ID == 0 {
|
||||
t.Fatalf("no id in %s", rec.Body.String())
|
||||
}
|
||||
return data.ID
|
||||
}
|
||||
|
||||
// TestAdminUserPassword covers the password field (D-19): required and
|
||||
// confirmed on create, optional on update, hashed, never returned, and an
|
||||
// admin reset ends the user's sessions.
|
||||
func TestAdminUserPassword(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
stamp := adminStamp()
|
||||
const first, second = "first-password-1", "second-password-2"
|
||||
email := "password-" + stamp + "@example.test"
|
||||
|
||||
// Create: the hash verifies, the user is not activated, nothing leaks.
|
||||
created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{
|
||||
"name": "Password", "surname": "pw" + stamp, "email": email, "password": first, "password_confirmation": first, "send_invite": false,
|
||||
})
|
||||
assertNoSecret(t, created.Body.String(), first)
|
||||
id := createdID(t, created)
|
||||
user := env.reload(t, id)
|
||||
if !bouncer.CheckPassword(user.Password, first) || user.Password == first {
|
||||
t.Fatal("the stored password is not a hash of the submitted one")
|
||||
}
|
||||
if user.IsActivated || user.ActivatedAt != nil || !user.HasSelfSetPassword || user.TokensValidAfter != nil || user.DeletedAt.Valid {
|
||||
t.Fatalf("created user = %+v", user)
|
||||
}
|
||||
for _, rec := range []*httptest.ResponseRecorder{
|
||||
env.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil),
|
||||
env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"?search=pw"+stamp), token, nil),
|
||||
} {
|
||||
assertNoSecret(t, rec.Body.String(), first, user.Password)
|
||||
}
|
||||
// A request cannot turn the activation on, and cannot store a hash directly.
|
||||
if status, _ := env.userLogin(t, email, first); status != http.StatusOK {
|
||||
t.Fatalf("login with the created password = %d", status)
|
||||
}
|
||||
|
||||
// Create failures answer 422 on the field and create nothing.
|
||||
failures := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
field string
|
||||
}{
|
||||
{"confirmation mismatch", map[string]any{"password": first, "password_confirmation": second}, "password"},
|
||||
{"missing confirmation", map[string]any{"password": first}, "password"},
|
||||
{"missing password", map[string]any{}, "password"},
|
||||
{"short password", map[string]any{"password": "short", "password_confirmation": "short"}, "password"},
|
||||
{"duplicate email", map[string]any{"email": email, "password": first, "password_confirmation": first}, "email"},
|
||||
}
|
||||
for i, failure := range failures {
|
||||
body := map[string]any{"name": "Failure", "email": "failure-" + stamp + strconv.Itoa(i) + "@example.test"}
|
||||
for key, value := range failure.body {
|
||||
body[key] = value
|
||||
}
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, body)
|
||||
if details := adminDecode(t, rec).Error.Details; len(details[failure.field]) == 0 {
|
||||
t.Fatalf("%s details = %v", failure.name, details)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), first) {
|
||||
t.Fatalf("%s echoes the password: %s", failure.name, rec.Body.String())
|
||||
}
|
||||
var n int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, body["email"]).Scan(&n).Error; err != nil || (n != 0 && failure.field != "email") {
|
||||
t.Fatalf("%s created a user: %d %v", failure.name, n, err)
|
||||
}
|
||||
}
|
||||
// The email of a deactivated user is taken as well: a 422, not a 500
|
||||
// from the unique constraint.
|
||||
gone := env.seedUser(t, "gone", nil)
|
||||
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token,
|
||||
map[string]any{"name": "Clash", "email": gone.Email, "password": first, "password_confirmation": first})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["email"]) != 1 || details["email"][0] != "The email has already been taken." {
|
||||
t.Fatalf("deactivated email clash = %s", rec.Body.String())
|
||||
}
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(id, ""), token, map[string]any{"name": "Password", "email": gone.Email})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["email"]) != 1 {
|
||||
t.Fatalf("deactivated email clash on update = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Update without a password: only the name changes (RESEARCH Pitfall 1).
|
||||
for _, body := range []map[string]any{
|
||||
{"name": "Renamed", "email": email},
|
||||
{"name": "Renamed", "email": email, "password": "", "password_confirmation": ""},
|
||||
} {
|
||||
updated := env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token, body)
|
||||
assertNoSecret(t, updated.Body.String(), first, user.Password)
|
||||
if got := env.reload(t, id); got.Password != user.Password || got.TokensValidAfter != nil || got.Name == nil || *got.Name != "Renamed" {
|
||||
t.Fatalf("an update without a password changed the credentials: %+v", got)
|
||||
}
|
||||
}
|
||||
// A mismatching confirmation on update is refused and changes nothing.
|
||||
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(id, ""), token,
|
||||
map[string]any{"name": "Renamed", "email": email, "password": second, "password_confirmation": first})
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["password"]) == 0 {
|
||||
t.Fatalf("update mismatch details = %v", details)
|
||||
}
|
||||
if got := env.reload(t, id); got.Password != user.Password {
|
||||
t.Fatal("a refused password reset changed the hash")
|
||||
}
|
||||
|
||||
// Update with a password: the hash changes, and tokens issued before the
|
||||
// reset stop working.
|
||||
_, userToken := env.userLogin(t, email, first)
|
||||
if fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil); fetch.Code != http.StatusOK {
|
||||
t.Fatalf("fetch before the reset = %d %s", fetch.Code, fetch.Body.String())
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond) // token timestamps have one-second resolution
|
||||
reset := env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token,
|
||||
map[string]any{"name": "Renamed", "email": email, "password": second, "password_confirmation": second})
|
||||
assertNoSecret(t, reset.Body.String(), second)
|
||||
after := env.reload(t, id)
|
||||
if !bouncer.CheckPassword(after.Password, second) || bouncer.CheckPassword(after.Password, first) || after.TokensValidAfter == nil {
|
||||
t.Fatalf("user after the reset = %+v", after)
|
||||
}
|
||||
if fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil); fetch.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a token from before the reset still works: %d", fetch.Code)
|
||||
}
|
||||
if status, _ := env.userLogin(t, email, first); status != http.StatusUnauthorized {
|
||||
t.Fatalf("the old password still signs in: %d", status)
|
||||
}
|
||||
if status, _ := env.userLogin(t, email, second); status != http.StatusOK {
|
||||
t.Fatalf("the new password does not sign in: %d", status)
|
||||
}
|
||||
// The reset did not activate the user or touch anything else.
|
||||
if after.IsActivated || after.Email != email {
|
||||
t.Fatalf("the reset changed more than the password: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminUserInvite covers send_invite (D-19): exactly one invitation
|
||||
// after the create commits, with a link that activates the user.
|
||||
func TestAdminUserInvite(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
stamp := adminStamp()
|
||||
const password = "invite-password-1"
|
||||
create := func(status int, email string, extra map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := map[string]any{"name": "Invited", "email": email, "password": password, "password_confirmation": password}
|
||||
for key, value := range extra {
|
||||
body[key] = value
|
||||
}
|
||||
return env.expect(t, status, http.MethodPost, adminAPI(adminUsersPath), token, body)
|
||||
}
|
||||
|
||||
// The form schema turns the checkbox on by default, on create only.
|
||||
var form struct {
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Default any `json:"default"`
|
||||
Context any `json:"context"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
|
||||
seen := false
|
||||
for _, field := range form.Fields {
|
||||
if field.Name == "send_invite" {
|
||||
seen = field.Type == "checkbox" && field.Default == true && field.Context == "create"
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
t.Fatalf("send_invite field = %+v", form.Fields)
|
||||
}
|
||||
|
||||
invited := "invited-" + stamp + "@example.test"
|
||||
id := createdID(t, create(http.StatusCreated, invited, map[string]any{"send_invite": true}))
|
||||
mails := env.mails(invited)
|
||||
if len(mails) != 1 {
|
||||
t.Fatalf("invitations sent = %d, want exactly 1: %+v", len(mails), mails)
|
||||
}
|
||||
mail := mails[0]
|
||||
if mail.Subject != "An account has been created for you" || !strings.Contains(mail.Text, "Invited") ||
|
||||
!strings.Contains(mail.Text, invited) || !strings.Contains(mail.Text, "72 hours") || strings.Contains(mail.Text, password) {
|
||||
t.Fatalf("invitation = %+v", mail)
|
||||
}
|
||||
user := env.reload(t, id)
|
||||
if user.IsActivated || user.ActivationCode == nil || user.ActivationCodeIssuedAt == nil {
|
||||
t.Fatalf("invited user = %+v", user)
|
||||
}
|
||||
// The link carries {id}!{code} and activates through the user API.
|
||||
start := strings.Index(mail.Text, "https://app.example.test/activate?code=")
|
||||
if start < 0 {
|
||||
t.Fatalf("no activation link in %q", mail.Text)
|
||||
}
|
||||
link := mail.Text[start:]
|
||||
link = link[:strings.IndexAny(link, ")\n ")]
|
||||
parsed, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packed := parsed.Query().Get("code")
|
||||
if packed != strconv.FormatUint(uint64(id), 10)+"!"+*user.ActivationCode {
|
||||
t.Fatalf("link code = %q", packed)
|
||||
}
|
||||
activated := env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": packed})
|
||||
if activated.Code != http.StatusOK {
|
||||
t.Fatalf("activate-by-code = %d %s", activated.Code, activated.Body.String())
|
||||
}
|
||||
if got := env.reload(t, id); !got.IsActivated || got.ActivationCode != nil {
|
||||
t.Fatalf("user after the invitation link = %+v", got)
|
||||
}
|
||||
|
||||
// No invitation without the checkbox, whether it is off or not sent.
|
||||
off := "invite-off-" + stamp + "@example.test"
|
||||
offID := createdID(t, create(http.StatusCreated, off, map[string]any{"send_invite": false}))
|
||||
absent := "invite-absent-" + stamp + "@example.test"
|
||||
create(http.StatusCreated, absent, nil)
|
||||
if n := len(env.mails(off)) + len(env.mails(absent)); n != 0 {
|
||||
t.Fatalf("invitations without the checkbox = %d", n)
|
||||
}
|
||||
if got := env.reload(t, offID); got.ActivationCode != nil {
|
||||
t.Fatal("an activation code was issued without an invitation")
|
||||
}
|
||||
// A create that fails validation sends nothing.
|
||||
failed := "invite-failed-" + stamp + "@example.test"
|
||||
create(http.StatusUnprocessableEntity, failed, map[string]any{"send_invite": true, "password_confirmation": "does-not-match"})
|
||||
create(http.StatusUnprocessableEntity, invited, map[string]any{"send_invite": true})
|
||||
if n := len(env.mails(failed)); n != 0 || len(env.mails(invited)) != 1 {
|
||||
t.Fatalf("a failed create sent an invitation: %d, %d", n, len(env.mails(invited)))
|
||||
}
|
||||
// send_invite is a create-only field: an update that carries it sends nothing.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(offID, ""), token, map[string]any{"name": "Invited", "email": off, "send_invite": true})
|
||||
if n := len(env.mails(off)); n != 0 {
|
||||
t.Fatalf("an update sent an invitation: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAvatarSharedWithAPI covers D-20: the admin form's avatar field
|
||||
// and the user API read and write the same attachment.
|
||||
func TestAdminAvatarSharedWithAPI(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
user := env.seedUser(t, "avatar", nil)
|
||||
_, userToken := env.userLogin(t, user.Email, "correct-horse")
|
||||
payload := func() map[string]any {
|
||||
t.Helper()
|
||||
rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("fetch = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
User map[string]any `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body.User
|
||||
}
|
||||
adminFiles := func() []cabana.FileItem {
|
||||
t.Helper()
|
||||
var files []cabana.FileItem
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, "/files/avatar"), token, nil), &files)
|
||||
return files
|
||||
}
|
||||
if got := payload(); got["has_avatar"] != false || len(adminFiles()) != 0 {
|
||||
t.Fatalf("a new user has an avatar: %v", got)
|
||||
}
|
||||
|
||||
// The form schema describes the field as PHP does: an image, 260 by 260.
|
||||
var form struct {
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Mode string `json:"mode"`
|
||||
ImageWidth int `json:"imageWidth"`
|
||||
ImageHeight int `json:"imageHeight"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
|
||||
described := false
|
||||
for _, field := range form.Fields {
|
||||
if field.Name == "avatar" {
|
||||
described = field.Type == "fileupload" && field.Mode == "image" && field.ImageWidth == 260 && field.ImageHeight == 260
|
||||
}
|
||||
}
|
||||
if !described {
|
||||
t.Fatalf("avatar field = %+v", form.Fields)
|
||||
}
|
||||
|
||||
// Admin to app: an upload attached by the form save shows in the payload.
|
||||
key := adminSessionKey(t)
|
||||
if up := env.upload(t, token, user.ID, "avatar", "from-admin.jpg", jpegBytes(t), key); up.Code != http.StatusCreated && up.Code != http.StatusOK {
|
||||
t.Fatalf("admin upload = %d %s", up.Code, up.Body.String())
|
||||
}
|
||||
if got := payload(); got["has_avatar"] != false {
|
||||
t.Fatal("a pending upload already shows in the user payload")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"name": "avatar", "email": user.Email})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved := env.send(t, http.MethodPut, userPath(user.ID, ""), token, body, "application/json", map[string]string{cabana.SessionKeyHeader: key}); saved.Code != http.StatusOK {
|
||||
t.Fatalf("save with the upload = %d %s", saved.Code, saved.Body.String())
|
||||
}
|
||||
got := payload()
|
||||
avatarURL, _ := got["avatar_url"].(string)
|
||||
if got["has_avatar"] != true || avatarURL == "" || strings.Contains(avatarURL, "gravatar") {
|
||||
t.Fatalf("user payload after the admin upload = %v", got)
|
||||
}
|
||||
var row attach.File
|
||||
if err := env.gdb.Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), "avatar").Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["avatar"] != row.DiskName || !row.Public() || row.FileName != "from-admin.jpg" {
|
||||
t.Fatalf("avatar row = %+v payload avatar = %v", row, got["avatar"])
|
||||
}
|
||||
if files := adminFiles(); len(files) != 1 || files[0].ID != row.ID || files[0].Pending {
|
||||
t.Fatalf("admin file list = %+v", files)
|
||||
}
|
||||
|
||||
// App to admin: an avatar uploaded through the user API replaces it and
|
||||
// shows in the admin field.
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("avatar", "from-app.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(jpegBytes(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if up := env.send(t, http.MethodPost, "/_user/api/v1/avatar", userToken, buf.Bytes(), mw.FormDataContentType(), nil); up.Code != http.StatusOK {
|
||||
t.Fatalf("user API upload = %d %s", up.Code, up.Body.String())
|
||||
}
|
||||
files := adminFiles()
|
||||
if len(files) != 1 || files[0].FileName != "from-app.jpg" || files[0].ID == row.ID {
|
||||
t.Fatalf("admin file list after the user API upload = %+v", files)
|
||||
}
|
||||
if got := payload(); got["has_avatar"] != true {
|
||||
t.Fatalf("user payload after the user API upload = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminUserFormFields covers the organisation picker (D-22) and the
|
||||
// frontend permission editor (D-15, D-16) of the user form.
|
||||
func TestAdminUserFormFields(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
stamp := adminStamp()
|
||||
user := env.seedUser(t, "fields", nil)
|
||||
_, userToken := env.userLogin(t, user.Email, "correct-horse")
|
||||
base := map[string]any{"name": "fields", "email": user.Email}
|
||||
save := func(status int, extra map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := map[string]any{}
|
||||
for key, value := range base {
|
||||
body[key] = value
|
||||
}
|
||||
for key, value := range extra {
|
||||
body[key] = value
|
||||
}
|
||||
return env.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body)
|
||||
}
|
||||
|
||||
// Organisation: a belongsTo picker that writes organisation_id.
|
||||
first := models.Organisation{Slug: "first-" + stamp, Name: "First " + stamp}
|
||||
second := models.Organisation{Slug: "second-" + stamp, Name: "Second " + stamp}
|
||||
for _, organisation := range []*models.Organisation{&first, &second} {
|
||||
if err := env.gdb.Create(organisation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var options []struct {
|
||||
Value uint `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/fields/organisation/options?search="+stamp), token, nil), &options)
|
||||
if len(options) != 2 {
|
||||
t.Fatalf("organisation options = %+v", options)
|
||||
}
|
||||
saved := save(http.StatusOK, map[string]any{"organisation": first.ID})
|
||||
if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != first.ID {
|
||||
t.Fatalf("organisation_id after the save = %v", got.OrganisationID)
|
||||
}
|
||||
if !strings.Contains(saved.Body.String(), first.Name) {
|
||||
t.Fatalf("the record answer lacks the organisation label: %s", saved.Body.String())
|
||||
}
|
||||
save(http.StatusOK, map[string]any{"organisation": second.ID})
|
||||
if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID {
|
||||
t.Fatalf("organisation_id after the change = %v", got.OrganisationID)
|
||||
}
|
||||
// An id that is not an organisation is refused; the plain column name is ignored.
|
||||
save(http.StatusUnprocessableEntity, map[string]any{"organisation": 987654321})
|
||||
save(http.StatusOK, map[string]any{"organisation_id": first.ID})
|
||||
if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID {
|
||||
t.Fatalf("a plain organisation_id key was written: %v", got.OrganisationID)
|
||||
}
|
||||
save(http.StatusOK, map[string]any{"organisation": nil})
|
||||
if got := env.reload(t, user.ID); got.OrganisationID != nil {
|
||||
t.Fatalf("organisation_id after clearing = %v", *got.OrganisationID)
|
||||
}
|
||||
|
||||
// Permissions: the options are the table's rows, grouped by tab.
|
||||
read, write, admin := "forum."+stamp+".read", "forum."+stamp+".write", "shop."+stamp+".admin"
|
||||
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab, comment) VALUES
|
||||
(?, 'Write', ?, 'May write posts'), (?, 'Read', ?, NULL), (?, 'Administer', ?, NULL)`,
|
||||
write, "Forum "+stamp, read, "Forum "+stamp, admin, "Shop "+stamp).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var form struct {
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Mode string `json:"mode"`
|
||||
Tab string `json:"tab"`
|
||||
Context any `json:"context"`
|
||||
Options []struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Tab string `json:"tab"`
|
||||
Comment string `json:"comment"`
|
||||
Locked bool `json:"locked"`
|
||||
} `json:"permissionOptions"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
|
||||
var offered []string
|
||||
var names []string
|
||||
for _, field := range form.Fields {
|
||||
names = append(names, field.Name)
|
||||
if field.Name != "permissions" {
|
||||
continue
|
||||
}
|
||||
if field.Type != "permissioneditor" || field.Mode != "radio" || field.Tab != "Permissions" || field.Context != "update" {
|
||||
t.Fatalf("permissions field = %+v", field)
|
||||
}
|
||||
for _, option := range field.Options {
|
||||
if !strings.Contains(option.Code, stamp) {
|
||||
continue
|
||||
}
|
||||
offered = append(offered, option.Tab+"|"+option.Code+"|"+option.Label+"|"+option.Comment)
|
||||
if option.Locked {
|
||||
t.Fatalf("a frontend permission is locked: %+v", option)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []string{
|
||||
"Forum " + stamp + "|" + read + "|Read|",
|
||||
"Forum " + stamp + "|" + write + "|Write|May write posts",
|
||||
"Shop " + stamp + "|" + admin + "|Administer|",
|
||||
}; !reflect.DeepEqual(offered, want) {
|
||||
t.Fatalf("permission options = %v, want %v", offered, want)
|
||||
}
|
||||
// The form has no login-name, block-mail, guest or groups field (D-03,
|
||||
// D-18, D-21; groups arrive with their guard).
|
||||
if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "organisation", "avatar", "created_ip_address", "last_ip_address", "permissions"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("form fields = %v, want %v", names, want)
|
||||
}
|
||||
|
||||
// Allow and deny are stored as the JSON object PHP writes; inherit is no entry.
|
||||
save(http.StatusOK, map[string]any{"permissions": map[string]int{read: 1, write: -1, admin: 0}})
|
||||
var stored string
|
||||
if err := env.gdb.Raw(`SELECT permissions FROM users WHERE id = ?`, user.ID).Scan(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded map[string]int
|
||||
if err := json.Unmarshal([]byte(stored), &decoded); err != nil || !reflect.DeepEqual(decoded, map[string]int{read: 1, write: -1}) {
|
||||
t.Fatalf("stored permissions = %q (%v)", stored, err)
|
||||
}
|
||||
var record struct {
|
||||
Permissions map[string]int `json:"permissions"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil), &record)
|
||||
if !reflect.DeepEqual(record.Permissions, map[string]int{read: 1, write: -1}) {
|
||||
t.Fatalf("record permissions = %v", record.Permissions)
|
||||
}
|
||||
// The resolver answers from what the editor stored.
|
||||
for code, want := range map[string]bool{read: true, write: false, admin: false, "forum." + stamp + ".*": true} {
|
||||
held, err := classes.UserHasPermission(t.Context(), env.gdb, user.ID, code)
|
||||
if err != nil || held != want {
|
||||
t.Fatalf("UserHasPermission(%s) = %v %v, want %v", code, held, err, want)
|
||||
}
|
||||
}
|
||||
// An unknown code and a value outside the set are refused, and nothing changes.
|
||||
for _, bad := range []map[string]any{
|
||||
{"permissions": map[string]int{"unknown." + stamp: 1}},
|
||||
{"permissions": map[string]int{read: 2}},
|
||||
{"permissions": "all"},
|
||||
} {
|
||||
rec := save(http.StatusUnprocessableEntity, bad)
|
||||
if details := adminDecode(t, rec).Error.Details; len(details["permissions"]) == 0 {
|
||||
t.Fatalf("permission refusal details = %v", details)
|
||||
}
|
||||
}
|
||||
if got := env.reload(t, user.ID); !reflect.DeepEqual(got.Permissions, models.PermissionSet{read: 1, write: -1}) {
|
||||
t.Fatalf("a refused save changed the permissions: %v", got.Permissions)
|
||||
}
|
||||
// A save that does not send the field leaves the stored set alone; an
|
||||
// empty object clears it to NULL.
|
||||
save(http.StatusOK, nil)
|
||||
if got := env.reload(t, user.ID); len(got.Permissions) != 2 {
|
||||
t.Fatalf("a save without the field changed the permissions: %v", got.Permissions)
|
||||
}
|
||||
save(http.StatusOK, map[string]any{"permissions": map[string]int{}})
|
||||
var isNull bool
|
||||
if err := env.gdb.Raw(`SELECT permissions IS NULL FROM users WHERE id = ?`, user.ID).Scan(&isNull).Error; err != nil || !isNull {
|
||||
t.Fatalf("cleared permissions are not NULL: %v", err)
|
||||
}
|
||||
|
||||
// None of it reaches the user API payload: permissions stays null and
|
||||
// groups an empty list (D-08).
|
||||
save(http.StatusOK, map[string]any{"permissions": map[string]int{read: 1}, "organisation": first.ID})
|
||||
fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil)
|
||||
if fetch.Code != http.StatusOK {
|
||||
t.Fatalf("fetch = %d %s", fetch.Code, fetch.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
User map[string]any `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(fetch.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, ok := body.User["permissions"]; !ok || value != nil {
|
||||
t.Fatalf("user payload permissions = %v (%v)", value, ok)
|
||||
}
|
||||
if groups, ok := body.User["groups"].([]any); !ok || len(groups) != 0 {
|
||||
t.Fatalf("user payload groups = %v", body.User["groups"])
|
||||
}
|
||||
for _, leaked := range []string{"last_seen", "created_at", "updated_at", "organisation", "organisation_id", read} {
|
||||
if strings.Contains(fetch.Body.String(), `"`+leaked+`"`) {
|
||||
t.Fatalf("user payload carries %s: %s", leaked, fetch.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
@@ -29,4 +30,9 @@ func init() {
|
||||
var _ pact.HasAdminBulkActions = usersAdminController{}
|
||||
var _ pact.HasAdminRecordActions = usersAdminController{}
|
||||
var _ pact.AdminPartialData = usersAdminController{}
|
||||
var _ pact.FormVirtualFields = usersAdminController{}
|
||||
var _ pact.FormBeforeCreate = usersAdminController{}
|
||||
var _ pact.FormAfterCreate = usersAdminController{}
|
||||
var _ cabana.FieldRelationProvider = usersAdminController{}
|
||||
var _ cabana.PermissionEditorProvider = usersAdminController{}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ form: ~/plugins/golem15/user/models/user/fields.yaml
|
||||
modelClass: Golem15\User\Models\User
|
||||
defaultRedirect: golem15/user/users
|
||||
|
||||
create:
|
||||
redirect: golem15/user/users/preview/:id
|
||||
redirectClose: golem15/user/users
|
||||
|
||||
update:
|
||||
redirect: golem15/user/users/update/:id
|
||||
redirectClose: golem15/user/users/preview/:id
|
||||
@@ -13,6 +17,7 @@ preview:
|
||||
recordActions: [activate, unban, unsuspend]
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.users.new_user
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
deleteConfirm: golem15.user::lang.users.delete_confirm
|
||||
|
||||
@@ -9,13 +9,14 @@ showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
toolbar:
|
||||
buttons: [delete]
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
bulkActions: [activate, deactivate, restore, ban, unban]
|
||||
|
||||
messages:
|
||||
create: golem15.user::lang.users.new_user
|
||||
empty: golem15.user::lang.users.list_empty
|
||||
deleteConfirm: golem15.user::lang.users.delete_selected_confirm
|
||||
deleted: backend::lang.messages.list.deleted
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
@@ -125,13 +127,39 @@ func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, re
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormVirtualFields are the form fields that are not columns of the form:
|
||||
// the password with its confirmation, and the create-only invitation
|
||||
// checkbox. The framework never fills or returns them; the Form hooks read
|
||||
// them with cabana.VirtualFieldsFromContext.
|
||||
func (usersAdminController) FormVirtualFields() []string {
|
||||
return []string{"password", "password_confirmation", "send_invite"}
|
||||
}
|
||||
|
||||
// FormRules are the rules of an admin save. They replace User.Rules(), which
|
||||
// is the register contract and would ask every admin update for a confirmed
|
||||
// password.
|
||||
func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string {
|
||||
return map[string]string{
|
||||
"email": "required|between:6,255|email|unique:users",
|
||||
// password: a create needs a confirmed password, an update takes one only
|
||||
// when the administrator types it (empty means unchanged). The minimum
|
||||
// length is golem15.user.password.min_length.
|
||||
func (c usersAdminController) FormRules(_ context.Context, op string) map[string]string {
|
||||
length := "between:" + strconv.Itoa(c.configInt("golem15.user.password.min_length", 8)) + ",255"
|
||||
password := "nullable|" + length + "|confirmed"
|
||||
if op == "create" {
|
||||
password = "required|" + length + "|confirmed"
|
||||
}
|
||||
return map[string]string{
|
||||
"email": "required|between:6,255|email|unique:users",
|
||||
"password": password,
|
||||
}
|
||||
}
|
||||
|
||||
// configInt reads a positive integer config value, else fallback.
|
||||
func (c usersAdminController) configInt(key string, fallback int) int {
|
||||
if cfg := c.config(); cfg != nil {
|
||||
if n := cfg.Int(key); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// FilterOptions serves the choices of the groups filter (scope
|
||||
@@ -192,6 +220,191 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
|
||||
if err := c.guardCredentials(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.requireFreeEmail(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := c.storePassword(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
// An admin password reset ends every session of the user, as the
|
||||
// user's own change-password does.
|
||||
now := time.Now()
|
||||
user.TokensValidAfter = &now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks the email against deactivated users too and stores
|
||||
// the hash of the submitted password. A user created here starts not
|
||||
// activated: is_activated is never written from the form, and only the
|
||||
// activate actions (or the invitation link) set it.
|
||||
func (c usersAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
user, ok := model.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return errors.New("users admin: form model is not a user")
|
||||
}
|
||||
if err := c.requireFreeEmail(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.storePassword(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
user.IsActivated = false
|
||||
// The column defaults to true; only an account created through a social
|
||||
// login starts without a password of its own.
|
||||
user.HasSelfSetPassword = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireFreeEmail refuses an email another user already has, deactivated
|
||||
// users included: the unique rule leaves deactivated rows out, and the
|
||||
// database constraint does not.
|
||||
func (c usersAdminController) requireFreeEmail(ctx context.Context, user *models.User) error {
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
var taken int64
|
||||
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Model(&models.User{}).Where("email = ? AND id <> ?", user.Email, user.ID).Count(&taken).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken == 0 {
|
||||
return nil
|
||||
}
|
||||
message := "The email has already been taken."
|
||||
if tr := appTranslator(c.application()); tr != nil {
|
||||
const key = "lagoon::validate.unique"
|
||||
if text := tr.Get(ctx, key, map[string]string{"attribute": "email"}); text != "" && text != key {
|
||||
message = text
|
||||
}
|
||||
}
|
||||
return &cabana.ValidationError{Details: map[string]any{"email": []string{message}}}
|
||||
}
|
||||
|
||||
// storePassword hashes a submitted password onto the user with
|
||||
// golem15.user.password.bcrypt_cost and reports whether one was submitted.
|
||||
// An absent or empty password changes nothing.
|
||||
func (c usersAdminController) storePassword(ctx context.Context, user *models.User) (bool, error) {
|
||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||
plain, _ := values["password"].(string)
|
||||
if plain == "" {
|
||||
return false, nil
|
||||
}
|
||||
hashed, err := bouncer.HashPassword(c.configInt("golem15.user.password.bcrypt_cost", 10), plain)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
user.Password = hashed
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// FormAfterCreate sends the invitation when the send_invite checkbox was
|
||||
// set: an activation code is issued inside the create's transaction, and
|
||||
// exactly one golem15.user::mail.invite message with the activation link
|
||||
// leaves after the commit. A create that rolls back sends nothing.
|
||||
func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||
user, ok := model.(*models.User)
|
||||
if !ok || user == nil {
|
||||
return errors.New("users admin: form model is not a user")
|
||||
}
|
||||
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
||||
if invite, _ := values["send_invite"].(bool); !invite {
|
||||
return nil
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("users admin: a create needs its transaction")
|
||||
}
|
||||
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := c.application()
|
||||
id, email := user.ID, user.Email
|
||||
vars := mailVars(displayName(user), activationLink(app, id, code), strconv.FormatUint(uint64(id), 10)+"!"+code)
|
||||
vars["login"], vars["Login"] = email, email
|
||||
hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72)
|
||||
vars["hours"], vars["Hours"] = hours, hours
|
||||
template := mailName(ctx, user, "golem15.user::mail.invite")
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
sendTemplateVars(ctx, app, template, email, vars)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminFieldRelations binds the organisation picker. organisation_id is a
|
||||
// protected column, so the contract opts in to writing it through this one
|
||||
// field; the submitted id is still checked against the options.
|
||||
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{
|
||||
{
|
||||
Field: "organisation",
|
||||
Kind: "belongsTo",
|
||||
NewRelated: func() any { return &models.Organisation{} },
|
||||
ForeignKey: "organisation_id",
|
||||
LabelColumn: "name",
|
||||
WritableForeignKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// permissionsField is the form's permission editor field.
|
||||
const permissionsField = "permissions"
|
||||
|
||||
// AdminPermissionOptions offers the rows of
|
||||
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
||||
func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
||||
if field != permissionsField {
|
||||
return nil, fmt.Errorf("users admin: unknown permission field %q", field)
|
||||
}
|
||||
db := requestDB(ctx, c.db)
|
||||
if db == nil {
|
||||
return nil, errors.New("users admin: gorm handle is missing")
|
||||
}
|
||||
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := make([]cabana.PermissionOption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
||||
if row.Comment != nil {
|
||||
option.Comment = *row.Comment
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// AdminPermissionValues returns the user's own stored permissions.
|
||||
func (usersAdminController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
|
||||
user, ok := record.(*models.User)
|
||||
if field != permissionsField || !ok || user == nil {
|
||||
return nil, fmt.Errorf("users admin: cannot read permission field %q", field)
|
||||
}
|
||||
values := make(map[string]int, len(user.Permissions))
|
||||
for code, value := range user.Permissions {
|
||||
values[code] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// AdminSetPermissionValues puts the validated set on the user; the save
|
||||
// writes the row. Zero values are left out on write (PermissionSet.Value).
|
||||
func (usersAdminController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
|
||||
user, ok := record.(*models.User)
|
||||
if field != permissionsField || !ok || user == nil {
|
||||
return fmt.Errorf("users admin: cannot write permission field %q", field)
|
||||
}
|
||||
set := make(models.PermissionSet, len(values))
|
||||
for code, value := range values {
|
||||
set[code] = value
|
||||
}
|
||||
user.Permissions = set
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ plugin:
|
||||
manage_privileged_groups: Manage privileged group membership
|
||||
users:
|
||||
menu_label: Users
|
||||
new_user: New User
|
||||
list_title: Manage Users
|
||||
list_empty: No users match this view. Clear the filters, or add one with New User.
|
||||
state_deactivated: Deactivated
|
||||
@@ -64,5 +65,16 @@ user:
|
||||
created_ip_address: Created IP Address
|
||||
last_ip_address: Last IP Address
|
||||
status_activated: Activated
|
||||
send_invite: Send invitation by email
|
||||
send_invite_comment: Sends a welcome message containing login and password information.
|
||||
password: Password
|
||||
password_comment: On a new user, enter the sign-in password. On an existing user, leave empty to keep the current password.
|
||||
confirm_password: Password Confirmation
|
||||
confirm_password_comment: Enter the password again to confirm it.
|
||||
avatar: Avatar
|
||||
permissions_tab: Permissions
|
||||
group:
|
||||
label: Group
|
||||
organisation:
|
||||
label: Organisation
|
||||
empty_organisation: — No organisation —
|
||||
|
||||
@@ -10,6 +10,7 @@ plugin:
|
||||
manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych
|
||||
users:
|
||||
menu_label: Użytkownicy
|
||||
new_user: Nowy użytkownik
|
||||
list_title: Zarządzaj użytkownikami
|
||||
list_empty: Brak użytkowników w tym widoku. Wyczyść filtry lub dodaj użytkownika przyciskiem Nowy użytkownik.
|
||||
state_deactivated: Dezaktywowany
|
||||
@@ -64,5 +65,16 @@ user:
|
||||
created_ip_address: Stworzono z IP
|
||||
last_ip_address: Ostatnie IP
|
||||
status_activated: Aktywowany
|
||||
send_invite: Wyślij zaproszenie pocztą e-mail
|
||||
send_invite_comment: Wysyła wiadomość powitalną zawierającą dane logowania i hasło.
|
||||
password: Hasło
|
||||
password_comment: Dla nowego użytkownika wpisz hasło do logowania. Dla istniejącego pozostaw puste, aby nie zmieniać hasła.
|
||||
confirm_password: Potwierdzenie hasła
|
||||
confirm_password_comment: Wprowadź ponownie hasło, aby je potwierdzić.
|
||||
avatar: Avatar
|
||||
permissions_tab: Uprawnienia
|
||||
group:
|
||||
label: Grupa
|
||||
organisation:
|
||||
label: Organizacja
|
||||
empty_organisation: — Brak organizacji —
|
||||
|
||||
@@ -14,6 +14,41 @@ fields:
|
||||
type: text
|
||||
span: full
|
||||
tab: golem15.user::lang.user.account
|
||||
send_invite:
|
||||
label: golem15.user::lang.user.send_invite
|
||||
comment: golem15.user::lang.user.send_invite_comment
|
||||
type: checkbox
|
||||
default: true
|
||||
tab: golem15.user::lang.user.account
|
||||
context: create
|
||||
password:
|
||||
label: golem15.user::lang.user.password
|
||||
comment: golem15.user::lang.user.password_comment
|
||||
type: password
|
||||
span: left
|
||||
tab: golem15.user::lang.user.account
|
||||
context: [create, update]
|
||||
password_confirmation:
|
||||
label: golem15.user::lang.user.confirm_password
|
||||
comment: golem15.user::lang.user.confirm_password_comment
|
||||
type: password
|
||||
span: right
|
||||
tab: golem15.user::lang.user.account
|
||||
context: [create, update]
|
||||
organisation:
|
||||
label: golem15.user::lang.organisation.label
|
||||
type: relation
|
||||
nameFrom: name
|
||||
span: full
|
||||
emptyOption: golem15.user::lang.organisation.empty_organisation
|
||||
tab: golem15.user::lang.user.account
|
||||
avatar:
|
||||
label: golem15.user::lang.user.avatar
|
||||
type: fileupload
|
||||
mode: image
|
||||
imageWidth: 260
|
||||
imageHeight: 260
|
||||
tab: golem15.user::lang.user.account
|
||||
created_ip_address:
|
||||
label: golem15.user::lang.user.created_ip_address
|
||||
type: text
|
||||
@@ -26,3 +61,9 @@ fields:
|
||||
span: right
|
||||
tab: golem15.user::lang.user.account
|
||||
context: preview
|
||||
permissions:
|
||||
type: permissioneditor
|
||||
mode: radio
|
||||
span: full
|
||||
tab: golem15.user::lang.user.permissions_tab
|
||||
context: update
|
||||
|
||||
@@ -189,6 +189,8 @@ func (p *Plugin) MailTemplates() []string {
|
||||
"golem15.user::mail.restore-en",
|
||||
"golem15.user::mail.reactivate",
|
||||
"golem15.user::mail.reactivate-en",
|
||||
"golem15.user::mail.invite",
|
||||
"golem15.user::mail.invite-en",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
views/mail/invite-en.htm
Normal file
13
views/mail/invite-en.htm
Normal file
@@ -0,0 +1,13 @@
|
||||
subject = "An account has been created for you"
|
||||
description = "Invite a new user to the website"
|
||||
layout = "user"
|
||||
==
|
||||
Hello **{{ .Name }}**.
|
||||
|
||||
An account has been created for you. Use the link below to activate it:
|
||||
|
||||
[Activate your account]({{ .Link }})
|
||||
|
||||
This link expires in {{ .Hours }} hours. If it has expired, ask your administrator for a new invitation.
|
||||
|
||||
Your login: `{{ .Login }}`
|
||||
13
views/mail/invite.htm
Normal file
13
views/mail/invite.htm
Normal file
@@ -0,0 +1,13 @@
|
||||
subject = "Utworzono dla Ciebie konto"
|
||||
description = "Zaproszenie nowego użytkownika"
|
||||
layout = "user"
|
||||
==
|
||||
Cześć **{{ .Name }}**.
|
||||
|
||||
Utworzono dla Ciebie konto. Aktywuj je, korzystając z poniższego linku:
|
||||
|
||||
[Aktywuj konto]({{ .Link }})
|
||||
|
||||
Link wygasa po {{ .Hours }} godz. Jeśli wygasł, poproś administratora o nowe zaproszenie.
|
||||
|
||||
Twój login: `{{ .Login }}`
|
||||
Reference in New Issue
Block a user