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", "organisations=golem15.user.organisations"}) { 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) } }