Files
sm-user-plugin/admin_organisations_edge_test.go
Jakub Zych a848aa17d6 test: edge cases of the Users, User Groups and Organisations admin controllers
- each controller driven directly on records of another type, an application that is not booted, one without a database and one whose database fails: a guard that cannot read answers a plain error, never a pass
- D-30: a superuser passes the takeover guard; an unreadable membership is the opaque 500 with nothing changed
- unknown partials, undeclared filters and sorts, options of fields that have none, search and sort on the three lists
- a name-only update of a privileged group, validation before the guard, a seeded group protected through the config list
- known gaps pinned: boolean values in a group's permissions, organisation_role after an unlink
2026-10-05 15:23:23 +02:00

203 lines
9.7 KiB
Go

package user
import (
"context"
"net/http"
"reflect"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestAdminOrganisationsControllerGuards drives the Organisations controller
// directly on the inputs the HTTP tests cannot produce.
func TestAdminOrganisationsControllerGuards(t *testing.T) {
ctx := context.Background()
const id = "golem15.user.organisations"
notOrganisation := &models.User{}
var nilOrganisation *models.Organisation
env := newAdminEnv(t)
sets := map[string]adminControllerSet{
"no accessor": adminControllersOn(nil),
"not booted": adminControllersOn(func() *backpack.App { return nil }),
"no database": adminControllersOn(func() *backpack.App { return bareApp(t) }),
"the live database": adminControllersOn(func() *backpack.App { return env.app }),
}
for name, set := range sets {
t.Run(name, func(t *testing.T) {
ctl := set[id]
for _, record := range []any{notOrganisation, nilOrganisation, nil} {
wantPlainError(t, "FormBeforeCreate", ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, record))
wantPlainError(t, "FormBeforeUpdate", ctl.(pact.FormBeforeUpdate).FormBeforeUpdate(ctx, record))
wantPlainError(t, "FormBeforeDelete", ctl.(pact.FormBeforeDelete).FormBeforeDelete(ctx, record))
}
wantPlainError(t, "FormBeforeDelete without a transaction", ctl.(pact.FormBeforeDelete).FormBeforeDelete(ctx, &models.Organisation{ID: 1}))
// The slug check needs no database; without a translator the
// refusal is in English.
if err := ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, &models.Organisation{Name: "Fine", Slug: "fine_slug-1"}); err != nil {
t.Fatal(err)
}
if err := ctl.(pact.FormBeforeUpdate).FormBeforeUpdate(ctx, &models.Organisation{Name: "Empty"}); err != nil {
t.Fatalf("an empty slug is left to the required rule: %v", err)
}
err := ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, &models.Organisation{Name: "Bad", Slug: "bad slug"})
invalid, ok := err.(*cabana.ValidationError)
if !ok || !reflect.DeepEqual(invalid.Details["slug"], []string{"The slug may only contain letters, numbers, dashes and underscores."}) {
t.Fatalf("slug refusal = %v", err)
}
// The members manager is the only relation: any other name has
// no candidates.
manage := ctl.(pact.RelationExtendManageQuery)
if manage.RelationExtendManageQuery(ctx, "members", nil) != nil || manage.RelationExtendManageQuery(ctx, "groups", nil) != nil {
t.Fatal("RelationExtendManageQuery invented a handle")
}
contracts := ctl.(cabana.AdminRelationContractProvider).AdminRelationContracts()
if len(contracts) != 1 || contracts[0].Name != "members" || contracts[0].Kind != cabana.RelationHasMany || contracts[0].ForeignKey != "organisation_id" {
t.Fatalf("relation contracts = %+v", contracts)
}
if _, ok := contracts[0].NewRelated().(*models.User); !ok {
t.Fatal("the members relation is not on users")
}
if name == "the live database" {
var all, none int64
users := env.gdb.Model(&models.User{})
if err := manage.RelationExtendManageQuery(ctx, "members", users).Count(&all).Error; err != nil {
t.Fatal(err)
}
if err := manage.RelationExtendManageQuery(ctx, "groups", env.gdb.Model(&models.User{})).Count(&none).Error; err != nil || none != 0 {
t.Fatalf("candidates of another relation = %d err = %v", none, err)
}
_ = all
}
})
}
}
// TestAdminOrganisationsEdge covers the remaining branches of the
// Organisations screen through the admin API.
func TestAdminOrganisationsEdge(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
stamp := adminStamp()
// An organisation without members is deleted and nothing else changes.
bystander := env.seedUser(t, "bystander-"+stamp, nil)
empty := env.seedOrganisation(t, "Edge empty "+stamp)
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(empty.ID, ""), token, nil)
if env.organisation(t, empty.ID) != nil {
t.Fatal("the organisation survived its delete")
}
if env.reload(t, bystander.ID) == nil {
t.Fatal("deleting an organisation without members deleted a user")
}
env.expect(t, http.StatusNotFound, http.MethodGet, organisationPath(empty.ID, ""), token, nil)
// A slug with other characters is a 422 on the field, on create and on
// update; a duplicate slug too.
home := env.seedOrganisation(t, "Edge home "+stamp)
for _, slug := range []string{"with space", "dot.ted", "sl/ash", "ąę"} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminOrganisationsPath), token, map[string]any{"name": "Edge bad " + stamp, "slug": slug})
if details := adminDecode(t, rec).Error.Details; len(details["slug"]) == 0 {
t.Fatalf("slug %q: details = %v", slug, details)
}
env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, organisationPath(home.ID, ""), token, map[string]any{"slug": slug})
}
env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminOrganisationsPath), token, map[string]any{"name": "Edge twin " + stamp, "slug": home.Slug})
if got := env.organisation(t, home.ID); got.Slug != home.Slug {
t.Fatalf("a refused update changed the slug to %q", got.Slug)
}
// Search and sort on the list.
for _, name := range []string{"b", "a", "c"} {
env.seedOrganisation(t, "Orgsort "+stamp+" "+name)
}
order := func(query string) []string {
var rows []map[string]any
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath)+"?search=Orgsort%20"+stamp+"&"+query, token, nil), &rows)
names := []string{}
for _, row := range rows {
name, _ := row["name"].(string)
names = append(names, name[len(name)-1:])
}
return names
}
if got := order("sort=name&dir=asc"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Fatalf("name asc = %v", got)
}
if got := order("sort=slug&dir=desc"); !reflect.DeepEqual(got, []string{"c", "b", "a"}) {
t.Fatalf("slug desc = %v", got)
}
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, adminAPI(adminOrganisationsPath)+"?filter[slug]=x", token, nil)
// A deactivated user is not offered as a member and cannot be linked:
// the candidates leave soft-deleted users out.
gone := env.seedUser(t, "gone-"+stamp, nil)
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil {
t.Fatal(err)
}
var candidates []map[string]any
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, organisationPath(home.ID, "/relations/members/candidates?search=gone-"+stamp), token, nil), &candidates)
if len(candidates) != 0 {
t.Fatalf("a deactivated user is a candidate: %v", candidates)
}
rec := env.call(t, http.MethodPost, organisationPath(home.ID, "/relations/members/link"), token, map[string]any{"ids": []uint{gone.ID}})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("link of a deactivated user = %d %s", rec.Code, rec.Body.String())
}
if got := env.reload(t, gone.ID); got.OrganisationID != nil {
t.Fatalf("a deactivated user was linked to %d", *got.OrganisationID)
}
// Ids that are not users, and an empty list, change nothing.
for _, body := range []map[string]any{{"ids": []uint{987654321}}, {"ids": []uint{}}, {}} {
rec := env.call(t, http.MethodPost, organisationPath(home.ID, "/relations/members/link"), token, body)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("link %v = %d %s", body, rec.Code, rec.Body.String())
}
}
}
// TestAdminOrganisationRoleOutlivesMembership pins a known gap
// (12.1-SECURITY-REVIEW.md, open item 6): removing a member through the
// members manager clears organisation_id and nothing else, so the user keeps
// organisation_role and carries it into the next organisation they are
// added to. Deleting the organisation clears both. When the unlink learns to
// clear the role, this test changes with it.
func TestAdminOrganisationRoleOutlivesMembership(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
stamp := adminStamp()
first := env.seedOrganisation(t, "Role first "+stamp)
second := env.seedOrganisation(t, "Role second "+stamp)
doomed := env.seedOrganisation(t, "Role doomed "+stamp)
owner := "owner"
member := env.seedUser(t, "role-"+stamp, func(u *models.User) { u.OrganisationID, u.OrganisationRole = &first.ID, &owner })
other := env.seedUser(t, "role-other-"+stamp, func(u *models.User) { u.OrganisationID, u.OrganisationRole = &doomed.ID, &owner })
ids := map[string]any{"ids": []uint{member.ID}}
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(first.ID, "/relations/members/unlink"), token, ids)
got := env.reload(t, member.ID)
if got.OrganisationID != nil {
t.Fatalf("organisation_id after the unlink = %d", *got.OrganisationID)
}
if got.OrganisationRole == nil || *got.OrganisationRole != "owner" {
t.Fatalf("organisation_role after the unlink = %v; the known gap is closed, update this test and the review", got.OrganisationRole)
}
env.expect(t, http.StatusOK, http.MethodPost, organisationPath(second.ID, "/relations/members/link"), token, ids)
got = env.reload(t, member.ID)
if got.OrganisationID == nil || *got.OrganisationID != second.ID || got.OrganisationRole == nil || *got.OrganisationRole != "owner" {
t.Fatalf("after the link elsewhere: organisation %v role %v", got.OrganisationID, got.OrganisationRole)
}
// The delete of an organisation releases its members completely.
env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(doomed.ID, ""), token, nil)
if got := env.reload(t, other.ID); got == nil || got.OrganisationID != nil || got.OrganisationRole != nil {
t.Fatalf("member of a deleted organisation = %+v", got)
}
}