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