Files
sm-user-plugin/admin_organisations_test.go
Jakub Zych 58595fef27 feat: Organisations admin screen with the avatar and the members manager
- 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
2026-10-05 13:58:54 +02:00

483 lines
20 KiB
Go

package user
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// adminOrganisationsPath is the Organisations controller under the admin API.
const adminOrganisationsPath = "/golem15/user/organisations"
// organisationPath is the admin API path of one organisation, with an
// optional suffix such as "/relations/members".
func organisationPath(id uint, suffix string) string {
return adminAPI(adminOrganisationsPath) + "/" + strconv.FormatUint(uint64(id), 10) + suffix
}
// organisation reads an organisation row; nil when it is gone.
func (e *adminEnv) organisation(t *testing.T, id uint) *models.Organisation {
t.Helper()
var rows []models.Organisation
if err := e.gdb.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) == 0 {
return nil
}
return &rows[0]
}
// seedOrganisation stores an organisation with a unique slug.
func (e *adminEnv) seedOrganisation(t *testing.T, name string) *models.Organisation {
t.Helper()
organisation := &models.Organisation{Name: name, Slug: models.Slugify(name)}
if err := e.gdb.Create(organisation).Error; err != nil {
t.Fatal(err)
}
return organisation
}
// TestSlugify pins the slug rule the organisation model shares with the
// admin form's slug preset.
func TestSlugify(t *testing.T) {
for text, want := range map[string]string{
"Acme Records": "acme-records",
" Acme -- Records ": "acme-records",
"ACME_Records 2": "acme-records-2",
"Zażółć gęślą": "za-g-l",
"---": "",
"": "",
"a": "a",
} {
if got := models.Slugify(text); got != want {
t.Fatalf("Slugify(%q) = %q, want %q", text, got, want)
}
}
}
// TestAdminOrganisations covers the Organisations screen (D-22, D-20): its
// permission, the list, the form with the slug rules and the avatar, and the
// delete that releases the members.
func TestAdminOrganisations(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
groupsOnly := env.admin(t, permAccessGroups)
both := env.admin(t, permAccessUsers, permAccessGroups)
stamp := adminStamp()
// The screen needs golem15.users.access_users.
for _, path := range []string{adminAPI(adminOrganisationsPath), adminAPI(adminOrganisationsPath + "/schema/list"), adminAPI(adminOrganisationsPath + "/schema/form")} {
rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, groupsOnly, 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(adminOrganisationsPath), groupsOnly, map[string]any{"name": "Nope " + stamp})
// All three screens are side items of `user`, in this order, each shown
// only with its permission.
if got := env.sideMenu(t, both); !reflect.DeepEqual(got, []string{
"users=golem15.user.users", "usergroups=golem15.user.usergroups", "organisations=golem15.user.organisations",
}) {
t.Fatalf("side menu with both permissions = %v", got)
}
if got := env.sideMenu(t, groupsOnly); !reflect.DeepEqual(got, []string{"usergroups=golem15.user.usergroups"}) {
t.Fatalf("side menu with the groups permission = %v", got)
}
// The list schema: Name, Slug and Created; rows open the update form.
var schema struct {
RecordURL string `json:"recordUrl"`
ShowCheckboxes bool `json:"showCheckboxes"`
ToolbarButtons []string `json:"toolbarButtons"`
Columns []struct {
Key string `json:"key"`
Invisible bool `json:"invisible"`
Searchable bool `json:"searchable"`
Type string `json:"type"`
} `json:"columns"`
Messages map[string]map[string]string `json:"messages"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/list"), token, nil), &schema)
var visible []string
for _, column := range schema.Columns {
if !column.Invisible {
visible = append(visible, column.Key)
}
}
if !reflect.DeepEqual(visible, []string{"name", "slug", "created_at"}) || schema.RecordURL != "golem15/user/organisations/update/:id" ||
schema.ShowCheckboxes || !reflect.DeepEqual(schema.ToolbarButtons, []string{"create"}) {
t.Fatalf("list schema = %+v", schema)
}
if got := schema.Messages["empty"]["other"]; got != "No organisations yet. Create the first one with New Organisation." {
t.Fatalf("empty message = %q", got)
}
if got := schema.Messages["create"]["other"]; got != "New Organisation" {
t.Fatalf("create message = %q", got)
}
// The form: name, slug (slug preset from name), description, the avatar
// as a 120 by 120 image and the members manager on update only; no
// preview.
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"`
ImageWidth int `json:"imageWidth"`
ImageHeight int `json:"imageHeight"`
Preset *struct {
Field string `json:"field"`
Type string `json:"type"`
} `json:"preset"`
} `json:"fields"`
Preview json.RawMessage `json:"preview"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/form"), token, nil), &form)
var names []string
for _, field := range form.Fields {
names = append(names, field.Name+":"+field.Type)
switch field.Name {
case "slug":
if field.Preset == nil || field.Preset.Field != "name" || field.Preset.Type != "slug" {
t.Fatalf("slug preset = %+v", field.Preset)
}
case "avatar":
if field.Mode != "image" || field.ImageWidth != 120 || field.ImageHeight != 120 {
t.Fatalf("avatar field = %+v", field)
}
case "members":
if field.Context != "update" || field.Tab != "Members" {
t.Fatalf("members field = %+v", field)
}
}
}
if want := []string{"name:text", "slug:text", "description:textarea", "avatar:fileupload", "members:relation-manager"}; !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 organisation form has a preview: %s", form.Preview)
}
// Create: an empty slug is derived from the name; a given slug is kept.
create := func(status int, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return env.expect(t, status, http.MethodPost, adminAPI(adminOrganisationsPath), token, body)
}
name := "Acme Records " + stamp
id := createdID(t, create(http.StatusCreated, map[string]any{"name": name, "slug": "", "description": "A label"}))
if got := env.organisation(t, id); got == nil || got.Name != name || got.Slug != "acme-records-"+stamp || got.Description == nil || *got.Description != "A label" {
t.Fatalf("created organisation = %+v", got)
}
omitted := createdID(t, create(http.StatusCreated, map[string]any{"name": "Omitted Slug " + stamp}))
if got := env.organisation(t, omitted); got.Slug != "omitted-slug-"+stamp {
t.Fatalf("slug of a create without the key = %q", got.Slug)
}
given := createdID(t, create(http.StatusCreated, map[string]any{"name": "Given " + stamp, "slug": "Given_Slug-" + stamp}))
if got := env.organisation(t, given); got.Slug != "Given_Slug-"+stamp {
t.Fatalf("given slug = %q", got.Slug)
}
// A slug with other characters, a duplicate slug and a missing name are
// refused on their field and create nothing.
for _, bad := range []string{"has space " + stamp, "dot." + stamp, "ślad-" + stamp} {
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Bad " + stamp, "slug": bad})
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) != 1 || details["slug"][0] != "The slug may only contain letters, numbers, dashes and underscores." {
t.Fatalf("slug refusal for %q = %s", bad, rec.Body.String())
}
}
rec := create(http.StatusUnprocessableEntity, map[string]any{"name": "Duplicate " + stamp, "slug": "acme-records-" + stamp})
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) == 0 {
t.Fatalf("duplicate slug refusal = %s", rec.Body.String())
}
// The derived slug collides as well.
rec = create(http.StatusUnprocessableEntity, map[string]any{"name": name})
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) == 0 {
t.Fatalf("derived duplicate slug refusal = %s", rec.Body.String())
}
rec = create(http.StatusUnprocessableEntity, map[string]any{"slug": "nameless-" + stamp})
if details := adminDecode(t, rec).Error.Details; len(details["name"]) == 0 {
t.Fatalf("missing name refusal = %s", rec.Body.String())
}
var count int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_organisations WHERE name LIKE ?`, "%"+stamp).Scan(&count).Error; err != nil || count != 3 {
t.Fatalf("organisations after the refused creates = %d (%v)", count, err)
}
// The list finds it by name and by slug.
for _, term := range []string{"Acme+Records+" + stamp, "acme-records-" + stamp} {
var rows []map[string]any
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath)+"?search="+term, token, nil), &rows)
if len(rows) != 1 || rows[0]["name"] != name || rows[0]["slug"] != "acme-records-"+stamp {
t.Fatalf("list for %q = %v", term, rows)
}
}
// Update: the fields change; a bad slug is refused and changes nothing.
env.expect(t, http.StatusOK, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "acme-" + stamp, "description": "Renamed slug"})
if got := env.organisation(t, id); got.Slug != "acme-"+stamp || *got.Description != "Renamed slug" {
t.Fatalf("updated organisation = %+v", got)
}
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "acme " + stamp})
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, organisationPath(id, ""), token, map[string]any{"name": name, "slug": "Given_Slug-" + stamp})
if got := env.organisation(t, id); got.Slug != "acme-"+stamp {
t.Fatalf("a refused update changed the slug to %q", got.Slug)
}
// Avatar: a file uploaded through the admin file route is attached to
// the organisation under its morph name when the form is saved.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file_data", "logo.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)
}
sessionKey := adminSessionKey(t)
headers := map[string]string{cabana.SessionKeyHeader: sessionKey}
if up := env.send(t, http.MethodPost, organisationPath(id, "/files/avatar"), token, buf.Bytes(), mw.FormDataContentType(), headers); up.Code != http.StatusCreated && up.Code != http.StatusOK {
t.Fatalf("avatar upload = %d %s", up.Code, up.Body.String())
}
body, err := json.Marshal(map[string]any{"name": name, "slug": "acme-" + stamp})
if err != nil {
t.Fatal(err)
}
if saved := env.send(t, http.MethodPut, organisationPath(id, ""), token, body, "application/json", headers); saved.Code != http.StatusOK {
t.Fatalf("save with the avatar = %d %s", saved.Code, saved.Body.String())
}
files := func() int64 {
t.Helper()
var n int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`,
`Golem15\User\Models\Organisation`, strconv.FormatUint(uint64(id), 10)).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
if files() != 1 || (models.Organisation{}).MorphName() != `Golem15\User\Models\Organisation` {
t.Fatalf("avatar rows of the organisation = %d", files())
}
// Delete: an organisation with members goes; its members stay, active or
// deactivated, without an organisation and without a role in it. Users
// of another organisation are not touched.
role := "owner"
owner := env.seedUser(t, "org-owner", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &id, &role })
gone := env.seedUser(t, "org-gone", func(u *models.User) { u.OrganisationID = &id })
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil {
t.Fatal(err)
}
other := env.seedUser(t, "org-other", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &given, &role })
group := env.seedGroup(t, "Org "+stamp, "org-"+stamp)
env.join(t, owner, group)
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(id, ""), token, nil)
if env.organisation(t, id) != nil {
t.Fatal("the organisation survived its delete")
}
for _, member := range []*models.User{owner, gone} {
got := env.reload(t, member.ID)
if got == nil || got.OrganisationID != nil || got.OrganisationRole != nil || got.Email != member.Email {
t.Fatalf("member %d after the delete = %+v", member.ID, got)
}
}
if got := env.reload(t, other.ID); got.OrganisationID == nil || *got.OrganisationID != given || got.OrganisationRole == nil {
t.Fatalf("a member of another organisation changed: %+v", got)
}
if got := env.groupIDs(t, owner.ID); !reflect.DeepEqual(got, []uint{group.ID}) {
t.Fatalf("the delete changed the owner's groups: %v", got)
}
env.expect(t, http.StatusNotFound, http.MethodGet, organisationPath(id, ""), token, nil)
}
// TestAdminOrganisationMembers covers the members relation manager: link
// sets and unlink clears users.organisation_id, the lists are scoped to the
// organisation, and nothing else of a user changes, least of all
// users_groups (T-12.1-30).
func TestAdminOrganisationMembers(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
groupsOnly := env.admin(t, permAccessGroups)
stamp := adminStamp()
here := env.seedOrganisation(t, "Here "+stamp)
there := env.seedOrganisation(t, "There "+stamp)
label := "Member" + stamp
named := func(suffix string, mutate func(*models.User)) *models.User {
return env.seedUser(t, "member-"+suffix, func(u *models.User) {
name := label + " " + suffix
u.Name = &name
if mutate != nil {
mutate(u)
}
})
}
first, second := named("first", nil), named("second", nil)
role := "member"
foreign := named("foreign", func(u *models.User) { u.OrganisationID, u.OrganisationRole = &there.ID, &role })
group := env.seedGroup(t, "Members "+stamp, "members-"+stamp)
adminGroup := env.seedGroup(t, "Admins "+stamp, "admin")
env.join(t, first, group, adminGroup)
env.join(t, second, group)
env.join(t, foreign, adminGroup)
everyone := []*models.User{first, second, foreign}
memberships := map[uint][]uint{}
stored := map[uint]*models.User{}
for _, user := range everyone {
memberships[user.ID] = env.groupIDs(t, user.ID)
stored[user.ID] = env.reload(t, user.ID)
}
untouched := func() {
t.Helper()
for _, user := range everyone {
if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, memberships[user.ID]) {
t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, memberships[user.ID])
}
got, want := env.reload(t, user.ID), stored[user.ID]
if got == nil || got.Email != want.Email || got.Password != want.Password || got.IsActivated != want.IsActivated ||
!reflect.DeepEqual(got.Name, want.Name) || !reflect.DeepEqual(got.OrganisationRole, want.OrganisationRole) ||
!reflect.DeepEqual(got.TokensValidAfter, want.TokensValidAfter) || got.DeletedAt.Valid {
t.Fatalf("user %d changed beyond organisation_id: %+v", user.ID, got)
}
}
}
organisationOf := func(user *models.User) uint {
t.Helper()
got := env.reload(t, user.ID)
if got == nil || got.OrganisationID == nil {
return 0
}
return *got.OrganisationID
}
ids := func(path string) []uint {
t.Helper()
var rows []map[string]any
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, path, token, nil), &rows)
out := []uint{}
for _, row := range rows {
out = append(out, uint(row["id"].(float64)))
}
return out
}
members := func(organisation *models.Organisation) []uint {
t.Helper()
return ids(organisationPath(organisation.ID, "/relations/members"))
}
candidates := func() []uint {
t.Helper()
return ids(organisationPath(here.ID, "/relations/members/candidates?search="+label))
}
mutate := func(organisation *models.Organisation, action string, users ...*models.User) *httptest.ResponseRecorder {
t.Helper()
list := make([]uint, len(users))
for i, user := range users {
list[i] = user.ID
}
return env.call(t, http.MethodPost, organisationPath(organisation.ID, "/relations/members/"+action), token, map[string]any{"ids": list})
}
// The relation schema carries the screen's copy: the unlink confirm
// counts and says the users are not deleted.
var relation struct {
Messages map[string]map[string]string `json:"messages"`
}
rawRelation := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/relation/members"), token, nil)
adminInto(t, rawRelation, &relation)
for key, want := range map[string]string{
"link": "Add member",
"unlinkSelected": "Remove selected",
"unlinkConfirm": "Remove the selected (:count) from this organisation? The users are not deleted.",
"empty": "This organisation has no members yet. Add users with Add member.",
} {
if got := relation.Messages[key]["other"]; got != want {
t.Fatalf("relation message %s = %q, want %q (%s)", key, got, want, rawRelation.Body.String())
}
}
env.expect(t, http.StatusNotFound, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/relation/groups"), token, nil)
// The routes need the screen's permission.
env.expect(t, http.StatusForbidden, http.MethodGet, organisationPath(here.ID, "/relations/members"), groupsOnly, nil)
env.expect(t, http.StatusForbidden, http.MethodPost, organisationPath(here.ID, "/relations/members/link"), groupsOnly, map[string]any{"ids": []uint{first.ID}})
if organisationOf(first) != 0 {
t.Fatal("a refused link set organisation_id")
}
// An organisation without members lists none; the candidates are the
// users without an organisation.
if got := members(here); len(got) != 0 {
t.Fatalf("members of a new organisation = %v", got)
}
if got := candidates(); !reflect.DeepEqual(got, []uint{first.ID, second.ID}) {
t.Fatalf("candidates = %v, want %v", got, []uint{first.ID, second.ID})
}
// Link sets organisation_id of both users.
if rec := mutate(here, "link", first, second); rec.Code != http.StatusOK {
t.Fatalf("link = %d %s", rec.Code, rec.Body.String())
}
if organisationOf(first) != here.ID || organisationOf(second) != here.ID {
t.Fatalf("organisation_id after the link = %d, %d", organisationOf(first), organisationOf(second))
}
untouched()
// Each list shows only its own organisation's members.
if got := members(here); !reflect.DeepEqual(got, []uint{first.ID, second.ID}) {
t.Fatalf("members here = %v", got)
}
if got := members(there); !reflect.DeepEqual(got, []uint{foreign.ID}) {
t.Fatalf("members there = %v", got)
}
if got := candidates(); len(got) != 0 {
t.Fatalf("candidates after the link = %v", got)
}
// A member of another organisation is not a candidate and cannot be
// taken over by a link: they stay where they are.
refused := mutate(here, "link", foreign)
if details := adminDecode(t, refused).Error.Details; refused.Code != http.StatusUnprocessableEntity || len(details["ids"]) == 0 {
t.Fatalf("link of a foreign member = %d %s", refused.Code, refused.Body.String())
}
if organisationOf(foreign) != there.ID {
t.Fatalf("a foreign member moved to %d", organisationOf(foreign))
}
// Unlinking through the wrong organisation is ignored.
mutate(here, "unlink", foreign)
if organisationOf(foreign) != there.ID {
t.Fatal("an unlink through another organisation cleared organisation_id")
}
untouched()
// Unlink clears organisation_id of the chosen user only; the user stays.
if rec := mutate(here, "unlink", first); rec.Code != http.StatusOK {
t.Fatalf("unlink = %d %s", rec.Code, rec.Body.String())
}
if organisationOf(first) != 0 || organisationOf(second) != here.ID || env.reload(t, first.ID) == nil {
t.Fatalf("organisation_id after the unlink = %d, %d", organisationOf(first), organisationOf(second))
}
if got := members(here); !reflect.DeepEqual(got, []uint{second.ID}) {
t.Fatalf("members after the unlink = %v", got)
}
untouched()
// Once released there, the user can be linked here: this is how a user
// moves between organisations.
if rec := mutate(there, "unlink", foreign); rec.Code != http.StatusOK {
t.Fatalf("unlink there = %d %s", rec.Code, rec.Body.String())
}
if rec := mutate(here, "link", foreign); rec.Code != http.StatusOK || organisationOf(foreign) != here.ID {
t.Fatalf("link after the release = %d %s", rec.Code, rec.Body.String())
}
untouched()
}