From cb759bd810215686ba9fd39e5a0cbac5916335f0 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 27 Sep 2023 18:02:01 +0200 Subject: [PATCH] Add a new service to mock permission checks for stubbed users --- spec/support/mocked_permission_helper.rb | 187 ++++++++++++++ .../mocked_permission_helper_spec.rb | 237 ++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 spec/support/mocked_permission_helper.rb create mode 100644 spec/support_spec/mocked_permission_helper_spec.rb diff --git a/spec/support/mocked_permission_helper.rb b/spec/support/mocked_permission_helper.rb new file mode 100644 index 00000000000..1d55263688d --- /dev/null +++ b/spec/support/mocked_permission_helper.rb @@ -0,0 +1,187 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Provides a nice DSL to mock permissions on the user. It also validates that the permissions are +# mocked in the right context. So when you try to mock a project permission globally, it will complain. +# For examples on usage, see spec/support_spec/mocked_permission_helper_spec.rb + +class PermissionMock + attr_reader :user, :permitted_entities, :allow_all_permissions + + def initialize(user) + @user = user + @permitted_entities = Hash.new do |hash, entity_project_or_global| + hash[entity_project_or_global] = Array.new + end + @allow_all_permissions = false + end + + def all_permissions_allowed! + @allow_all_permissions = true + end + + def forbid_everything! + @allow_all_permissions = false + @permitted_entites = {} + end + + def in_project(*permissions, project:) + return if project.nil? + + permissions.each do |permission| + Authorization.contextual_permissions(permission, :project, raise_on_unknown: true) + end + permitted_entities[project] += permissions + end + + def in_work_package(*permissions, work_package:) + return if work_package.nil? + + permissions.each do |permission| + Authorization.contextual_permissions(permission, :work_package, raise_on_unknown: true) + end + permitted_entities[work_package] += permissions + end + + def globally(*permissions) + permissions.each do |permission| + Authorization.contextual_permissions(permission, :global, raise_on_unknown: true) + end + permitted_entities[:global] += permissions + end +end + +module MockedPermissionHelper + def mock_permissions_for(user) # rubocop:disable Metrics/PerceivedComplexity + permission_mock = PermissionMock.new(user) + + yield permission_mock if block_given? + + # Instead of mocking directly on the user, we mock on the UserPermissibleService + # Advantage is that we can handle the `allowed_in_entity?` calls correctly without needing to write + # a mock for each of them + permissible_service = user.send(:user_permissible_service) # access the private instance + + # Permission is allowed globally, when it has been given globally + allow(permissible_service).to receive(:allowed_globally?) do |permission_or_action| + next true if permission_mock.allow_all_permissions + + permissions = Authorization.permissions_for(permission_or_action).map(&:name) + permission_mock.permitted_entities[:global].intersect?(permissions) + end + + # Permission allowed on one (or more) projects, when it has been given to all of them + allow(permissible_service).to receive(:allowed_in_project?) do |permission_or_action, project_or_projects| + next true if permission_mock.allow_all_permissions + + projects = Array(project_or_projects) + permissions = Authorization.permissions_for(permission_or_action).map(&:name) + + projects.all? do |project| + permission_mock.permitted_entities[project].intersect?(permissions) + end + end + + # Permission allowed on any project, if it has been given to any project + allow(permissible_service).to receive(:allowed_in_any_project?) do |permission_or_action| + next true if permission_mock.allow_all_permissions + + permissions = Authorization.permissions_for(permission_or_action).map(&:name) + + permission_mock.permitted_entities + .select { |k, _| k.is_a?(Project) } + .values + .flatten + .intersect?(permissions) + end + + # Permission is allowed on any entity, if + # - filtering for one specific project, when + # - the permission has been given to that project + # - the permission has been given to any work package belonging to that project + # - NOT filtering for one specific project, when + # - the permission has been given to any project + # - the permission has been given to any entity + allow(permissible_service).to receive(:allowed_in_any_entity?) do |permission_or_action, entity_class, in_project:| + next true if permission_mock.allow_all_permissions + + permissions = Authorization.permissions_for(permission_or_action).map(&:name) + + next true if in_project && permission_mock.permitted_entities[in_project].intersect?(permissions) + + filtered_entities = if in_project + permission_mock.permitted_entities.select do |k, _| + k.is_a?(entity_class) && k.respond_to?(:project) && k.project == in_project + end + else + permission_mock.permitted_entities.select { |k, _| k.is_a?(entity_class) || k.is_a?(Project) } + end + + filtered_entities + .values + .flatten + .intersect?(permissions) + end + + # Permission is allowed on a specific entity, if + # - the permission has been given to the project the entity belongs to + # - the permission has been given to the entity itself + allow(permissible_service).to receive(:allowed_in_entity?) do |permission_or_action, entity| + next true if permission_mock.allow_all_permissions + + permissions = Authorization.permissions_for(permission_or_action).map(&:name) + + (entity.respond_to?(:project) && permission_mock.permitted_entities[entity.project].intersect?(permissions)) || + permission_mock.permitted_entities[entity].intersect?(permissions) + end + + # Also mock the legacy interface using the `allowed_to?` method + allow(user).to receive(:allowed_to?) do |permission_or_action, project, global: false| + next true if permission_mock.allow_all_permissions + + permissions = Authorization.permissions_for(permission_or_action).map(&:name) + + if global + # global permission is true, when it is either allowed globally (for global permissions) or + # when it is allowed in any project (for project permissions). + permission_mock.permitted_entities[:global].intersect?(permissions) || + permission_mock.permitted_entities + .select { |k, _| k.is_a?(Project) } + .values + .flatten + .intersect?(permissions) + elsif project + permission_mock.permitted_entities[project].intersect?(permissions) + end + end + end +end + +RSpec.configure do |config| + config.include MockedPermissionHelper +end diff --git a/spec/support_spec/mocked_permission_helper_spec.rb b/spec/support_spec/mocked_permission_helper_spec.rb new file mode 100644 index 00000000000..555973aa385 --- /dev/null +++ b/spec/support_spec/mocked_permission_helper_spec.rb @@ -0,0 +1,237 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +RSpec.describe MockedPermissionHelper do + let(:user) { build(:user) } + let(:project) { build(:project) } + let(:other_project) { build(:project) } + let(:work_package_in_project) { build(:work_package, project:) } + let(:other_work_package_in_project) { build(:work_package, project:) } + let(:other_work_package) { build(:work_package) } + + context 'when trying to mock a permission that does not exist' do + it 'raises UnknownPermissionError exception' do + expect do + mock_permissions_for(user) do |mock| + mock.globally :this_permission_does_not_exist + end + end.to raise_error(Authorization::UnknownPermissionError) + end + end + + context 'when trying to mock a permission in the wrong context' do + it 'raises IllegalPermissionContext exception' do + expect do + mock_permissions_for(user) do |mock| + mock.globally :view_work_packages # this is a project/work_package permission + end + end.to raise_error(Authorization::IllegalPermissionContextError) + end + end + + context 'when not mocking any permissions' do + before do + mock_permissions_for(user) + end + + it 'does not allow anything' do + expect(user).not_to be_allowed_globally(:add_project) + expect(user).not_to be_allowed_in_project(:add_work_packages, project) + expect(user).not_to be_allowed_in_any_project(:add_work_packages) + expect(user).not_to be_allowed_in_work_package(:add_work_packages, work_package_in_project) + expect(user).not_to be_allowed_in_any_work_package(:add_work_packages) + end + end + + context 'when explicitly forbidding everything' do + before do + mock_permissions_for(user) do |mock| + mock.all_permissions_allowed! + mock.in_project :add_work_packages, project: + + # this removes all permissions previously set + mock.forbid_everything! + end + end + + it 'does not allow anything' do + expect(user).not_to be_allowed_globally(:add_project) + expect(user).not_to be_allowed_in_project(:add_work_packages, project) + expect(user).not_to be_allowed_in_any_project(:add_work_packages) + expect(user).not_to be_allowed_in_work_package(:add_work_packages, work_package_in_project) + expect(user).not_to be_allowed_in_any_work_package(:add_work_packages) + end + end + + context 'when mocking all permissions' do + before do + mock_permissions_for(user, &:all_permissions_allowed!) + end + + it 'allows everything' do + expect(user).to be_allowed_globally(:add_project) + expect(user).to be_allowed_in_project(:add_work_packages, project) + expect(user).to be_allowed_in_any_project(:add_work_packages) + expect(user).to be_allowed_in_work_package(:add_work_packages, work_package_in_project) + expect(user).to be_allowed_in_any_work_package(:add_work_packages) + + # legacy interface + expect(user).to be_allowed_to_globally(:add_project) + expect(user).to be_allowed_to_in_project(:add_work_packages, project) + expect(user).to be_allowed_to_globally(:add_work_packages) + end + end + + context 'when mocking a global permission' do + before do + mock_permissions_for(user) do |mock| + mock.globally :add_project + end + end + + it 'allows the global permission' do + expect(user).to be_allowed_globally(:add_project) + end + + it 'allows the global permission when querying with controller and action hash' do + expect(user).to be_allowed_globally({ controller: 'projects', action: 'new' }) + end + + it 'allows the global permission using the deprecated interface' do + expect(user).to be_allowed_to_globally(:add_project) + expect(user).to be_allowed_to(:add_project, nil, global: true) + end + end + + context 'when mocking a permission in the project' do + before do + mock_permissions_for(user) do |mock| + mock.in_project :view_work_packages, :add_work_packages, project: + end + end + + it 'allows the permissions when asking for the project' do + expect(user).to be_allowed_in_project(:view_work_packages, project) + expect(user).not_to be_allowed_in_project(:view_work_packages, other_project) + + expect(user).to be_allowed_in_project(:add_work_packages, project) + expect(user).not_to be_allowed_in_project(:add_work_packages, other_project) + end + + it 'allows the project permission when querying with controller and action hash' do + expect(user).to be_allowed_in_project({ controller: 'work_packages', action: 'index', project_id: project.id }) + expect(user).to be_allowed_in_any_project({ controller: 'work_packages', action: 'index' }) + end + + it 'allows the permission when using the deprecated interface' do + expect(user).to be_allowed_to_in_project(:view_work_packages, project) + expect(user).to be_allowed_to(:view_work_packages, project) + expect(user).to be_allowed_to_globally(:view_work_packages) + end + + it 'allows the permissions when asking for any project' do + expect(user).to be_allowed_in_any_project(:view_work_packages) + expect(user).to be_allowed_in_any_project(:add_work_packages) + end + + it 'allows the permissions when asking for any work package within the project' do + expect(user).to be_allowed_in_any_work_package(:view_work_packages, in_project: project) + expect(user).not_to be_allowed_in_any_work_package(:view_work_packages, in_project: other_project) + + expect(user).to be_allowed_in_any_work_package(:add_work_packages, in_project: project) + expect(user).not_to be_allowed_in_any_work_package(:add_work_packages, in_project: other_project) + end + + it 'allows the permissions when asking for any work package' do + expect(user).to be_allowed_in_any_work_package(:view_work_packages) + expect(user).to be_allowed_in_any_work_package(:add_work_packages) + + expect(user).not_to be_allowed_in_any_work_package(:copy_work_packages) + end + + it 'allows the permission when asking for a specific work package within the project' do + expect(user).to be_allowed_in_work_package(:view_work_packages, work_package_in_project) + expect(user).not_to be_allowed_in_work_package(:view_work_packages, other_work_package) + end + end + + context 'when mocking a permission in the work package' do + before do + mock_permissions_for(user) do |mock| + mock.in_work_package :view_work_packages, work_package: work_package_in_project + mock.in_work_package :view_work_packages, :edit_work_packages, work_package: other_work_package_in_project + end + end + + it 'does not allow the permissions when asking for the project' do + expect(user).not_to be_allowed_in_project(:view_work_packages, project) + end + + it 'does not allow the permissions when asking for any project' do + expect(user).not_to be_allowed_in_any_project(:view_work_packages) + expect(user).not_to be_allowed_in_any_project(:edit_work_packages) + end + + it 'allows the permissions when asking for any work package within the project' do + expect(user).to be_allowed_in_any_work_package(:view_work_packages, in_project: project) + expect(user).not_to be_allowed_in_any_work_package(:view_work_packages, in_project: other_project) + + expect(user).to be_allowed_in_any_work_package(:edit_work_packages, in_project: project) + expect(user).not_to be_allowed_in_any_work_package(:edit_work_packages, in_project: other_project) + + expect(user).not_to be_allowed_in_any_work_package(:copy_work_packages, in_project: project) + end + + it 'allows the work package permission when querying with controller and action hash' do + expect(user).to be_allowed_in_work_package({ controller: 'work_packages', action: 'index', project_id: project.id }, + work_package_in_project) + expect(user).to be_allowed_in_any_work_package({ controller: 'work_packages', action: 'index', project_id: project.id }) + expect(user).to be_allowed_in_any_work_package({ controller: 'work_packages', action: 'index', project_id: project.id }, + in_project: project) + end + + it 'allows the permissions when asking for any work package' do + expect(user).to be_allowed_in_any_work_package(:view_work_packages) + expect(user).to be_allowed_in_any_work_package(:edit_work_packages) + + expect(user).not_to be_allowed_in_any_work_package(:copy_work_packages) + end + + it 'allows the permission when asking for a specific work package within the project' do + expect(user).to be_allowed_in_work_package(:view_work_packages, work_package_in_project) + expect(user).to be_allowed_in_work_package(:view_work_packages, other_work_package_in_project) + expect(user).not_to be_allowed_in_work_package(:view_work_packages, other_work_package) + + expect(user).not_to be_allowed_in_work_package(:edit_work_packages, work_package_in_project) + expect(user).to be_allowed_in_work_package(:edit_work_packages, other_work_package_in_project) + expect(user).not_to be_allowed_in_work_package(:edit_work_packages, other_work_package) + end + end +end