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" ) // manageUsers is the role grant of a delegated administrator. var manageUsers = map[string]int{cabana.PermissionManageUsers: 1} // newAdminBody is a create body with a valid, confirmed password. func newAdminBody(login string, extra map[string]any) map[string]any { body := map[string]any{ "login": login, "email": login + "@example.test", "first_name": "New", "last_name": "Admin", "password": "pass-1234", "password_confirmation": "pass-1234", } for key, value := range extra { body[key] = value } return body } // cleanupLogin soft-deletes and demotes an administrator a test created // through the API, by login. func (e *screensEnv) cleanupLogin(t *testing.T, login string) { t.Helper() t.Cleanup(func() { e.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE, deleted_at = COALESCE(deleted_at, NOW()) WHERE login = ?`, login) }) } // countLogin counts the administrators with login, deleted ones included. func (e *screensEnv) countLogin(t *testing.T, login string) int64 { t.Helper() var n int64 if err := e.gdb.Raw(`SELECT count(*) FROM backend_users WHERE login = ?`, login).Scan(&n).Error; err != nil { t.Fatal(err) } return n } // TestAdministratorsSuperuserFlag (D-05): only an administrator acting as a // superuser may send the superuser switch, on create and on update, whatever // its value; a superuser grants and removes the flag. func TestAdministratorsSuperuserFlag(t *testing.T) { env := newScreensEnv(t) super := env.seedAdmin(t, adminSeed{Name: "flag-super", Superuser: true}) superToken := env.login(t, super.Login) delegate := env.seedAdmin(t, adminSeed{Name: "flag-delegate", RolePermissions: manageUsers}) delegateToken := env.login(t, delegate.Login) target := env.seedAdmin(t, adminSeed{Name: "flag-target"}) for _, flag := range []bool{true, false} { login := env.unique("flag-create") env.cleanupLogin(t, login) env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPost, usersPath, delegateToken, newAdminBody(login, map[string]any{"is_superuser": flag}), "is_superuser", enSuperuserForbidden, true) if n := env.countLogin(t, login); n != 0 { t.Fatalf("a refused create (is_superuser=%v) left %d rows", flag, n) } 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, "first_name": "Changed", "is_superuser": flag}, "is_superuser", enSuperuserForbidden, true) env.unchanged(t, target.ID, before, "a refused is_superuser update") } // A superuser grants the flag on create... login := env.unique("flag-granted") env.cleanupLogin(t, login) created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, superToken, newAdminBody(login, map[string]any{"is_superuser": true})) id := uint(dataOf(created)["id"].(float64)) if got := env.storedUser(t, id); !got.IsSuperuser { t.Fatalf("created superuser = %+v", got) } // ...and removes it from that other superuser through update. env.expect(t, http.StatusOK, http.MethodPut, userURL(id), superToken, map[string]any{"login": login, "email": login + "@example.test", "is_superuser": false}) if got := env.storedUser(t, id); got.IsSuperuser { t.Fatal("the superuser flag was not removed") } // The same superuser grants it on update. env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), superToken, map[string]any{"login": target.Login, "email": target.Email, "is_superuser": true}) if got := env.storedUser(t, target.ID); !got.IsSuperuser { 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") } }