diff --git a/app/components/work_package_relations_tab/index_component.html.erb b/app/components/work_package_relations_tab/index_component.html.erb index a087cd35bf9b..6a3fdfe3ad0a 100644 --- a/app/components/work_package_relations_tab/index_component.html.erb +++ b/app/components/work_package_relations_tab/index_component.html.erb @@ -57,6 +57,7 @@ # Combine visible and invisible relations into a single list all_relations = relation_group.visible_relations.map { |r| [r, :visible] } + relation_group.ghost_relations.map { |r| [r, :ghost] } + all_relations.sort_by! { |r| r[0].id } flex.with_row(mb: 4) do render_relation_group( @@ -84,6 +85,7 @@ # Combine visible and invisible children into a single list all_children = visible_children.map { |r| [r, :visible] } + ghost_children.map { |r| [r, :ghost] } + all_children.sort_by! { |r| r[0].created_at } flex.with_row do render_relation_group( diff --git a/app/components/work_packages/date_picker/date_form_component.rb b/app/components/work_packages/date_picker/date_form_component.rb index 51e279880e51..905d8ebd338a 100644 --- a/app/components/work_packages/date_picker/date_form_component.rb +++ b/app/components/work_packages/date_picker/date_form_component.rb @@ -80,7 +80,7 @@ def text_field_options(name:, label:) show_clear_button: !disabled?(name) && !duration_field?(name), classes: "op-datepicker-modal--date-field #{'op-datepicker-modal--date-field_current' if @focused_field == name}", validation_message: validation_message(name), - type: duration_field?(name) ? :number : :text + type: field_type(name) ) if duration_field?(name) @@ -177,6 +177,12 @@ def field_value(name) end end + def field_type(name) + return :number if duration_field?(name) + + helpers.browser.device.mobile? ? :date : :text + end + def validation_message(name) # it's ok to take the first error only, that's how primer_view_component does it anyway. message = @work_package.errors.messages_for(name).first diff --git a/app/components/work_packages/date_picker/dialog_content_component.sass b/app/components/work_packages/date_picker/dialog_content_component.sass index c7f0f4fc4a1d..3f2370e71c4a 100644 --- a/app/components/work_packages/date_picker/dialog_content_component.sass +++ b/app/components/work_packages/date_picker/dialog_content_component.sass @@ -22,8 +22,10 @@ $body-height: 460px @media screen and (max-width: $breakpoint-sm) .wp-datepicker-dialog - &--UnderlineNav + &--UnderlineNav, + &--date-picker-instance display: none !important + &--body padding-top: var(--stack-padding-normal) min-height: unset diff --git a/app/components/work_packages/date_picker/form_component.html.erb b/app/components/work_packages/date_picker/form_component.html.erb index 72f50f2efff4..e3cd37f7df15 100644 --- a/app/components/work_packages/date_picker/form_component.html.erb +++ b/app/components/work_packages/date_picker/form_component.html.erb @@ -97,7 +97,7 @@ ) end - body.with_row("aria-hidden": true) do + body.with_row("aria-hidden": true, classes: "wp-datepicker-dialog--date-picker-instance") do helpers.angular_component_tag "opce-wp-date-picker-instance", inputs: { start_date: work_package.start_date, diff --git a/app/models/service_account.rb b/app/models/service_account.rb new file mode 100644 index 000000000000..2048e2b318ba --- /dev/null +++ b/app/models/service_account.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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. +#++ + +class ServiceAccount < User + alias_attribute(:name, :lastname) + validates :name, presence: true + validates :name, length: { maximum: 256 } + + has_one :service_account_association, dependent: :destroy + + def to_s + name + end + + def available_custom_fields = [] + + def logged? = false + + def builtin? = true + + def mail = "" + + def time_zone + ActiveSupport::TimeZone[Setting.user_default_timezone.presence || "Etc/UTC"] + end + + def rss_key = nil +end diff --git a/app/models/service_account_association.rb b/app/models/service_account_association.rb new file mode 100644 index 000000000000..9675e0d07b5c --- /dev/null +++ b/app/models/service_account_association.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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. +#++ + +class ServiceAccountAssociation < ApplicationRecord + belongs_to :service_account + belongs_to :service, polymorphic: true +end diff --git a/app/services/work_packages/create_service.rb b/app/services/work_packages/create_service.rb index 69961b847cda..c9de3c8571da 100644 --- a/app/services/work_packages/create_service.rb +++ b/app/services/work_packages/create_service.rb @@ -60,12 +60,13 @@ def create(attributes, work_package) end if result.success? - result.merge!(reschedule_related(work_package)) - + # update ancestors before rescheduling, as the parent might switch to automatic mode update_ancestors_all_attributes(result.all_results).each do |ancestor_result| result.merge!(ancestor_result) end + result.merge!(reschedule_related(work_package)) + set_user_as_watcher(work_package) end diff --git a/app/views/admin/settings/work_package_priorities/edit.html.erb b/app/views/admin/settings/work_package_priorities/edit.html.erb index 48ae3a3131b0..8d359a45514b 100644 --- a/app/views/admin/settings/work_package_priorities/edit.html.erb +++ b/app/views/admin/settings/work_package_priorities/edit.html.erb @@ -43,6 +43,6 @@ See COPYRIGHT and LICENSE files for more details. end %> -<%= primer_form_with(model: @enumeration, url: url_for(action: :update, id: @enumeration)) do |f| %> +<%= primer_form_with(model: @enumeration, scope: :enumeration, url: url_for(action: :update, id: @enumeration)) do |f| %> <%= render(Admin::Enumerations::ItemForm.new(f)) %> <% end %> diff --git a/db/migrate/20250324133701_create_service_account_associations.rb b/db/migrate/20250324133701_create_service_account_associations.rb new file mode 100644 index 000000000000..ade2f5fa30aa --- /dev/null +++ b/db/migrate/20250324133701_create_service_account_associations.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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. +#++ + +class CreateServiceAccountAssociations < ActiveRecord::Migration[7.1] + def change + create_table :service_account_associations do |t| + t.belongs_to :service_account, null: false, index: { unique: true } + t.belongs_to :service, null: false, index: false # necessary index covered by composite + t.string :service_type, null: false + + t.timestamps null: false + + t.index %i[service_type service_id], unique: true + end + end +end diff --git a/docs/user-guide/meetings/one-time-meetings/README.md b/docs/user-guide/meetings/one-time-meetings/README.md index 473fef020c0b..7f1d9970045a 100644 --- a/docs/user-guide/meetings/one-time-meetings/README.md +++ b/docs/user-guide/meetings/one-time-meetings/README.md @@ -217,7 +217,7 @@ You can send an email reminder to all the meeting participants. Select the dropd ## Meeting attachments -You can attachments in the meetings in the **Attachments** section in the right bottom corner. You can either user the **+Attach files** link to select files from your computer or drag and drop them. +You can add attachments in the meetings in the **Attachments** section in the right bottom corner. You can either user the **+Attach files** link to select files from your computer or drag and drop them. Added attachments can be added to the Notes section of agenda packages by dragging and dropping them from the Attachments section. diff --git a/docs/user-guide/work-packages/duplicate-move-delete/README.md b/docs/user-guide/work-packages/duplicate-move-delete/README.md index bf094c3ab0e1..0bc01f7a73a9 100644 --- a/docs/user-guide/work-packages/duplicate-move-delete/README.md +++ b/docs/user-guide/work-packages/duplicate-move-delete/README.md @@ -34,7 +34,7 @@ Copying a work package allows to easily create and adjust new work packages base ## Copy link to clipboard -This option copies a short link to the work package to your clipboard so you can quickly paste it elsewhere. It can also be useful when you want to quickly copy links to multiple work packages without having to open the detailed view of each one. +This option copies a short link to the work package to your clipboard so you can quickly paste it elsewhere. It can also be useful when you want to quickly copy links to multiple work packages without having to open the detailed view of each one. ## Move a work package to a different project diff --git a/frontend/src/global_styles/content/work_packages/_table_content.sass b/frontend/src/global_styles/content/work_packages/_table_content.sass index 2a9c611f168d..e70009fc568b 100644 --- a/frontend/src/global_styles/content/work_packages/_table_content.sass +++ b/frontend/src/global_styles/content/work_packages/_table_content.sass @@ -37,6 +37,10 @@ .wp-table--row cursor: pointer + // Some padding for the inner cells of the display fields + .inline-edit--display-field:not(.op-table-baseline--field) + padding: 2px + .wp-table--row, #empty-row-notification @@ -152,10 +156,6 @@ html:not(.-browser-mobile) &.bcfThumbnail outline: none -// Some padding for the inner cells of the display fields -.inline-edit--display-field:not(.op-table-baseline--field) - padding: 2px - .inplace-editing--container @include unset-button-styles display: inline-block diff --git a/lib_static/open_project/authentication/strategies/warden/jwt_oidc.rb b/lib_static/open_project/authentication/strategies/warden/jwt_oidc.rb index 573cddb809f3..a3fadbb4fb74 100644 --- a/lib_static/open_project/authentication/strategies/warden/jwt_oidc.rb +++ b/lib_static/open_project/authentication/strategies/warden/jwt_oidc.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module OpenProject module Authentication module Strategies @@ -24,7 +26,11 @@ def authenticate! ->(payload_and_provider) do payload, provider = payload_and_provider user = User.find_by(identity_url: "#{provider.slug}:#{payload['sub']}") - success!(user) if user + if user + success!(user) + else + fail_with_header!(error: "invalid_token", error_description: "The user identified by the token is not known") + end end, ->(error) { fail_with_header!(error: "invalid_token", error_description: error) } ) diff --git a/modules/costs/app/views/admin/settings/time_entry_activities/edit.html.erb b/modules/costs/app/views/admin/settings/time_entry_activities/edit.html.erb index a9016dffcd3a..f141496879e2 100644 --- a/modules/costs/app/views/admin/settings/time_entry_activities/edit.html.erb +++ b/modules/costs/app/views/admin/settings/time_entry_activities/edit.html.erb @@ -43,6 +43,6 @@ See COPYRIGHT and LICENSE files for more details. end %> -<%= primer_form_with(model: @enumeration, url: url_for(action: :update, id: @enumeration)) do |f| %> +<%= primer_form_with(model: @enumeration, scope: :enumeration, url: url_for(action: :update, id: @enumeration)) do |f| %> <%= render(Admin::Enumerations::ItemForm.new(f)) %> <% end %> diff --git a/modules/costs/spec/features/time_entry/activity_spec.rb b/modules/costs/spec/features/time_entry/activity_spec.rb index bc3bcb0d2d90..9055793499be 100644 --- a/modules/costs/spec/features/time_entry/activity_spec.rb +++ b/modules/costs/spec/features/time_entry/activity_spec.rb @@ -46,21 +46,32 @@ # we are redirected back to the index page expect(page).to have_current_path(admin_settings_time_entry_activities_path) - expect(page).to have_content("A new activity") + # It allows editing (Regression #62459) + click_link "A new activity" + + fill_in "Name", with: "Development" + click_on("Save") + + expect(page).to have_current_path(admin_settings_time_entry_activities_path) + expect(page).to have_content("Development") + + expect(TimeEntryActivity).to exist(name: "Development") + expect(TimeEntryActivity).not_to exist(name: "A new activity") + visit project_settings_general_path(project) click_on "Time tracking activities" - expect(page).to have_field("A new activity", checked: true) + expect(page).to have_field("Development", checked: true) - uncheck "A new activity" + uncheck "Development" click_on "Save" expect(page).to have_content "Successful update." - expect(page).to have_field("A new activity", checked: false) + expect(page).to have_field("Development", checked: false) end end diff --git a/modules/documents/app/views/admin/settings/document_categories/edit.html.erb b/modules/documents/app/views/admin/settings/document_categories/edit.html.erb index 61242a48df34..c1fb93378a5d 100644 --- a/modules/documents/app/views/admin/settings/document_categories/edit.html.erb +++ b/modules/documents/app/views/admin/settings/document_categories/edit.html.erb @@ -26,7 +26,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("documents.categories") %> +<% html_title t(:label_administration), t("documents.label_categories") %> <%= render(Primer::OpenProject::PageHeader.new) do |header| @@ -35,13 +35,13 @@ See COPYRIGHT and LICENSE files for more details. [ { href: admin_index_path, text: t("label_administration") }, { href: admin_settings_storages_path, text: t("project_module_storages") }, - { href: url_for(action: :index), text: t("documents.categories") }, + { href: url_for(action: :index), text: t("documents.label_categories") }, @enumeration.name ] ) end %> -<%= primer_form_with(model: @enumeration, url: url_for(action: :update, id: @enumeration)) do |f| %> +<%= primer_form_with(model: @enumeration, scope: :enumeration, url: url_for(action: :update, id: @enumeration)) do |f| %> <%= render(Admin::Enumerations::ItemForm.new(f)) %> <% end %> diff --git a/modules/documents/spec/features/document_categories_spec.rb b/modules/documents/spec/features/document_categories_spec.rb index dc32243c4526..676553e55cb6 100644 --- a/modules/documents/spec/features/document_categories_spec.rb +++ b/modules/documents/spec/features/document_categories_spec.rb @@ -45,7 +45,18 @@ # we are redirected back to the index page expect(page).to have_current_path(admin_settings_document_categories_path) - expect(page).to have_content("Documentation") + + # It allows editing (Regression #62459) + click_link "Documentation" + + fill_in "Name", with: "Specification" + click_on("Save") + + expect(page).to have_current_path(admin_settings_document_categories_path) + expect(page).to have_content("Specification") + + expect(DocumentCategory).to exist(name: "Specification") + expect(DocumentCategory).not_to exist(name: "Documentation") end end diff --git a/modules/storages/app/common/storages/peripherals/connection_validators/check_result.rb b/modules/storages/app/common/storages/peripherals/connection_validators/check_result.rb index b489a816e6fe..0d7aaae19b8e 100644 --- a/modules/storages/app/common/storages/peripherals/connection_validators/check_result.rb +++ b/modules/storages/app/common/storages/peripherals/connection_validators/check_result.rb @@ -38,7 +38,7 @@ def self.skipped(key) end def self.failure(key, message) - new(key:, state: :failure, message: message, timestamp: Time.zone.now) + new(key:, state: :failure, message:, timestamp: Time.zone.now) end def self.success(key) @@ -46,7 +46,7 @@ def self.success(key) end def self.warning(key, message) - new(key:, state: :warning, message: message, timestamp: Time.zone.now) + new(key:, state: :warning, message:, timestamp: Time.zone.now) end def success? = state == :success diff --git a/modules/storages/app/common/storages/peripherals/connection_validators/nextcloud/ampf_connection_validator.rb b/modules/storages/app/common/storages/peripherals/connection_validators/nextcloud/ampf_connection_validator.rb new file mode 100644 index 000000000000..3bdebff3dacf --- /dev/null +++ b/modules/storages/app/common/storages/peripherals/connection_validators/nextcloud/ampf_connection_validator.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Storages + module Peripherals + module ConnectionValidators + module Nextcloud + class AmpfConnectionValidator < BaseValidator + using ServiceResultRefinements + + private + + def validate + register_checks( + :userless_access, :group_folder_presence, :files_request, :group_folder_contents + ) + + userless_access_denied + group_folder_not_found + files_request_failed_with_unknown_error + with_unexpected_content + end + + def userless_access_denied + if files.result == :unauthorized + fail_check(:userless_access, message(:userless_access_denied)) + else + pass_check(:userless_access) + end + end + + def group_folder_not_found + if files.result == :not_found + fail_check(:group_folder_presence, message(:group_folder_not_found)) + else + pass_check(:group_folder_presence) + end + end + + def files_request_failed_with_unknown_error + if files.result == :error + error "Connection validation failed with unknown error:\n" \ + "\tstorage: ##{@storage.id} #{@storage.name}\n" \ + "\trequest: Group folder content\n" \ + "\tstatus: #{files.result}\n" \ + "\tresponse: #{files.error_payload}" + + fail_check(:files_request, message(:unknown_error)) + else + pass_check(:files_request) + end + end + + def with_unexpected_content + unexpected_files = files.result.files.reject { managed_project_folder_ids.include?(it.id) } + return pass_check(:group_folder_contents) if unexpected_files.empty? + + log_extraneous_files(unexpected_files) + warn_check(:group_folder_contents, message(:unexpected_content)) + end + + def log_extraneous_files(unexpected_files) + file_representation = unexpected_files.map do |file| + "Name: #{file.name}, ID: #{file.id}, Location: #{file.location}" + end + + warn "Unexpected files/folder found in group folder:\n\t#{file_representation.join("\n\t")}" + end + + def auth_strategy = Registry["nextcloud.authentication.userless"].call + + def managed_project_folder_ids + @managed_project_folder_ids ||= ProjectStorage.automatic.where(storage: @storage) + .pluck(:project_folder_id).to_set + end + + def files + @files ||= Peripherals::Registry + .resolve("#{@storage}.queries.files") + .call(storage: @storage, auth_strategy:, folder: ParentFolder.new(@storage.group_folder)) + end + end + end + end + end +end diff --git a/modules/storages/app/common/storages/peripherals/connection_validators/nextcloud/base_validator.rb b/modules/storages/app/common/storages/peripherals/connection_validators/nextcloud/base_validator.rb index bd8c3be4d1ef..34afb6f8842b 100644 --- a/modules/storages/app/common/storages/peripherals/connection_validators/nextcloud/base_validator.rb +++ b/modules/storages/app/common/storages/peripherals/connection_validators/nextcloud/base_validator.rb @@ -33,6 +33,8 @@ module Peripherals module ConnectionValidators module Nextcloud class BaseValidator + include TaggedLogging + def initialize(storage) @storage = storage end diff --git a/modules/storages/spec/common/storages/peripherals/connection_validators/nextcloud/ampf_connection_validator_spec.rb b/modules/storages/spec/common/storages/peripherals/connection_validators/nextcloud/ampf_connection_validator_spec.rb new file mode 100644 index 000000000000..c5ffae86fa5e --- /dev/null +++ b/modules/storages/spec/common/storages/peripherals/connection_validators/nextcloud/ampf_connection_validator_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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" +require_module_spec_helper + +module Storages + module Peripherals + module ConnectionValidators + module Nextcloud + RSpec.describe AmpfConnectionValidator, :webmock do + let(:storage) { create(:nextcloud_storage_configured, :as_automatically_managed) } + let(:project_folder_id) { "1337" } + let!(:project_storage) do + create(:project_storage, :as_automatically_managed, project_folder_id:, storage:, project: create(:project)) + end + + let(:files_response) do + ServiceResult.success(result: StorageFiles.new( + [StorageFile.new(id: project_folder_id, name: project_storage.managed_project_folder_name)], + StorageFile.new(id: "root", name: "root"), + [] + )) + end + + subject(:validator) { described_class.new(storage) } + + before do + Registry.stub("nextcloud.queries.files", ->(*) { files_response }) + end + + it "pass all checks" do + results = validator.call + + expect(results.values).to all(be_success) + end + + context "if userless authentication fails" do + let(:files_response) { build_failure(code: :unauthorized, payload: nil) } + + it "fails and skips the next checks" do + results = validator.call + + states = results.values.map { it.state }.tally + expect(states).to eq({ failure: 1, skipped: 3 }) + expect(results[:userless_access]).to be_failure + expect(results[:userless_access].message).to eq(i18n_message(:userless_access_denied)) + end + end + + context "if the files request returns not_found" do + let(:files_response) { build_failure(code: :not_found, payload: nil) } + + it "fails the check" do + results = validator.call + + expect(results[:group_folder_presence]).to be_failure + expect(results[:group_folder_presence].message).to eq(i18n_message(:group_folder_not_found)) + end + end + + context "if the files request returns an unknown error" do + let(:files_response) { StorageInteraction::Nextcloud::Util.error(:error) } + + before { allow(Rails.logger).to receive(:error) } + + it "fails the check and logs the error" do + results = validator.call + + expect(results[:files_request]).to be_failure + expect(results[:files_request].message) + .to eq(i18n_message(:unknown_error)) + + expect(Rails.logger).to have_received(:error).with(/Connection validation failed with unknown error/) + end + end + + context "if the files request returns unexpected files" do + let(:files_response) do + ServiceResult.success(result: StorageFiles.new( + [ + StorageFile.new(id: project_folder_id, name: "I am your father"), + StorageFile.new(id: "noooooooooo", name: "testimony_of_luke_skywalker.md") + ], + StorageFile.new(id: "root", name: "root"), + [] + )) + end + + it "warns the user about extraneous folders" do + results = validator.call + + expect(results[:group_folder_contents]).to be_a_warning + expect(results[:group_folder_contents].message).to eq(i18n_message(:unexpected_content)) + end + end + + private + + def i18n_message(key, context = {}) = I18n.t("storages.health.connection_validation.#{key}", **context) + + def build_failure(code:, payload:) + data = StorageErrorData.new(source: "query", payload:) + error = StorageError.new(code:, data:) + ServiceResult.failure(result: code, errors: error) + end + end + end + end + end +end diff --git a/spec/components/work_package_relations_tab/index_component_spec.rb b/spec/components/work_package_relations_tab/index_component_spec.rb new file mode 100644 index 000000000000..ccde984654da --- /dev/null +++ b/spec/components/work_package_relations_tab/index_component_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 "rails_helper" + +RSpec.describe WorkPackageRelationsTab::IndexComponent, type: :component do + create_shared_association_defaults_for_work_package_factory + + shared_let(:user) { create(:admin) } + shared_let(:work_package) { create(:work_package) } + + current_user { user } + + def render_component(**params) + render_inline(described_class.new(work_package:, **params)) + page + end + + context "with no relations" do + it "renders a message" do + expect(render_component).to have_heading "No relations" + expect(page).to have_text "This work package does not have any relations yet." + end + end + + context "with child relations" do + shared_let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | + work_package | X | automatic | + child1 | XXX | manual | + child2 | | automatic | + TABLE + + it "renders the relations group" do + expect(render_component).to have_test_selector("op-relation-group-children") + end + + it "renders the relations in child creation order" do + expect(render_component).to have_list "Children" + + list = page.find(:list, "Children") + expect(list).to have_list_item count: 2, text: /child\d/ + expect(list).to have_list_item position: 1, text: "child1" + expect(list).to have_list_item position: 2, text: "child2" + end + end + + context "with follows relations" do + shared_let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | predecessors + predecessor1 | XXX | manual | + predecessor2 | XX | manual | + predecessor3 | XX | manual | + predecessor4 | | manual | + work_package | X | automatic | predecessor1 with lag 2, predecessor2 with lag 7, predecessor3 with lag 7, predecessor4 with lag 10 + TABLE + + it "renders the relations group" do + expect(render_component).to have_test_selector("op-relation-group-follows") + end + + it "renders the relations in relation creation order" do + expect(render_component).to have_list "Predecessors (before)" + + list = page.find(:list, "Predecessors (before)") + expect(list).to have_list_item count: 4, text: /predecessor\d/ + expect(list).to have_list_item position: 1, text: "predecessor1" + expect(list).to have_list_item position: 2, text: "predecessor2" + expect(list).to have_list_item position: 3, text: "predecessor3" + expect(list).to have_list_item position: 4, text: "predecessor4" + + # delete and recreate relation to predecessor2. + relation_attributes = predecessor2.relations.first.attributes + predecessor2.relations.first.destroy + Relation.create!(relation_attributes.without("id")) + + # predecessor2 should now be at last position + render_component + list = page.find(:list, "Predecessors (before)") + expect(list).to have_list_item position: 1, text: "predecessor1" + expect(list).to have_list_item position: 2, text: "predecessor3" + expect(list).to have_list_item position: 3, text: "predecessor4" + expect(list).to have_list_item position: 4, text: "predecessor2" + end + + it "renders the closest relation" do + render_component + + list_item = page.find(:list_item, text: "predecessor2") + expect(list_item).to have_primer_label("Closest") + end + end +end diff --git a/spec/components/work_package_relations_tab/relation_component_spec.rb b/spec/components/work_package_relations_tab/relation_component_spec.rb new file mode 100644 index 000000000000..0331ec156492 --- /dev/null +++ b/spec/components/work_package_relations_tab/relation_component_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 "rails_helper" + +RSpec.describe WorkPackageRelationsTab::RelationComponent, type: :component do + shared_let(:user) { create(:admin) } + shared_let(:work_package) { create(:work_package) } + + current_user { user } + + shared_let_work_packages(<<~TABLE) + hierarchy | MTWTFSS | scheduling mode | predecessors + predecessor | XXX | manual | + work_package | X | automatic | predecessor with lag 2 + child | | automatic | + TABLE + + def render_component(**params) + render_inline(described_class.new(work_package:, **params)) + end + + context "with child relations" do + context "when visible" do + it "renders a title link" do + expect(render_component(relation: nil, child: child, visibility: :visible)) + .to have_link "child" + end + + context "when editable" do + it "renders an action menu" do + component = render_component(relation: nil, child: child, visibility: :visible, editable: true) + expect(component).to have_menu # FIXME: aria-labelledby does not resolve here "Relation actions" + expect(component).to have_selector :menuitem, "Delete relation" + end + end + end + + context "when ghost" do + it "does not render a title link" do + expect(render_component(relation: nil, child: child, visibility: :ghost)) + .to have_no_link "child" + end + + it "renders a title and message without details" do + expect(render_component(relation: nil, child: child, visibility: :ghost)) + .to have_text "Related work package" + expect(render_component(relation: nil, child: child, visibility: :ghost)) + .to have_text "This is not visible to you due to permissions." + end + + it "does not render an action menu" do + expect(render_component(relation: nil, child: child, visibility: :ghost)) + .to have_no_menu + end + end + end + + context "with follows relations" do + context "when visible" do + it "renders a title link" do + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :visible)) + .to have_link "predecessor" + end + + it "renders the lag" do + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :visible)) + .to have_text "Lag: 2 days" + end + + context "when editable" do + it "renders a action menu" do + component = render_component(relation: _table.relation(predecessor: predecessor), visibility: :visible, editable: true) + expect(component).to have_menu # FIXME: aria-labelledby does not resolve here "Relation actions" + expect(component).to have_selector :menuitem, "Edit relation" + expect(component).to have_selector :menuitem, "Delete relation" + end + end + end + + context "when ghost" do + it "does not render a title link" do + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :ghost)) + .to have_no_link "child" + end + + it "renders a title and message without details" do + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :ghost)) + .to have_text "Related work package" + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :ghost)) + .to have_text "This is not visible to you due to permissions." + end + + it "does not render an action menu" do + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :ghost)) + .to have_no_menu + end + end + + context "when closest" do + it "always renders a closest label" do + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :visible, closest: true)) + .to have_primer_label "Closest", scheme: :primary + expect(render_component(relation: _table.relation(predecessor: predecessor), visibility: :ghost, closest: true)) + .to have_primer_label "Closest", scheme: :primary + end + end + end +end diff --git a/spec/factories/service_account_factory.rb b/spec/factories/service_account_factory.rb new file mode 100644 index 000000000000..b0d104b07d34 --- /dev/null +++ b/spec/factories/service_account_factory.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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. +#++ + +FactoryBot.define do + factory :service_account, parent: :user, class: "ServiceAccount" do + transient do + service { nil } + end + + after(:create) do |instance, evaluator| + if evaluator.service.present? + instance.create_service_account_association!(service: evaluator.service) + end + end + end +end diff --git a/spec/features/admin/settings/work_package_priorities_spec.rb b/spec/features/admin/settings/work_package_priorities_spec.rb index 847690abdd92..78a4e6d4492b 100644 --- a/spec/features/admin/settings/work_package_priorities_spec.rb +++ b/spec/features/admin/settings/work_package_priorities_spec.rb @@ -47,7 +47,18 @@ # we are redirected back to the index page expect(page).to have_current_path(admin_settings_work_package_priorities_path) - expect(page).to have_content("Immediate") + + # It allows editing (Regression #62459) + click_link "Immediate" + + fill_in "Name", with: "Urgent" + click_on("Save") + + expect(page).to have_current_path(admin_settings_work_package_priorities_path) + expect(page).to have_content("Urgent") + + expect(IssuePriority).to exist(name: "Urgent") + expect(IssuePriority).not_to exist(name: "Immediate") end end diff --git a/spec/requests/api/v3/authentication_spec.rb b/spec/requests/api/v3/authentication_spec.rb index 02414c634bd5..3be1938395a0 100644 --- a/spec/requests/api/v3/authentication_spec.rb +++ b/spec/requests/api/v3/authentication_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -421,10 +423,11 @@ def set_basic_auth_header(user, password) .to_return(status: 200, body: JWT::JWK::Set.new(jwk_response).export.to_json, headers: {}) end let(:jwk_response) { jwk } + let(:user) { create(:user, identity_url: "keycloak:#{token_sub}") } before do create(:oidc_provider, slug: "keycloak") - create(:user, identity_url: "keycloak:#{token_sub}") + user.save! keys_request_stub header "Authorization", "Bearer #{token}" @@ -511,5 +514,18 @@ def set_basic_auth_header(user, password) .to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="#{error}"}) end end + + context "when user identified by token is not known" do + let(:user) { create(:user, identity_url: "keycloak:not-the-token-sub") } + + it "fails with HTTP 401 Unauthorized" do + get resource + expect(last_response).to have_http_status :unauthorized + expect(JSON.parse(last_response.body)).to eq(error_response_body) + error = "The user identified by the token is not known" + expect(last_response.header["WWW-Authenticate"]) + .to eq(%{Bearer realm="OpenProject API", error="invalid_token", error_description="#{error}"}) + end + end end end diff --git a/spec/requests/api/v3/work_packages/create_resource_spec.rb b/spec/requests/api/v3/work_packages/create_resource_spec.rb index 69c2cf506f70..2c35e375884b 100644 --- a/spec/requests/api/v3/work_packages/create_resource_spec.rb +++ b/spec/requests/api/v3/work_packages/create_resource_spec.rb @@ -180,50 +180,90 @@ end end - context "when scheduled manually" do - let(:work_package) { WorkPackage.first } + describe "scheduleManually parameter" do + let(:created_work_package) { WorkPackage.find_by(subject: "new work packages") } - context "with true" do + context "when true" do # mind the () for the super call, those are required in rspec's super let(:parameters) { super().merge(scheduleManually: true) } it "sets the scheduling mode to manual (schedule_manually: true)" do - expect(work_package.schedule_manually).to be true + expect(created_work_package.schedule_manually).to be true + end + + context "when also being the first child of a manually scheduled parent" do + let(:extra_permissions) { %i[manage_subtasks] } + let(:parent) do + create(:work_package, project:, + subject: "parent", + schedule_manually: true, + start_date: Date.new(2025, 1, 1), + due_date: Date.new(2025, 1, 31)) + end + let(:parameters) do + super().deep_merge( + startDate: nil, + dueDate: nil, + _links: { + parent: { + href: api_v3_paths.work_package(parent.id) + } + } + ) + end + + it "changes the scheduling mode of the parent work package to automatic " \ + "and sets its dates to match the child's dates" do + expect(created_work_package.parent).to eq(parent.reload) + expect(created_work_package.parent.schedule_manually).to be false + expect(created_work_package.parent.start_date).to be_nil + expect(created_work_package.parent.due_date).to be_nil + end end end - context "with false" do + context "when false" do let(:parameters) do super().merge(scheduleManually: false) end context "when the created work package has an indirect predecessor" do - let(:predecessor) { create(:work_package, project:) } - let(:parent) { create(:work_package, project:, schedule_manually: false) } - let(:parameters) do - super().merge(parent: parent) + let(:extra_permissions) { %i[manage_subtasks] } + let(:predecessor) { create(:work_package, project:, subject: "predecessor") } + let(:parent) do + create(:work_package, project:, + subject: "parent", + schedule_manually: false).tap do |parent| + create(:follows_relation, predecessor:, successor: parent) + end end - - before do - create(:follows_relation, from: parent, to: predecessor) + let(:parameters) do + super().deep_merge( + _links: { + parent: { + href: api_v3_paths.work_package(parent.id) + } + } + ) end - it "sets the scheduling mode to automatic (schedule_manually: false)" do - expect(work_package.schedule_manually).to be false + it "sets the scheduling mode to automatic as requested (schedule_manually: false)" do + expect(created_work_package.schedule_manually).to be false end end context "when the work package has no direct or indirect predecessors and no children" do # TODO: should the API return an error here? - it "does not set the scheduling mode to automatic and keeps manual scheduling mode (schedule_manually: true)" do - expect(work_package.schedule_manually).to be true + it "does not set the scheduling mode to automatic as requested " \ + "and keeps manual scheduling mode (schedule_manually: true)" do + expect(created_work_package.schedule_manually).to be true end end end - context "with scheduleManually absent" do + context "when absent" do it "sets the scheduling mode to manual (schedule_manually: true, the default)" do - expect(work_package.schedule_manually).to be true + expect(created_work_package.schedule_manually).to be true end end end diff --git a/spec/support/capybara/additional_accessible_selectors.rb b/spec/support/capybara/additional_accessible_selectors.rb index 5654f464970e..8c728a3d9190 100644 --- a/spec/support/capybara/additional_accessible_selectors.rb +++ b/spec/support/capybara/additional_accessible_selectors.rb @@ -29,13 +29,29 @@ #++ Capybara.add_selector(:list) do - xpath { ".//ul | .//ol" } + xpath do |*| + XPath.descendant[[ + XPath.self(:ul), + XPath.self(:ol) + ].reduce(:|)] + end + + locator_filter skip_if: nil do |node, locator, exact:, **| + method = exact ? :eql? : :include? + if node[:"aria-labelledby"] + CapybaraAccessibleSelectors::Helpers.element_labelledby(node).public_send(method, locator) + elsif node[:"aria-label"] + node[:"aria-label"].public_send(method, locator.to_s) + end + end end Capybara.add_selector(:list_item) do label "list item" - xpath { ".//li" } + xpath do |*| + XPath.descendant[XPath.self(:li)] + end expression_filter(:position) do |xpath, position| position ? "#{xpath}[#{position}]" : xpath diff --git a/spec/workers/principals/delete_job_integration_spec.rb b/spec/workers/principals/delete_job_integration_spec.rb index f5b6f56d3607..3cd98037d300 100644 --- a/spec/workers/principals/delete_job_integration_spec.rb +++ b/spec/workers/principals/delete_job_integration_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -516,6 +518,25 @@ end end + context "with a service account" do + describe "service account association" do + let(:principal) { create(:service_account, service:) } + let(:service) { create(:oauth_application) } + + before do + principal.save! + end + + it "deletes the service account association" do + expect { job }.to change(ServiceAccountAssociation, :count).from(1).to(0) + end + + it "does not delete the service associated to the service account" do + expect { job }.not_to change(Doorkeeper::Application, :count) + end + end + end + context "with a placeholder user" do let(:principal) { create(:placeholder_user) }