From b684bb7d8d2956846625c2c358659ba1bf4763f7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 12:01:06 +0200 Subject: [PATCH] test(19.6.1-03): save-path privilege rule tests - TestAdministratorsSuperuserHidden (D-03, scoped-token superusers) - TestAdministratorsSuperuserProjected (Pitfall 1) - TestAdministratorsPermissionCeiling and TestAdministratorsRoleCeiling (D-06, D-07) - TestAdministratorsSelfEdit (D-08), TestAdministratorsPasswordAndIdentity (D-11) - TestAdministratorsOutranked (D-19), TestAdministratorsOwnPassword (D-20) - TestRoleCeiling (-short) against grantsCovered --- .../cabana/administrators_postgres_test.go | 581 ++++++++++++++++++ .../backend_privileges_internal_test.go | 47 ++ 2 files changed, 628 insertions(+) create mode 100644 modules/cabana/backend_privileges_internal_test.go diff --git a/modules/cabana/administrators_postgres_test.go b/modules/cabana/administrators_postgres_test.go index e10b7faa..c6aea0fd 100644 --- a/modules/cabana/administrators_postgres_test.go +++ b/modules/cabana/administrators_postgres_test.go @@ -1,9 +1,13 @@ package cabana_test import ( + "encoding/json" + "fmt" "net/http" + "strings" "testing" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" ) @@ -90,3 +94,580 @@ func TestAdministratorsSuperuserFlag(t *testing.T) { t.Fatal("the superuser flag was not granted on update") } } + +// listSchemaKeys returns a list schema response's column keys and filter +// names. +func listSchemaKeys(body map[string]any) (map[string]bool, map[string]bool) { + columns, filters := map[string]bool{}, map[string]bool{} + data := dataOf(body) + items, _ := data["columns"].([]any) + for _, item := range items { + column, _ := item.(map[string]any) + key, _ := column["key"].(string) + columns[key] = true + } + items, _ = data["filters"].([]any) + for _, item := range items { + filter, _ := item.(map[string]any) + name, _ := filter["name"].(string) + filters[name] = true + } + return columns, filters +} + +// TestAdministratorsSuperuserHidden (D-03): an administrator who does not act +// as a superuser never sees a superuser, in the list, any lookup or the +// schemas; a superuser's scoped API token does not act as a superuser. +func TestAdministratorsSuperuserHidden(t *testing.T) { + env := newScreensEnv(t) + super := env.seedAdmin(t, adminSeed{Name: "hidden-super", Superuser: true}) + superToken := env.login(t, super.Login) + delegate := env.seedAdmin(t, adminSeed{Name: "hidden-delegate", RolePermissions: manageUsers}) + delegateToken := env.login(t, delegate.Login) + plain := env.seedAdmin(t, adminSeed{Name: "hidden-plain"}) + + for _, query := range []string{ + "?search=" + env.stamp, + "?search=" + super.Login, + "?search=" + env.stamp + "&filter[is_superuser]=1", + "?search=" + env.stamp + "&filter[activated]=1", + "?search=" + env.stamp + "&filter[trashed]=hide", + } { + rec := env.call(t, http.MethodGet, usersPath+query, delegateToken, nil) + if rec.Code == http.StatusOK { + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if listIDs(t, body)[super.ID] { + t.Fatalf("list %s shows the superuser to a delegate", query) + } + } else if rec.Code >= 500 { + t.Fatalf("list %s status=%d", query, rec.Code) + } + } + if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, delegateToken, nil)); !ids[plain.ID] || !ids[delegate.ID] { + t.Fatalf("delegate list = %v, want %d and %d", ids, plain.ID, delegate.ID) + } + + before := env.userRow(t, super.ID) + env.expectRefusal(t, http.StatusNotFound, http.MethodGet, userURL(super.ID), delegateToken, nil, "") + env.expectRefusal(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, super.ID), delegateToken, nil, "") + env.expectRefusal(t, http.StatusNotFound, http.MethodPost, userURL(super.ID, "actions", "deactivate"), delegateToken, map[string]any{}, "") + env.expectRefusal(t, http.StatusNotFound, http.MethodPut, userURL(super.ID), delegateToken, + map[string]any{"login": super.Login, "email": super.Email, "first_name": "Hijacked"}, "") + // The framework's delete answers a missing id with deleted 0; a hidden + // superuser gets exactly that answer, so it reveals nothing. + hiddenDelete := env.call(t, http.MethodDelete, userURL(super.ID), delegateToken, nil) + missingDelete := env.call(t, http.MethodDelete, userURL(2000000000), delegateToken, nil) + if hiddenDelete.Code != missingDelete.Code || hiddenDelete.Body.String() != missingDelete.Body.String() { + t.Fatalf("delete of a hidden superuser %d %s, of a missing id %d %s", hiddenDelete.Code, hiddenDelete.Body.String(), missingDelete.Code, missingDelete.Body.String()) + } + // A bulk selection never resolves to the superuser. + if rec := env.call(t, http.MethodPost, usersPath+"/bulk/deactivate", delegateToken, map[string]any{"ids": []uint{super.ID}}); rec.Code >= 500 || rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), `"affected":1`) { + t.Fatalf("bulk deactivate of a hidden superuser status=%d body=%s", rec.Code, rec.Body.String()) + } + env.unchanged(t, super.ID, before, "lookups of a hidden superuser") + + form := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil)) + if _, ok := form["is_superuser"]; ok { + t.Fatal("the delegate's form schema has is_superuser") + } + columns, filters := listSchemaKeys(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/list", delegateToken, nil)) + if columns["is_superuser"] || filters["is_superuser"] || !columns["login"] || !filters["role"] { + t.Fatalf("delegate list schema columns=%v filters=%v", columns, filters) + } + + // A superuser sees all three, and the other superuser. + form = schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", superToken, nil)) + if _, ok := form["is_superuser"]; !ok { + t.Fatal("the superuser's form schema lacks is_superuser") + } + columns, filters = listSchemaKeys(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/list", superToken, nil)) + if !columns["is_superuser"] || !filters["is_superuser"] { + t.Fatalf("superuser list schema columns=%v filters=%v", columns, filters) + } + other := env.seedAdmin(t, adminSeed{Name: "hidden-other", Superuser: true}) + if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, superToken, nil)); !ids[other.ID] || !ids[super.ID] { + t.Fatalf("superuser list = %v, want both superusers", ids) + } + + // A superuser's scoped token does not act as a superuser; a full one does. + scoped, err := cabana.IssueTestAPIToken(env.gdb, super.ID, []string{pingScope}, nil) + if err != nil { + t.Fatal(err) + } + full, err := cabana.IssueTestAPIToken(env.gdb, super.ID, []string{"*"}, nil) + if err != nil { + t.Fatal(err) + } + ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, scoped, nil)) + if ids[other.ID] || !ids[plain.ID] { + t.Fatalf("scoped token list = %v, want the plain admin and no superuser", ids) + } + if _, ok := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", scoped, nil))["is_superuser"]; ok { + t.Fatal("the scoped token's form schema has is_superuser") + } + before = env.userRow(t, plain.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(plain.ID), scoped, + map[string]any{"login": plain.Login, "email": plain.Email, "is_superuser": true}, "is_superuser", enSuperuserForbidden, true) + env.unchanged(t, plain.ID, before, "a scoped token's is_superuser update") + if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, full, nil)); !ids[other.ID] { + t.Fatalf("full token list = %v, want the other superuser", ids) + } +} + +// editablePayload is the save body the admin builds from a record GET: every +// editable field of the form with its loaded value, the passwords blank. +func editablePayload(record map[string]any, form map[string]map[string]any) map[string]any { + body := map[string]any{"password": "", "password_confirmation": ""} + for name := range form { + if name == "password" || name == "password_confirmation" || name == "last_login" { + continue + } + if value, ok := record[name]; ok { + body[name] = value + } + } + return body +} + +// TestAdministratorsSuperuserProjected (Pitfall 1): a superuser reads another +// superuser's stored flag, and saving the untouched form keeps it. +func TestAdministratorsSuperuserProjected(t *testing.T) { + env := newScreensEnv(t) + super := env.seedAdmin(t, adminSeed{Name: "proj-super", Superuser: true}) + superToken := env.login(t, super.Login) + other := env.seedAdmin(t, adminSeed{Name: "proj-other", Superuser: true, Permissions: map[string]int{pingPermission: 1}}) + + record := dataOf(env.expect(t, http.StatusOK, http.MethodGet, userURL(other.ID), superToken, nil)) + if record["is_superuser"] != true { + t.Fatalf("record data is_superuser = %v", record["is_superuser"]) + } + form := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", superToken, nil)) + body := editablePayload(record, form) + if _, ok := body["is_superuser"]; !ok { + t.Fatalf("payload lacks is_superuser: %v", body) + } + env.expect(t, http.StatusOK, http.MethodPut, userURL(other.ID), superToken, body) + got := env.storedUser(t, other.ID) + if !got.IsSuperuser || got.Permissions != `{"`+pingPermission+`":1}` { + t.Fatalf("after an untouched save = %+v", got) + } +} + +// TestAdministratorsPermissionCeiling (D-06, D-07): a delegate may set only +// the overrides of codes it holds itself; stored values of other codes are +// kept, and an override is merged last over the role. +func TestAdministratorsPermissionCeiling(t *testing.T) { + env := newScreensEnv(t) + delegate := env.seedAdmin(t, adminSeed{Name: "ceil-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}}) + delegateToken := env.login(t, delegate.Login) + target := env.seedAdmin(t, adminSeed{Name: "ceil-target", RolePermissions: manageUsers}) + targetToken := env.login(t, target.Login) + + locked := permissionLocked(schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil))["permissions"]) + if len(locked) < 4 { + t.Fatalf("permission options = %v", locked) + } + for code, isLocked := range locked { + held := code == cabana.PermissionManageUsers || code == pingPermission + if isLocked == held { + t.Fatalf("option %s locked=%v, want %v", code, isLocked, !held) + } + } + + for _, value := range []int{1, -1} { + for _, code := range []string{"acme.demo.access", cabana.PermissionManageRoles} { + before := env.userRow(t, target.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken, + map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{code: value}}, + "permissions", enPermissionLocked, false) + env.unchanged(t, target.ID, before, "a locked override") + } + } + + // A stored value of a locked code is kept when the delegate saves. + keeper := env.seedAdmin(t, adminSeed{Name: "ceil-keeper", Permissions: map[string]int{"acme.demo.access": -1}}) + env.expect(t, http.StatusOK, http.MethodPut, userURL(keeper.ID), delegateToken, + map[string]any{"login": keeper.Login, "email": keeper.Email, "first_name": "Kept"}) + if got := env.storedUser(t, keeper.ID); got.FirstName != "Kept" || got.Permissions != `{"acme.demo.access":-1}` { + t.Fatalf("keeper after a save without permissions = %+v", got) + } + env.expect(t, http.StatusOK, http.MethodPut, userURL(keeper.ID), delegateToken, + map[string]any{"login": keeper.Login, "email": keeper.Email, "permissions": map[string]any{"acme.demo.access": -1, pingPermission: 1}}) + if got := decodeJSON(t, env.storedUser(t, keeper.ID).Permissions); got["acme.demo.access"] != -1 || got[pingPermission] != 1 || len(got) != 2 { + t.Fatalf("keeper overrides = %v", got) + } + before := env.userRow(t, keeper.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(keeper.ID), delegateToken, + map[string]any{"login": keeper.Login, "email": keeper.Email, "permissions": map[string]any{pingPermission: 1}}, + "permissions", enPermissionLocked, false) + env.unchanged(t, keeper.ID, before, "dropping a locked stored override") + + // Overrides of held codes save and are merged last over the role. + if rec := env.call(t, http.MethodGet, usersPath, targetToken, nil); rec.Code != http.StatusOK { + t.Fatalf("target list before status=%d", rec.Code) + } + env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken, + map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{cabana.PermissionManageUsers: -1}}) + if rec := env.call(t, http.MethodGet, usersPath, targetToken, nil); rec.Code != http.StatusForbidden { + t.Fatalf("target list after a deny override status=%d", rec.Code) + } + env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken, + map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{pingPermission: 1}}) + if got := decodeJSON(t, env.storedUser(t, target.ID).Permissions); got[pingPermission] != 1 || len(got) != 1 { + t.Fatalf("target overrides = %v", got) + } + show := env.expect(t, http.StatusOK, http.MethodGet, userURL(keeper.ID), targetToken, nil) + if names := recordActionNames(show); !containsName(names, "acme_ping") { + t.Fatalf("an allowed override did not reach the target: actions %v", names) + } +} + +func decodeJSON(t *testing.T, raw string) map[string]int { + t.Helper() + out := map[string]int{} + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatalf("permissions %q: %v", raw, err) + } + return out +} + +func containsName(names []string, want string) bool { + for _, name := range names { + if name == want { + return true + } + } + return false +} + +// roleOptions returns the role field's options as id to locked. +func (e *screensEnv) roleOptions(t *testing.T, token, search string) map[uint]bool { + t.Helper() + body := e.expect(t, http.StatusOK, http.MethodGet, usersPath+"/fields/role/options?per_page=100&search="+search, token, nil) + rows, _ := body["data"].([]any) + out := map[uint]bool{} + for _, row := range rows { + option, _ := row.(map[string]any) + id, _ := option["value"].(float64) + locked, _ := option["locked"].(bool) + out[uint(id)] = locked + } + return out +} + +// TestAdministratorsRoleCeiling (D-07): a delegate may assign only roles +// whose grants it holds; a held role above the ceiling stays unchanged. +func TestAdministratorsRoleCeiling(t *testing.T) { + env := newScreensEnv(t) + delegate := env.seedAdmin(t, adminSeed{Name: "rc-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}}) + delegateToken := env.login(t, delegate.Login) + above := env.seedRole(t, roleSeed{Name: "rc-above", Permissions: map[string]int{"acme.demo.access": 1}}) + covered := env.seedRole(t, roleSeed{Name: "rc-covered", Permissions: map[string]int{pingPermission: 1}}) + target := env.seedAdmin(t, adminSeed{Name: "rc-target"}) + // Holds the above-ceiling role, with its extra grant denied: it does not + // outrank the delegate, but its role stays locked. + holder := env.seedAdmin(t, adminSeed{Name: "rc-holder", Role: above.ID, Permissions: map[string]int{"acme.demo.access": -1}}) + + options := env.roleOptions(t, delegateToken, env.stamp) + if locked, ok := options[above.ID]; !ok || !locked { + t.Fatalf("above-ceiling role option = %v (present %v)", locked, ok) + } + if locked, ok := options[covered.ID]; !ok || locked { + t.Fatalf("covered role option = %v (present %v)", locked, ok) + } + var developer uint + if err := env.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&developer).Error; err != nil { + t.Fatal(err) + } + if locked := env.roleOptions(t, delegateToken, "Developer")[developer]; !locked { + t.Fatal("the developer role (code-declared acme.demo.access) is not locked for the delegate") + } + + before := env.userRow(t, target.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken, + map[string]any{"login": target.Login, "email": target.Email, "role": above.ID}, "role", enRoleCeiling, true) + env.unchanged(t, target.ID, before, "assigning an above-ceiling role") + + env.expect(t, http.StatusOK, http.MethodPut, userURL(holder.ID), delegateToken, + map[string]any{"login": holder.Login, "email": holder.Email, "first_name": "Renamed", "role": above.ID}) + if got := env.storedUser(t, holder.ID); got.FirstName != "Renamed" || got.RoleID == nil || *got.RoleID != above.ID { + t.Fatalf("holder after a rename = %+v", got) + } + before = env.userRow(t, holder.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(holder.ID), delegateToken, + map[string]any{"login": holder.Login, "email": holder.Email, "role": covered.ID}, "role", enRoleCeiling, true) + env.unchanged(t, holder.ID, before, "moving off an above-ceiling role") + + env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken, + map[string]any{"login": target.Login, "email": target.Email, "role": covered.ID}) + if got := env.storedUser(t, target.ID); got.RoleID == nil || *got.RoleID != covered.ID { + t.Fatalf("target role = %v, want %d", got.RoleID, covered.ID) + } +} + +// TestAdministratorsSelfEdit (D-08): an administrator edits their own name, +// login and email, never their own role, superuser flag, overrides, +// activation or existence. +func TestAdministratorsSelfEdit(t *testing.T) { + env := newScreensEnv(t) + other := env.seedRole(t, roleSeed{Name: "self-other", Permissions: manageUsers}) + delegate := env.seedAdmin(t, adminSeed{Name: "self-delegate", RolePermissions: manageUsers}) + delegateToken := env.login(t, delegate.Login) + super := env.seedAdmin(t, adminSeed{Name: "self-super", Superuser: true}) + superToken := env.login(t, super.Login) + peer := env.seedAdmin(t, adminSeed{Name: "self-peer"}) + + login := env.unique("self-renamed") + env.expect(t, http.StatusOK, http.MethodPut, userURL(delegate.ID), delegateToken, + map[string]any{"login": login, "email": login + "@example.test", "first_name": "Own"}) + self := env.storedUser(t, delegate.ID) + if self.Login != login || self.Email != login+"@example.test" || self.FirstName != "Own" { + t.Fatalf("own edit = %+v", self) + } + identity := map[string]any{"login": self.Login, "email": self.Email} + with := func(key string, value any) map[string]any { + body := map[string]any{key: value} + for k, v := range identity { + body[k] = v + } + return body + } + + // Own role: refused after assignment and rolled back. + before := env.userRow(t, delegate.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(delegate.ID), delegateToken, + with("role", other.ID), "role", enSelfPrivileged, true) + env.unchanged(t, delegate.ID, before, "an own role change") + + // Own superuser flag, for a superuser. + superBefore := env.userRow(t, super.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(super.ID), superToken, + map[string]any{"login": super.Login, "email": super.Email, "is_superuser": false}, "is_superuser", enSelfPrivileged, true) + env.unchanged(t, super.ID, superBefore, "an own superuser removal") + + // Own overrides: every code is record-locked on the own record. + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(delegate.ID), delegateToken, + with("permissions", map[string]any{cabana.PermissionManageUsers: -1}), "permissions", enPermissionLocked, false) + env.unchanged(t, delegate.ID, before, "an own override change") + // An untouched own save (the record-locked overrides left out) passes. + env.expect(t, http.StatusOK, http.MethodPut, userURL(delegate.ID), delegateToken, with("first_name", "Own")) + + // Own delete and own bulk deactivate. + before = env.userRow(t, delegate.ID) + env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, userURL(delegate.ID), delegateToken, nil, enSelfDelete) + env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk-delete", delegateToken, + map[string]any{"ids": []uint{peer.ID, delegate.ID}}, enSelfDelete) + peerBefore := env.userRow(t, peer.ID) + env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk/deactivate", delegateToken, + map[string]any{"ids": []uint{peer.ID, delegate.ID}}, enSelfDeactivate) + env.unchanged(t, delegate.ID, before, "an own delete or deactivate") + env.unchanged(t, peer.ID, peerBefore, "a bulk selection refused for the own row") + + // Deactivate is not offered on the own preview, and does not run there. + own := env.expect(t, http.StatusOK, http.MethodGet, userURL(delegate.ID), delegateToken, nil) + if names := recordActionNames(own); containsName(names, "deactivate") { + t.Fatalf("own preview offers %v", names) + } + if rec := env.call(t, http.MethodPost, userURL(delegate.ID, "actions", "deactivate"), delegateToken, map[string]any{}); rec.Code != http.StatusConflict { + t.Fatalf("own deactivate action status=%d body=%s", rec.Code, rec.Body.String()) + } + env.unchanged(t, delegate.ID, before, "an own deactivate action") +} + +// TestAdministratorsPasswordAndIdentity (D-11): the password rules on create +// and update, and login and email unique ignoring case, deleted +// administrators included. +func TestAdministratorsPasswordAndIdentity(t *testing.T) { + env := newScreensEnv(t) + super := env.seedAdmin(t, adminSeed{Name: "pw-super", Superuser: true}) + superToken := env.login(t, super.Login) + + long := strings.Repeat("p", 73) + for _, tc := range []struct { + name, password, confirmation, want string + omit bool + }{ + {name: "no password", omit: true, want: "required"}, + {name: "no confirmation", password: "pass-1234", confirmation: "", want: "confirm"}, + {name: "mismatch", password: "pass-1234", confirmation: "pass-5678", want: "confirm"}, + {name: "too short", password: "abc", confirmation: "abc", want: "at least 4 characters"}, + {name: "73 bytes", password: long, confirmation: long, want: "greater than 72"}, + } { + t.Run(tc.name, func(t *testing.T) { + login := env.unique("pw-new") + env.cleanupLogin(t, login) + body := newAdminBody(login, nil) + if tc.omit { + delete(body, "password") + delete(body, "password_confirmation") + } else { + body["password"], body["password_confirmation"] = tc.password, tc.confirmation + } + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, superToken, body, "password", tc.want, false) + if n := env.countLogin(t, login); n != 0 { + t.Fatalf("a refused create left %d rows", n) + } + }) + } + + login := env.unique("pw-valid") + env.cleanupLogin(t, login) + created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, superToken, newAdminBody(login, nil)) + id := uint(dataOf(created)["id"].(float64)) + stored := env.storedUser(t, id) + if !strings.HasPrefix(stored.Password, "$2") || !bouncer.CheckPassword(stored.Password, "pass-1234") { + t.Fatalf("stored hash %q does not verify", stored.Password) + } + env.expect(t, http.StatusOK, http.MethodPut, userURL(id), superToken, + map[string]any{"login": login, "email": login + "@example.test", "first_name": "Kept", "password": "", "password_confirmation": ""}) + if got := env.storedUser(t, id); got.Password != stored.Password || got.FirstName != "Kept" { + t.Fatalf("a blank password changed the hash: %+v", got) + } + + taken := env.seedAdmin(t, adminSeed{Name: "pw-taken"}) + gone := env.seedAdmin(t, adminSeed{Name: "pw-gone", Deleted: true}) + for _, tc := range []struct { + name, field, login, email, want string + }{ + {"login taken", "login", taken.Login, "", enLoginTaken}, + {"login taken in another case", "login", strings.ToUpper(taken.Login), "", enLoginTaken}, + {"login equal to another email", "login", taken.Email, "", enLoginTaken}, + {"email taken", "email", "", taken.Email, enEmailTaken}, + {"email taken in another case", "email", "", strings.ToUpper(taken.Email), enEmailTaken}, + {"login of a deleted admin", "login", gone.Login, "", enLoginTakenDeleted}, + {"email of a deleted admin", "email", "", gone.Email, enEmailTakenDeleted}, + } { + t.Run(tc.name, func(t *testing.T) { + fresh := env.unique("pw-fresh") + env.cleanupLogin(t, fresh) + body := newAdminBody(fresh, nil) + if tc.login != "" { + body["login"] = tc.login + } + if tc.email != "" { + body["email"] = tc.email + } + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, superToken, body, tc.field, tc.want, false) + if n := env.countLogin(t, fresh); n != 0 { + t.Fatalf("a refused create left %d rows", n) + } + // The same value on update of another administrator. + before := env.userRow(t, id) + update := map[string]any{"login": login, "email": login + "@example.test"} + if tc.login != "" { + update["login"] = tc.login + } + if tc.email != "" { + update["email"] = tc.email + } + env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, userURL(id), superToken, update, tc.field, tc.want, false) + env.unchanged(t, id, before, "a refused identity update") + }) + } +} + +// TestAdministratorsOutranked (D-19): a delegate cannot change, deactivate, +// activate, restore or delete an administrator holding a grant the delegate +// lacks; covered administrators pass. +func TestAdministratorsOutranked(t *testing.T) { + env := newScreensEnv(t) + delegate := env.seedAdmin(t, adminSeed{Name: "out-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}}) + delegateToken := env.login(t, delegate.Login) + strong := map[string]int{cabana.PermissionManageUsers: 1, "acme.demo.access": 1} + active := env.seedAdmin(t, adminSeed{Name: "out-active", RolePermissions: strong}) + inactive := env.seedAdmin(t, adminSeed{Name: "out-inactive", RolePermissions: strong, Inactive: true}) + deleted := env.seedAdmin(t, adminSeed{Name: "out-deleted", RolePermissions: strong, Deleted: true}) + // A user-level allow outranks too (the override is merged last). + override := env.seedAdmin(t, adminSeed{Name: "out-override", Permissions: map[string]int{"acme.demo.access": 1}}) + + type attempt struct { + name string + method string + path string + body func(cabana.BackendUser) any + target cabana.BackendUser + } + identity := func(u cabana.BackendUser) map[string]any { return map[string]any{"login": u.Login, "email": u.Email} } + attempts := []attempt{ + {"update", http.MethodPut, userURL(active.ID), func(u cabana.BackendUser) any { + b := identity(u) + b["first_name"] = "Taken" + return b + }, active}, + {"password", http.MethodPut, userURL(active.ID), func(u cabana.BackendUser) any { + b := identity(u) + b["password"], b["password_confirmation"] = "pass-9999", "pass-9999" + return b + }, active}, + {"override update", http.MethodPut, userURL(override.ID), func(u cabana.BackendUser) any { + b := identity(u) + b["first_name"] = "Taken" + return b + }, override}, + {"delete", http.MethodDelete, userURL(active.ID), nil, active}, + {"bulk delete", http.MethodPost, usersPath + "/bulk-delete", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, active}, + {"record deactivate", http.MethodPost, userURL(active.ID, "actions", "deactivate"), func(cabana.BackendUser) any { return map[string]any{} }, active}, + {"record activate", http.MethodPost, userURL(inactive.ID, "actions", "activate"), func(cabana.BackendUser) any { return map[string]any{} }, inactive}, + {"record restore", http.MethodPost, userURL(deleted.ID, "actions", "restore"), func(cabana.BackendUser) any { return map[string]any{} }, deleted}, + {"bulk deactivate", http.MethodPost, usersPath + "/bulk/deactivate", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, active}, + {"bulk activate", http.MethodPost, usersPath + "/bulk/activate", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, inactive}, + {"bulk restore", http.MethodPost, usersPath + "/bulk/restore", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, deleted}, + } + for _, a := range attempts { + t.Run(a.name, func(t *testing.T) { + var body any + if a.body != nil { + body = a.body(a.target) + } + before := env.userRow(t, a.target.ID) + env.expectRefusal(t, http.StatusForbidden, a.method, a.path, delegateToken, body, enOutranked) + env.unchanged(t, a.target.ID, before, a.name) + }) + } + + // Covered administrators pass. + covered := env.seedAdmin(t, adminSeed{Name: "out-covered", RolePermissions: map[string]int{pingPermission: 1}}) + env.expect(t, http.StatusOK, http.MethodPut, userURL(covered.ID), delegateToken, + map[string]any{"login": covered.Login, "email": covered.Email, "first_name": "Covered", "password": "pass-9999", "password_confirmation": "pass-9999"}) + env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "deactivate"), delegateToken, map[string]any{}) + env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "activate"), delegateToken, map[string]any{}) + env.expect(t, http.StatusOK, http.MethodDelete, userURL(covered.ID), delegateToken, nil) + env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "restore"), delegateToken, map[string]any{}) + if got := env.storedUser(t, covered.ID); got.FirstName != "Covered" || !got.IsActivated || got.DeletedAt.Valid || !bouncer.CheckPassword(got.Password, "pass-9999") { + t.Fatalf("covered admin = %+v", got) + } +} + +// TestAdministratorsOwnPassword (D-20, backend::lang.user.own_password): an +// own password is changed in My account only; another administrator's +// password is set here. +func TestAdministratorsOwnPassword(t *testing.T) { + env := newScreensEnv(t) + delegate := env.seedAdmin(t, adminSeed{Name: "own-delegate", RolePermissions: manageUsers}) + delegateToken := env.login(t, delegate.Login) + super := env.seedAdmin(t, adminSeed{Name: "own-super", Superuser: true}) + superToken := env.login(t, super.Login) + other := env.seedAdmin(t, adminSeed{Name: "own-other"}) + + for _, who := range []struct { + user cabana.BackendUser + token string + }{{delegate, delegateToken}, {super, superToken}} { + before := env.userRow(t, who.user.ID) + env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(who.user.ID), who.token, + map[string]any{"login": who.user.Login, "email": who.user.Email, "password": "pass-5678", "password_confirmation": "pass-5678"}, + "password", enOwnPassword, true) + env.unchanged(t, who.user.ID, before, "an own password") + if got := env.storedUser(t, who.user.ID); !bouncer.CheckPassword(got.Password, adminTestPassword) { + t.Fatal("the own hash changed") + } + } + env.expect(t, http.StatusOK, http.MethodPut, userURL(other.ID), superToken, + map[string]any{"login": other.Login, "email": other.Email, "password": "pass-5678", "password_confirmation": "pass-5678"}) + if got := env.storedUser(t, other.ID); !bouncer.CheckPassword(got.Password, "pass-5678") { + t.Fatal("another administrator's password was not set") + } +} diff --git a/modules/cabana/backend_privileges_internal_test.go b/modules/cabana/backend_privileges_internal_test.go new file mode 100644 index 00000000..e0824593 --- /dev/null +++ b/modules/cabana/backend_privileges_internal_test.go @@ -0,0 +1,47 @@ +package cabana + +import "testing" + +// TestRoleCeiling (D-07) pins grantsCovered: plain codes go through granted, +// a wildcard a role grants is covered only by the same or a broader holder +// wildcard. +func TestRoleCeiling(t *testing.T) { + set := func(codes ...string) map[string]bool { + out := map[string]bool{} + for _, code := range codes { + out[code] = true + } + return out + } + for _, tc := range []struct { + name string + holder map[string]bool + wanted map[string]bool + want bool + }{ + {"exact codes held", set("acme.blog.posts", "acme.blog.tags"), set("acme.blog.posts"), true}, + {"exact code missing", set("acme.blog.posts"), set("acme.blog.posts", "acme.blog.tags"), false}, + {"holder wildcard covers exact codes", set("acme.blog.*"), set("acme.blog.posts", "acme.blog.tags"), true}, + {"broader holder wildcard covers exact codes", set("acme.*"), set("acme.blog.posts"), true}, + {"holder wildcard of another prefix", set("acme.shop.*"), set("acme.blog.posts"), false}, + {"role wildcard covered by the same wildcard", set("acme.blog.*"), set("acme.blog.*"), true}, + {"role wildcard covered by a broader wildcard", set("acme.*"), set("acme.blog.*"), true}, + {"role wildcard not covered by a narrower wildcard", set("acme.blog.posts.*"), set("acme.blog.*"), false}, + {"role wildcard not covered by exact codes", set("acme.blog.posts", "acme.blog.tags"), set("acme.blog.*"), false}, + {"suffix wildcard covered only by itself", set("*.manage"), set("*.manage"), true}, + {"suffix wildcard not covered by exact codes", set("acme.manage", "shop.manage"), set("*.manage"), false}, + {"suffix wildcard not covered by a prefix wildcard", set("acme.*"), set("*.manage"), false}, + {"false role entries are ignored", set(), map[string]bool{"acme.blog.posts": false}, true}, + {"empty role", set("acme.blog.posts"), set(), true}, + {"nil role", nil, nil, true}, + {"nil holder and an empty role", nil, set(), true}, + {"nil holder and a granting role", nil, set("acme.blog.posts"), false}, + {"nil holder and a wildcard role", nil, set("acme.*"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := grantsCovered(tc.holder, tc.wanted); got != tc.want { + t.Fatalf("grantsCovered(%v, %v) = %v, want %v", tc.holder, tc.wanted, got, tc.want) + } + }) + } +}