diff --git a/Gemfile b/Gemfile index c92a180e8ebd..ceb487a68242 100644 --- a/Gemfile +++ b/Gemfile @@ -255,10 +255,11 @@ group :test do gem 'rails-controller-testing', '~> 1.0.2' gem 'capybara', '~> 3.39.0' + gem 'capybara_accessible_selectors', git: 'https://github.com/citizensadvice/capybara_accessible_selectors', branch: 'main' gem 'capybara-screenshot', '~> 1.0.17' gem 'cuprite', '~> 0.15.0' + gem 'selenium-devtools' gem 'selenium-webdriver', '~> 4.16.0' - gem 'capybara_accessible_selectors', git: 'https://github.com/citizensadvice/capybara_accessible_selectors', branch: 'main' gem 'fuubar', '~> 2.5.0' gem 'timecop', '~> 0.9.0' diff --git a/Gemfile.lock b/Gemfile.lock index 4492fff74027..2adaadf1802a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -731,7 +731,7 @@ GEM paper_trail (12.3.0) activerecord (>= 5.2) request_store (~> 1.1) - parallel (1.23.0) + parallel (1.24.0) parallel_tests (4.3.0) parallel parser (3.2.2.4) @@ -849,7 +849,7 @@ GEM rb-inotify (0.10.1) ffi (~> 1.0) rbtree3 (0.7.1) - rdoc (6.6.1) + rdoc (6.6.2) psych (>= 4.0.0) recaptcha (5.16.0) redcarpet (3.6.0) @@ -920,9 +920,9 @@ GEM activesupport rubocop rubocop-rspec - rubocop-performance (1.19.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) + rubocop-performance (1.20.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) rubocop-rails (2.22.2) activesupport (>= 4.2.0) rack (>= 1.1) @@ -951,6 +951,8 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) secure_headers (6.5.0) + selenium-devtools (0.120.0) + selenium-webdriver (~> 4.2) selenium-webdriver (4.16.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) @@ -1225,6 +1227,7 @@ DEPENDENCIES rubytree (~> 2.0.0) sanitize (~> 6.1.0) secure_headers (~> 6.5.0) + selenium-devtools selenium-webdriver (~> 4.16.0) semantic (~> 1.6.1) shoulda-context (~> 2.0) diff --git a/app/models/project.rb b/app/models/project.rb index e433196e2b96..63a804ab551f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -150,13 +150,13 @@ class Project < ApplicationRecord friendly_id :identifier, use: :finders include ::Scopes::Scoped - scopes :allowed_to + scopes :allowed_to, + :visible scope :has_module, ->(mod) { where(["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s]) } scope :public_projects, -> { where(public: true) } - scope :visible, ->(user = User.current) { where(id: Project.visible_by(user)) } scope :with_visible_work_packages, ->(user = User.current) do where(id: WorkPackage.visible(user).select(:project_id)).or(allowed_to(user, :view_work_packages)) end @@ -199,16 +199,6 @@ def self.selectable_projects Project.visible.select { |p| User.current.member_of? p }.sort_by(&:to_s) end - # Returns all projects the user is allowed to see. - # - # Employs the :view_project permission to perform the - # authorization check as the permission is public, meaning it is granted - # to everybody having at least one role in a project regardless of the - # role's permissions. - def self.visible_by(user = User.current) - allowed_to(user, :view_project).or(where(id: WorkPackage.visible(user).select(:project_id))) - end - # Returns a :conditions SQL string that can be used to find the issues associated with this project. # # Examples: diff --git a/app/models/projects/scopes/visible.rb b/app/models/projects/scopes/visible.rb new file mode 100644 index 000000000000..792d7f227951 --- /dev/null +++ b/app/models/projects/scopes/visible.rb @@ -0,0 +1,50 @@ +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-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. +# ++ + +module Projects::Scopes + module Visible + extend ActiveSupport::Concern + + class_methods do + # Returns all projects the user is allowed to see. + # Those include projects where the user has the permission: + # * :view_project via a project role (which might also be the non member/anonymous role) or by being administrator + # * :view_work_packages via a work package role + def visible(user = User.current) + # Use a shortcut for admins and anonymous where + # we don't need to calculate for work package roles which is more expensive + if user.admin? || user.anonymous? + Project.allowed_to(user, :view_project) + else + Project.allowed_to(user, :view_project) + .or(where(id: WorkPackage.allowed_to(user, :view_work_packages).select(:project_id))) + end + end + end + end +end diff --git a/docker/ci/entrypoint.sh b/docker/ci/entrypoint.sh index 0a74346c633e..b8885aed7fa4 100755 --- a/docker/ci/entrypoint.sh +++ b/docker/ci/entrypoint.sh @@ -116,8 +116,8 @@ setup_tests() { run_background backend_stuff run_background frontend_stuff # pre-cache browsers and their drivers binaries - run_background $(bundle show selenium)/bin/linux/selenium-manager --browser chrome --debug - run_background $(bundle show selenium)/bin/linux/selenium-manager --browser firefox --debug + run_background $(bundle show selenium-webdriver)/bin/linux/selenium-manager --browser chrome --debug + run_background $(bundle show selenium-webdriver)/bin/linux/selenium-manager --browser firefox --debug wait_for_background } diff --git a/frontend/src/app/core/setup/globals/global-listeners.ts b/frontend/src/app/core/setup/globals/global-listeners.ts index dbdd790e4f4a..753ff1881e46 100644 --- a/frontend/src/app/core/setup/globals/global-listeners.ts +++ b/frontend/src/app/core/setup/globals/global-listeners.ts @@ -63,6 +63,12 @@ export function initializeGlobalListeners():void { return; } + // Avoid opening new tab when clicking links while editing in ckeditor + if (linkElement.classList.contains('ck-link_selected')) { + evt.preventDefault(); + return; + } + const callbacks = [ openExternalLinksInNewTab, performAnchorHijacking, diff --git a/lib_static/plugins/acts_as_searchable/lib/acts_as_searchable.rb b/lib_static/plugins/acts_as_searchable/lib/acts_as_searchable.rb index 51c14688a010..a01ac75a66f0 100644 --- a/lib_static/plugins/acts_as_searchable/lib/acts_as_searchable.rb +++ b/lib_static/plugins/acts_as_searchable/lib/acts_as_searchable.rb @@ -134,7 +134,7 @@ def search(tokens, projects = nil, options = {}) def searchable_projects_condition projects = if searchable_options[:permission].nil? - Project.visible_by(User.current) + Project.visible(User.current) else Project.allowed_to(User.current, searchable_options[:permission]) end diff --git a/modules/bim/spec/features/bcf/api_authorization_spec.rb b/modules/bim/spec/features/bcf/api_authorization_spec.rb index 8561414d50c6..5945f0ed4ba6 100644 --- a/modules/bim/spec/features/bcf/api_authorization_spec.rb +++ b/modules/bim/spec/features/bcf/api_authorization_spec.rb @@ -128,8 +128,18 @@ def oauth_path(client_id) logout + # A basic auth alert is displayed asking to enter name and password Register + # some basic auth credentials + # - A non-matching url is used so that capybara will issue a CancelAuth + # instead of trying to authenticate + # - The register method is not recognized by selenium-webdriver with Chrome + # 120 with old headless + if page.driver.browser.respond_to?(:register) + page.driver.browser.register(username: 'foo', password: 'bar', uri: /does_not_match/) + end # While not being logged in and without a token, the api cannot be accessed visit("/api/bcf/2.1/projects/#{project.id}") + # Cancel button of basic auth should have been chosen now expect(page) .to have_content(JSON.dump({ message: "You need to be authenticated to access this resource." })) diff --git a/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb b/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb index b1643d62d8cc..8cd962c23d88 100644 --- a/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb +++ b/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb @@ -1,13 +1,14 @@ <%= content_tag("turbo-frame", id: "edit-participants-dialog-frame") do - component_wrapper do + component_wrapper(class: 'Overlay-form') do primer_form_with( model: @meeting, method: :put, - url: update_participants_meeting_path(@meeting) + url: update_participants_meeting_path(@meeting), + class: 'Overlay-form' ) do |f| component_collection do |collection| - collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 460px;", my: 3)) do + collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3)) do flex_layout(mt: 3) do |form_container| form_container.with_row do flex_layout(justify_content: :flex_end) do |header| diff --git a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb index 2f55d2afbf57..2955f10eb7f3 100644 --- a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb +++ b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb @@ -14,6 +14,7 @@ id: "#{@id}-frame", loading: :lazy, src: @src, + class: 'Overlay-form', data: { 'op-turbo-op-primer-async-dialog-target': "frameElement" }) do flex_layout(justify_content: :center) do |flex| flex.with_column(my: 5) do diff --git a/modules/meeting/app/components/work_package_meetings_tab/add_work_package_to_meeting_form_component.html.erb b/modules/meeting/app/components/work_package_meetings_tab/add_work_package_to_meeting_form_component.html.erb index 30319961c8f9..f663caa2b5de 100644 --- a/modules/meeting/app/components/work_package_meetings_tab/add_work_package_to_meeting_form_component.html.erb +++ b/modules/meeting/app/components/work_package_meetings_tab/add_work_package_to_meeting_form_component.html.erb @@ -1,10 +1,11 @@ <%= content_tag("turbo-frame", id: "add-work-package-to-meeting-dialog-frame") do - component_wrapper do + component_wrapper(class: 'Overlay-form') do primer_form_with( model: @meeting_agenda_item, method: :post, - url: work_package_meeting_agenda_items_path(@work_package) + url: work_package_meeting_agenda_items_path(@work_package), + class: 'Overlay-form' ) do |f| component_collection do |collection| collection.with_component(Primer::Alpha::Dialog::Body.new(test_selector: 'op-add-work-package-to-meeting-dialog-body')) do diff --git a/modules/my_page/spec/features/my/my_spent_time_widget_with_a_negative_time_zone_spec.rb b/modules/my_page/spec/features/my/my_spent_time_widget_with_a_negative_time_zone_spec.rb index b91d6c29907a..cae201f0f76b 100644 --- a/modules/my_page/spec/features/my/my_spent_time_widget_with_a_negative_time_zone_spec.rb +++ b/modules/my_page/spec/features/my/my_spent_time_widget_with_a_negative_time_zone_spec.rb @@ -31,6 +31,7 @@ require_relative '../../support/pages/my/page' RSpec.describe 'My spent time widget with a negative time zone', :js, + driver: :chrome_headless_new, with_settings: { start_of_week: 1 } do let(:beginning_of_week) { monday } let(:end_of_week) { sunday } diff --git a/spec/models/projects/scopes/visible_spec.rb b/spec/models/projects/scopes/visible_spec.rb new file mode 100644 index 000000000000..1f0a0e8060d2 --- /dev/null +++ b/spec/models/projects/scopes/visible_spec.rb @@ -0,0 +1,130 @@ +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-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 Projects::Scopes::Visible do + shared_let(:activity) { create(:time_entry_activity) } + shared_let(:project) { create(:project) } + shared_let(:public_project) { create(:public_project) } + shared_let(:work_package) { create(:work_package, project:) } + shared_let(:shared_in_project) { create(:project) } + shared_let(:shared_work_package) { create(:work_package, project: shared_in_project) } + shared_let(:view_work_package_role) { create(:view_work_package_role) } + shared_let(:non_member_role) { create(:non_member) } + shared_let(:anonymous_role) { create(:anonymous_role) } + shared_let(:shared_user) do + create(:user).tap do |u| + create(:member, + project:, + principal: u, + roles: [create(:project_role)]) + + create(:work_package_member, + entity: shared_work_package, + principal: u, + roles: [view_work_package_role]) + end + end + shared_let(:admin_user) { create(:admin) } + shared_let(:only_project_user) do + create(:user).tap do |u| + create(:member, + project:, + principal: u, + roles: [create(:project_role)]) + end + end + shared_let(:only_shared_user) do + create(:user).tap do |u| + create(:work_package_member, + entity: shared_work_package, + principal: u, + roles: [view_work_package_role]) + end + end + shared_let(:no_membership_user) do + create(:user) + end + + subject { Project.visible(current_user) } + + context 'for an admin user' do + let(:current_user) { admin_user } + + it 'list all projects' do + expect(subject) + .to contain_exactly(shared_in_project, project, public_project) + end + end + + context 'for a user a work package is shared with and who has a memberships' do + let(:current_user) { shared_user } + + it 'list all projects' do + expect(subject) + .to contain_exactly(shared_in_project, project, public_project) + end + end + + context 'for a user having only a project membership' do + let(:current_user) { only_project_user } + + it 'list only the project in which the user has the membership and the public project' do + expect(subject) + .to contain_exactly(project, public_project) + end + end + + context 'for a user only having a share' do + let(:current_user) { only_shared_user } + + it 'list only the project in which the shared work package is and the public project' do + expect(subject) + .to contain_exactly(shared_in_project, public_project) + end + end + + context 'for a user without any permission' do + let(:current_user) { no_membership_user } + + it 'list only the public project' do + expect(subject) + .to contain_exactly(public_project) + end + end + + context 'for an anonymous user' do + let(:current_user) { create(:anonymous) } + + it 'list only the public project' do + expect(subject) + .to contain_exactly(public_project) + end + end +end diff --git a/spec/support/browsers/chrome.rb b/spec/support/browsers/chrome.rb index a1affe4122fd..0aff91f896a1 100644 --- a/spec/support/browsers/chrome.rb +++ b/spec/support/browsers/chrome.rb @@ -1,5 +1,5 @@ # rubocop:disable Metrics/PerceivedComplexity -def register_chrome(language, name: :"chrome_#{language}", override_time_zone: nil) +def register_chrome(language, name: :"chrome_#{language}", headless: 'old', override_time_zone: nil) Capybara.register_driver name do |app| options = Selenium::WebDriver::Chrome::Options.new @@ -12,7 +12,7 @@ def register_chrome(language, name: :"chrome_#{language}", override_time_zone: n end else options.add_argument('--window-size=1920,1080') - options.add_argument('--headless') + options.add_argument("--headless=#{headless}") end options.add_argument('--no-sandbox') @@ -96,6 +96,19 @@ def register_chrome(language, name: :"chrome_#{language}", override_time_zone: n end end +# Register Chrome with new headless implementation +# +# Our tests are not that stable with the new headless mode, but since +# Chrome 120 the old headless mode has browser name +# "chrome-headless-shell". This name is not recognized by the current +# selenium-webdriver and so some extensions like `execute_cdp` are +# missing. This wil be fixed in next selenium-webdriver version. See +# https://github.com/SeleniumHQ/selenium/pull/13271 for more information. +# +# In the meantime, registering a :chrome_headless_new driver which uses the +# `headless=new` flag for tests that need it. +register_chrome 'en', name: :chrome_headless_new, headless: 'new' + # Register mocking proxy driver register_chrome 'en', name: :chrome_billy do |options| options.add_argument("proxy-server=#{Billy.proxy.host}:#{Billy.proxy.port}") diff --git a/spec/support/shared/drag_and_drop_helper_spec.rb b/spec/support/shared/drag_and_drop_helper_spec.rb index d1f8de97748d..9e266c1a64e3 100644 --- a/spec/support/shared/drag_and_drop_helper_spec.rb +++ b/spec/support/shared/drag_and_drop_helper_spec.rb @@ -63,7 +63,7 @@ def drag_n_drop_element(from:, to:, offset_x: nil, offset_y: nil) end def drag_by_pixel(element:, by_x:, by_y:) - scroll_to_element(element) + scroll_to_element(element, block: :center) page .driver diff --git a/spec/support/shared/scroll_into_view_helpers.rb b/spec/support/shared/scroll_into_view_helpers.rb index 79e3f24fb3e4..f531f6062cd9 100644 --- a/spec/support/shared/scroll_into_view_helpers.rb +++ b/spec/support/shared/scroll_into_view_helpers.rb @@ -27,9 +27,14 @@ #++ # Scrolls a native element into view using JS -def scroll_to_element(element) +# @param element [Capybara::Node::Element] the element to scroll into view +# @param block [Symbol] (optional) Defines vertical alignment. +# One of `:start`, `:center`, `:end`, or `:nearest`. Defaults to `:start`. +# @param inline [Symbol] (optional) Defines horizontal alignment. +# One of `:start`, `:center`, `:end`, or `:nearest`. Defaults to `:nearest`.. +def scroll_to_element(element, block: :start, inline: :nearest) script = <<-JS - arguments[0].scrollIntoView(true); + arguments[0].scrollIntoView({block: "#{block}", inline: "#{inline}"}); JS if using_cuprite? page.driver.execute_script(script, element.native)