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