- controller golem15.user.organisations behind golem15.users.access_users - list and form (name, slug preset from the name, description, avatar) - members relation manager: link sets and unlink clears users.organisation_id - deleting an organisation releases its members first - Organisation gains Fillable, Rules, MorphName, AttachRelations, BeforeValidate and the Members relation; models.Slugify - navigation side item organisations - TestAdminOrganisations, TestAdminOrganisationMembers, TestSlugify
1502 lines
66 KiB
Go
1502 lines
66 KiB
Go
package user
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
privilegedMemberForbidden = "This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved."
|
|
privilegedMemberDeleteForbidden = "Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted."
|
|
)
|
|
|
|
// TestAdminUsersTracer drives the Users screen end to end: navigation and
|
|
// permission gating, the list schema, row states, search, the three filters
|
|
// and an update.
|
|
func TestAdminUsersTracer(t *testing.T) {
|
|
// The permission catalog: the four Winter codes default to the developer
|
|
// role, the privileged-groups code to no role at all.
|
|
var codes []string
|
|
for _, permission := range (&Plugin{}).Permissions() {
|
|
role := "-"
|
|
if len(permission.Roles) == 1 {
|
|
role = permission.Roles[0]
|
|
}
|
|
codes = append(codes, permission.Code+"@"+role)
|
|
if permission.Tab != "golem15.user::lang.plugin.tab" || !strings.HasPrefix(permission.Label, "golem15.user::lang.plugin.") {
|
|
t.Fatalf("permission %+v", permission)
|
|
}
|
|
}
|
|
if want := []string{
|
|
"golem15.users.access_users@developer", "golem15.users.access_groups@developer",
|
|
"golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer",
|
|
classes.PermissionManagePrivilegedGroups + "@-",
|
|
}; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged {
|
|
t.Fatalf("permissions = %v", codes)
|
|
}
|
|
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
stranger := env.admin(t)
|
|
|
|
// Navigation lists the item only for an administrator who may open it.
|
|
var nav []struct {
|
|
Code string `json:"code"`
|
|
Order int `json:"order"`
|
|
Controller string `json:"controller"`
|
|
SideMenu []struct {
|
|
Code string `json:"code"`
|
|
} `json:"sideMenu"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), token, nil), &nav)
|
|
if len(nav) != 1 || nav[0].Code != "user" || nav[0].Order != 555 || nav[0].Controller != "golem15.user.users" ||
|
|
len(nav[0].SideMenu) != 2 || nav[0].SideMenu[0].Code != "users" || nav[0].SideMenu[1].Code != "organisations" {
|
|
t.Fatalf("navigation = %+v", nav)
|
|
}
|
|
nav = nil
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), stranger, nil), &nav)
|
|
if len(nav) != 0 {
|
|
t.Fatalf("navigation without the permission = %+v", nav)
|
|
}
|
|
for _, path := range []string{adminAPI(adminUsersPath), adminAPI(adminUsersPath + "/schema/list"), adminAPI(adminUsersPath + "/schema/form")} {
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, stranger, nil)
|
|
if code := adminDecode(t, rec).Error.Code; code != "forbidden" {
|
|
t.Fatalf("%s code = %q", path, code)
|
|
}
|
|
}
|
|
env.expect(t, http.StatusUnauthorized, http.MethodGet, adminAPI(adminUsersPath), "", nil)
|
|
|
|
// The list schema: exactly the ported columns and filters. There is no
|
|
// login-name column (D-18) and no guest column (D-03).
|
|
var schema struct {
|
|
RecordURL string `json:"recordUrl"`
|
|
Columns []struct {
|
|
Key string `json:"key"`
|
|
Invisible bool `json:"invisible"`
|
|
Searchable bool `json:"searchable"`
|
|
Type string `json:"type"`
|
|
} `json:"columns"`
|
|
Filters []struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Scope string `json:"scope"`
|
|
Column string `json:"column"`
|
|
} `json:"filters"`
|
|
Messages map[string]map[string]string `json:"messages"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/list"), token, nil), &schema)
|
|
var columns, invisible, searchable, filters []string
|
|
for _, column := range schema.Columns {
|
|
columns = append(columns, column.Key)
|
|
if column.Invisible {
|
|
invisible = append(invisible, column.Key)
|
|
}
|
|
if column.Searchable {
|
|
searchable = append(searchable, column.Key)
|
|
}
|
|
}
|
|
for _, filter := range schema.Filters {
|
|
filters = append(filters, filter.Name+":"+filter.Type+":"+filter.Scope+filter.Column)
|
|
}
|
|
if want := []string{"id", "name", "surname", "email", "created_at", "last_seen", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(columns, want) {
|
|
t.Fatalf("columns = %v, want %v", columns, want)
|
|
}
|
|
if want := []string{"id", "surname", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(invisible, want) {
|
|
t.Fatalf("invisible columns = %v, want %v", invisible, want)
|
|
}
|
|
if want := []string{"name", "surname", "email", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(searchable, want) {
|
|
t.Fatalf("searchable columns = %v, want %v", searchable, want)
|
|
}
|
|
if want := []string{"groups:scope:filterByGroup", "created_date:daterange:created_at", "activated:switch:is_activated"}; !reflect.DeepEqual(filters, want) {
|
|
t.Fatalf("filters = %v, want %v", filters, want)
|
|
}
|
|
for key, want := range map[string]string{
|
|
"empty": "No users match this view. Clear the filters, or add one with New User.",
|
|
"rowStateDeleted": "Deactivated",
|
|
"rowStateNegative": "Banned",
|
|
"rowStateDisabled": "Not activated",
|
|
} {
|
|
if got := schema.Messages[key]["other"]; got != want {
|
|
t.Fatalf("list message %s = %q", key, got)
|
|
}
|
|
}
|
|
|
|
// Seed: every user of this test shares one marker in the surname, which
|
|
// is searchable and not shown.
|
|
marker := "tracer" + adminStamp()
|
|
seed := func(label string, mutate func(*models.User)) *models.User {
|
|
return env.seedUser(t, label, func(u *models.User) {
|
|
u.Surname = &marker
|
|
if mutate != nil {
|
|
mutate(u)
|
|
}
|
|
})
|
|
}
|
|
active := seed("active", nil)
|
|
deactivated := seed("deactivated", nil)
|
|
banned := seed("banned", nil)
|
|
inactive := seed("inactive", func(u *models.User) { u.IsActivated = false })
|
|
old := seed("old", nil)
|
|
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, deactivated.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := env.gdb.Exec(`INSERT INTO user_throttle (user_id, ip_address, is_banned) VALUES (?, NULL, TRUE)`, banned.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := env.gdb.Exec(`UPDATE users SET created_at = '2020-06-15 12:00:00+00' WHERE id = ?`, old.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
group := env.seedGroup(t, "Tracer group "+marker, "tracer-"+marker)
|
|
env.join(t, active, group)
|
|
env.join(t, deactivated, group)
|
|
|
|
// Search by the invisible surname finds all five, deactivated included,
|
|
// with their row states; the rows carry no surname and no secret.
|
|
page := env.users(t, token, "search="+marker)
|
|
if page.Total != 5 {
|
|
t.Fatalf("search total = %d: %s", page.Total, page.Raw)
|
|
}
|
|
for _, user := range []*models.User{active, deactivated, banned, inactive, old} {
|
|
row := page.row(user.ID)
|
|
if row == nil {
|
|
t.Fatalf("user %d missing from the list: %s", user.ID, page.Raw)
|
|
}
|
|
for _, hidden := range []string{"surname", "created_ip_address", "last_ip_address", "permissions", "is_activated"} {
|
|
if _, ok := row[hidden]; ok {
|
|
t.Fatalf("list row carries %s: %v", hidden, row)
|
|
}
|
|
}
|
|
for _, shown := range []string{"id", "name", "email", "created_at", "last_seen"} {
|
|
if _, ok := row[shown]; !ok {
|
|
t.Fatalf("list row lacks %s: %v", shown, row)
|
|
}
|
|
}
|
|
}
|
|
assertNoSecret(t, page.Raw)
|
|
for id, want := range map[uint][]string{
|
|
active.ID: nil,
|
|
deactivated.ID: {"deleted"},
|
|
banned.ID: {"negative"},
|
|
inactive.ID: {"disabled"},
|
|
} {
|
|
if got := page.states(id); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("row states of %d = %v, want %v", id, got, want)
|
|
}
|
|
}
|
|
|
|
// The groups filter keeps the group's members; its choices are the groups.
|
|
gid := strconv.FormatUint(uint64(group.ID), 10)
|
|
members := env.users(t, token, "search="+marker+"&filter[groups]="+gid)
|
|
if members.Total != 2 || !members.has(active.ID) || !members.has(deactivated.ID) {
|
|
t.Fatalf("group filter = %s", members.Raw)
|
|
}
|
|
var options []struct {
|
|
Value string `json:"value"`
|
|
Label string `json:"label"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/filters/groups/options"), token, nil), &options)
|
|
found := false
|
|
for _, option := range options {
|
|
if option.Value == gid && option.Label == group.Name {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("group %s missing from the filter options: %+v", gid, options)
|
|
}
|
|
env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath+"/filters/groups/options"), stranger, nil)
|
|
|
|
// The date range and the activated switch.
|
|
ranged := env.users(t, token, "search="+marker+"&filter[created_date]=2020-06-01..2020-06-30")
|
|
if ranged.Total != 1 || !ranged.has(old.ID) {
|
|
t.Fatalf("date range filter = %s", ranged.Raw)
|
|
}
|
|
notActivated := env.users(t, token, "search="+marker+"&filter[activated]=false")
|
|
if notActivated.Total != 1 || !notActivated.has(inactive.ID) {
|
|
t.Fatalf("activated=false filter = %s", notActivated.Raw)
|
|
}
|
|
activated := env.users(t, token, "search="+marker+"&filter[activated]=true")
|
|
if activated.Total != 4 || activated.has(inactive.ID) {
|
|
t.Fatalf("activated=true filter = %s", activated.Raw)
|
|
}
|
|
|
|
// An update of name and email persists and returns no secret.
|
|
renamed := "renamed-" + marker + "@example.test"
|
|
rec := env.expect(t, http.StatusOK, http.MethodPut, userPath(active.ID, ""), token, map[string]any{"name": "Renamed", "email": renamed})
|
|
assertNoSecret(t, rec.Body.String())
|
|
if got := env.reload(t, active.ID); got.Email != renamed || got.Name == nil || *got.Name != "Renamed" {
|
|
t.Fatalf("update did not persist: %+v", got)
|
|
}
|
|
// The stored hash survives an update that does not name a password.
|
|
if got := env.reload(t, active.ID); got.Password != active.Password {
|
|
t.Fatal("an update without a password changed the stored hash")
|
|
}
|
|
// A name-only update needs no password (the admin rules replace the
|
|
// register rules).
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(active.ID, ""), token, map[string]any{"name": "Renamed again", "email": renamed})
|
|
|
|
// A deactivated user can be shown and updated and stays deactivated.
|
|
show := env.expect(t, http.StatusOK, http.MethodGet, userPath(deactivated.ID, ""), token, nil)
|
|
assertNoSecret(t, show.Body.String())
|
|
before := env.reload(t, deactivated.ID)
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(deactivated.ID, ""), token, map[string]any{"name": "Still gone", "email": deactivated.Email})
|
|
after := env.reload(t, deactivated.ID)
|
|
if !after.DeletedAt.Valid || !after.DeletedAt.Time.Equal(before.DeletedAt.Time) || after.Name == nil || *after.Name != "Still gone" {
|
|
t.Fatalf("deactivated user after update = %+v", after)
|
|
}
|
|
|
|
// Validation failures answer 422 and mark the field.
|
|
dup := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(banned.ID, ""), token, map[string]any{"name": "Dup", "email": renamed})
|
|
if details := adminDecode(t, dup).Error.Details; len(details["email"]) == 0 {
|
|
t.Fatalf("duplicate email details = %v", details)
|
|
}
|
|
bad := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(banned.ID, ""), token, map[string]any{"name": "Bad", "email": "nope"})
|
|
if details := adminDecode(t, bad).Error.Details; len(details["email"]) == 0 {
|
|
t.Fatalf("invalid email details = %v", details)
|
|
}
|
|
if got := env.reload(t, banned.ID); got.Email != banned.Email {
|
|
t.Fatal("a refused update changed the email")
|
|
}
|
|
|
|
// Protected columns are never written from a body.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(inactive.ID, ""), token, map[string]any{
|
|
"name": "Still inactive", "email": inactive.Email, "is_activated": true, "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 || 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)
|
|
}
|
|
|
|
// Writes need the permission too.
|
|
env.expect(t, http.StatusForbidden, http.MethodPut, userPath(active.ID, ""), stranger, map[string]any{"name": "Nope", "email": renamed})
|
|
}
|
|
|
|
// TestAdminPrivilegedMember covers the takeover guard (D-30): the email of a
|
|
// member of a privileged group, and the delete of such a user, need
|
|
// golem15.users.manage_privileged_groups on top of
|
|
// golem15.users.access_users.
|
|
func TestAdminPrivilegedMember(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
limited := env.admin(t, permAccessUsers)
|
|
manager := env.admin(t, permAccessUsers, permManagePrivileged)
|
|
|
|
stamp := adminStamp()
|
|
// The default privileged list is [admin]; several groups may carry the code.
|
|
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
|
|
ordinaryCode := "ordinary-" + stamp
|
|
ordinary := env.seedGroup(t, "Ordinary "+stamp, ordinaryCode)
|
|
uncoded := env.seedGroup(t, "Uncoded "+stamp, "")
|
|
|
|
member := env.seedUser(t, "member", nil)
|
|
env.join(t, member, adminGroup, ordinary)
|
|
plain := env.seedUser(t, "plain", nil)
|
|
env.join(t, plain, ordinary, uncoded)
|
|
gone := env.seedUser(t, "gone", nil)
|
|
env.join(t, gone, adminGroup)
|
|
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
assertRefused := func(body map[string]any, id uint, fields ...string) {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodPut, userPath(id, ""), limited, body)
|
|
answer := adminDecode(t, rec)
|
|
if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberForbidden {
|
|
t.Fatalf("refusal = %s", rec.Body.String())
|
|
}
|
|
if len(answer.Error.Details) != len(fields) {
|
|
t.Fatalf("refusal details = %v, want fields %v", answer.Error.Details, fields)
|
|
}
|
|
for _, field := range fields {
|
|
if got := answer.Error.Details[field]; len(got) != 1 || got[0] != privilegedMemberForbidden {
|
|
t.Fatalf("refusal details[%s] = %v", field, got)
|
|
}
|
|
}
|
|
}
|
|
unchanged := func(original *models.User) {
|
|
t.Helper()
|
|
got := env.reload(t, original.ID)
|
|
if got == nil {
|
|
t.Fatalf("user %d is gone", original.ID)
|
|
}
|
|
if got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) || got.Password != original.Password ||
|
|
got.DeletedAt.Valid != original.DeletedAt.Valid {
|
|
t.Fatalf("user %d changed: %+v", original.ID, got)
|
|
}
|
|
}
|
|
gone = env.reload(t, gone.ID)
|
|
|
|
// An email change on a privileged member is refused as a whole: the name
|
|
// in the same body is not saved either.
|
|
assertRefused(map[string]any{"name": "Taken over", "email": "takeover-" + stamp + "@example.test"}, member.ID, "email")
|
|
unchanged(member)
|
|
// A deactivated privileged member is protected the same way.
|
|
assertRefused(map[string]any{"name": "Taken over", "email": "takeover-gone-" + stamp + "@example.test"}, gone.ID, "email")
|
|
unchanged(gone)
|
|
|
|
// The stored email sent back unchanged with a new name is an ordinary update.
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "New name", "email": member.Email})
|
|
if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "New name" || got.Email != member.Email {
|
|
t.Fatalf("name-only update of a privileged member = %+v", got)
|
|
}
|
|
member = env.reload(t, member.ID)
|
|
|
|
// 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)
|
|
answer := adminDecode(t, rec)
|
|
if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberDeleteForbidden || len(answer.Error.Details) != 0 {
|
|
t.Fatalf("delete refusal = %s", rec.Body.String())
|
|
}
|
|
unchanged(target)
|
|
if got := env.groupIDs(t, target.ID); len(got) == 0 {
|
|
t.Fatalf("a refused delete removed the memberships of %d", target.ID)
|
|
}
|
|
}
|
|
|
|
// A member of ordinary groups only, and of a group without a code, is
|
|
// not protected.
|
|
plainEmail := "plain-new-" + stamp + "@example.test"
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(plain.ID, ""), limited, map[string]any{"name": "Plain", "email": plainEmail})
|
|
if got := env.reload(t, plain.ID); got.Email != plainEmail {
|
|
t.Fatalf("ordinary member email = %q", got.Email)
|
|
}
|
|
|
|
// With the permission both refused requests succeed.
|
|
memberEmail := "member-new-" + stamp + "@example.test"
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Managed", "email": memberEmail})
|
|
if got := env.reload(t, member.ID); got.Email != memberEmail {
|
|
t.Fatalf("manager email change = %q", got.Email)
|
|
}
|
|
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
|
|
// row and its blob.
|
|
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 {
|
|
t.Fatalf("member seed = %+v", memberHangers)
|
|
}
|
|
rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(member.ID, ""), limited, nil)
|
|
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberDeleteForbidden {
|
|
t.Fatalf("delete refusal = %s", rec.Body.String())
|
|
}
|
|
if env.reload(t, member.ID) == nil || env.hangers(t, member) != memberHangers || !env.blobExists(t, memberAvatar) {
|
|
t.Fatalf("a refused delete changed the member: %+v", env.hangers(t, member))
|
|
}
|
|
|
|
// A bulk delete with one privileged member in the selection is refused as
|
|
// a whole: the two ordinary users and their rows stay as well.
|
|
first := env.seedUser(t, "bulk-first", nil)
|
|
second := env.seedUser(t, "bulk-second", nil)
|
|
env.join(t, first, ordinary)
|
|
env.seedThrottle(t, first, "", false, nil)
|
|
firstAvatar := env.seedAvatar(t, first)
|
|
firstHangers := env.hangers(t, first)
|
|
// Ids are processed in ascending order, so the member (the lowest id) is
|
|
// refused first; the reverse order is covered by the second selection.
|
|
late := env.seedUser(t, "late-member", nil)
|
|
env.join(t, late, adminGroup)
|
|
for _, selection := range [][]*models.User{{member, first, second}, {first, second, late}} {
|
|
rec = env.bulk(t, limited, "delete", selection...)
|
|
if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Message != privilegedMemberDeleteForbidden {
|
|
t.Fatalf("bulk delete refusal = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
for _, user := range selection {
|
|
if env.reload(t, user.ID) == nil {
|
|
t.Fatalf("a refused bulk delete removed user %d", user.ID)
|
|
}
|
|
}
|
|
if env.hangers(t, first) != firstHangers || env.hangers(t, member) != memberHangers ||
|
|
!env.blobExists(t, firstAvatar) || !env.blobExists(t, memberAvatar) {
|
|
t.Fatal("a refused bulk delete removed rows or blobs")
|
|
}
|
|
}
|
|
|
|
// Deactivating a privileged member is not a permanent delete and needs
|
|
// only the users permission; so does restoring them.
|
|
if rec = env.bulk(t, limited, "deactivate", late); rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk deactivate of a privileged member = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.reload(t, late.ID); got == nil || !got.DeletedAt.Valid {
|
|
t.Fatalf("privileged member after deactivate = %+v", got)
|
|
}
|
|
if rec = env.bulk(t, limited, "restore", late); rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk restore of a privileged member = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// With the permission the form delete and the bulk delete remove the
|
|
// member for good, with the full cleanup.
|
|
env.expect(t, http.StatusOK, http.MethodDelete, userPath(member.ID, ""), manager, nil)
|
|
if env.reload(t, member.ID) != nil || env.hangers(t, member) != (hangers{}) || env.blobExists(t, memberAvatar) {
|
|
t.Fatalf("manager delete left something of the member: %+v", env.hangers(t, member))
|
|
}
|
|
if rec = env.bulk(t, manager, "delete", first, second, late); rec.Code != http.StatusOK {
|
|
t.Fatalf("manager bulk delete = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
for _, user := range []*models.User{first, second, late} {
|
|
if env.reload(t, user.ID) != nil || env.hangers(t, user) != (hangers{}) {
|
|
t.Fatalf("manager bulk delete left user %d: %+v", user.ID, env.hangers(t, user))
|
|
}
|
|
}
|
|
if env.blobExists(t, firstAvatar) {
|
|
t.Fatal("manager bulk delete left the avatar blob")
|
|
}
|
|
|
|
// The privileged list is config, read per request: with the ordinary
|
|
// group's code in it the protection moves to that group's member, and the
|
|
// admin group's member is no longer protected.
|
|
moved := newAdminEnv(t, func(cfg *compass.Config) {
|
|
if err := cfg.Set("golem15.user.privileged_groups", []string{ordinaryCode}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
movedLimited := moved.admin(t, permAccessUsers)
|
|
rec = moved.expect(t, http.StatusForbidden, http.MethodPut, userPath(plain.ID, ""), movedLimited, map[string]any{"name": "Plain", "email": "plain-moved-" + stamp + "@example.test"})
|
|
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 {
|
|
t.Fatalf("moved refusal = %s", rec.Body.String())
|
|
}
|
|
if got := moved.reload(t, plain.ID); got.Email != plainEmail {
|
|
t.Fatalf("a refused update changed the email to %q", got.Email)
|
|
}
|
|
goneEmail := "gone-moved-" + stamp + "@example.test"
|
|
moved.expect(t, http.StatusOK, http.MethodPut, userPath(gone.ID, ""), movedLimited, map[string]any{"name": "Gone", "email": goneEmail})
|
|
if got := moved.reload(t, gone.ID); got.Email != goneEmail || !got.DeletedAt.Valid {
|
|
t.Fatalf("admin-group member under the moved list = %+v", got)
|
|
}
|
|
if strings.Contains(rec.Body.String(), adminHarnessSecret) {
|
|
t.Fatal("a refusal carries the admin secret")
|
|
}
|
|
}
|
|
|
|
// bulkAnswer is the data of a bulk action answer.
|
|
type bulkAnswer struct {
|
|
Message string `json:"message"`
|
|
Affected int `json:"affected"`
|
|
}
|
|
|
|
// TestAdminUserActions covers the five bulk actions, the three record
|
|
// actions and the preview status hint (D-11, D-14), and that none of them
|
|
// writes users_groups.
|
|
func TestAdminUserActions(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
stranger := env.admin(t)
|
|
|
|
// Nothing but the ported actions is offered: no impersonate, no guest.
|
|
var schema struct {
|
|
RecordURL string `json:"recordUrl"`
|
|
BulkActions []struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Confirm string `json:"confirm"`
|
|
} `json:"bulkActions"`
|
|
ToolbarButtons []string `json:"toolbarButtons"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/list"), token, nil), &schema)
|
|
var bulkNames []string
|
|
for _, action := range schema.BulkActions {
|
|
bulkNames = append(bulkNames, action.Name)
|
|
if action.Label == "" || strings.Contains(action.Label, "::") || strings.Contains(action.Confirm, "::") {
|
|
t.Fatalf("bulk action copy is not translated: %+v", action)
|
|
}
|
|
}
|
|
if want := []string{"delete", "activate", "deactivate", "restore", "ban", "unban"}; !reflect.DeepEqual(bulkNames, want) {
|
|
t.Fatalf("bulk actions = %v, want %v", bulkNames, want)
|
|
}
|
|
if schema.RecordURL != "golem15/user/users/preview/:id" {
|
|
t.Fatalf("recordUrl = %q", schema.RecordURL)
|
|
}
|
|
var registered []string
|
|
for _, ctl := range controllers.AdminControllers(func() *backpack.App { return env.app }) {
|
|
if ctl.ID() != "golem15.user.users" {
|
|
continue
|
|
}
|
|
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
|
|
registered = append(registered, action.Name)
|
|
}
|
|
}
|
|
if want := []string{"activate", "unban", "unsuspend"}; !reflect.DeepEqual(registered, want) {
|
|
t.Fatalf("record actions = %v, want %v", registered, want)
|
|
}
|
|
var form struct {
|
|
Preview *struct {
|
|
HeaderPartial string `json:"headerPartial"`
|
|
} `json:"preview"`
|
|
Fields []struct {
|
|
Name string `json:"name"`
|
|
Context any `json:"context"`
|
|
} `json:"fields"`
|
|
Messages map[string]map[string]string `json:"messages"`
|
|
}
|
|
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
|
|
if form.Preview == nil || form.Preview.HeaderPartial != "hint" || form.Messages["edit"]["other"] != "Update details" {
|
|
t.Fatalf("form preview = %+v messages = %v", form.Preview, form.Messages["edit"])
|
|
}
|
|
previewOnly := map[string]bool{}
|
|
for _, field := range form.Fields {
|
|
if field.Context == "preview" {
|
|
previewOnly[field.Name] = true
|
|
}
|
|
}
|
|
if !previewOnly["created_ip_address"] || !previewOnly["last_ip_address"] || len(previewOnly) != 2 {
|
|
t.Fatalf("preview-only fields = %v", previewOnly)
|
|
}
|
|
|
|
group := env.seedGroup(t, "Actions "+adminStamp(), "actions-"+adminStamp())
|
|
seed := func(label string, mutate func(*models.User)) *models.User {
|
|
user := env.seedUser(t, label, mutate)
|
|
env.join(t, user, group)
|
|
return user
|
|
}
|
|
notActive := func(u *models.User) { u.IsActivated = false }
|
|
idleA, idleB, live := seed("idle-a", notActive), seed("idle-b", notActive), seed("live", nil)
|
|
code := "pending-code"
|
|
if err := env.gdb.Exec(`UPDATE users SET activation_code = ?, activation_code_issued_at = NOW() WHERE id = ?`, code, idleA.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
everyone := []*models.User{idleA, idleB, live}
|
|
memberships := func() map[uint][]uint {
|
|
out := map[uint][]uint{}
|
|
for _, user := range everyone {
|
|
out[user.ID] = env.groupIDs(t, user.ID)
|
|
}
|
|
return out
|
|
}
|
|
before := memberships()
|
|
untouched := func(step string) {
|
|
t.Helper()
|
|
if got := memberships(); !reflect.DeepEqual(got, before) {
|
|
t.Fatalf("%s changed users_groups: %v, want %v", step, got, before)
|
|
}
|
|
}
|
|
run := func(action string, users ...*models.User) bulkAnswer {
|
|
t.Helper()
|
|
rec := env.bulk(t, token, action, users...)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk %s = %d %s", action, rec.Code, rec.Body.String())
|
|
}
|
|
var answer bulkAnswer
|
|
adminInto(t, rec, &answer)
|
|
untouched("bulk " + action)
|
|
return answer
|
|
}
|
|
|
|
// Activate skips the user who is already active and reports the count
|
|
// without the fixed success phrase, which would overstate.
|
|
if answer := run("activate", everyone...); answer.Affected != 2 || answer.Message != "" {
|
|
t.Fatalf("bulk activate = %+v", answer)
|
|
}
|
|
for _, user := range everyone {
|
|
got := env.reload(t, user.ID)
|
|
if !got.IsActivated || (user.ID != live.ID && got.ActivatedAt == nil) || got.ActivationCode != nil {
|
|
t.Fatalf("user %d after bulk activate = %+v", user.ID, got)
|
|
}
|
|
}
|
|
if answer := run("activate", everyone...); answer.Affected != 0 || answer.Message != "" {
|
|
t.Fatalf("second bulk activate = %+v", answer)
|
|
}
|
|
// When every selected user changes, the fixed phrase is the message.
|
|
fresh := seed("fresh", notActive)
|
|
everyone = append(everyone, fresh)
|
|
before = memberships()
|
|
if answer := run("activate", fresh); answer.Affected != 1 || answer.Message != "Successfully activated the selected users." {
|
|
t.Fatalf("full bulk activate = %+v", answer)
|
|
}
|
|
|
|
// Deactivate is the soft delete; the users stay listed as deleted.
|
|
if answer := run("deactivate", idleA, idleB); answer.Affected != 2 || answer.Message != "Successfully deactivated the selected users." {
|
|
t.Fatalf("bulk deactivate = %+v", answer)
|
|
}
|
|
page := env.users(t, token, "search="+idleA.Email)
|
|
if !reflect.DeepEqual(page.states(idleA.ID), []string{"deleted"}) {
|
|
t.Fatalf("deactivated row = %s", page.Raw)
|
|
}
|
|
if got := env.reload(t, idleB.ID); !got.DeletedAt.Valid {
|
|
t.Fatal("bulk deactivate did not set deleted_at")
|
|
}
|
|
if answer := run("deactivate", idleA, live); answer.Affected != 1 || answer.Message != "" {
|
|
t.Fatalf("mixed bulk deactivate = %+v", answer)
|
|
}
|
|
// Restore brings them back.
|
|
if answer := run("restore", idleA, idleB, live); answer.Affected != 3 || answer.Message != "Successfully restored the selected users." {
|
|
t.Fatalf("bulk restore = %+v", answer)
|
|
}
|
|
for _, user := range []*models.User{idleA, idleB, live} {
|
|
if got := env.reload(t, user.ID); got.DeletedAt.Valid {
|
|
t.Fatalf("user %d is still deactivated", user.ID)
|
|
}
|
|
}
|
|
// Activating a deactivated, not activated user restores them as well.
|
|
sleeper := seed("sleeper", notActive)
|
|
everyone = append(everyone, sleeper)
|
|
before = memberships()
|
|
run("deactivate", sleeper)
|
|
if answer := run("activate", sleeper); answer.Affected != 1 {
|
|
t.Fatalf("activate of a deactivated user = %+v", answer)
|
|
}
|
|
if got := env.reload(t, sleeper.ID); !got.IsActivated || got.DeletedAt.Valid {
|
|
t.Fatalf("sleeper after activate = %+v", got)
|
|
}
|
|
|
|
// Ban refuses the login from any address and marks the row; unban lifts it.
|
|
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
|
|
t.Fatalf("login before the ban = %d", status)
|
|
}
|
|
if answer := run("ban", live); answer.Affected != 1 || answer.Message != "Successfully banned the selected users." {
|
|
t.Fatalf("bulk ban = %+v", answer)
|
|
}
|
|
for range 2 {
|
|
if status, token := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusUnauthorized || token != "" {
|
|
t.Fatalf("banned login = %d", status)
|
|
}
|
|
}
|
|
page = env.users(t, token, "search="+live.Email)
|
|
if !reflect.DeepEqual(page.states(live.ID), []string{"negative"}) {
|
|
t.Fatalf("banned row = %s", page.Raw)
|
|
}
|
|
if answer := run("ban", live, idleA); answer.Affected != 1 || answer.Message != "" {
|
|
t.Fatalf("second bulk ban = %+v", answer)
|
|
}
|
|
if answer := run("unban", live, idleA, idleB); answer.Affected != 2 || answer.Message != "" {
|
|
t.Fatalf("bulk unban = %+v", answer)
|
|
}
|
|
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
|
|
t.Fatalf("login after the unban = %d", status)
|
|
}
|
|
|
|
// Record actions are offered only while they apply.
|
|
pending := seed("pending", notActive)
|
|
everyone = append(everyone, pending)
|
|
before = memberships()
|
|
if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"activate"}) {
|
|
t.Fatalf("offered for a not activated user = %v", got)
|
|
}
|
|
if got := env.offered(t, token, live.ID); len(got) != 0 {
|
|
t.Fatalf("offered for an ordinary user = %v", got)
|
|
}
|
|
act := func(status int, action string, user *models.User) string {
|
|
t.Helper()
|
|
rec := env.expect(t, status, http.MethodPost, userPath(user.ID, "/actions/"+action), token, map[string]any{})
|
|
untouched("record " + action)
|
|
if status != http.StatusOK {
|
|
return ""
|
|
}
|
|
var answer struct {
|
|
Message string `json:"message"`
|
|
}
|
|
adminInto(t, rec, &answer)
|
|
return answer.Message
|
|
}
|
|
if message := act(http.StatusOK, "activate", pending); message != "User has been activated" {
|
|
t.Fatalf("activate message = %q", message)
|
|
}
|
|
if got := env.reload(t, pending.ID); !got.IsActivated || got.ActivatedAt == nil {
|
|
t.Fatalf("pending after activate = %+v", got)
|
|
}
|
|
act(http.StatusConflict, "activate", pending)
|
|
act(http.StatusConflict, "unban", pending)
|
|
act(http.StatusConflict, "unsuspend", pending)
|
|
|
|
run("ban", pending)
|
|
if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"unban"}) {
|
|
t.Fatalf("offered for a banned user = %v", got)
|
|
}
|
|
if message := act(http.StatusOK, "unban", pending); message != "User has been unbanned" {
|
|
t.Fatalf("unban message = %q", message)
|
|
}
|
|
if got := env.offered(t, token, pending.ID); len(got) != 0 {
|
|
t.Fatalf("offered after unban = %v", got)
|
|
}
|
|
|
|
// Suspended means suspended inside the window; an expired suspension
|
|
// offers nothing and is not lifted by reading it.
|
|
now, longAgo := time.Now(), time.Now().Add(-2*time.Hour)
|
|
suspended := seed("suspended", nil)
|
|
expired := seed("expired", nil)
|
|
everyone = append(everyone, suspended, expired)
|
|
before = memberships()
|
|
env.seedThrottle(t, suspended, "203.0.113.9", false, &now)
|
|
env.seedThrottle(t, expired, "203.0.113.9", false, &longAgo)
|
|
if got := env.offered(t, token, suspended.ID); !reflect.DeepEqual(got, []string{"unsuspend"}) {
|
|
t.Fatalf("offered for a suspended user = %v", got)
|
|
}
|
|
if got := env.offered(t, token, expired.ID); len(got) != 0 {
|
|
t.Fatalf("offered for an expired suspension = %v", got)
|
|
}
|
|
var stillFlagged int64
|
|
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_throttle WHERE user_id = ? AND is_suspended`, expired.ID).Scan(&stillFlagged).Error; err != nil || stillFlagged != 1 {
|
|
t.Fatalf("reading an expired suspension changed it: %d %v", stillFlagged, err)
|
|
}
|
|
if message := act(http.StatusOK, "unsuspend", suspended); message != "User has been unsuspended." {
|
|
t.Fatalf("unsuspend message = %q", message)
|
|
}
|
|
var row models.Throttle
|
|
if err := env.gdb.Where("user_id = ?", suspended.ID).Take(&row).Error; err != nil || row.IsSuspended || row.SuspendedAt != nil || row.Attempts != 0 {
|
|
t.Fatalf("throttle row after unsuspend = %+v err=%v", row, err)
|
|
}
|
|
act(http.StatusConflict, "unsuspend", suspended)
|
|
|
|
// The preview hint: one callout, by precedence banned, deactivated, not
|
|
// activated, suspended.
|
|
hint := func(user *models.User) string {
|
|
t.Helper()
|
|
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id="+strconv.FormatUint(uint64(user.ID), 10)), token, nil)
|
|
return rec.Body.String()
|
|
}
|
|
worst := seed("worst", notActive)
|
|
everyone = append(everyone, worst)
|
|
before = memberships()
|
|
env.seedThrottle(t, worst, "203.0.113.9", false, &now)
|
|
if body := hint(worst); !strings.Contains(body, "User not activated!") || !strings.Contains(body, "summer-callout--warning") || strings.Contains(body, "suspended") {
|
|
t.Fatalf("not activated beats suspended: %s", body)
|
|
}
|
|
run("deactivate", worst)
|
|
if body := hint(worst); !strings.Contains(body, "User has deactivated their account") || !strings.Contains(body, "summer-callout--danger") || strings.Contains(body, "not activated") {
|
|
t.Fatalf("deactivated beats not activated: %s", body)
|
|
}
|
|
run("ban", worst)
|
|
if body := hint(worst); !strings.Contains(body, "User has been banned") || strings.Contains(body, "deactivated their account") {
|
|
t.Fatalf("banned beats deactivated: %s", body)
|
|
}
|
|
lifted := seed("lifted", nil)
|
|
everyone = append(everyone, lifted)
|
|
before = memberships()
|
|
env.seedThrottle(t, lifted, "203.0.113.9", false, &now)
|
|
if body := hint(lifted); !strings.Contains(body, "User is temporarily suspended") || !strings.Contains(body, "summer-callout--warning") {
|
|
t.Fatalf("suspended hint: %s", body)
|
|
}
|
|
if body := hint(live); strings.Contains(body, "summer-callout") {
|
|
t.Fatalf("hint for an ordinary user: %s", body)
|
|
}
|
|
|
|
// Every action needs the users permission.
|
|
if rec := env.bulk(t, stranger, "ban", live); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("bulk ban without the permission = %d", rec.Code)
|
|
}
|
|
env.expect(t, http.StatusForbidden, http.MethodPost, userPath(lifted.ID, "/actions/unsuspend"), stranger, map[string]any{})
|
|
env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id=1"), stranger, nil)
|
|
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
|
|
t.Fatal("a refused ban changed the user")
|
|
}
|
|
// An undeclared action name is not run.
|
|
if rec := env.bulk(t, token, "impersonate", live); rec.Code == http.StatusOK {
|
|
t.Fatalf("an unknown bulk action answered %d", rec.Code)
|
|
}
|
|
if rec := env.call(t, http.MethodPost, userPath(live.ID, "/actions/impersonate"), token, map[string]any{}); rec.Code == http.StatusOK {
|
|
t.Fatalf("an unknown record action answered %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestAdminUserForceDelete covers the permanent delete (D-13): the form
|
|
// button and the bulk delete remove the user row with its throttle rows,
|
|
// group memberships and attachments, and the blobs after the commit.
|
|
func TestAdminUserForceDelete(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
stranger := env.admin(t)
|
|
group := env.seedGroup(t, "Delete "+adminStamp(), "delete-"+adminStamp())
|
|
|
|
loaded := func(label string) (*models.User, string) {
|
|
user := env.seedUser(t, label, nil)
|
|
env.join(t, user, group)
|
|
// The throttle foreign key has no ON DELETE: without the cleanup the
|
|
// delete would fail on it.
|
|
env.seedThrottle(t, user, "203.0.113.20", false, nil)
|
|
env.seedThrottle(t, user, "", true, nil)
|
|
return user, env.seedAvatar(t, user)
|
|
}
|
|
assertGone := func(user *models.User, key string) {
|
|
t.Helper()
|
|
if env.reload(t, user.ID) != nil {
|
|
t.Fatalf("user %d still exists", user.ID)
|
|
}
|
|
if left := env.hangers(t, user); left != (hangers{}) {
|
|
t.Fatalf("user %d left orphans: %+v", user.ID, left)
|
|
}
|
|
if env.blobExists(t, key) {
|
|
t.Fatalf("user %d left its avatar blob", user.ID)
|
|
}
|
|
}
|
|
|
|
// Without the permission nothing is deleted.
|
|
victim, victimKey := loaded("victim")
|
|
env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(victim.ID, ""), stranger, nil)
|
|
if rec := env.bulk(t, stranger, "delete", victim); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("bulk delete without the permission = %d", rec.Code)
|
|
}
|
|
if env.reload(t, victim.ID) == nil || env.hangers(t, victim) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, victimKey) {
|
|
t.Fatalf("a refused delete changed the user: %+v", env.hangers(t, victim))
|
|
}
|
|
|
|
// The form delete.
|
|
env.expect(t, http.StatusOK, http.MethodDelete, userPath(victim.ID, ""), token, nil)
|
|
assertGone(victim, victimKey)
|
|
env.expect(t, http.StatusNotFound, http.MethodGet, userPath(victim.ID, ""), token, nil)
|
|
|
|
// The bulk delete, with a deactivated user in the selection.
|
|
one, oneKey := loaded("bulk-one")
|
|
two, twoKey := loaded("bulk-two")
|
|
bystander, bystanderKey := loaded("bystander")
|
|
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, two.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := env.bulk(t, token, "delete", one, two)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("bulk delete = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var deleted struct {
|
|
Deleted int `json:"deleted"`
|
|
}
|
|
adminInto(t, rec, &deleted)
|
|
if deleted.Deleted != 2 {
|
|
t.Fatalf("bulk delete count = %d", deleted.Deleted)
|
|
}
|
|
assertGone(one, oneKey)
|
|
assertGone(two, twoKey)
|
|
if env.reload(t, bystander.ID) == nil || env.hangers(t, bystander) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, bystanderKey) {
|
|
t.Fatal("the bulk delete touched a user outside the selection")
|
|
}
|
|
// The group itself is untouched.
|
|
var groups int64
|
|
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE id = ?`, group.ID).Scan(&groups).Error; err != nil || groups != 1 {
|
|
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 or guest field (D-03, D-18,
|
|
// D-21).
|
|
if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "groups", "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())
|
|
}
|
|
}
|
|
}
|