- 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
203 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|