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:
Jakub Zych
2026-10-05 13:21:51 +02:00
parent cdea47a178
commit f7ca3c4f59
12 changed files with 1080 additions and 16 deletions

View File

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