From ba43ad9c0351f800a4fcda2ec30925abd77e0f6a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 14:30:31 +0200 Subject: [PATCH] test: pin every mitigated Phase 12.1 threat of the admin screens in TestPhase121Threats - one subtest per threat id T-12.1-18 to T-12.1-25, 27 to 31, 34, 38 and 39 - T-12.1-28 revisits T-12-18: the groups field is the one writer of users_groups - T-12.1-38 and T-12.1-39 cover the takeover guard for privileged-group members --- phase121_security_test.go | 950 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 950 insertions(+) create mode 100644 phase121_security_test.go diff --git a/phase121_security_test.go b/phase121_security_test.go new file mode 100644 index 0000000..211cb92 --- /dev/null +++ b/phase121_security_test.go @@ -0,0 +1,950 @@ +package user + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/compass" +) + +// pivotSnapshot is the whole users_groups table as text, for byte-identical +// comparisons around a request. +func (e *adminEnv) pivotSnapshot(t *testing.T) string { + t.Helper() + var rows []struct{ UserID, UserGroupID uint } + if err := e.gdb.Raw(`SELECT user_id, user_group_id FROM users_groups ORDER BY user_id, user_group_id`).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + var b strings.Builder + for _, row := range rows { + fmt.Fprintf(&b, "%d:%d;", row.UserID, row.UserGroupID) + } + return b.String() +} + +// userPayload signs the user in and returns the token and the user payloads +// of login and fetch. +func (e *adminEnv) userPayloads(t *testing.T, email, password string) (string, map[string]map[string]any, map[string]string) { + t.Helper() + payloads, raws := map[string]map[string]any{}, map[string]string{} + login := e.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": email, "password": password}) + var body struct { + Token string `json:"token"` + User map[string]any `json:"user"` + } + if err := json.Unmarshal(login.Body.Bytes(), &body); err != nil || body.Token == "" { + t.Fatalf("login body %s (%v)", login.Body.String(), err) + } + payloads["login"], raws["login"] = body.User, login.Body.String() + fetch := e.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", body.Token, nil) + var fetched struct { + User map[string]any `json:"user"` + } + if err := json.Unmarshal(fetch.Body.Bytes(), &fetched); err != nil { + t.Fatalf("fetch body %s (%v)", fetch.Body.String(), err) + } + payloads["fetch"], raws["fetch"] = fetched.User, fetch.Body.String() + return body.Token, payloads, raws +} + +// privilegedList boots an environment whose privileged group list is codes. +func privilegedList(t *testing.T, codes ...string) *adminEnv { + t.Helper() + return newAdminEnv(t, func(cfg *compass.Config) { + if err := cfg.Set("golem15.user.privileged_groups", codes); err != nil { + t.Fatal(err) + } + }) +} + +// TestPhase121Threats has one subtest per mitigated plugin threat of Phase +// 12.1, named by its id. Each asserts the protection through the admin API or +// the user API of the booted plugin, so removing the protection fails the +// subtest on an assertion (scripts/check-phase12.1.sh --removal in the +// framework repository). +// +// T-12.1-26 and T-12.1-32 are accepted risks and T-12.1-33 is a publication +// rule; they have no subtest. +func TestPhase121Threats(t *testing.T) { + // T-12.1-18: every Users, User Groups and Organisations route, bulk action + // and record action needs its permission. + t.Run("T-12.1-18", func(t *testing.T) { + env := newAdminEnv(t) + stranger := env.admin(t) + usersOnly := env.admin(t, permAccessUsers) + groupsOnly := env.admin(t, permAccessGroups) + stamp := adminStamp() + user := env.seedUser(t, "gated", func(u *models.User) { u.IsActivated = false }) + group := env.seedGroup(t, "Gated "+stamp, "gated-"+stamp) + organisation := env.seedOrganisation(t, "Gated "+stamp) + ids := map[string]any{"ids": []uint{user.ID}} + type route struct { + method, path string + body any + } + users := []route{ + {http.MethodGet, adminAPI(adminUsersPath), nil}, + {http.MethodGet, adminAPI(adminUsersPath + "/schema/list"), nil}, + {http.MethodGet, adminAPI(adminUsersPath + "/schema/form"), nil}, + {http.MethodGet, adminAPI(adminUsersPath + "/filters/groups/options"), nil}, + {http.MethodGet, adminAPI(adminUsersPath + "/fields/groups/options"), nil}, + {http.MethodGet, adminAPI(adminUsersPath + "/fields/organisation/options"), nil}, + {http.MethodGet, adminAPI(adminUsersPath + "/partials/hint?id=" + strconv.FormatUint(uint64(user.ID), 10)), nil}, + {http.MethodGet, userPath(user.ID, ""), nil}, + {http.MethodPost, adminAPI(adminUsersPath), map[string]any{"name": "Made", "email": "made-" + stamp + "@example.test", "password": "made-password-1", "password_confirmation": "made-password-1"}}, + {http.MethodPut, userPath(user.ID, ""), map[string]any{"name": "Changed", "email": user.Email}}, + {http.MethodDelete, userPath(user.ID, ""), nil}, + {http.MethodPost, adminAPI(adminUsersPath + "/bulk-delete"), ids}, + } + for _, action := range []string{"activate", "deactivate", "restore", "ban", "unban"} { + users = append(users, route{http.MethodPost, adminAPI(adminUsersPath + "/bulk/" + action), ids}) + } + for _, action := range []string{"activate", "unban", "unsuspend"} { + users = append(users, route{http.MethodPost, userPath(user.ID, "/actions/"+action), map[string]any{}}) + } + groups := []route{ + {http.MethodGet, adminAPI(adminGroupsPath), nil}, + {http.MethodGet, adminAPI(adminGroupsPath + "/schema/list"), nil}, + {http.MethodGet, adminAPI(adminGroupsPath + "/schema/form"), nil}, + {http.MethodGet, groupPath(group.ID), nil}, + {http.MethodPost, adminAPI(adminGroupsPath), map[string]any{"name": "Made " + stamp, "code": "made-" + stamp}}, + {http.MethodPut, groupPath(group.ID), map[string]any{"name": "Changed " + stamp, "code": "gated-" + stamp}}, + {http.MethodDelete, groupPath(group.ID), nil}, + {http.MethodPost, adminAPI(adminGroupsPath + "/bulk-delete"), map[string]any{"ids": []uint{group.ID}}}, + } + organisations := []route{ + {http.MethodGet, adminAPI(adminOrganisationsPath), nil}, + {http.MethodGet, adminAPI(adminOrganisationsPath + "/schema/list"), nil}, + {http.MethodGet, adminAPI(adminOrganisationsPath + "/schema/form"), nil}, + {http.MethodGet, adminAPI(adminOrganisationsPath + "/schema/relation/members"), nil}, + {http.MethodGet, organisationPath(organisation.ID, ""), nil}, + {http.MethodPost, adminAPI(adminOrganisationsPath), map[string]any{"name": "Made " + stamp}}, + {http.MethodPut, organisationPath(organisation.ID, ""), map[string]any{"name": "Changed " + stamp, "slug": organisation.Slug}}, + {http.MethodDelete, organisationPath(organisation.ID, ""), nil}, + {http.MethodGet, organisationPath(organisation.ID, "/relations/members"), nil}, + {http.MethodGet, organisationPath(organisation.ID, "/relations/members/candidates"), nil}, + {http.MethodPost, organisationPath(organisation.ID, "/relations/members/link"), ids}, + {http.MethodPost, organisationPath(organisation.ID, "/relations/members/unlink"), ids}, + } + refused := func(screen, token string, routes []route) { + t.Helper() + for _, r := range routes { + rec := env.call(t, r.method, r.path, token, r.body) + if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Code != "forbidden" { + t.Fatalf("%s: %s %s without the permission = %d %s", screen, r.method, r.path, rec.Code, rec.Body.String()) + } + } + } + refused("users", stranger, users) + refused("users", groupsOnly, users) + refused("user groups", stranger, groups) + refused("user groups", usersOnly, groups) + refused("organisations", stranger, organisations) + refused("organisations", groupsOnly, organisations) + + // Nothing was written by any refused request. + got := env.reload(t, user.ID) + if got == nil || got.IsActivated || got.DeletedAt.Valid || got.OrganisationID != nil || got.Name == nil || *got.Name != "gated" { + t.Fatalf("a refused request changed the user: %+v", got) + } + if stored := env.group(t, group.ID); stored == nil || stored.Name != "Gated "+stamp { + t.Fatalf("a refused request changed the group: %+v", stored) + } + if stored := env.organisation(t, organisation.ID); stored == nil || stored.Name != "Gated "+stamp { + t.Fatalf("a refused request changed the organisation: %+v", stored) + } + var made int64 + if err := env.gdb.Raw(`SELECT (SELECT COUNT(*) FROM users WHERE email = ?) + (SELECT COUNT(*) FROM user_groups WHERE code = ?) + (SELECT COUNT(*) FROM golem15_user_organisations WHERE name = ?)`, + "made-"+stamp+"@example.test", "made-"+stamp, "Made "+stamp).Scan(&made).Error; err != nil || made != 0 { + t.Fatalf("a refused create wrote %d rows (%v)", made, err) + } + // The same routes open with the permission. + env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), usersOnly, nil) + env.expect(t, http.StatusOK, http.MethodGet, groupPath(group.ID), groupsOnly, nil) + env.expect(t, http.StatusOK, http.MethodGet, organisationPath(organisation.ID, ""), usersOnly, nil) + }) + + // T-12.1-19: no admin response carries a password, a hash or a one-time + // code. + t.Run("T-12.1-19", func(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stamp := adminStamp() + activation, reset := "activation-"+stamp, "reset-"+stamp + marker := "leak" + stamp + user := env.seedUser(t, "leak", func(u *models.User) { + u.Surname, u.IsActivated = &marker, false + u.ActivationCode, u.ResetPasswordCode = &activation, &reset + }) + const plain = "leak-password-1" + id := strconv.FormatUint(uint64(user.ID), 10) + bodies := map[string]string{ + "list": env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"?search="+marker), token, nil).Body.String(), + "show": env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil).Body.String(), + "partial": env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id="+id), token, nil).Body.String(), + "update": env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, + map[string]any{"name": "Leak", "email": user.Email, "password": plain, "password_confirmation": plain}).Body.String(), + "mismatch": env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token, + map[string]any{"name": "Leak", "email": user.Email, "password": plain, "password_confirmation": "other-password-1"}).Body.String(), + "create": env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, + map[string]any{"name": "Leak two", "email": "leak-two-" + stamp + "@example.test", "password": plain, "password_confirmation": plain}).Body.String(), + "bulk": env.bulk(t, token, "ban", user).Body.String(), + "action": env.expect(t, http.StatusOK, http.MethodPost, userPath(user.ID, "/actions/activate"), token, map[string]any{}).Body.String(), + } + stored := env.reload(t, user.ID) + if !bouncer.CheckPassword(stored.Password, plain) { + t.Fatal("the update did not store the submitted password") + } + for route, body := range bodies { + if strings.Contains(body, plain) || strings.Contains(body, activation) || strings.Contains(body, reset) || + strings.Contains(body, stored.Password) || strings.Contains(body, user.Password) || strings.Contains(body, "$2a$") { + t.Fatalf("the %s response carries a secret: %s", route, body) + } + // A 422 names the refused field; no other response has such a key. + if route != "mismatch" { + assertNoSecret(t, body) + } + } + }) + + // T-12.1-20: protected columns are never written from a body. + t.Run("T-12.1-20", func(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stamp := adminStamp() + organisation := env.seedOrganisation(t, "Assign "+stamp) + user := env.seedUser(t, "assign", func(u *models.User) { u.IsActivated = false }) + forged, err := bouncer.HashPassword(4, "forged-password-1") + if err != nil { + t.Fatal(err) + } + same := func(step string) { + t.Helper() + got := env.reload(t, user.ID) + if got.IsActivated || got.ActivatedAt != nil || got.OrganisationID != nil || got.OrganisationRole != nil || + len(got.Permissions) != 0 || got.Password != user.Password || got.TokensValidAfter != nil || got.MustChangePassword { + t.Fatalf("%s wrote a protected column: %+v", step, got) + } + } + // Plain columns in a body are dropped; the rest of the body is saved. + env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{ + "name": "Assigned", "email": user.Email, "is_activated": true, "activated_at": "2020-01-01T00:00:00Z", + "organisation_id": organisation.ID, "organisation_role": "owner", "must_change_password": true, + "Password": forged, "tokens_valid_after": "2020-01-01T00:00:00Z", + }) + same("a body with plain protected columns") + if got := env.reload(t, user.ID); got.Name == nil || *got.Name != "Assigned" { + t.Fatalf("the allowed part of the body was not saved: %+v", got.Name) + } + // permissions is written by the editor's validated path only. + for _, scalar := range []any{1, "all", true, []string{"blog.post"}} { + env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token, + map[string]any{"name": "Assigned", "email": user.Email, "permissions": scalar}) + same(fmt.Sprintf("permissions sent as %v", scalar)) + } + env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token, + map[string]any{"name": "Assigned", "email": user.Email, "permissions": map[string]int{"never.offered." + stamp: 1}}) + same("an unknown permission code") + // A password without its confirmation is not a column write. + env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(user.ID, ""), token, + map[string]any{"name": "Assigned", "email": user.Email, "password": forged}) + same("a password without confirmation") + // A create cannot switch the activation on either. + created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ + "name": "Born", "email": "born-" + stamp + "@example.test", "password": "born-password-1", "password_confirmation": "born-password-1", + "is_activated": true, "organisation_id": organisation.ID, + }) + if got := env.reload(t, createdID(t, created)); got.IsActivated || got.OrganisationID != nil { + t.Fatalf("a create wrote a protected column: %+v", got) + } + // The organisation is writable through its relation field only. + env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, + map[string]any{"name": "Assigned", "email": user.Email, "organisation": organisation.ID}) + if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != organisation.ID { + t.Fatal("the organisation relation field did not write organisation_id") + } + }) + + // T-12.1-21: an admin password reset ends the user's sessions. + t.Run("T-12.1-21", func(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + user := env.seedUser(t, "reset", nil) + status, old := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK { + t.Fatalf("login = %d", status) + } + env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", old, nil) + time.Sleep(1100 * time.Millisecond) // token timestamps have one-second resolution + const next = "reset-password-2" + env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, + map[string]any{"name": "reset", "email": user.Email, "password": next, "password_confirmation": next}) + if got := env.reload(t, user.ID); got.TokensValidAfter == nil { + t.Fatal("an admin password reset did not stamp tokens_valid_after") + } + if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", old, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("a token issued before the reset still works: %d", rec.Code) + } + if status, _ := env.userLogin(t, user.Email, next); status != http.StatusOK { + t.Fatalf("login with the new password = %d", status) + } + }) + + // T-12.1-22: exactly one invitation per created user and none on update. + t.Run("T-12.1-22", func(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stamp := adminStamp() + invited := "threat-invited-" + stamp + "@example.test" + body := map[string]any{"name": "Invited", "email": invited, "password": "invite-password-1", "password_confirmation": "invite-password-1", "send_invite": true} + id := createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, body)) + if n := len(env.mails(invited)); n != 1 { + t.Fatalf("invitations for one created user = %d, want 1", n) + } + // The same body again is a duplicate email: no second mail. + env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, body) + // An update carrying the flag sends nothing. + env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token, map[string]any{"name": "Invited", "email": invited, "send_invite": true}) + if n := len(env.mails(invited)); n != 1 { + t.Fatalf("invitations after a failed create and an update = %d, want 1", n) + } + // No bulk or record action invites. + for _, name := range []string{"invite", "send_invite"} { + if rec := env.call(t, http.MethodPost, adminAPI(adminUsersPath+"/bulk/"+name), token, map[string]any{"ids": []uint{id}}); rec.Code != http.StatusNotFound { + t.Fatalf("bulk %s = %d, want 404", name, rec.Code) + } + } + }) + + // T-12.1-23: a permanent delete leaves no orphan and needs the permission. + t.Run("T-12.1-23", func(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stranger := env.admin(t) + group := env.seedGroup(t, "Purge "+adminStamp(), "purge-"+adminStamp()) + user := env.seedUser(t, "purge", nil) + env.join(t, user, group) + env.seedThrottle(t, user, "203.0.113.30", false, nil) + key := env.seedAvatar(t, user) + env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(user.ID, ""), stranger, nil) + if env.reload(t, user.ID) == nil || env.hangers(t, user) != (hangers{Throttle: 1, Groups: 1, Files: 1}) || !env.blobExists(t, key) { + t.Fatalf("a refused delete changed the user: %+v", env.hangers(t, user)) + } + env.expect(t, http.StatusOK, http.MethodDelete, userPath(user.ID, ""), token, nil) + if env.reload(t, user.ID) != nil { + t.Fatal("the user row survived the permanent delete") + } + if left := env.hangers(t, user); left != (hangers{}) { + t.Fatalf("the permanent delete left user_throttle, users_groups or system_files rows: %+v", left) + } + if env.blobExists(t, key) { + t.Fatal("the permanent delete left the avatar blob") + } + }) + + // T-12.1-24: the admin columns never reach a user API payload. + t.Run("T-12.1-24", func(t *testing.T) { + env := newAdminEnv(t) + stamp := adminStamp() + group := env.seedGroup(t, "Payload "+stamp, "payload-"+stamp) + user := env.seedUser(t, "payload", func(u *models.User) { u.Permissions = models.PermissionSet{"payload." + stamp: 1} }) + env.join(t, user, group) + if err := env.gdb.Exec(`UPDATE users SET last_seen = NOW() - INTERVAL '1 minute' WHERE id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + userToken, payloads, raws := env.userPayloads(t, user.Email, "correct-horse") + update := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/update", userToken, map[string]any{"name": "Payload", "surname": "Shown", "email": user.Email}) + raws["update"] = update.Body.String() + raws["refresh"] = env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/refresh", userToken, nil).Body.String() + for name, raw := range raws { + for _, key := range []string{"last_seen", "created_at", "updated_at", "LastSeen", "CreatedAt", "UpdatedAt", "Permissions", "payload." + stamp, "Payload " + stamp} { + if strings.Contains(raw, key) { + t.Fatalf("the %s body carries %s: %s", name, key, raw) + } + } + } + for name, payload := range payloads { + if value, ok := payload["permissions"]; !ok || value != nil { + t.Fatalf("%s payload permissions = %v (present %v), want null", name, value, ok) + } + if groups, ok := payload["groups"].([]any); !ok || len(groups) != 0 { + t.Fatalf("%s payload groups = %v, want an empty list", name, payload["groups"]) + } + } + }) + + // T-12.1-25: a failing last_seen write never fails authentication. + t.Run("T-12.1-25", func(t *testing.T) { + env := newAdminEnv(t) + user := env.seedUser(t, "seen", nil) + drop := func() { + env.gdb.Exec(`DROP TRIGGER IF EXISTS threat_refuse_last_seen ON users`) + env.gdb.Exec(`DROP FUNCTION IF EXISTS threat_refuse_last_seen()`) + } + t.Cleanup(drop) + for _, stmt := range []string{ + `CREATE OR REPLACE FUNCTION threat_refuse_last_seen() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'last_seen cannot be written in this test'; END $$ LANGUAGE plpgsql`, + `CREATE TRIGGER threat_refuse_last_seen BEFORE UPDATE OF last_seen ON users FOR EACH ROW EXECUTE FUNCTION threat_refuse_last_seen()`, + } { + if err := env.gdb.Exec(stmt).Error; err != nil { + t.Fatal(err) + } + } + if err := classes.TouchLastSeen(t.Context(), env.gdb, user.ID); err == nil { + t.Fatal("the trigger does not refuse the write") + } + status, token := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK || token == "" { + t.Fatalf("login with a failing last_seen write = %d", status) + } + if rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil); rec.Code != http.StatusOK { + t.Fatalf("refresh with a failing last_seen write = %d %s", rec.Code, rec.Body.String()) + } + if got := env.reload(t, user.ID); got.LastSeen != nil { + t.Fatalf("last_seen was written although the write is refused: %v", got.LastSeen) + } + drop() + // With the write allowed again, a login stamps it once. + env.userLogin(t, user.Email, "correct-horse") + first := env.reload(t, user.ID).LastSeen + env.userLogin(t, user.Email, "correct-horse") + if second := env.reload(t, user.ID).LastSeen; first == nil || second == nil || !second.Equal(*first) { + t.Fatalf("last_seen is stamped more than once per five minutes: %v then %v", first, second) + } + }) + + // T-12.1-27: the permission resolver's override, exactly-1 and wildcard + // rules. + t.Run("T-12.1-27", func(t *testing.T) { + env := newAdminEnv(t) + stamp := adminStamp() + store := func(group *models.UserGroup, permissions string) { + t.Helper() + if err := env.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, permissions, group.ID).Error; err != nil { + t.Fatal(err) + } + } + allow := env.seedGroup(t, "Allow "+stamp, "allow-"+stamp) + deny := env.seedGroup(t, "Deny "+stamp, "deny-"+stamp) + store(allow, `{"blog.post":1,"blog.edit":1,"shop.buy":-1,"wiki.*":1}`) + store(deny, `{"blog.post":-1,"shop.buy":-1,"blog.zero":0}`) + user := env.seedUser(t, "resolver", func(u *models.User) { u.Permissions = models.PermissionSet{"blog.edit": -1, "shop.buy": 1} }) + env.join(t, user, allow, deny) + merged, err := classes.MergedPermissions(t.Context(), env.gdb, user.ID) + if err != nil { + t.Fatal(err) + } + // Among groups the higher value wins; the user's own value replaces it. + want := map[string]int{"blog.post": 1, "blog.edit": -1, "shop.buy": 1, "wiki.*": 1, "blog.zero": 0} + if !reflect.DeepEqual(merged, want) { + t.Fatalf("merged = %v, want %v", merged, want) + } + for code, held := range map[string]bool{ + "blog.post": true, // a group's allow beats another group's deny + "blog.edit": false, // a user-level deny overrides a group's allow + "shop.buy": true, // a user-level allow overrides the groups' deny + "blog.zero": false, // held only at exactly 1 + "blog.none": false, + "wiki.page": true, // a stored trailing wildcard + "wiki.": false, + "blog.*": true, // an asked trailing wildcard + "*.buy": true, // an asked leading wildcard + "*.edit": false, + "nothing.*": false, + "*.nothing": false, + "wikipedia": false, + "shop.buy.x": false, + } { + if got := classes.HasPermission(merged, code); got != held { + t.Fatalf("HasPermission(%q) = %v, want %v", code, got, held) + } + if got, err := classes.UserHasPermission(t.Context(), env.gdb, user.ID, code); err != nil || got != held { + t.Fatalf("UserHasPermission(%q) = %v (%v), want %v", code, got, err, held) + } + } + }) + + // T-12.1-28 is T-12-18 revisited (the Phase 12 threat "a users_groups + // write path makes a site admin"): the groups field of the user form is + // the one writer of users_groups, and adding or removing a privileged + // group through it needs golem15.users.manage_privileged_groups. + t.Run("T-12.1-28", func(t *testing.T) { + env := newAdminEnv(t) + limited := env.admin(t, permAccessUsers) + manager := env.admin(t, permAccessUsers, permManagePrivileged) + stamp := adminStamp() + adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") + ordinary := env.seedGroup(t, "Ordinary "+stamp, "ordinary-"+stamp) + target := env.seedUser(t, "target", nil) + env.join(t, target, ordinary) + member := env.seedUser(t, "member", nil) + env.join(t, member, adminGroup, ordinary) + isAdmin := func(id uint) bool { + t.Helper() + held, err := classes.HasGroupCode(t.Context(), env.gdb, id, "admin") + if err != nil { + t.Fatal(err) + } + return held + } + put := func(token string, user *models.User, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + if _, ok := body["email"]; !ok { + body["email"] = user.Email + } + return env.call(t, http.MethodPut, userPath(user.ID, ""), token, body) + } + refused := func(step string, rec *httptest.ResponseRecorder) { + t.Helper() + answer := adminDecode(t, rec) + if rec.Code != http.StatusForbidden || answer.Error.Message != privilegedGroupForbidden || len(answer.Error.Details["groups"]) != 1 { + t.Fatalf("%s = %d %s, want the privileged-group 403 on groups", step, rec.Code, rec.Body.String()) + } + } + create := func(token, email string, groups ...uint) *httptest.ResponseRecorder { + t.Helper() + return env.call(t, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ + "name": "Born", "email": email, "password": "born-password-1", "password_confirmation": "born-password-1", "groups": groups, + }) + } + + // Without the permission: add, remove and create are refused and the + // pivot, the other fields of the body and the site-admin answer stay. + before := env.pivotSnapshot(t) + if isAdmin(target.ID) || !isAdmin(member.ID) { + t.Fatal("seed: wrong site-admin state") + } + refused("add on update", put(limited, target, map[string]any{"name": "Promoted", "groups": []uint{ordinary.ID, adminGroup.ID}})) + refused("add, crafted with other fields", put(limited, target, map[string]any{ + "name": "Promoted", "email": "promoted-" + stamp + "@example.test", "surname": "Crafted", "groups": []uint{adminGroup.ID}, + })) + refused("remove on update", put(limited, member, map[string]any{"name": "Demoted", "groups": []uint{ordinary.ID}})) + refused("remove by clearing", put(limited, member, map[string]any{"groups": []uint{}})) + refusedEmail := "born-admin-" + stamp + "@example.test" + refused("create with the group", create(limited, refusedEmail, adminGroup.ID, ordinary.ID)) + if got := env.pivotSnapshot(t); got != before { + t.Fatalf("a refused request changed users_groups:\n%s\nwant\n%s", got, before) + } + for _, user := range []*models.User{target, member} { + got := env.reload(t, user.ID) + if got.Email != user.Email || !reflect.DeepEqual(got.Name, user.Name) || got.Surname != nil { + t.Fatalf("a refused request saved another field of user %d: %+v", user.ID, got) + } + } + 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) + } + if isAdmin(target.ID) || !isAdmin(member.ID) { + t.Fatal("a refused request moved the site-admin membership") + } + // Ordinary memberships still need only the users permission. + if rec := put(limited, target, map[string]any{"name": "target", "groups": []uint{}}); rec.Code != http.StatusOK { + t.Fatalf("an ordinary change = %d %s", rec.Code, rec.Body.String()) + } + + // With the permission each of them succeeds. + if rec := put(manager, target, map[string]any{"name": "target", "groups": []uint{adminGroup.ID}}); rec.Code != http.StatusOK || !isAdmin(target.ID) { + t.Fatalf("manager add = %d %s", rec.Code, rec.Body.String()) + } + if rec := put(manager, member, map[string]any{"name": "member", "groups": []uint{ordinary.ID}}); rec.Code != http.StatusOK || isAdmin(member.ID) { + t.Fatalf("manager remove = %d %s", rec.Code, rec.Body.String()) + } + rec := create(manager, "born-admin-ok-"+stamp+"@example.test", adminGroup.ID) + if rec.Code != http.StatusCreated || !isAdmin(createdID(t, rec)) { + t.Fatalf("manager create = %d %s", rec.Code, rec.Body.String()) + } + }) + + // T-12.1-29: creating, re-coding to or from, and deleting a privileged + // group code. + t.Run("T-12.1-29", func(t *testing.T) { + stamp := adminStamp() + priv, second := "priv-"+stamp, "priv2-"+stamp + env := privilegedList(t, "admin", priv, second) + groupsOnly := env.admin(t, permAccessGroups) + manager := env.admin(t, permAccessGroups, permManagePrivileged) + privGroup := env.seedGroup(t, "Privileged "+stamp, priv) + plainGroup := env.seedGroup(t, "Plain "+stamp, "plain-"+stamp) + cases := []struct { + name string + run func(token string) *httptest.ResponseRecorder + status int + }{ + {"create with a privileged code", func(token string) *httptest.ResponseRecorder { + return env.call(t, http.MethodPost, adminAPI(adminGroupsPath), token, map[string]any{"name": "Second " + stamp, "code": second}) + }, http.StatusCreated}, + {"re-code to a privileged code", func(token string) *httptest.ResponseRecorder { + return env.call(t, http.MethodPut, groupPath(plainGroup.ID), token, map[string]any{"name": "Plain " + stamp, "code": "admin-like-" + stamp}) + }, http.StatusOK}, + {"re-code from a privileged code", func(token string) *httptest.ResponseRecorder { + return env.call(t, http.MethodPut, groupPath(privGroup.ID), token, map[string]any{"name": "Privileged " + stamp, "code": "freed-" + stamp}) + }, http.StatusOK}, + {"delete a privileged group", func(token string) *httptest.ResponseRecorder { + return env.call(t, http.MethodDelete, groupPath(privGroup.ID), token, nil) + }, http.StatusOK}, + } + // The second case re-codes an ordinary group to a privileged code. + cases[1].run = func(token string) *httptest.ResponseRecorder { + return env.call(t, http.MethodPut, groupPath(plainGroup.ID), token, map[string]any{"name": "Plain " + stamp, "code": second}) + } + state := func() string { + t.Helper() + var rows []struct { + ID uint + Code *string + } + if err := env.gdb.Raw(`SELECT id, code FROM user_groups WHERE id IN (?, ?) OR code = ? ORDER BY id`, privGroup.ID, plainGroup.ID, second).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + var b strings.Builder + for _, row := range rows { + code := "" + if row.Code != nil { + code = *row.Code + } + fmt.Fprintf(&b, "%d=%s;", row.ID, code) + } + return b.String() + } + before := state() + for _, tc := range cases { + rec := tc.run(groupsOnly) + answer := adminDecode(t, rec) + if rec.Code != http.StatusForbidden || answer.Error.Message != privilegedGroupCodeForbidden || len(answer.Error.Details["code"]) != 1 { + t.Fatalf("%s without the permission = %d %s", tc.name, rec.Code, rec.Body.String()) + } + if got := state(); got != before { + t.Fatalf("%s without the permission changed the groups: %s, want %s", tc.name, got, before) + } + } + // A name-only edit of a privileged group needs only the groups permission. + if rec := env.call(t, http.MethodPut, groupPath(privGroup.ID), groupsOnly, map[string]any{"name": "Privileged renamed " + stamp, "code": priv}); rec.Code != http.StatusOK { + t.Fatalf("a name-only edit of a privileged group = %d %s", rec.Code, rec.Body.String()) + } + // With the permission: create, re-code from, then delete what is left. + if rec := cases[0].run(manager); rec.Code != http.StatusCreated { + t.Fatalf("manager %s = %d %s", cases[0].name, rec.Code, rec.Body.String()) + } + if rec := cases[2].run(manager); rec.Code != http.StatusOK { + t.Fatalf("manager %s = %d %s", cases[2].name, rec.Code, rec.Body.String()) + } + if rec := env.call(t, http.MethodPut, groupPath(plainGroup.ID), manager, 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 rec := env.call(t, http.MethodDelete, groupPath(plainGroup.ID), manager, nil); rec.Code != http.StatusOK || env.group(t, plainGroup.ID) != nil { + t.Fatalf("manager delete of a privileged group = %d %s", rec.Code, rec.Body.String()) + } + }) + + // T-12.1-30: no second, unguarded writer of users_groups. + t.Run("T-12.1-30", func(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers, permAccessGroups) + stamp := adminStamp() + adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") + ordinary := env.seedGroup(t, "Ordinary "+stamp, "ordinary-"+stamp) + organisation := env.seedOrganisation(t, "Writers "+stamp) + now := time.Now() + member := env.seedUser(t, "writer-member", func(u *models.User) { u.IsActivated = false }) + plain := env.seedUser(t, "writer-plain", nil) + env.join(t, member, adminGroup, ordinary) + env.join(t, plain, ordinary) + before := env.pivotSnapshot(t) + same := func(step string) { + t.Helper() + if got := env.pivotSnapshot(t); got != before { + t.Fatalf("%s changed users_groups:\n%s\nwant\n%s", step, got, before) + } + } + for _, action := range []string{"activate", "deactivate", "restore", "ban", "unban"} { + if rec := env.bulk(t, token, action, member, plain); rec.Code != http.StatusOK { + t.Fatalf("bulk %s = %d %s", action, rec.Code, rec.Body.String()) + } + same("bulk " + action) + } + // Bring each record action into its applicable state, then run it. + if err := env.gdb.Exec(`UPDATE users SET is_activated = FALSE WHERE id = ?`, member.ID).Error; err != nil { + t.Fatal(err) + } + env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/activate"), token, map[string]any{}) + same("record activate") + env.bulk(t, token, "ban", member) + env.expect(t, http.StatusOK, http.MethodPost, userPath(member.ID, "/actions/unban"), token, map[string]any{}) + same("record unban") + env.seedThrottle(t, plain, "203.0.113.40", false, &now) + env.expect(t, http.StatusOK, http.MethodPost, userPath(plain.ID, "/actions/unsuspend"), token, map[string]any{}) + same("record unsuspend") + // The organisation members manager writes organisation_id only. + ids := map[string]any{"ids": []uint{member.ID, plain.ID}} + env.expect(t, http.StatusOK, http.MethodPost, organisationPath(organisation.ID, "/relations/members/link"), token, ids) + if got := env.reload(t, member.ID); got.OrganisationID == nil || *got.OrganisationID != organisation.ID { + t.Fatal("the members link did not run") + } + same("organisation members link") + env.expect(t, http.StatusOK, http.MethodPost, organisationPath(organisation.ID, "/relations/members/unlink"), token, ids) + same("organisation members unlink") + // A user update without the groups field and a group update. + env.expect(t, http.StatusOK, http.MethodPut, userPath(plain.ID, ""), token, map[string]any{"name": "Renamed", "email": plain.Email}) + env.expect(t, http.StatusOK, http.MethodPut, groupPath(ordinary.ID), token, map[string]any{"name": "Ordinary renamed " + stamp, "code": "ordinary-" + stamp}) + same("updates without the groups field") + + // No relation manager exists for groups on either controller, so the + // relation routes (which do not ask the lock provider) cannot reach + // the pivot. + for _, path := range []string{ + userPath(member.ID, "/relations/groups"), + userPath(member.ID, "/relations/Groups"), + adminAPI(adminUsersPath + "/schema/relation/groups"), + groupPath(ordinary.ID) + "/relations/users", + adminAPI(adminGroupsPath + "/schema/relation/users"), + organisationPath(organisation.ID, "/relations/groups"), + } { + if rec := env.call(t, http.MethodGet, path, token, nil); rec.Code != http.StatusNotFound { + t.Fatalf("GET %s = %d, want 404", path, rec.Code) + } + } + for _, path := range []string{ + userPath(plain.ID, "/relations/groups/link"), + groupPath(adminGroup.ID) + "/relations/users/link", + organisationPath(organisation.ID, "/relations/groups/link"), + } { + body := map[string]any{"ids": []uint{adminGroup.ID, plain.ID}} + if rec := env.call(t, http.MethodPost, path, token, body); rec.Code != http.StatusNotFound { + t.Fatalf("POST %s = %d, want 404", path, rec.Code) + } + } + same("relation routes for groups") + if held, err := classes.HasGroupCode(t.Context(), env.gdb, plain.ID, "admin"); err != nil || held { + t.Fatalf("the plain user became a site admin: %v (%v)", held, err) + } + }) + + // T-12.1-31: the privileged list is read per request, compared + // case-sensitively, and a group without a code is never privileged. + t.Run("T-12.1-31", func(t *testing.T) { + stamp := adminStamp() + custom := "custom-" + stamp + env := privilegedList(t, custom) + limited := env.admin(t, permAccessUsers) + adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") + customGroup := env.seedGroup(t, "Custom "+stamp, custom) + upper := env.seedGroup(t, "Upper "+stamp, strings.ToUpper(custom)) + uncoded := env.seedGroup(t, "Uncoded "+stamp, "") + options := env.groupOptions(t, limited, stamp) + if len(options) != 4 || !options[customGroup.ID] || options[adminGroup.ID] || options[upper.ID] || options[uncoded.ID] { + t.Fatalf("locked options under the overridden list = %v", options) + } + user := env.seedUser(t, "listed", nil) + put := func(groups ...uint) *httptest.ResponseRecorder { + t.Helper() + return env.call(t, http.MethodPut, userPath(user.ID, ""), limited, map[string]any{"name": "listed", "email": user.Email, "groups": groups}) + } + // admin is no longer privileged; the case variant and the group + // without a code never are. + if rec := put(adminGroup.ID, upper.ID, uncoded.ID); rec.Code != http.StatusOK { + t.Fatalf("ordinary groups under the overridden list = %d %s", rec.Code, rec.Body.String()) + } + if rec := put(adminGroup.ID, customGroup.ID); rec.Code != http.StatusForbidden { + t.Fatalf("the overridden privileged group = %d %s, want 403", rec.Code, rec.Body.String()) + } + if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(adminGroup, upper, uncoded)) { + t.Fatalf("users_groups after the refusal = %v", got) + } + // The same request under the default list: read per request, so a new + // environment with another list decides differently. + byDefault := newAdminEnv(t) + defaultLimited := byDefault.admin(t, permAccessUsers) + rec := byDefault.call(t, http.MethodPut, userPath(user.ID, ""), defaultLimited, map[string]any{"name": "listed", "email": user.Email, "groups": []uint{adminGroup.ID, customGroup.ID, upper.ID, uncoded.ID}}) + if rec.Code != http.StatusOK { + t.Fatalf("adding the custom group under the default list = %d %s", rec.Code, rec.Body.String()) + } + rec = byDefault.call(t, http.MethodPut, userPath(user.ID, ""), defaultLimited, map[string]any{"name": "listed", "email": user.Email, "groups": []uint{customGroup.ID}}) + if rec.Code != http.StatusForbidden { + t.Fatalf("removing admin under the default list = %d %s, want 403", rec.Code, rec.Body.String()) + } + // The class functions agree. + codes := classes.PrivilegedGroupCodes(env.app.Config) + if !reflect.DeepEqual(codes, []string{custom}) || classes.IsPrivilegedCode(codes, upper.Code) || classes.IsPrivilegedCode(codes, nil) || !classes.IsPrivilegedCode(codes, customGroup.Code) { + t.Fatalf("privileged codes = %v", codes) + } + }) + + // T-12.1-34: group membership never reaches a user API payload. + t.Run("T-12.1-34", func(t *testing.T) { + env := newAdminEnv(t) + stamp := adminStamp() + group := env.seedGroup(t, "Hidden "+stamp, "admin") + user := env.seedUser(t, "hidden", nil) + env.join(t, user, group) + _, payloads, raws := env.userPayloads(t, user.Email, "correct-horse") + for name, payload := range payloads { + if groups, ok := payload["groups"].([]any); !ok || len(groups) != 0 { + t.Fatalf("%s payload groups = %v, want an empty list", name, payload["groups"]) + } + if strings.Contains(raws[name], "Hidden "+stamp) || strings.Contains(raws[name], `"code":"admin"`) { + t.Fatalf("the %s body names the group: %s", name, raws[name]) + } + } + loaded := models.User{} + if err := env.gdb.Preload("Groups").First(&loaded, user.ID).Error; err != nil || len(loaded.Groups) != 1 { + t.Fatalf("the relation did not load: %d groups (%v)", len(loaded.Groups), err) + } + marshalled, err := json.Marshal(loaded) + if err != nil { + t.Fatal(err) + } + if lower := strings.ToLower(string(marshalled)); strings.Contains(lower, "groups") || strings.Contains(lower, "hidden "+stamp) { + t.Fatalf("a marshalled user with loaded groups serializes them: %s", marshalled) + } + }) + + // T-12.1-38 (D-30): takeover of a privileged-group member's account by an + // administrator who holds only golem15.users.access_users. + t.Run("T-12.1-38", func(t *testing.T) { + env := newAdminEnv(t) + limited := env.admin(t, permAccessUsers) + manager := env.admin(t, permAccessUsers, permManagePrivileged) + stamp := adminStamp() + adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") + member := env.seedUser(t, "takeover", nil) + // The membership is given through the groups field, by an + // administrator who may. + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, + map[string]any{"name": "takeover", "email": member.Email, "groups": []uint{adminGroup.ID}}) + original := env.reload(t, member.ID) + const attempted = "takeover-password-1" + takeover := "takeover-" + stamp + "@example.test" + refused := func(step string, body map[string]any, fields ...string) { + t.Helper() + rec := env.call(t, http.MethodPut, userPath(member.ID, ""), limited, body) + answer := adminDecode(t, rec) + if rec.Code != http.StatusForbidden || answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberForbidden { + t.Fatalf("%s = %d %s, want the privileged-member 403", step, rec.Code, rec.Body.String()) + } + if len(answer.Error.Details) != len(fields) { + t.Fatalf("%s details = %v, want %v", step, answer.Error.Details, fields) + } + for _, field := range fields { + if len(answer.Error.Details[field]) != 1 { + t.Fatalf("%s has no detail on %s: %v", step, field, answer.Error.Details) + } + } + got := env.reload(t, member.ID) + if got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) || got.Password != original.Password || + !reflect.DeepEqual(got.TokensValidAfter, original.TokensValidAfter) { + t.Fatalf("%s changed the member: email=%q name=%v tokens_valid_after=%v", step, got.Email, got.Name, got.TokensValidAfter) + } + } + refused("an email change", map[string]any{"name": "takeover", "email": takeover}, "email") + refused("a password reset", map[string]any{"name": "takeover", "email": member.Email, "password": attempted, "password_confirmation": attempted}, "password") + refused("email, password and name in one body", map[string]any{"name": "Taken over", "email": takeover, "password": attempted, "password_confirmation": attempted}, "email", "password") + if status, _ := env.userLogin(t, member.Email, "correct-horse"); status != http.StatusOK { + t.Fatalf("the old password after the refused requests = %d", status) + } + if status, _ := env.userLogin(t, member.Email, attempted); status != http.StatusUnauthorized { + t.Fatalf("the attempted password signs in: %d", status) + } + if status, _ := env.userLogin(t, takeover, attempted); status == http.StatusOK { + t.Fatal("the attempted email and password sign in") + } + // A name-only update is an ordinary update. + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "Renamed", "email": member.Email}) + if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "Renamed" { + t.Fatal("a name-only update of a privileged member was not saved") + } + // With the permission both succeed. + managed := "managed-" + stamp + "@example.test" + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Renamed", "email": managed}) + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, + map[string]any{"name": "Renamed", "email": managed, "password": "managed-password-1", "password_confirmation": "managed-password-1"}) + if got := env.reload(t, member.ID); got.Email != managed || !bouncer.CheckPassword(got.Password, "managed-password-1") { + t.Fatalf("the manager's changes were not saved: %+v", got) + } + // Once the membership is removed the limited administrator may change + // the email. + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Renamed", "email": managed, "groups": []uint{}}) + released := "released-" + stamp + "@example.test" + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "Renamed", "email": released}) + if got := env.reload(t, member.ID); got.Email != released { + t.Fatalf("email after the membership was removed = %q", got.Email) + } + }) + + // T-12.1-39 (D-30): permanent delete of a privileged-group member. + t.Run("T-12.1-39", func(t *testing.T) { + env := newAdminEnv(t) + limited := env.admin(t, permAccessUsers) + manager := env.admin(t, permAccessUsers, permManagePrivileged) + stamp := adminStamp() + adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") + ordinary := env.seedGroup(t, "Ordinary "+stamp, "ordinary-"+stamp) + loaded := func(label string, groups ...*models.UserGroup) (*models.User, string) { + user := env.seedUser(t, label, nil) + env.join(t, user, groups...) + env.seedThrottle(t, user, "203.0.113.50", false, nil) + return user, env.seedAvatar(t, user) + } + first, firstKey := loaded("del-first", ordinary) + member, memberKey := loaded("del-member", adminGroup, ordinary) + last, lastKey := loaded("del-last", ordinary) + everyone := []*models.User{first, member, last} + keys := []string{firstKey, memberKey, lastKey} + before := map[uint]hangers{} + for _, user := range everyone { + before[user.ID] = env.hangers(t, user) + } + intact := func(step string) { + t.Helper() + for i, user := range everyone { + if env.reload(t, user.ID) == nil { + t.Fatalf("%s removed user %d", step, user.ID) + } + if got := env.hangers(t, user); got != before[user.ID] { + t.Fatalf("%s removed rows of user %d: %+v, want %+v", step, user.ID, got, before[user.ID]) + } + if !env.blobExists(t, keys[i]) { + t.Fatalf("%s removed the avatar blob of user %d", step, user.ID) + } + } + } + refused := func(step string, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Message != privilegedMemberDeleteForbidden { + t.Fatalf("%s = %d %s, want the privileged-member delete 403", step, rec.Code, rec.Body.String()) + } + intact(step) + } + refused("the form delete", env.call(t, http.MethodDelete, userPath(member.ID, ""), limited, nil)) + // The member sits between two ordinary users, so rows before it were + // already deleted inside the transaction when it is refused. + refused("the bulk delete", env.bulk(t, limited, "delete", first, member, last)) + // An ordinary user is deleted by the limited administrator. + spare, spareKey := loaded("del-spare", ordinary) + env.expect(t, http.StatusOK, http.MethodDelete, userPath(spare.ID, ""), limited, nil) + if env.reload(t, spare.ID) != nil || env.blobExists(t, spareKey) { + t.Fatal("an ordinary user was not deleted") + } + // With the permission both succeed. + if rec := env.bulk(t, manager, "delete", first, member); rec.Code != http.StatusOK { + t.Fatalf("manager bulk delete = %d %s", rec.Code, rec.Body.String()) + } + other, _ := loaded("del-other", adminGroup) + env.expect(t, http.StatusOK, http.MethodDelete, userPath(other.ID, ""), manager, nil) + for _, user := range []*models.User{first, member, other} { + if env.reload(t, user.ID) != nil || env.hangers(t, user) != (hangers{}) { + t.Fatalf("the manager's delete left user %d: %+v", user.ID, env.hangers(t, user)) + } + } + }) +}