package cabana_test import ( "fmt" "net/http" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" ) // roleURL is the record path of a role. func roleURL(id uint) string { return fmt.Sprintf("%s/%d", rolesPath, id) } // roleBody is a role save body. func roleBody(role cabana.BackendUserRole, extra map[string]any) map[string]any { body := map[string]any{"name": role.Name, "code": role.Code, "description": role.Description} for key, value := range extra { body[key] = value } return body } // roleExists reports whether a role row with id exists. func (e *screensEnv) roleExists(t *testing.T, id uint) bool { t.Helper() var n int64 if err := e.gdb.Raw(`SELECT count(*) FROM backend_user_roles WHERE id = ?`, id).Scan(&n).Error; err != nil { t.Fatal(err) } return n == 1 } // TestRolesLifecycle (D-16, D-17): role codes are unique; a system role (is_system // or one code-declared grants attach to) keeps its code and cannot be // deleted; a role any administrator holds, deleted ones included, cannot be // deleted, alone or in a bulk selection. func TestRolesLifecycle(t *testing.T) { declared := fmt.Sprintf("plugrole-%d", time.Now().UnixNano()) env := newScreensEnv(t, grantPlugin{id: "acme.granter", grants: []pact.Permission{{Code: "acme.granter.use", Roles: []string{declared}}}}) super := env.seedAdmin(t, adminSeed{Name: "roles-super", Superuser: true}) token := env.login(t, super.Login) // Create with the slug the form's preset makes from the name. name := "Editors " + env.stamp code := "editors-" + env.stamp created := dataOf(env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, token, map[string]any{"name": name, "code": code, "description": "Edit things"})) createdID := uint(created["id"].(float64)) t.Cleanup(func() { env.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, createdID) }) var stored cabana.BackendUserRole if err := env.gdb.First(&stored, createdID).Error; err != nil || stored.Code != code || stored.IsSystem { t.Fatalf("created role = %+v, %v", stored, err) } env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, token, map[string]any{"name": "Other " + env.stamp, "code": code}, "code", enCodeTaken, false) before := env.roleRow(t, createdID) other := env.seedRole(t, roleSeed{Name: "roles-other"}) env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(other.ID), token, roleBody(other, map[string]any{"code": code}), "code", enCodeTaken, false) if env.roleRow(t, createdID) != before { t.Fatal("a refused code change touched the created role") } // System roles: is_system, and a role a plugin's code-declared grant // attaches to. var developer cabana.BackendUserRole if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil { t.Fatal(err) } pluginRole := env.seedRole(t, roleSeed{Name: "roles-declared", Code: declared}) for _, role := range []cabana.BackendUserRole{developer, pluginRole} { rowBefore := env.roleRow(t, role.ID) env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(role.ID), token, roleBody(role, map[string]any{"code": role.Code + "-renamed"}), "code", enCodeLocked, false) env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(role.ID), token, nil, enDeleteSystem) if env.roleRow(t, role.ID) != rowBefore { t.Fatalf("a refused change touched system role %s", role.Code) } } // A system role's name and description stay editable. env.expect(t, http.StatusOK, http.MethodPut, roleURL(pluginRole.ID), token, roleBody(pluginRole, map[string]any{"description": "Changed"})) // A held role, one holder active and one soft-deleted. held := env.seedRole(t, roleSeed{Name: "roles-held"}) env.seedAdmin(t, adminSeed{Name: "roles-holder", Role: held.ID}) env.seedAdmin(t, adminSeed{Name: "roles-gone", Role: held.ID, Deleted: true}) env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(held.ID), token, nil, "The role “"+held.Name+"” is assigned to 2 administrators.") if !env.roleExists(t, held.ID) { t.Fatal("a refused delete removed the role") } // A bulk delete of an unused role and the held one is refused as a whole. unused := env.seedRole(t, roleSeed{Name: "roles-unused"}) env.expectRefusal(t, http.StatusForbidden, http.MethodPost, rolesPath+"/bulk-delete", token, map[string]any{"ids": []uint{unused.ID, held.ID}}, "is assigned to 2 administrators") if !env.roleExists(t, unused.ID) || !env.roleExists(t, held.ID) { t.Fatal("a refused bulk delete removed a role") } // The unused role alone deletes. env.expect(t, http.StatusOK, http.MethodDelete, roleURL(unused.ID), token, nil) if env.roleExists(t, unused.ID) { t.Fatal("the unused role was not deleted") } } // TestRolesPermissionCeiling (D-07): a non-superuser holding // backend.manage_roles can neither add nor remove a code it does not hold on // any role; codes it holds toggle. func TestRolesPermissionCeiling(t *testing.T) { env := newScreensEnv(t) editor := env.seedAdmin(t, adminSeed{Name: "rceil-editor", RolePermissions: map[string]int{cabana.PermissionManageRoles: 1, pingPermission: 1}}) token := env.login(t, editor.Login) field := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, rolesPath+"/schema/form", token, nil))["permissions"] locked := permissionLocked(field) if len(locked) < 4 { t.Fatalf("permission options = %v", locked) } for code, isLocked := range locked { held := code == cabana.PermissionManageRoles || code == pingPermission if isLocked == held { t.Fatalf("option %s locked=%v, want %v", code, isLocked, !held) } } strong := env.seedRole(t, roleSeed{Name: "rceil-strong", Permissions: map[string]int{"acme.demo.access": 1}}) empty := env.seedRole(t, roleSeed{Name: "rceil-empty"}) strong, empty = env.reloadRole(t, strong.ID), env.reloadRole(t, empty.ID) for _, tc := range []struct { name string role cabana.BackendUserRole permissions map[string]any }{ {"add a locked code", empty, map[string]any{"acme.demo.access": 1}}, {"add a locked code next to a held one", empty, map[string]any{"acme.demo.access": 1, pingPermission: 1}}, {"remove a locked code", strong, map[string]any{}}, {"remove a locked code, add a held one", strong, map[string]any{pingPermission: 1}}, } { t.Run(tc.name, func(t *testing.T) { before := env.roleRow(t, tc.role.ID) env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, roleURL(tc.role.ID), token, roleBody(tc.role, map[string]any{"permissions": tc.permissions}), "permissions", enPermissionLocked, false) if env.roleRow(t, tc.role.ID) != before { t.Fatal("a refused permission change touched the role") } }) } env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1, pingPermission: 1}})) if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || got[pingPermission] != 1 || len(got) != 2 { t.Fatalf("strong role after adding the held code = %v", got) } env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1}})) if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || len(got) != 1 { t.Fatalf("strong role after removing the held code = %v", got) } // Without the permissions key the stored grants stay. env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, roleBody(strong, map[string]any{"description": "Renamed"})) if got := env.reloadRole(t, strong.ID); got.Description != "Renamed" || got.Permissions != `{"acme.demo.access":1}` { t.Fatalf("strong role after a description change = %+v", got) } } // reloadRole reads a role row. func (e *screensEnv) reloadRole(t *testing.T, id uint) cabana.BackendUserRole { t.Helper() var role cabana.BackendUserRole if err := e.gdb.First(&role, id).Error; err != nil { t.Fatal(err) } return role } // permissionLocks returns a record response's meta.permissionLocks for the // permissions field as code to [value, note]. func permissionLocks(body map[string]any) map[string][2]any { out := map[string][2]any{} meta, _ := body["meta"].(map[string]any) locks, _ := meta["permissionLocks"].(map[string]any) list, _ := locks["permissions"].([]any) for _, item := range list { lock, _ := item.(map[string]any) code, _ := lock["code"].(string) out[code] = [2]any{lock["value"], lock["note"]} } return out } // TestRolesCodeDeclaredLocks (D-21 for D-16 and D-08): a role's code-declared // grants travel as record locks with the locked_by_code note and survive a // save that leaves them out; an administrator's own record locks every // offered code with the locked_self note. func TestRolesCodeDeclaredLocks(t *testing.T) { env := newScreensEnv(t, grantPlugin{id: "acme.reporter", grants: []pact.Permission{{Code: "acme.screens.report", Roles: []string{"developer"}}}}) super := env.seedAdmin(t, adminSeed{Name: "locks-super", Superuser: true}) token := env.login(t, super.Login) developer := env.reloadRoleByCode(t, "developer") t.Cleanup(func() { env.gdb.Exec(`UPDATE backend_user_roles SET description = ?, permissions = ? WHERE id = ?`, developer.Description, developer.Permissions, developer.ID) }) show := env.expect(t, http.StatusOK, http.MethodGet, roleURL(developer.ID), token, nil) locks := permissionLocks(show) for _, code := range []string{"acme.screens.report", "acme.demo.access"} { if got, ok := locks[code]; !ok || got[0] != float64(1) || got[1] != enLockedByCode { t.Fatalf("lock of %s = %v (present %v), all %v", code, got, ok, locks) } } if _, ok := locks[cabana.PermissionManageUsers]; ok { t.Fatalf("a code the role does not get from a plugin is locked: %v", locks) } // The admin's payload leaves the record-locked codes out. stored, _ := dataOf(show)["permissions"].(map[string]any) payload := map[string]any{} for code, value := range stored { if _, locked := locks[code]; !locked { payload[code] = value } } payload[pingPermission] = 1 env.expect(t, http.StatusOK, http.MethodPut, roleURL(developer.ID), token, roleBody(developer, map[string]any{"description": "Changed " + env.stamp, "permissions": payload})) saved := env.reloadRoleByCode(t, "developer") if saved.Description != "Changed "+env.stamp || decodeJSON(t, saved.Permissions)[pingPermission] != 1 { t.Fatalf("developer after the save = %+v", saved) } if strings.Contains(saved.Permissions, "acme.screens.report") { t.Fatalf("a code-declared grant was written to the stored JSON: %s", saved.Permissions) } // The delegate's own record: every offered code record-locked. delegate := env.seedAdmin(t, adminSeed{Name: "locks-delegate", RolePermissions: manageUsers, Permissions: map[string]int{pingPermission: -1}}) delegateToken := env.login(t, delegate.Login) offered := permissionLocked(schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil))["permissions"]) own := permissionLocks(env.expect(t, http.StatusOK, http.MethodGet, userURL(delegate.ID), delegateToken, nil)) if len(own) != len(offered) || len(own) == 0 { t.Fatalf("own record locks %v, offered %v", own, offered) } for code := range offered { got, ok := own[code] want := float64(0) if code == pingPermission { want = -1 } if !ok || got[0] != want || got[1] != enLockedSelf { t.Fatalf("own lock of %s = %v (present %v)", code, got, ok) } } // Another administrator's record carries no locks for the delegate. other := env.seedAdmin(t, adminSeed{Name: "locks-other"}) if locks := permissionLocks(env.expect(t, http.StatusOK, http.MethodGet, userURL(other.ID), delegateToken, nil)); len(locks) != 0 { t.Fatalf("locks on another record = %v", locks) } } // TestRolesCodeDeclaredCodeRefused (CR-01, SC-2, D-07, D-16): a role never // becomes a code-declared system role through the Roles screen. Create and // rename onto a code that pact.Permission.Roles attaches grants to answer // 422 code_locked, including when the editor is a superuser. A unique // non-declared code still creates and a same-code save of a system role // still succeeds. func TestRolesCodeDeclaredCodeRefused(t *testing.T) { declared := fmt.Sprintf("plugcap-%d", time.Now().UnixNano()) env := newScreensEnv(t, grantPlugin{id: "acme.capturer", grants: []pact.Permission{{Code: "acme.capturer.secret", Roles: []string{declared}}}}) editor := env.seedAdmin(t, adminSeed{Name: "codecap-editor", RolePermissions: map[string]int{cabana.PermissionManageRoles: 1}}) editorToken := env.login(t, editor.Login) mutable := env.seedRole(t, roleSeed{Name: "codecap-mutable"}) before := env.roleRow(t, mutable.ID) env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(mutable.ID), editorToken, roleBody(mutable, map[string]any{"code": declared}), "code", enCodeLocked, false) if env.roleRow(t, mutable.ID) != before { t.Fatal("a refused rename onto a code-declared code touched the role") } env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, editorToken, map[string]any{"name": "Capture " + env.stamp, "code": declared}, "code", enCodeLocked, false) if env.roleCountByCode(t, declared) != 0 { t.Fatal("a refused create inserted a role with the code-declared code") } super := env.seedAdmin(t, adminSeed{Name: "codecap-super", Superuser: true}) superToken := env.login(t, super.Login) env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(mutable.ID), superToken, roleBody(mutable, map[string]any{"code": declared}), "code", enCodeLocked, false) if env.roleRow(t, mutable.ID) != before { t.Fatal("a superuser rename onto a code-declared code touched the role") } env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, superToken, map[string]any{"name": "Super capture " + env.stamp, "code": declared}, "code", enCodeLocked, false) if env.roleCountByCode(t, declared) != 0 { t.Fatal("a superuser create inserted a role with the code-declared code") } plain := "plain-" + env.stamp created := dataOf(env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, editorToken, map[string]any{"name": "Plain " + env.stamp, "code": plain, "description": "Not declared"})) createdID := uint(created["id"].(float64)) t.Cleanup(func() { env.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, createdID) }) var developer cabana.BackendUserRole if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil { t.Fatal(err) } env.expect(t, http.StatusOK, http.MethodPut, roleURL(developer.ID), superToken, roleBody(developer, map[string]any{"description": "Same code " + env.stamp})) t.Cleanup(func() { env.gdb.Exec(`UPDATE backend_user_roles SET description = ? WHERE id = ?`, developer.Description, developer.ID) }) } // roleCountByCode counts backend_user_roles rows with code. func (e *screensEnv) roleCountByCode(t *testing.T, code string) int64 { t.Helper() var n int64 if err := e.gdb.Raw(`SELECT count(*) FROM backend_user_roles WHERE code = ?`, code).Scan(&n).Error; err != nil { t.Fatal(err) } return n } // reloadRoleByCode reads the role with code. func (e *screensEnv) reloadRoleByCode(t *testing.T, code string) cabana.BackendUserRole { t.Helper() var role cabana.BackendUserRole if err := e.gdb.Where("code = ?", code).First(&role).Error; err != nil { t.Fatal(err) } return role }