Files
sm-user-plugin/admin_groups_test.go
Jakub Zych a3992879db feat: User Groups admin screen with the users count and privileged codes protected
- controller golem15.user.usergroups behind golem15.users.access_groups
- list with users_count from the list's own query; form with the checkbox
  permission editor stored in user_groups.permissions
- creating a group with a privileged code, changing a code to or from one and
  deleting a privileged group need golem15.users.manage_privileged_groups
- a group delete removes its users_groups rows in the same transaction
- UserGroup gains Fillable, Rules and the read-only UsersCount; PermissionSet.Text
- TestAdminGroups and the D-06 matrix in TestAdminPrivilegedGroups
2026-10-05 13:52:26 +02:00

406 lines
17 KiB
Go

package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
)
const (
// adminGroupsPath is the User Groups controller under the admin API.
adminGroupsPath = "/golem15/user/usergroups"
permAccessGroups = "golem15.users.access_groups"
privilegedGroupCodeForbidden = "You do not have permission to create, re-code or delete a privileged group. Nothing was saved."
)
// groupPath is the admin API path of one user group.
func groupPath(id uint) string {
return adminAPI(adminGroupsPath) + "/" + strconv.FormatUint(uint64(id), 10)
}
// storedGroup is a user_groups row as the database holds it.
type storedGroup struct {
Name string
Code *string
Description *string
Permissions *string
}
// group reads a user_groups row; nil when it is gone.
func (e *adminEnv) group(t *testing.T, id uint) *storedGroup {
t.Helper()
var rows []storedGroup
if err := e.gdb.Raw(`SELECT name, code, description, permissions FROM user_groups WHERE id = ?`, id).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) == 0 {
return nil
}
return &rows[0]
}
// sideMenu returns the side item codes of the navigation item `user` as the
// administrator sees them; nil when the item is not shown.
func (e *adminEnv) sideMenu(t *testing.T, token string) []string {
t.Helper()
var nav []struct {
Code string `json:"code"`
SideMenu []struct {
Code string `json:"code"`
Controller string `json:"controller"`
} `json:"sideMenu"`
}
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), token, nil), &nav)
for _, item := range nav {
if item.Code != "user" {
continue
}
codes := []string{}
for _, side := range item.SideMenu {
codes = append(codes, side.Code+"="+side.Controller)
}
return codes
}
return nil
}
// TestAdminGroups covers the User Groups screen (D-06 ordinary path, D-15,
// D-16, D-24, D-29): its permission, the list with the member count, the
// form with the code rules and the checkbox permission editor, and the
// delete with its pivot cleanup.
func TestAdminGroups(t *testing.T) {
env := newAdminEnv(t)
usersOnly := env.admin(t, permAccessUsers)
token := env.admin(t, permAccessGroups)
stamp := adminStamp()
// The screen needs golem15.users.access_groups: the users permission
// alone opens nothing here and does not show the side item.
for _, path := range []string{adminAPI(adminGroupsPath), adminAPI(adminGroupsPath + "/schema/list"), adminAPI(adminGroupsPath + "/schema/form")} {
rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, usersOnly, nil)
if code := adminDecode(t, rec).Error.Code; code != "forbidden" {
t.Fatalf("%s code = %q", path, code)
}
}
env.expect(t, http.StatusForbidden, http.MethodPost, adminAPI(adminGroupsPath), usersOnly, map[string]any{"name": "Nope " + stamp, "code": "nope-" + stamp})
if got := env.sideMenu(t, usersOnly); !reflect.DeepEqual(got, []string{"users=golem15.user.users"}) {
t.Fatalf("side menu with the users permission = %v", got)
}
if got := env.sideMenu(t, token); !reflect.DeepEqual(got, []string{"usergroups=golem15.user.usergroups"}) {
t.Fatalf("side menu with the groups permission = %v", got)
}
// The users screen stays closed to the groups permission.
env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath), token, nil)
// The list schema: Name, Code, Created and Users; rows open the update
// form; no checkboxes, no bulk actions, no delete in the toolbar.
var schema struct {
RecordURL string `json:"recordUrl"`
ShowCheckboxes bool `json:"showCheckboxes"`
Columns []struct {
Key string `json:"key"`
Label string `json:"label"`
Invisible bool `json:"invisible"`
Sortable bool `json:"sortable"`
Type string `json:"type"`
} `json:"columns"`
ToolbarButtons []string `json:"toolbarButtons"`
BulkActions []json.RawMessage `json:"bulkActions"`
Messages map[string]map[string]string `json:"messages"`
}
rawSchema := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath+"/schema/list"), token, nil)
adminInto(t, rawSchema, &schema)
var visible []string
for _, column := range schema.Columns {
if column.Invisible {
continue
}
visible = append(visible, column.Key+":"+column.Label)
if column.Key == "users_count" && column.Sortable {
t.Fatal("users_count is sortable")
}
}
if want := []string{"name:Name", "code:Code", "created_at:Created", "users_count:Users"}; !reflect.DeepEqual(visible, want) {
t.Fatalf("visible columns = %v, want %v", visible, want)
}
if schema.RecordURL != "golem15/user/usergroups/update/:id" || schema.ShowCheckboxes || len(schema.BulkActions) != 0 ||
!reflect.DeepEqual(schema.ToolbarButtons, []string{"create"}) {
t.Fatalf("list schema = %s", rawSchema.Body.String())
}
if got := schema.Messages["empty"]["other"]; got != "No user groups yet. Create the first one with New Group." {
t.Fatalf("empty message = %q", got)
}
if got := schema.Messages["create"]["other"]; got != "New Group" {
t.Fatalf("create message = %q", got)
}
// Without checkboxes there is no bulk delete on this screen.
if rec := env.call(t, http.MethodPost, adminAPI(adminGroupsPath+"/bulk-delete"), token, map[string]any{"ids": []uint{1}}); rec.Code == http.StatusOK {
t.Fatalf("bulk delete of groups = %d %s", rec.Code, rec.Body.String())
}
// The list carries the member count from its own query: 0 for a group
// without members (a number, not an absent value), the count otherwise.
empty := env.seedGroup(t, "Empty "+stamp, "empty-"+stamp)
pair := env.seedGroup(t, "Pair "+stamp, "pair-"+stamp)
single := env.seedGroup(t, "Single "+stamp, "single-"+stamp)
alice := env.seedUser(t, "alice", nil)
bob := env.seedUser(t, "bob", nil)
env.join(t, alice, pair, single)
env.join(t, bob, pair)
list := func(query string) (map[uint]map[string]any, int) {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath)+"?"+query, token, nil)
var rows []map[string]any
answer := adminInto(t, rec, &rows)
var meta struct {
Total int `json:"total"`
}
if err := json.Unmarshal(answer.Meta, &meta); err != nil {
t.Fatal(err)
}
out := map[uint]map[string]any{}
for _, row := range rows {
out[uint(row["id"].(float64))] = row
}
return out, meta.Total
}
rows, total := list("search=" + stamp)
if total != 3 || len(rows) != 3 {
t.Fatalf("list total = %d rows = %d", total, len(rows))
}
for id, want := range map[uint]float64{empty.ID: 0, pair.ID: 2, single.ID: 1} {
got, ok := rows[id]["users_count"].(float64)
if !ok || got != want {
t.Fatalf("users_count of %d = %v, want %v", id, rows[id]["users_count"], want)
}
}
if rows[pair.ID]["name"] != "Pair "+stamp || rows[pair.ID]["code"] != "pair-"+stamp {
t.Fatalf("list row = %v", rows[pair.ID])
}
// Paging counts groups, not memberships.
if _, total = list("search=" + stamp + "&per_page=20&page=1&sort=name&dir=asc"); total != 3 {
t.Fatalf("sorted list total = %d", total)
}
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, adminAPI(adminGroupsPath)+"?sort=users_count", token, nil)
// The form: name, code (preset from name), description and the checkbox
// permission editor on the Permissions tab.
read, write := "wiki."+stamp+".read", "wiki."+stamp+".write"
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Read', ?), (?, 'Write', ?)`,
read, "Wiki "+stamp, write, "Wiki "+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"`
Preset *struct {
Field string `json:"field"`
} `json:"preset"`
Options []struct {
Code string `json:"code"`
} `json:"permissionOptions"`
} `json:"fields"`
Preview json.RawMessage `json:"preview"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath+"/schema/form"), token, nil), &form)
var names []string
for _, field := range form.Fields {
names = append(names, field.Name+":"+field.Type)
switch field.Name {
case "code":
if field.Preset == nil || field.Preset.Field != "name" {
t.Fatalf("code preset = %+v", field.Preset)
}
case "permissions":
offered := 0
for _, option := range field.Options {
if option.Code == read || option.Code == write {
offered++
}
}
if field.Mode != "checkbox" || field.Tab != "Permissions" || offered != 2 {
t.Fatalf("permissions field = %+v", field)
}
}
}
if want := []string{"name:text", "code:text", "description:textarea", "permissions:permissioneditor"}; !reflect.DeepEqual(names, want) {
t.Fatalf("form fields = %v, want %v", names, want)
}
if len(form.Preview) != 0 && string(form.Preview) != "null" {
t.Fatalf("the group form has a preview: %s", form.Preview)
}
// Create: the code needs the PHP format and must be unique; the name
// needs three characters.
create := func(status int, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return env.expect(t, status, http.MethodPost, adminAPI(adminGroupsPath), token, body)
}
code := "editors-" + stamp
for field, body := range map[string]map[string]any{
"code": {"name": "Editors " + stamp, "code": "bad code " + stamp},
"name": {"name": "ab", "code": "short-" + stamp},
} {
rec := create(http.StatusUnprocessableEntity, body)
if details := adminDecode(t, rec).Error.Details; len(details[field]) == 0 {
t.Fatalf("create refusal has no detail on %s: %s", field, rec.Body.String())
}
}
for _, bad := range []string{"dot." + stamp, "ąę" + stamp, "semi;" + stamp} {
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Editors " + stamp, "code": bad})
if details := adminDecode(t, rec).Error.Details; len(details["code"]) != 1 || details["code"][0] != "The code may only contain letters, numbers, dashes and underscores." {
t.Fatalf("code refusal for %q = %s", bad, rec.Body.String())
}
}
create(http.StatusUnprocessableEntity, map[string]any{"name": "No code " + stamp})
var before int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE name LIKE ?`, "%"+stamp).Scan(&before).Error; err != nil || before != 3 {
t.Fatalf("refused creates left rows: %d (%v)", before, err)
}
id := createdID(t, create(http.StatusCreated, map[string]any{"name": "Editors " + stamp, "code": code, "description": "May edit"}))
if got := env.group(t, id); got == nil || got.Name != "Editors "+stamp || got.Code == nil || *got.Code != code ||
got.Description == nil || *got.Description != "May edit" || got.Permissions != nil {
t.Fatalf("created group = %+v", got)
}
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Editors again " + stamp, "code": code})
if details := adminDecode(t, rec).Error.Details; len(details["code"]) == 0 {
t.Fatalf("duplicate code refusal = %s", rec.Body.String())
}
// Update: name and description; the group's own code sent back is not a
// duplicate; a bad code is refused and changes nothing.
update := func(status int, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return env.expect(t, status, http.MethodPut, groupPath(id), token, body)
}
update(http.StatusOK, map[string]any{"name": "Editors renamed " + stamp, "code": code, "description": "Edits the wiki"})
if got := env.group(t, id); got.Name != "Editors renamed "+stamp || *got.Code != code || *got.Description != "Edits the wiki" {
t.Fatalf("updated group = %+v", got)
}
update(http.StatusUnprocessableEntity, map[string]any{"name": "Broken " + stamp, "code": "bad code"})
update(http.StatusUnprocessableEntity, map[string]any{"name": "Broken " + stamp, "code": "pair-" + stamp})
if got := env.group(t, id); got.Name != "Editors renamed "+stamp || *got.Code != code {
t.Fatalf("a refused update changed the group: %+v", got)
}
// An ordinary code changes freely.
code = "wiki-editors-" + stamp
update(http.StatusOK, map[string]any{"name": "Editors renamed " + stamp, "code": code})
if got := env.group(t, id); *got.Code != code {
t.Fatalf("code after the change = %q", *got.Code)
}
base := map[string]any{"name": "Editors renamed " + stamp, "code": code}
save := func(status int, permissions any) *httptest.ResponseRecorder {
t.Helper()
body := map[string]any{"permissions": permissions}
for key, value := range base {
body[key] = value
}
return update(status, body)
}
storedPermissions := func() map[string]int {
t.Helper()
got := env.group(t, id)
if got.Permissions == nil {
return nil
}
var decoded map[string]int
if err := json.Unmarshal([]byte(*got.Permissions), &decoded); err != nil {
t.Fatalf("stored permissions %q: %v", *got.Permissions, err)
}
return decoded
}
// Checkbox mode stores a JSON object of code to 1.
save(http.StatusOK, map[string]int{read: 1, write: 1})
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1, write: 1}) {
t.Fatalf("stored permissions = %v", got)
}
var record struct {
Permissions map[string]int `json:"permissions"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, groupPath(id), token, nil), &record)
if !reflect.DeepEqual(record.Permissions, map[string]int{read: 1, write: 1}) {
t.Fatalf("record permissions = %v", record.Permissions)
}
// A deny has no place in checkbox mode; neither has an unknown code.
for _, bad := range []any{map[string]int{read: -1}, map[string]int{read: 2}, map[string]int{"unknown." + stamp: 1}, "all"} {
rec := save(http.StatusUnprocessableEntity, bad)
if details := adminDecode(t, rec).Error.Details; len(details["permissions"]) == 0 {
t.Fatalf("permission refusal of %v = %s", bad, rec.Body.String())
}
}
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1, write: 1}) {
t.Fatalf("a refused save changed the permissions: %v", got)
}
// A save without the field leaves them alone; unchecking removes the
// entry; an empty object stores NULL.
update(http.StatusOK, base)
if got := storedPermissions(); len(got) != 2 {
t.Fatalf("a save without the field changed the permissions: %v", got)
}
save(http.StatusOK, map[string]int{read: 1, write: 0})
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{read: 1}) {
t.Fatalf("permissions after unchecking = %v", got)
}
save(http.StatusOK, map[string]int{})
if got := env.group(t, id); got.Permissions != nil {
t.Fatalf("cleared permissions = %q", *got.Permissions)
}
// A stored code that is no longer offered survives an edit, and a member
// of the group holds what the editor stored.
legacy := "legacy." + stamp
if err := env.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, `{"`+legacy+`":1}`, id).Error; err != nil {
t.Fatal(err)
}
save(http.StatusOK, map[string]int{write: 1})
if got := storedPermissions(); !reflect.DeepEqual(got, map[string]int{legacy: 1, write: 1}) {
t.Fatalf("permissions after an edit over a legacy code = %v", got)
}
// The screen cannot write a membership or the count.
created := &models.UserGroup{ID: id}
env.join(t, alice, created)
update(http.StatusOK, map[string]any{"name": base["name"], "code": code, "users_count": 99, "users": []uint{bob.ID}, "Users": []uint{bob.ID}})
if got := env.groupIDs(t, bob.ID); !reflect.DeepEqual(got, sortedIDs(pair)) {
t.Fatalf("a group save changed bob's memberships: %v", got)
}
if rows, _ = list("search=" + stamp); rows[id]["users_count"].(float64) != 1 {
t.Fatalf("users_count after a save that sent one = %v", rows[id]["users_count"])
}
// Delete (D-29): the standard form delete removes the group and its
// memberships in one transaction; the members and their other
// memberships stay.
rec = env.expect(t, http.StatusOK, http.MethodDelete, groupPath(pair.ID), token, nil)
if env.group(t, pair.ID) != nil {
t.Fatalf("the group survived its delete: %s", rec.Body.String())
}
var left int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM users_groups WHERE user_group_id = ?`, pair.ID).Scan(&left).Error; err != nil || left != 0 {
t.Fatalf("memberships of the deleted group = %d (%v)", left, err)
}
if env.reload(t, alice.ID) == nil || env.reload(t, bob.ID) == nil {
t.Fatal("a group delete removed a member")
}
if got := env.groupIDs(t, alice.ID); !reflect.DeepEqual(got, sortedIDs(single, created)) {
t.Fatalf("alice's memberships after the delete = %v", got)
}
if got := env.groupIDs(t, bob.ID); len(got) != 0 {
t.Fatalf("bob's memberships after the delete = %v", got)
}
env.expect(t, http.StatusNotFound, http.MethodGet, groupPath(pair.ID), token, nil)
if rows, total = list("search=" + stamp); total != 3 || rows[pair.ID] != nil {
t.Fatalf("list after the delete: total %d", total)
}
}