package user import ( "encoding/json" "net/http" "net/http/httptest" "reflect" "strconv" "testing" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/compass" ) const privilegedGroupForbidden = "You do not have permission to change membership of privileged groups. Nothing was saved." // groupOptions returns the choices of the user form's groups field that // match search, as id to locked. func (e *adminEnv) groupOptions(t *testing.T, token, search string) map[uint]bool { t.Helper() var options []struct { Value uint `json:"value"` Locked bool `json:"locked"` } path := adminAPI(adminUsersPath + "/fields/groups/options?per_page=100&search=" + search) adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, path, token, nil), &options) out := map[uint]bool{} for _, option := range options { out[option.Value] = option.Locked } return out } // recordGroups reads a user through the admin API and returns the ids of // the groups field and, per id, whether its label is flagged locked. func (e *adminEnv) recordGroups(t *testing.T, token string, userID uint) ([]uint, map[uint]bool) { t.Helper() rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(userID, ""), token, nil) var data struct { Groups []uint `json:"groups"` } answer := adminInto(t, rec, &data) var meta struct { Labels map[string][]struct { Value uint `json:"value"` Locked bool `json:"locked"` } `json:"labels"` } if err := json.Unmarshal(answer.Meta, &meta); err != nil { t.Fatalf("record meta %q: %v", string(answer.Meta), err) } locked := map[uint]bool{} for _, label := range meta.Labels["groups"] { locked[label.Value] = label.Locked } return data.Groups, locked } // sortedIDs returns the ids of the groups in ascending order, the order // groupIDs reads the pivot in. func sortedIDs(groups ...*models.UserGroup) []uint { ids := make([]uint, 0, len(groups)) for _, group := range groups { ids = append(ids, group.ID) } for i := 1; i < len(ids); i++ { for j := i; j > 0 && ids[j-1] > ids[j]; j-- { ids[j-1], ids[j] = ids[j], ids[j-1] } } return ids } // TestAdminUserGroupsField covers the groups field of the user form with // ordinary groups: it is the writer of users_groups, it replaces the user's // memberships only when the field is sent, it refuses ids that are not // groups, and none of it reaches a user API payload (D-08). func TestAdminUserGroupsField(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stamp := adminStamp() first := env.seedGroup(t, "First "+stamp, "first-"+stamp) second := env.seedGroup(t, "Second "+stamp, "second-"+stamp) user := env.seedUser(t, "groups", nil) base := map[string]any{"name": "groups", "email": user.Email} save := func(status int, groups any) *httptest.ResponseRecorder { t.Helper() body := map[string]any{"groups": groups} for key, value := range base { body[key] = value } return env.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body) } pivot := func(want ...*models.UserGroup) { t.Helper() if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) { t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...)) } } // The field is a multiple relation field on the Account tab, and its // options are the user groups; none is locked for ordinary codes. var form struct { Fields []struct { Name string `json:"name"` Type string `json:"type"` Multiple bool `json:"multiple"` Tab string `json:"tab"` } `json:"fields"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) found := false for _, field := range form.Fields { if field.Name != "groups" { continue } found = true if field.Type != "relation" || !field.Multiple || field.Tab != "Account" { t.Fatalf("groups field = %+v", field) } } if !found { t.Fatal("the user form has no groups field") } if options := env.groupOptions(t, token, stamp); len(options) != 2 || options[first.ID] || options[second.ID] { t.Fatalf("group options = %v", options) } // Setting the field writes exactly the chosen memberships. saved := save(http.StatusOK, []uint{first.ID, second.ID}) pivot(first, second) var record struct { Groups []uint `json:"groups"` } adminInto(t, saved, &record) if !reflect.DeepEqual(record.Groups, sortedIDs(first, second)) { t.Fatalf("record groups = %v", record.Groups) } if ids, locked := env.recordGroups(t, token, user.ID); !reflect.DeepEqual(ids, sortedIDs(first, second)) || locked[first.ID] || locked[second.ID] || len(locked) != 2 { t.Fatalf("shown groups = %v locked %v", ids, locked) } save(http.StatusOK, []uint{second.ID}) pivot(second) // A save without the field leaves the memberships alone. env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "renamed", "email": user.Email}) pivot(second) if got := env.reload(t, user.ID); got.Name == nil || *got.Name != "renamed" { t.Fatalf("name after a save without groups = %v", got.Name) } // An id that is not a group, a duplicate and a value that is not a list // are refused on the field and change nothing. for _, bad := range []any{[]uint{second.ID, 987654321}, []uint{first.ID, first.ID}, first.ID, []string{"x"}} { rec := save(http.StatusUnprocessableEntity, bad) if details := adminDecode(t, rec).Error.Details; len(details["groups"]) == 0 { t.Fatalf("refusal of %v has no detail on groups: %s", bad, rec.Body.String()) } pivot(second) } // The plain relation name of the model is not a way around the field. env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{ "name": "renamed", "email": user.Email, "Groups": []map[string]any{{"id": first.ID}}, "users_groups": []uint{first.ID}, }) pivot(second) // The user API payload still answers an empty list (D-08), although the // user is a member of a group. status, userToken := env.userLogin(t, user.Email, "correct-horse") if status != http.StatusOK { t.Fatalf("user login = %d", status) } fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil) var body struct { User map[string]any `json:"user"` } if err := json.Unmarshal(fetch.Body.Bytes(), &body); err != nil || fetch.Code != http.StatusOK { t.Fatalf("fetch = %d %s (%v)", fetch.Code, fetch.Body.String(), err) } if groups, ok := body.User["groups"].([]any); !ok || len(groups) != 0 { t.Fatalf("user payload groups = %v", body.User["groups"]) } // Clearing the field removes every membership. save(http.StatusOK, []uint{}) pivot() // A create with the field writes the memberships of the new user. const password = "groups-password-1" created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ "name": "Created", "email": "groups-created-" + stamp + "@example.test", "password": password, "password_confirmation": password, "send_invite": false, "groups": []uint{first.ID}, }) if got := env.groupIDs(t, createdID(t, created)); !reflect.DeepEqual(got, []uint{first.ID}) { t.Fatalf("users_groups of the created user = %v", got) } // A refused create leaves no user behind. refusedEmail := "groups-refused-" + stamp + "@example.test" env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ "name": "Refused", "email": refusedEmail, "password": password, "password_confirmation": password, "send_invite": false, "groups": []uint{987654321}, }) var refused int64 if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&refused).Error; err != nil || refused != 0 { t.Fatalf("a refused create left %d users (%v)", refused, err) } } // TestAdminPrivilegedGroups covers threat T-12-18 as revisited by D-04 to // D-07: golem15.users.access_users changes ordinary memberships, and adding // or removing a privileged group additionally needs // golem15.users.manage_privileged_groups, on create and on update. It also // ties the field to the takeover guard (D-30). func TestAdminPrivilegedGroups(t *testing.T) { env := newAdminEnv(t) limited := env.admin(t, permAccessUsers) manager := env.admin(t, permAccessUsers, permManagePrivileged) stamp := adminStamp() // The migrations seed only guest and registered; the default privileged // list is [admin]. adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") ordinaryCode := "ordinary-" + stamp ordinary := env.seedGroup(t, "Ordinary "+stamp, ordinaryCode) uncoded := env.seedGroup(t, "Uncoded "+stamp, "") // A code that differs from a privileged one only by case is ordinary. upper := env.seedGroup(t, "Upper "+stamp, "ADMIN") isAdmin := func(e *adminEnv, userID uint) bool { t.Helper() held, err := classes.HasGroupCode(t.Context(), e.gdb, userID, "admin") if err != nil { t.Fatal(err) } return held } put := func(e *adminEnv, status int, token string, user *models.User, extra map[string]any) *httptest.ResponseRecorder { t.Helper() body := map[string]any{"name": "kept", "email": user.Email} for key, value := range extra { body[key] = value } return e.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body) } assertRefused := func(rec *httptest.ResponseRecorder) { t.Helper() answer := adminDecode(t, rec) if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedGroupForbidden { t.Fatalf("refusal = %s", rec.Body.String()) } if got := answer.Error.Details["groups"]; len(answer.Error.Details) != 1 || len(got) != 1 || got[0] != privilegedGroupForbidden { t.Fatalf("refusal details = %v", answer.Error.Details) } } pivot := func(e *adminEnv, user *models.User, want ...*models.UserGroup) { t.Helper() if got := e.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) { t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...)) } } unchanged := func(original *models.User) { t.Helper() got := env.reload(t, original.ID) if got == nil || got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) || got.Password != original.Password || !reflect.DeepEqual(got.OrganisationID, original.OrganisationID) { t.Fatalf("user %d changed: %+v", original.ID, got) } } // Without the permission the privileged group is flagged locked among the // options; a group without a code and one whose code differs by case are // not. With the permission nothing is locked. if options := env.groupOptions(t, limited, stamp); len(options) != 4 || !options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] { t.Fatalf("options without the permission = %v", options) } if options := env.groupOptions(t, manager, stamp); len(options) != 4 || options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] { t.Fatalf("options with the permission = %v", options) } // Adding the privileged group on update is refused as a whole: the name // in the same body is not saved and the pivot is unchanged. target := env.seedUser(t, "target", nil) env.join(t, target, ordinary) if isAdmin(env, target.ID) { t.Fatal("the target starts as a site admin") } assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"name": "Promoted", "groups": []uint{ordinary.ID, adminGroup.ID}})) pivot(env, target, ordinary) unchanged(target) if isAdmin(env, target.ID) { t.Fatal("a refused save made a site admin") } // The privileged group alone, replacing the ordinary one, is refused too. assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"groups": []uint{adminGroup.ID}})) pivot(env, target, ordinary) // Removing it from a member is refused: by leaving it out, and by // clearing the field. member := env.seedUser(t, "member", nil) env.join(t, member, adminGroup, ordinary) for _, groups := range [][]uint{{ordinary.ID}, {}} { assertRefused(put(env, http.StatusForbidden, limited, member, map[string]any{"name": "Demoted", "groups": groups})) pivot(env, member, adminGroup, ordinary) unchanged(member) if !isAdmin(env, member.ID) { t.Fatal("a refused save removed a site admin") } } // The member's privileged label is flagged locked for the limited admin // and not for the manager. if ids, locked := env.recordGroups(t, limited, member.ID); !reflect.DeepEqual(ids, sortedIDs(adminGroup, ordinary)) || !locked[adminGroup.ID] || locked[ordinary.ID] { t.Fatalf("member groups for the limited admin = %v locked %v", ids, locked) } if _, locked := env.recordGroups(t, manager, member.ID); locked[adminGroup.ID] || locked[ordinary.ID] { t.Fatalf("member groups for the manager are locked: %v", locked) } // Creating a user who is already in the privileged group is refused and // leaves no user row. const password = "privileged-password-1" create := func(token, email string, groups []uint) *httptest.ResponseRecorder { t.Helper() return env.call(t, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ "name": "Created", "email": email, "password": password, "password_confirmation": password, "send_invite": false, "groups": groups, }) } refusedEmail := "born-admin-" + stamp + "@example.test" rec := create(limited, refusedEmail, []uint{adminGroup.ID, ordinary.ID}) if rec.Code != http.StatusForbidden { t.Fatalf("create with a privileged group = %d %s", rec.Code, rec.Body.String()) } assertRefused(rec) var born int64 if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&born).Error; err != nil || born != 0 { t.Fatalf("a refused create left %d users (%v)", born, err) } // Ordinary changes need only the users permission, also on a member of // the privileged group as long as that membership is sent back as it is; // a group without a code and a case variant are ordinary. put(env, http.StatusOK, limited, member, map[string]any{"groups": []uint{adminGroup.ID, uncoded.ID, upper.ID}}) pivot(env, member, adminGroup, uncoded, upper) put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{uncoded.ID}}) pivot(env, target, uncoded) put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{}}) pivot(env, target) if isAdmin(env, target.ID) || !isAdmin(env, member.ID) { t.Fatal("ordinary changes moved the site-admin membership") } // With the permission every refused request succeeds. put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID, adminGroup.ID}}) pivot(env, target, ordinary, adminGroup) if !isAdmin(env, target.ID) { t.Fatal("the manager could not add the privileged group") } put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID}}) pivot(env, target, ordinary) put(env, http.StatusOK, manager, member, map[string]any{"groups": []uint{}}) pivot(env, member) if isAdmin(env, target.ID) || isAdmin(env, member.ID) { t.Fatal("the manager could not remove the privileged group") } rec = create(manager, "born-admin-ok-"+stamp+"@example.test", []uint{adminGroup.ID}) if rec.Code != http.StatusCreated { t.Fatalf("manager create with a privileged group = %d %s", rec.Code, rec.Body.String()) } if bornID := createdID(t, rec); !isAdmin(env, bornID) { t.Fatal("the manager's create did not write the privileged membership") } // D-30: the takeover guard follows the membership this field writes. No // row is inserted into users_groups directly here. guarded := env.seedUser(t, "guarded", nil) newEmail := "guarded-new-" + stamp + "@example.test" put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{adminGroup.ID}}) rec = env.expect(t, http.StatusForbidden, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail}) if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 { t.Fatalf("takeover refusal = %s", rec.Body.String()) } if got := env.reload(t, guarded.ID); got.Email != guarded.Email { t.Fatalf("a refused takeover changed the email to %q", got.Email) } put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{}}) env.expect(t, http.StatusOK, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail}) if got := env.reload(t, guarded.ID); got.Email != newEmail { t.Fatalf("email after the membership was removed = %q", got.Email) } // The privileged list is config, read per request. With the ordinary // group's code in it, that group is the locked one and admin is not. moved := newAdminEnv(t, func(cfg *compass.Config) { if err := cfg.Set("golem15.user.privileged_groups", []string{ordinaryCode}); err != nil { t.Fatal(err) } }) movedLimited := moved.admin(t, permAccessUsers) if options := moved.groupOptions(t, movedLimited, stamp); len(options) != 4 || options[adminGroup.ID] || !options[ordinary.ID] || options[uncoded.ID] { t.Fatalf("options under the moved list = %v", options) } put(moved, http.StatusOK, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID}}) pivot(moved, member, adminGroup) assertRefused(put(moved, http.StatusForbidden, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID, ordinary.ID}})) pivot(moved, member, adminGroup) // target is in the ordinary group, which is now the privileged one. assertRefused(put(moved, http.StatusForbidden, movedLimited, target, map[string]any{"groups": []uint{}})) pivot(moved, target, ordinary) // An empty list means no group is privileged: nothing is locked. open := newAdminEnv(t, func(cfg *compass.Config) { if err := cfg.Set("golem15.user.privileged_groups", []string{}); err != nil { t.Fatal(err) } }) openLimited := open.admin(t, permAccessUsers) for id, locked := range open.groupOptions(t, openLimited, stamp) { if locked { t.Fatalf("group %s is locked although no code is privileged", strconv.FormatUint(uint64(id), 10)) } } // D-06: the group's code carries the privilege, so creating a group with // a privileged code, changing a code to or from one, and deleting a // privileged group need the extra permission on the User Groups screen. // The harness database is shared and may hold several groups coded // admin, so the matrix runs on two codes of its own next to admin. priv, second := "priv-"+stamp, "priv2-"+stamp coded := newAdminEnv(t, func(cfg *compass.Config) { if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", priv, second}); err != nil { t.Fatal(err) } }) groupsOnly := coded.admin(t, permAccessGroups) groupsManager := coded.admin(t, permAccessGroups, permManagePrivileged) privGroup := coded.seedGroup(t, "Privileged "+stamp, priv) plainCode := "plain-" + stamp plainGroup := coded.seedGroup(t, "Plain "+stamp, plainCode) holder := coded.seedUser(t, "holder", nil) coded.join(t, holder, privGroup, plainGroup) holds := func(code string) bool { t.Helper() held, err := classes.HasGroupCode(t.Context(), coded.gdb, holder.ID, code) if err != nil { t.Fatal(err) } return held } refusedCode := func(rec *httptest.ResponseRecorder) { t.Helper() if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) } answer := adminDecode(t, rec) if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedGroupCodeForbidden { t.Fatalf("refusal = %s", rec.Body.String()) } if got := answer.Error.Details["code"]; len(answer.Error.Details) != 1 || len(got) != 1 || got[0] != privilegedGroupCodeForbidden { t.Fatalf("refusal details = %v", answer.Error.Details) } } sameGroup := func(id uint, name, code string) { t.Helper() got := coded.group(t, id) if got == nil || got.Name != name || got.Code == nil || *got.Code != code { t.Fatalf("group %d = %+v, want %s (%s)", id, got, name, code) } } codeCount := func(code string) int64 { t.Helper() var n int64 if err := coded.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE code = ?`, code).Scan(&n).Error; err != nil { t.Fatal(err) } return n } groupPut := func(token string, id uint, body map[string]any) *httptest.ResponseRecorder { t.Helper() return coded.call(t, http.MethodPut, groupPath(id), token, body) } // Without the permission each of the four is refused and changes nothing. refusedCode(coded.call(t, http.MethodPost, adminAPI(adminGroupsPath), groupsOnly, map[string]any{"name": "Second " + stamp, "code": second})) if codeCount(second) != 0 { t.Fatal("a refused create left a privileged group") } refusedCode(groupPut(groupsOnly, plainGroup.ID, map[string]any{"name": "Promoted " + stamp, "code": second})) sameGroup(plainGroup.ID, "Plain "+stamp, plainCode) refusedCode(groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Demoted " + stamp, "code": "demoted-" + stamp})) sameGroup(privGroup.ID, "Privileged "+stamp, priv) // From one privileged code to another is a change of a privileged code. refusedCode(groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Privileged " + stamp, "code": second})) sameGroup(privGroup.ID, "Privileged "+stamp, priv) refusedCode(coded.call(t, http.MethodDelete, groupPath(privGroup.ID), groupsOnly, nil)) sameGroup(privGroup.ID, "Privileged "+stamp, priv) pivot(coded, holder, privGroup, plainGroup) if !holds(priv) || holds(second) { t.Fatal("a refused group change moved the privilege") } // The default code admin is protected the same way, and a group without // a code cannot be given a privileged one. refusedCode(groupPut(groupsOnly, adminGroup.ID, map[string]any{"name": "Admins " + stamp, "code": "was-admin-" + stamp})) refusedCode(coded.call(t, http.MethodDelete, groupPath(adminGroup.ID), groupsOnly, nil)) sameGroup(adminGroup.ID, "Admins "+stamp, "admin") refusedCode(groupPut(groupsOnly, uncoded.ID, map[string]any{"name": "Uncoded " + stamp, "code": second})) if got := coded.group(t, uncoded.ID); got == nil || got.Code != nil { t.Fatalf("the uncoded group after a refused save = %+v", got) } // Everything else on a privileged group needs only the groups // permission: its name, description and frontend permissions, with the // code sent back as it is. if rec := groupPut(groupsOnly, privGroup.ID, map[string]any{"name": "Privileged renamed " + stamp, "code": priv, "description": "Kept code", "permissions": map[string]int{}}); rec.Code != http.StatusOK { t.Fatalf("name-only update of a privileged group = %d %s", rec.Code, rec.Body.String()) } sameGroup(privGroup.ID, "Privileged renamed "+stamp, priv) // Ordinary groups, a case variant of a privileged code included, are // re-coded and deleted freely. if rec := groupPut(groupsOnly, plainGroup.ID, map[string]any{"name": "Plain " + stamp, "code": "plain2-" + stamp}); rec.Code != http.StatusOK { t.Fatalf("ordinary re-code = %d %s", rec.Code, rec.Body.String()) } plainCode = "plain2-" + stamp if rec := groupPut(groupsOnly, uncoded.ID, map[string]any{"name": "Uncoded " + stamp, "code": "coded-" + stamp}); rec.Code != http.StatusOK { t.Fatalf("first code of an uncoded group = %d %s", rec.Code, rec.Body.String()) } if rec := coded.call(t, http.MethodDelete, groupPath(upper.ID), groupsOnly, nil); rec.Code != http.StatusOK || coded.group(t, upper.ID) != nil { t.Fatalf("delete of the ADMIN group = %d %s", rec.Code, rec.Body.String()) } // With the permission all four succeed. created := coded.call(t, http.MethodPost, adminAPI(adminGroupsPath), groupsManager, map[string]any{"name": "Second " + stamp, "code": second}) if created.Code != http.StatusCreated || codeCount(second) != 1 { t.Fatalf("manager create of a privileged group = %d %s", created.Code, created.Body.String()) } if rec := groupPut(groupsManager, privGroup.ID, map[string]any{"name": "Privileged renamed " + stamp, "code": "freed-" + stamp}); rec.Code != http.StatusOK { t.Fatalf("manager re-code from a privileged code = %d %s", rec.Code, rec.Body.String()) } if holds(priv) { t.Fatal("the holder kept the privilege after the group lost its code") } if rec := groupPut(groupsManager, plainGroup.ID, map[string]any{"name": "Plain " + stamp, "code": priv}); rec.Code != http.StatusOK { t.Fatalf("manager re-code to a privileged code = %d %s", rec.Code, rec.Body.String()) } if !holds(priv) { t.Fatal("the holder did not gain the privilege with the group's new code") } if rec := coded.call(t, http.MethodDelete, groupPath(plainGroup.ID), groupsManager, nil); rec.Code != http.StatusOK || coded.group(t, plainGroup.ID) != nil { t.Fatalf("manager delete of a privileged group = %d %s", rec.Code, rec.Body.String()) } pivot(coded, holder, privGroup) if holds(priv) || coded.reload(t, holder.ID) == nil { t.Fatal("the delete of the privileged group left the privilege or removed the member") } }