From d385b0437f6abf1f6ebcd44797caec2d21274d97 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Wed, 22 Jan 2025 09:16:09 +0100 Subject: [PATCH] Allow to configure authentication method through UI This includes a refactoring to our storage contracts, which so far required to perform all validations of a storage at once, even though the storage is assembled step by step. It's now possible to validate only one step of the storage creation, while still having a contract that validates the storage as a whole. By default the Create/Update contract will validate the whole storage, as before, but they can now be instructed to validate using one of the partial contracts. --- app/contracts/composed_contract.rb | 94 ++++++++++++++++++ .../peripherals/nextcloud_registry.rb | 4 + .../peripherals/nextcloud_storage_wizard.rb | 11 +++ .../peripherals/one_drive_registry.rb | 1 + .../general_info_form_component.html.erb | 6 ++ .../forms/general_info_form_component.rb | 21 ++-- ...nextcloud_audience_form_component.html.erb | 58 +++++++++++ .../nextcloud_audience_form_component.rb | 58 +++++++++++ .../nextcloud_audience_info_component.erb | 69 +++++++++++++ .../nextcloud_audience_info_component.rb | 37 +++++++ ...component.rb => storage_info_component.rb} | 8 +- .../storages/storages/base_contract.rb | 39 ++++++-- .../storages/storages/create_contract.rb | 6 ++ .../storages/nextcloud_audience_contract.rb | 49 +++++++++ ...nextcloud_automatic_management_contract.rb | 69 +++++++++++++ .../storages/storages/nextcloud_contract.rb | 56 +---------- .../nextcloud_general_information_contract.rb | 44 +++++++++ .../storages/storages/one_drive_contract.rb | 2 + .../storages/admin/storages_controller.rb | 45 ++++++--- .../admin/nextcloud_audience_input_form.rb | 44 +++++++++ ...tcloud_authentication_method_input_form.rb | 48 +++++++++ .../app/models/storages/nextcloud_storage.rb | 3 +- .../storages/storages/create_service.rb | 2 +- modules/storages/config/locales/en.yml | 13 +++ modules/storages/config/routes.rb | 1 + .../nextcloud_storage_wizard_spec.rb | 60 ++++++++++- .../storages/shared_contract_examples.rb | 6 ++ .../storages/admin/create_storage_spec.rb | 81 ++++++++++++++- .../storages/admin/edit_storage_spec.rb | 90 ++++++++++++++++- .../models/storages/nextcloud_storage_spec.rb | 38 +++++++ .../storages/storages/create_service_spec.rb | 2 +- spec/contracts/composed_contract_spec.rb | 99 +++++++++++++++++++ 32 files changed, 1071 insertions(+), 93 deletions(-) create mode 100644 app/contracts/composed_contract.rb create mode 100644 modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.html.erb create mode 100644 modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.rb create mode 100644 modules/storages/app/components/storages/admin/nextcloud_audience_info_component.erb create mode 100644 modules/storages/app/components/storages/admin/nextcloud_audience_info_component.rb rename modules/storages/app/components/storages/admin/{storage_Info_component.rb => storage_info_component.rb} (92%) create mode 100644 modules/storages/app/contracts/storages/storages/nextcloud_audience_contract.rb create mode 100644 modules/storages/app/contracts/storages/storages/nextcloud_automatic_management_contract.rb create mode 100644 modules/storages/app/contracts/storages/storages/nextcloud_general_information_contract.rb create mode 100644 modules/storages/app/forms/storages/admin/nextcloud_audience_input_form.rb create mode 100644 modules/storages/app/forms/storages/admin/nextcloud_authentication_method_input_form.rb create mode 100644 spec/contracts/composed_contract_spec.rb diff --git a/app/contracts/composed_contract.rb b/app/contracts/composed_contract.rb new file mode 100644 index 00000000000..200b9e01dc3 --- /dev/null +++ b/app/contracts/composed_contract.rb @@ -0,0 +1,94 @@ +# 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. +#++ + +## +# A contract that allows to compose itself from other contracts like: +# +# class MyContract < ComposedContract +# include_contract MyOtherContractA +# include_contract MyOtherContractB +# end +# +# This allows to run the smaller contracts separately, but also to have one contract +# that validates the model as a whole. The composed contract will make sure that all attributes +# defined as writable in one contract are also considered writable in other contracts. +# +# It's not possible to add validations to the composed contract directly. If you need additional validations, +# extract them into a separate contract that you include in the composed contract. +class ComposedContract + class << self + def included_contract_classes + @included_contract_classes ||= [] + end + + def include_contract(contract_class) + included_contract_classes << contract_class + end + end + + attr_reader :errors, :model, :writable_attributes + + def initialize(model, user, options: {}) + @model = model + @user = user + @options = options + + @errors = ActiveModel::Errors.new(model) + @writable_attributes = [] + end + + def validate + errors.clear + included_contracts.each do |subcontract| + subcontract.validate + errors.merge!(subcontract.errors) + end + + errors.empty? + end + + def valid? + validate + end + + private + + def included_contracts + @included_contracts ||= begin + contracts = self.class.included_contract_classes.map { |klass| klass.new(model, @user, options: @options) } + all_writable_attributes = (writable_attributes + contracts.flat_map(&:writable_attributes)).uniq + contracts.each do |subcontract| + subcontract.writable_attributes.append(*all_writable_attributes) + end + + contracts + end + end +end diff --git a/modules/storages/app/common/storages/peripherals/nextcloud_registry.rb b/modules/storages/app/common/storages/peripherals/nextcloud_registry.rb index 4bb5d62a25b..3c16587afcf 100644 --- a/modules/storages/app/common/storages/peripherals/nextcloud_registry.rb +++ b/modules/storages/app/common/storages/peripherals/nextcloud_registry.rb @@ -61,6 +61,7 @@ module Storages namespace("forms") do register(:automatically_managed_folders, ::Storages::Admin::Forms::AutomaticallyManagedProjectFoldersFormComponent) register(:general_information, ::Storages::Admin::Forms::GeneralInfoFormComponent) + register(:nextcloud_audience, ::Storages::Admin::Forms::NextcloudAudienceFormComponent) register(:oauth_application, ::Storages::Admin::OAuthApplicationInfoCopyComponent) register(:oauth_client, ::Storages::Admin::Forms::OAuthClientFormComponent) end @@ -69,12 +70,15 @@ module Storages register(:automatically_managed_folders, ::Storages::Admin::AutomaticallyManagedProjectFoldersInfoComponent) register(:general_information, ::Storages::Admin::GeneralInfoComponent) + register(:nextcloud_audience, ::Storages::Admin::NextcloudAudienceInfoComponent) register(:oauth_application, ::Storages::Admin::OAuthApplicationInfoComponent) register(:oauth_client, ::Storages::Admin::OAuthClientInfoComponent) end namespace("contracts") do register(:storage, ::Storages::Storages::NextcloudContract) + register(:general_information, ::Storages::Storages::NextcloudGeneralInformationContract) + register(:nextcloud_audience, ::Storages::Storages::NextcloudAudienceContract) end namespace("models") do diff --git a/modules/storages/app/common/storages/peripherals/nextcloud_storage_wizard.rb b/modules/storages/app/common/storages/peripherals/nextcloud_storage_wizard.rb index da4dd958725..2b00127002f 100644 --- a/modules/storages/app/common/storages/peripherals/nextcloud_storage_wizard.rb +++ b/modules/storages/app/common/storages/peripherals/nextcloud_storage_wizard.rb @@ -33,13 +33,24 @@ module Storages class NextcloudStorageWizard < Wizard step :general_information, completed_if: ->(storage) { storage.host.present? && storage.name.present? } + # OAuth 2.0 SSO + + step :nextcloud_audience, + section: :oauth_configuration, + if: ->(storage) { storage.authenticate_via_idp? }, + completed_if: ->(storage) { storage.nextcloud_audience.present? } + + # Two-Way OAuth 2.0 + step :oauth_application, section: :oauth_configuration, + if: ->(storage) { !storage.authenticate_via_idp? }, completed_if: ->(storage) { storage.oauth_application.present? }, preparation: :prepare_oauth_application step :oauth_client, section: :oauth_configuration, + if: ->(storage) { !storage.authenticate_via_idp? }, completed_if: ->(storage) { storage.oauth_client.present? }, preparation: ->(storage) { storage.build_oauth_client } diff --git a/modules/storages/app/common/storages/peripherals/one_drive_registry.rb b/modules/storages/app/common/storages/peripherals/one_drive_registry.rb index 35547090b28..6da98cbb453 100644 --- a/modules/storages/app/common/storages/peripherals/one_drive_registry.rb +++ b/modules/storages/app/common/storages/peripherals/one_drive_registry.rb @@ -69,6 +69,7 @@ module Storages namespace("contracts") do register(:storage, ::Storages::Storages::OneDriveContract) + register(:general_information, ::Storages::Storages::OneDriveContract) end namespace("models") do diff --git a/modules/storages/app/components/storages/admin/forms/general_info_form_component.html.erb b/modules/storages/app/components/storages/admin/forms/general_info_form_component.html.erb index 1fda21239d2..7f00cae1b0d 100644 --- a/modules/storages/app/components/storages/admin/forms/general_info_form_component.html.erb +++ b/modules/storages/app/components/storages/admin/forms/general_info_form_component.html.erb @@ -31,6 +31,12 @@ general_info_row.with_row(mb: 3) do render(Storages::Admin::ProviderHostInputForm.new(form)) end + + if OpenProject::FeatureDecisions.oidc_token_exchange_active? + general_info_row.with_row(mb: 3) do + render(Storages::Admin::NextcloudAuthenticationMethodInputForm.new(form)) + end + end end if storage.provider_type_one_drive? diff --git a/modules/storages/app/components/storages/admin/forms/general_info_form_component.rb b/modules/storages/app/components/storages/admin/forms/general_info_form_component.rb index 354e0bdfcc6..4c4cb5a7a3d 100644 --- a/modules/storages/app/components/storages/admin/forms/general_info_form_component.rb +++ b/modules/storages/app/components/storages/admin/forms/general_info_form_component.rb @@ -32,17 +32,16 @@ module Storages::Admin::Forms class GeneralInfoFormComponent < StorageFormComponent def self.wrapper_key = :storage_general_info_section - options form_method: :post, - submit_button_disabled: false + options submit_button_disabled: false def form_url - query = { continue_wizard: storage.id } if in_wizard + query = { origin_component: "general_information" } + query[:continue_wizard] = storage.id if in_wizard - case form_method - when :get, :post - admin_settings_storages_path(query) - when :patch, :put + if storage.persisted? admin_settings_storage_path(storage, query) + else + admin_settings_storages_path(query) end end @@ -62,6 +61,14 @@ module Storages::Admin::Forms private + def form_method + if storage.persisted? + :patch + else + :post + end + end + def cancel_button_path options.fetch(:cancel_button_path) do if storage.persisted? diff --git a/modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.html.erb b/modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.html.erb new file mode 100644 index 00000000000..b720fe418f7 --- /dev/null +++ b/modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.html.erb @@ -0,0 +1,58 @@ +<%#-- 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. + +++#%> + +<%= + component_wrapper(tag: "turbo-frame") do + render(Primer::Beta::Text.new(tag: :div, test_selector: "storage-nextcloud-audience-form")) do + primer_form_with( + model:, + url: form_url, + method: :patch, + data: { turbo_frame: "page-content" } + ) do |form| + flex_layout do |general_info_row| + general_info_row.with_row(mb: 3) do + render(Storages::Admin::NextcloudAudienceInputForm.new(form)) + end + + general_info_row.with_row do + render( + Storages::Admin::SubmitOrCancelForm.new( + form, + storage:, + submit_button_options:, + cancel_button_options: + ) + ) + end + end + end + end + end +%> diff --git a/modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.rb b/modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.rb new file mode 100644 index 00000000000..1f79f9521d1 --- /dev/null +++ b/modules/storages/app/components/storages/admin/forms/nextcloud_audience_form_component.rb @@ -0,0 +1,58 @@ +# 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::Admin::Forms + class NextcloudAudienceFormComponent < StorageFormComponent + def self.wrapper_key = :storage_nextcloud_audience_section + + options submit_button_disabled: false + + def form_url + query = { origin_component: "nextcloud_audience" } + query[:continue_wizard] = storage.id if in_wizard + + admin_settings_storage_path(storage, query) + end + + def submit_button_options + { disabled: submit_button_disabled } + end + + def cancel_button_options + { href: cancel_button_path, data: { turbo_stream: true } } + end + + private + + def cancel_button_path + edit_admin_settings_storage_path(storage) + end + end +end diff --git a/modules/storages/app/components/storages/admin/nextcloud_audience_info_component.erb b/modules/storages/app/components/storages/admin/nextcloud_audience_info_component.erb new file mode 100644 index 00000000000..8046080c048 --- /dev/null +++ b/modules/storages/app/components/storages/admin/nextcloud_audience_info_component.erb @@ -0,0 +1,69 @@ +<%#-- 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. + +++#%> + +<%= + component_wrapper(tag: 'turbo-frame') do + grid_layout('op-storage-view--row', tag: :div, align_items: :center) do |grid| + grid.with_area(:item, tag: :div, mr: 3) do + concat(render(Primer::Beta::Text.new(font_weight: :bold, mr: 1, test_selector: 'nextcloud-audience-label')) { I18n.t('storages.file_storage_view.nextcloud_audience') }) + concat(configuration_check_label_for(:nextcloud_audience_configured)) + end + + grid.with_area(:description, tag: :div, color: :subtle, test_selector: 'nextcloud-audience-description') do + concat(render(Primer::Beta::Text.new) { + if storage.nextcloud_audience.present? + I18n.t('storages.file_storage_view.nextcloud_audience_description', audience: storage.nextcloud_audience) + else + I18n.t('storages.file_storage_view.nextcloud_audience_blank') + end + }) + end + + if editable_storage? + grid.with_area(:"icon-button", tag: :div, color: :muted) do + flex_layout(justify_content: :flex_end) do |icons_container| + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: :pencil, + tag: :a, + scheme: :invisible, + href: edit_nextcloud_audience_admin_settings_storage_path(storage), + aria: { label: I18n.t('storages.label_edit_nextcloud_audience') }, + test_selector: 'storage-edit-nextcloud-audience-button', + data: { turbo_stream: true } + ) + ) + end + end + end + end + end + end +%> diff --git a/modules/storages/app/components/storages/admin/nextcloud_audience_info_component.rb b/modules/storages/app/components/storages/admin/nextcloud_audience_info_component.rb new file mode 100644 index 00000000000..dc2d030d2dc --- /dev/null +++ b/modules/storages/app/components/storages/admin/nextcloud_audience_info_component.rb @@ -0,0 +1,37 @@ +# 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 Admin + class NextcloudAudienceInfoComponent < StorageInfoComponent + def self.wrapper_key = :storage_nextcloud_audience_section + end + end +end diff --git a/modules/storages/app/components/storages/admin/storage_Info_component.rb b/modules/storages/app/components/storages/admin/storage_info_component.rb similarity index 92% rename from modules/storages/app/components/storages/admin/storage_Info_component.rb rename to modules/storages/app/components/storages/admin/storage_info_component.rb index 3335a401b5e..95546d2945e 100644 --- a/modules/storages/app/components/storages/admin/storage_Info_component.rb +++ b/modules/storages/app/components/storages/admin/storage_info_component.rb @@ -56,8 +56,8 @@ module Storages::Admin end def configuration_check_label_for(*configs) - # do not show the status label, if storage is completely empty (initial state) - return if storage.configuration_checks.values.none? + # do not show the status label, if storage is in initial state + return if storage.new_record? if storage.configuration_checks.slice(*configs.map(&:to_sym)).values.all? status_label(I18n.t(:label_completed), scheme: :success, test_selector: "label-#{configs.join('-')}-status") @@ -71,8 +71,8 @@ module Storages::Admin end def automatically_managed_project_folders_status_label - # do not show the status label, if storage is completely empty (initial state) - return if storage.configuration_checks.values.none? + # do not show the status label, if storage is in initial state + return if storage.new_record? test_selector = "label-managed-project-folders-status" diff --git a/modules/storages/app/contracts/storages/storages/base_contract.rb b/modules/storages/app/contracts/storages/storages/base_contract.rb index ea53150832f..6d1c68ed921 100644 --- a/modules/storages/app/contracts/storages/storages/base_contract.rb +++ b/modules/storages/app/contracts/storages/storages/base_contract.rb @@ -39,8 +39,24 @@ module Storages::Storages class BaseContract < ::BaseContract include ::Storages::Storages::Concerns::ManageStoragesGuarded - attribute :name - validates :name, presence: true, length: { maximum: 255 } + class Factory + def initialize(contract_class, provider_contract) + @contract_class = contract_class + @provider_contract = provider_contract + end + + def new(*, **) + @contract_class.new(*, provider_contract: @provider_contract, **) + end + + delegate :<=, to: :@contract_class + end + + class << self + def with_provider_contract(provider_contract) + Factory.new(self, provider_contract) + end + end attribute :provider_type validates :provider_type, inclusion: { in: Storages::Storage::PROVIDER_TYPES }, allow_nil: false @@ -50,11 +66,16 @@ module Storages::Storages validate :provider_type_strategy, unless: -> { errors.include?(:provider_type) || @options.delete(:skip_provider_type_strategy) } + def initialize(*, provider_contract: nil, **) + super(*, **) + + @provider_contract = provider_contract + end + private def provider_type_strategy - contract = ::Storages::Peripherals::Registry.resolve("#{model.short_provider_type}.contracts.storage") - .new(model, @user, options: @options) + contract = provider_contract.new(model, @user, options: @options) # Append the attributes defined in the internal contract # to the list of writable attributes. @@ -64,10 +85,12 @@ module Storages::Storages validate_and_merge_errors(contract) end - def require_ee_token_for_one_drive - if ::Storages::Storage.one_drive_without_ee_token?(provider_type) - errors.add(:base, I18n.t("api_v3.errors.code_500_missing_enterprise_token")) - end + def provider_contract + @provider_contract || default_provider_contract + end + + def default_provider_contract + ::Storages::Peripherals::Registry.resolve("#{model.short_provider_type}.contracts.storage") end end end diff --git a/modules/storages/app/contracts/storages/storages/create_contract.rb b/modules/storages/app/contracts/storages/storages/create_contract.rb index f884f3e9044..756ecf72f4a 100644 --- a/modules/storages/app/contracts/storages/storages/create_contract.rb +++ b/modules/storages/app/contracts/storages/storages/create_contract.rb @@ -41,5 +41,11 @@ module Storages::Storages errors.add(:creator, :invalid) end end + + def require_ee_token_for_one_drive + if ::Storages::Storage.one_drive_without_ee_token?(provider_type) + errors.add(:base, I18n.t("api_v3.errors.code_500_missing_enterprise_token")) + end + end end end diff --git a/modules/storages/app/contracts/storages/storages/nextcloud_audience_contract.rb b/modules/storages/app/contracts/storages/storages/nextcloud_audience_contract.rb new file mode 100644 index 00000000000..fb6f4669039 --- /dev/null +++ b/modules/storages/app/contracts/storages/storages/nextcloud_audience_contract.rb @@ -0,0 +1,49 @@ +# 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::Storages + class NextcloudAudienceContract < ::ModelContract + attribute :nextcloud_audience + validates :nextcloud_audience, presence: true, if: -> { nextcloud_storage_authenticate_via_idp? } + + # Adding this to allow writing the nextcloud_audience + attribute :provider_fields + + private + + def nextcloud_storage_authenticate_via_idp? + nextcloud_storage? && @model.authenticate_via_idp? + end + + def nextcloud_storage? + @model.is_a?(Storages::NextcloudStorage) + end + end +end diff --git a/modules/storages/app/contracts/storages/storages/nextcloud_automatic_management_contract.rb b/modules/storages/app/contracts/storages/storages/nextcloud_automatic_management_contract.rb new file mode 100644 index 00000000000..99636bcf224 --- /dev/null +++ b/modules/storages/app/contracts/storages/storages/nextcloud_automatic_management_contract.rb @@ -0,0 +1,69 @@ +# 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::Storages + class NextcloudAutomaticManagementContract < ::ModelContract + attribute :automatically_managed + + attribute :username + validates :username, presence: true, if: :nextcloud_storage_automatic_management_enabled? + validates :username, + absence: true, + unless: -> { nextcloud_storage_automatic_management_enabled? || nextcloud_default_storage_username? } + + attribute :password + validates :password, presence: true, if: :nextcloud_storage_automatic_management_enabled? + validates :password, absence: true, unless: :nextcloud_storage_automatic_management_enabled? + + validate do + if nextcloud_storage_automatic_management_enabled? && errors.exclude?(:password) && model.host.present? + NextcloudApplicationCredentialsValidator.new(self).call + end + end + + private + + def nextcloud_storage_automatic_management_enabled? + return false unless nextcloud_storage? + + @model.automatic_management_enabled? + end + + def nextcloud_default_storage_username? + return false unless nextcloud_storage? + + @model.username == @model.provider_fields_defaults[:username] + end + + def nextcloud_storage? + @model.is_a?(Storages::NextcloudStorage) + end + end +end diff --git a/modules/storages/app/contracts/storages/storages/nextcloud_contract.rb b/modules/storages/app/contracts/storages/storages/nextcloud_contract.rb index d9d218132dd..cd45897f685 100644 --- a/modules/storages/app/contracts/storages/storages/nextcloud_contract.rb +++ b/modules/storages/app/contracts/storages/storages/nextcloud_contract.rb @@ -29,57 +29,9 @@ #++ module Storages::Storages - class NextcloudContract < ::ModelContract - attribute :host - validates :host, url: { message: :invalid_host_url }, length: { maximum: 255 } - # Check that a host actually is a storage server. - # But only do so if the validations above for URL were successful. - validates :host, secure_context_uri: true, nextcloud_compatible_host: true, unless: -> { errors.include?(:host) } - - attribute :authentication_method - validates :authentication_method, presence: true, inclusion: { in: ::Storages::NextcloudStorage::AUTHENTICATION_METHODS } - - attribute :nextcloud_audience - validates :nextcloud_audience, presence: true, if: :nextcloud_storage_authenticate_via_idp? - - attribute :automatically_managed - - attribute :username - validates :username, presence: true, if: :nextcloud_storage_automatic_management_enabled? - validates :username, - absence: true, - unless: -> { nextcloud_storage_automatic_management_enabled? || nextcloud_default_storage_username? } - - attribute :password - validates :password, presence: true, if: :nextcloud_storage_automatic_management_enabled? - validates :password, absence: true, unless: :nextcloud_storage_automatic_management_enabled? - - validate do - if nextcloud_storage_automatic_management_enabled? && errors.exclude?(:host) && errors.exclude?(:password) - NextcloudApplicationCredentialsValidator.new(self).call - end - end - - private - - def nextcloud_storage_automatic_management_enabled? - return false unless nextcloud_storage? - - @model.automatic_management_enabled? - end - - def nextcloud_default_storage_username? - return false unless nextcloud_storage? - - @model.username == @model.provider_fields_defaults[:username] - end - - def nextcloud_storage_authenticate_via_idp? - nextcloud_storage? && @model.authenticate_via_idp? - end - - def nextcloud_storage? - @model.is_a?(Storages::NextcloudStorage) - end + class NextcloudContract < ComposedContract + include_contract NextcloudGeneralInformationContract + include_contract NextcloudAudienceContract + include_contract NextcloudAutomaticManagementContract end end diff --git a/modules/storages/app/contracts/storages/storages/nextcloud_general_information_contract.rb b/modules/storages/app/contracts/storages/storages/nextcloud_general_information_contract.rb new file mode 100644 index 00000000000..a9c4a8c673b --- /dev/null +++ b/modules/storages/app/contracts/storages/storages/nextcloud_general_information_contract.rb @@ -0,0 +1,44 @@ +# 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::Storages + class NextcloudGeneralInformationContract < ::ModelContract + attribute :name + validates :name, presence: true, length: { maximum: 255 } + attribute :host + validates :host, url: { message: :invalid_host_url }, length: { maximum: 255 } + # Check that a host actually is a storage server. + # But only do so if the validations above for URL were successful. + validates :host, secure_context_uri: true, nextcloud_compatible_host: true, unless: -> { errors.include?(:host) } + + attribute :authentication_method + validates :authentication_method, presence: true, inclusion: { in: ::Storages::NextcloudStorage::AUTHENTICATION_METHODS } + end +end diff --git a/modules/storages/app/contracts/storages/storages/one_drive_contract.rb b/modules/storages/app/contracts/storages/storages/one_drive_contract.rb index b2355440944..d9c79fbe46a 100644 --- a/modules/storages/app/contracts/storages/storages/one_drive_contract.rb +++ b/modules/storages/app/contracts/storages/storages/one_drive_contract.rb @@ -30,6 +30,8 @@ module Storages::Storages class OneDriveContract < ::ModelContract + attribute :name + validates :name, presence: true, length: { maximum: 255 } attribute :host validates :host, absence: true attribute :tenant_id diff --git a/modules/storages/app/controllers/storages/admin/storages_controller.rb b/modules/storages/app/controllers/storages/admin/storages_controller.rb index 3599b0d72f0..cd90f2138f9 100644 --- a/modules/storages/app/controllers/storages/admin/storages_controller.rb +++ b/modules/storages/app/controllers/storages/admin/storages_controller.rb @@ -45,7 +45,7 @@ class Storages::Admin::StoragesController < ApplicationController # and set the @ variable to the object referenced in the URL. before_action :require_admin before_action :find_model_object, - only: %i[show_oauth_application destroy edit edit_host confirm_destroy update + only: %i[show_oauth_application destroy edit edit_host edit_nextcloud_audience confirm_destroy update change_health_notifications_enabled replace_oauth_application] before_action :ensure_valid_wizard_parameters, only: [:new] before_action :require_ee_token_for_one_drive, only: [:new] @@ -83,8 +83,13 @@ class Storages::Admin::StoragesController < ApplicationController def create service_result = Storages::Storages::CreateService - .new(user: current_user, create_oauth_app: false) - .call(permitted_storage_params) + .new( + user: current_user, + create_oauth_app: false, + contract_class: Storages::Storages::CreateContract.with_provider_contract( + current_step_contract(permitted_storage_params[:provider_type]) + ) + ).call(permitted_storage_params) @storage = service_result.result @@ -112,21 +117,25 @@ class Storages::Admin::StoragesController < ApplicationController def edit_host update_via_turbo_stream( - component: Storages::Admin::Forms::GeneralInfoFormComponent.new( - @storage, - form_method: :patch, - cancel_button_path: edit_admin_settings_storage_path(@storage) - ) + component: Storages::Admin::Forms::GeneralInfoFormComponent.new(@storage) ) respond_with_turbo_streams end + def edit_nextcloud_audience + update_via_turbo_stream(component: Storages::Admin::Forms::NextcloudAudienceFormComponent.new(@storage)) + respond_with_turbo_streams + end + def update # rubocop:disable Metrics/AbcSize + contract_class = Storages::Storages::UpdateContract.with_provider_contract(current_step_contract(@storage)) service_result = ::Storages::Storages::UpdateService - .new(user: current_user, model: @storage) - .call(permitted_storage_params) - @storage = service_result.result + .new( + user: current_user, + model: @storage, + contract_class: + ).call(permitted_storage_params) if service_result.success? if params[:continue_wizard] @@ -135,11 +144,10 @@ class Storages::Admin::StoragesController < ApplicationController redirect_to(edit_admin_settings_storage_path(@storage), status: :see_other) end else + origin_component = params[:origin_component].presence || "general_information" update_via_turbo_stream( - component: Storages::Admin::Forms::GeneralInfoFormComponent.new( + component: ::Storages::Peripherals::Registry.resolve("#{@storage}.components.forms.#{origin_component}").new( @storage, - form_method: :patch, - cancel_button_path: edit_admin_settings_storage_path(@storage), in_wizard: params[:continue_wizard].present? ) ) @@ -243,6 +251,8 @@ class Storages::Admin::StoragesController < ApplicationController .permit("name", "provider_type", "host", + "authentication_method", + "nextcloud_audience", "oauth_client_id", "oauth_client_secret", "tenant_id", @@ -275,4 +285,11 @@ class Storages::Admin::StoragesController < ApplicationController def redirect_to_wizard(storage) redirect_to(new_admin_settings_storage_path(continue_wizard: storage.id), status: :see_other) end + + def current_step_contract(storage) + storage_name = storage.is_a?(String) ? ::Storages::Storage.shorten_provider_type(storage) : storage.to_s + origin_component = params[:origin_component].presence || "general_information" + + ::Storages::Peripherals::Registry.resolve("#{storage_name}.contracts.#{origin_component}") + end end diff --git a/modules/storages/app/forms/storages/admin/nextcloud_audience_input_form.rb b/modules/storages/app/forms/storages/admin/nextcloud_audience_input_form.rb new file mode 100644 index 00000000000..ffd6fd6fb1c --- /dev/null +++ b/modules/storages/app/forms/storages/admin/nextcloud_audience_input_form.rb @@ -0,0 +1,44 @@ +# 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::Admin + class NextcloudAudienceInputForm < ApplicationForm + form do |storage_form| + storage_form.text_field( + name: :nextcloud_audience, + label: I18n.t("activerecord.attributes.storages/nextcloud_storage.nextcloud_audience"), + required: true, + caption: I18n.t("storages.instructions.nextcloud.nextcloud_audience"), + placeholder: I18n.t("storages.instructions.nextcloud.nextcloud_audience_placeholder"), + input_width: :large + ) + end + end +end diff --git a/modules/storages/app/forms/storages/admin/nextcloud_authentication_method_input_form.rb b/modules/storages/app/forms/storages/admin/nextcloud_authentication_method_input_form.rb new file mode 100644 index 00000000000..9a5b13464c4 --- /dev/null +++ b/modules/storages/app/forms/storages/admin/nextcloud_authentication_method_input_form.rb @@ -0,0 +1,48 @@ +# 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::Admin + class NextcloudAuthenticationMethodInputForm < ApplicationForm + form do |storage_form| + storage_form.select_list( + name: :authentication_method, + label: I18n.t("activerecord.attributes.storages/storage.authentication_method"), + visually_hide_label: false, + required: true, + caption: I18n.t("storages.instructions.authentication_method"), + input_width: :large + ) do |list| + ::Storages::NextcloudStorage::AUTHENTICATION_METHODS.each do |m| + list.option(label: I18n.t("activerecord.attributes.storages/nextcloud_storage.authentication_methods.#{m}"), value: m) + end + end + end + end +end diff --git a/modules/storages/app/models/storages/nextcloud_storage.rb b/modules/storages/app/models/storages/nextcloud_storage.rb index 6fd5cbc2a52..67012bb3233 100644 --- a/modules/storages/app/models/storages/nextcloud_storage.rb +++ b/modules/storages/app/models/storages/nextcloud_storage.rb @@ -78,7 +78,8 @@ module Storages { storage_oauth_client_configured: oauth_client.present?, openproject_oauth_application_configured: oauth_application.present?, - host_name_configured: host.present? && name.present? + host_name_configured: host.present? && name.present?, + nextcloud_audience_configured: !authenticate_via_idp? || nextcloud_audience.present? } end diff --git a/modules/storages/app/services/storages/storages/create_service.rb b/modules/storages/app/services/storages/storages/create_service.rb index 68337086e64..fcaef35b5d6 100644 --- a/modules/storages/app/services/storages/storages/create_service.rb +++ b/modules/storages/app/services/storages/storages/create_service.rb @@ -50,7 +50,7 @@ module Storages::Storages storage = service_call.result # Automatically create an OAuthApplication object for the Nextcloud storage # using values from storage (particularly :host) as defaults - if storage.provider_type_nextcloud? + if storage.provider_type_nextcloud? && !storage.authenticate_via_idp? persist_service_result = ::Storages::OAuthApplications::CreateService.new(storage:, user:).call storage.oauth_application = persist_service_result.result if persist_service_result.success? service_call.add_dependent!(persist_service_result) diff --git a/modules/storages/config/locales/en.yml b/modules/storages/config/locales/en.yml index 1d87ee7fa9a..d374a5c650d 100644 --- a/modules/storages/config/locales/en.yml +++ b/modules/storages/config/locales/en.yml @@ -4,9 +4,15 @@ en: attributes: storages/file_link: origin_id: Origin Id + storages/nextcloud_storage: + authentication_methods: + oauth2_sso: Single-Sign-On through OpenID Connect Identity Provider + two_way_oauth2: Two-way OAuth 2.0 authorization code flow + nextcloud_audience: Nextcloud Audience storages/project_storage: storage: Storage storages/storage: + authentication_method: Authentication Method creator: Creator drive: Drive ID host: Host @@ -203,6 +209,9 @@ en: access_management_section: Access and project folders automatically_managed_folders: Automatically managed folders general_information: General information + nextcloud_audience: Nextcloud Audience + nextcloud_audience_blank: No audience has been configured + nextcloud_audience_description: Using audience %{audience} nextcloud_oauth: Nextcloud OAuth oauth_configuration: OAuth configuration one_drive_oauth: Azure OAuth @@ -254,6 +263,7 @@ en: project_folder_bulk: The project folder is the default folder for file uploads for all the projects selected. You can modify this individually in each project settings. Users can nevertheless still upload files to other locations. instructions: all_available_storages_already_added: All available storages are already added to the project. + authentication_method: The way that requests between OpenProject and the Storage are authenticated. automatic_folder: This will automatically create a root folder for this project and manage the access permissions for each project member. empty_project_folder_validation: Selecting a folder is mandatory to proceed. existing_manual_folder: You can designate an existing folder as the root folder for this project. The permissions are however not automatically managed, the administrator needs to manually ensure relevant users have access. The selected folder can be used by multiple projects. @@ -264,6 +274,8 @@ en: nextcloud: application_link_text: application “Integration OpenProject” integration: Nextcloud Administration / OpenProject + nextcloud_audience: Please enter the Client ID that the Nextcloud instance uses to communicate with the identity provider. + nextcloud_audience_placeholder: e.g. nextcloud oauth_configuration: Copy these values from %{application_link_text}. provider_configuration: Please make sure you have administration privileges in your Nextcloud instance and the %{application_link_text} is installed before doing the setup. no_specific_folder: By default, each user will start at their own home folder when they upload a file. @@ -296,6 +308,7 @@ en: label_creation_time: Creation time label_creator: Creator label_delete_storage: Delete storage + label_edit_nextcloud_audience: Edit Nextcloud Audience label_edit_storage_access_management: Edit storage access management label_edit_storage_automatically_managed_folders: Edit storage automatically managed folders label_edit_storage_host: Edit storage host diff --git a/modules/storages/config/routes.rb b/modules/storages/config/routes.rb index fe346ef8127..54878cbbb89 100644 --- a/modules/storages/config/routes.rb +++ b/modules/storages/config/routes.rb @@ -62,6 +62,7 @@ Rails.application.routes.draw do member do get :show_oauth_application get :edit_host + get :edit_nextcloud_audience patch :change_health_notifications_enabled get :confirm_destroy delete :replace_oauth_application diff --git a/modules/storages/spec/common/storages/peripherals/nextcloud_storage_wizard_spec.rb b/modules/storages/spec/common/storages/peripherals/nextcloud_storage_wizard_spec.rb index d3f4b0a781e..6af89ee88fe 100644 --- a/modules/storages/spec/common/storages/peripherals/nextcloud_storage_wizard_spec.rb +++ b/modules/storages/spec/common/storages/peripherals/nextcloud_storage_wizard_spec.rb @@ -51,10 +51,11 @@ RSpec.describe Storages::Peripherals::NextcloudStorageWizard do automatically_managed_folders]) end - context "when name and host were set" do + context "when name and host were set and authentication method is Two-Way OAuth 2.0" do before do model.name = "Karl" model.host = "https://nextcloud.local/" + model.authentication_method = "two_way_oauth2" model.save! end @@ -131,4 +132,61 @@ RSpec.describe Storages::Peripherals::NextcloudStorageWizard do end end end + + context "when name and host were set and authentication method is OAuth 2.0 SSO" do + before do + model.name = "Karl" + model.host = "https://nextcloud.local/" + model.authentication_method = "oauth2_sso" + model.save! + end + + it "has general_information step completed" do + expect(wizard.completed_steps).to eq(%i[general_information]) + end + + it "has new steps pending in correct order" do + expect(wizard.pending_steps).to eq(%i[nextcloud_audience + automatically_managed_folders]) + end + + context "and the nextcloud audience was set" do + before do + model.nextcloud_audience = "nextcloud" + end + + it "finished the nextcloud_audience step" do + expect(wizard.completed_steps).to eq(%i[general_information + nextcloud_audience]) + end + + it "still didn't specify how to manage folders" do + expect(model).to be_automatic_management_unspecified + end + + context "and after preparing the next step" do + before do + wizard.prepare_next_step + end + + it "enabled automatic storage management, but didn't persist it" do + expect(model).to be_automatic_management_enabled + + before, after = model.changes["provider_fields"] + expect(before.keys).not_to include("automatically_managed") + expect(after.keys).to include("automatically_managed") + end + + it "has no pending steps" do + expect(wizard.pending_steps).to be_empty + end + + it "has all steps completed" do + expect(wizard.completed_steps).to eq(%i[general_information + nextcloud_audience + automatically_managed_folders]) + end + end + end + end end diff --git a/modules/storages/spec/contracts/storages/storages/shared_contract_examples.rb b/modules/storages/spec/contracts/storages/storages/shared_contract_examples.rb index 4ffb31a273f..cb5f7935f84 100644 --- a/modules/storages/spec/contracts/storages/storages/shared_contract_examples.rb +++ b/modules/storages/spec/contracts/storages/storages/shared_contract_examples.rb @@ -343,4 +343,10 @@ RSpec.shared_examples_for "nextcloud storage contract", :storage_server_helpers, end end end + + describe ".with_provider_contract" do + it "looks like it descends from Storages::Storages::BaseContract" do + expect(described_class.with_provider_contract(instance_double(Class))).to be <= Storages::Storages::BaseContract + end + end end diff --git a/modules/storages/spec/features/storages/admin/create_storage_spec.rb b/modules/storages/spec/features/storages/admin/create_storage_spec.rb index fbf5450c971..4f49bef352b 100644 --- a/modules/storages/spec/features/storages/admin/create_storage_spec.rb +++ b/modules/storages/spec/features/storages/admin/create_storage_spec.rb @@ -46,7 +46,7 @@ RSpec.describe "Admin Create a new file storage", allow(Doorkeeper::OAuth::Helpers::UniqueToken).to receive(:generate).and_return(secret) end - it "renders a Nextcloud specific multi-step form", :webmock do + it "renders a Nextcloud specific multi-step form for Two-Way OAuth 2.0 by default", :webmock do visit admin_settings_storages_path expect(page).to be_axe_clean.within "#content" @@ -58,7 +58,7 @@ RSpec.describe "Admin Create a new file storage", expect(page).to have_current_path(new_admin_settings_storage_path(provider: "nextcloud")) - aggregate_failures "Select provider view" do + aggregate_failures "New provider view" do # Page Header expect(page).to have_test_selector("storage-new-page-header--title", text: "New Nextcloud storage") expect(page).to have_test_selector("storage-new-page-header--description", @@ -188,6 +188,81 @@ RSpec.describe "Admin Create a new file storage", ) end end + + it "renders a Nextcloud specific multi-step form when using OAuth 2.0 SSO", :webmock, with_flag: :oidc_token_exchange do + # Same setup as in default case, but without expectations + visit admin_settings_storages_path + + within(".SubHeader") do + page.find_test_selector("storages-create-new-provider-button").click + within_test_selector("storages-select-provider-action-menu") { click_on("Nextcloud") } + end + + within_test_selector("storage-general-info-form") do + fill_in "Name", with: "My Nextcloud", fill_options: { clear: :backspace } + + mock_server_capabilities_response("https://example.com") + mock_server_config_check_response("https://example.com") + fill_in "Host", with: "https://example.com" + + select "Single-Sign-On through OpenID Connect Identity Provider", from: "Authentication Method" + + click_on "Save and continue" + end + + aggregate_failures "Nextcloud Audience" do + within_test_selector("storage-nextcloud-audience-form") do + click_on "Save and continue" + expect(page).to have_text("Nextcloud Audience can't be blank.") + + fill_in "Nextcloud Audience", with: "nextcloud" + click_on "Save and continue" + end + + expect(page).to have_test_selector("label-nextcloud_audience_configured-status", text: "Completed") + expect(page).to have_test_selector("nextcloud-audience-description", text: "Using audience nextcloud") + end + + aggregate_failures "Automatically managed project folders" do + within_test_selector("storage-automatically-managed-project-folders-form") do + automatically_managed_switch = page.find('[name="storages_nextcloud_storage[automatic_management_enabled]"]') + application_password_input = page.find_by_id("storages_nextcloud_storage_password") + expect(automatically_managed_switch).to be_checked + expect(application_password_input.value).to be_empty + + # Clicking submit with application password empty should show an error + click_on("Done, complete setup") + expect(page).to have_text("Password can't be blank.") + + # Test the error path for an invalid storage password. + # Mock a valid response (=401) for example.com, so the password validation should fail + mock_nextcloud_application_credentials_validation("https://example.com", password: "1234567890", + response_code: 401) + automatically_managed_switch = page.find('[name="storages_nextcloud_storage[automatic_management_enabled]"]') + expect(automatically_managed_switch).to be_checked + fill_in "Application password", with: "1234567890" + # Clicking submit with application password empty should show an error + click_on("Done, complete setup") + expect(page).to have_text("Password is not valid.") + + # Test the happy path for a valid storage password. + # Mock a valid response (=200) for example.com, so the password validation should succeed + # Fill in application password and submit + mock_nextcloud_application_credentials_validation("https://example.com", password: "1234567890") + automatically_managed_switch = page.find('[name="storages_nextcloud_storage[automatic_management_enabled]"]') + expect(automatically_managed_switch).to be_checked + fill_in "Application password", with: "1234567890" + click_on("Done, complete setup") + end + + expect(page).to have_current_path(edit_admin_settings_storage_path(Storages::Storage.last)) + expect(page).to have_test_selector( + "op-primer-flash-message", + text: "Storage connected successfully! " \ + "Remember to activate the storage in the Projects tab for each desired project to use it." + ) + end + end end context "with OneDrive Storage and enterprise token missing", with_ee: false do @@ -221,7 +296,7 @@ RSpec.describe "Admin Create a new file storage", expect(page).to have_current_path(new_admin_settings_storage_path(provider: "one_drive")) - aggregate_failures "Select provider view" do + aggregate_failures "New provider view" do # Page Header expect(page).to have_test_selector("storage-new-page-header--title", text: "New OneDrive/SharePoint storage") expect(page).to have_test_selector("storage-new-page-header--description", diff --git a/modules/storages/spec/features/storages/admin/edit_storage_spec.rb b/modules/storages/spec/features/storages/admin/edit_storage_spec.rb index 86bc6a3d86d..1dab17fae53 100644 --- a/modules/storages/spec/features/storages/admin/edit_storage_spec.rb +++ b/modules/storages/spec/features/storages/admin/edit_storage_spec.rb @@ -59,7 +59,7 @@ RSpec.describe "Admin Edit File storage", expect(page).to have_current_path(admin_settings_storages_path) end - context "with Nextcloud Storage" do + context "with Two-Way OAuth Nextcloud Storage" do let(:storage) { create(:nextcloud_storage, :as_automatically_managed, name: "Cloud Storage") } let(:oauth_application) { create(:oauth_application, integration: storage) } let(:oauth_client) { create(:oauth_client, integration: storage) } @@ -241,6 +241,94 @@ RSpec.describe "Admin Edit File storage", end end + context "with OAuth 2.0 SSO Nextcloud Storage" do + let(:storage) do + create( + :nextcloud_storage, + :as_automatically_managed, + authentication_method: "oauth2_sso", + nextcloud_audience: "", + name: "Cloud Storage" + ) + end + let(:secret) { "awesome_secret" } + + before do + allow(Doorkeeper::OAuth::Helpers::UniqueToken).to receive(:generate).and_return(secret) + end + + it "renders an edit view", :webmock do + visit edit_admin_settings_storage_path(storage) + + expect(page).to be_axe_clean + .within("#content") + # NB: Heading order is pending app wide update. See https://community.openproject.org/projects/openproject/work_packages/48513 + .skipping("heading-order") + + expect(page).to have_test_selector("storage-new-page-header--title", text: "Cloud Storage (Nextcloud)") + + aggregate_failures "Storage edit view" do + # General information + expect(page).to have_test_selector("storage-provider-label", text: "Storage provider") + expect(page).to have_test_selector("label-host_name_configured-status", text: "Completed") + expect(page).to have_test_selector("storage-description", text: "Nextcloud - #{storage.name} - #{storage.host}") + + # Nextcloud audience + expect(page).to have_test_selector("nextcloud-audience-label", text: "Nextcloud Audience") + expect(page).to have_test_selector("label-nextcloud_audience_configured-status", text: "Incomplete") + expect(page).to have_test_selector("nextcloud-audience-description", text: "No audience has been configured") + + # Automatically managed project folders + expect(page).to have_test_selector("storage-managed-project-folders-label", + text: "Automatically managed folders") + + expect(page).to have_test_selector("label-managed-project-folders-status", text: "Active") + expect(page).to have_test_selector("storage-automatically-managed-project-folders-description", + text: "Let OpenProject create folders per project automatically.") + end + + # Only testing interaction with components not tested + # in Two-Way OAuth 2.0 case + + aggregate_failures "Nextcloud Audience" do + find_test_selector("storage-edit-nextcloud-audience-button").click + within_test_selector("storage-nextcloud-audience-form") do + click_on "Save and continue" + expect(page).to have_text("Nextcloud Audience can't be blank") + + fill_in "Nextcloud Audience", with: "schmaudience" + click_on "Save and continue" + end + + expect(page).to have_test_selector("label-nextcloud_audience_configured-status", text: "Completed") + expect(page).to have_test_selector("nextcloud-audience-description", text: "Using audience schmaudience") + end + end + + it "renders a sidebar component" do + visit edit_admin_settings_storage_path(storage) + + aggregate_failures "Health status" do + expect(page).to have_test_selector("validation-result--subtitle", text: "Connection validation") + expect(page).to have_test_selector("storage-health-status", text: "Pending") + end + + aggregate_failures "Health notifications" do + expect(page).to have_test_selector("storage-health-notifications-button", text: "Unsubscribe") + expect(page).to have_test_selector("storage-health-notifications-description", + text: "All administrators receive health status email notifications for this storage.") + + click_on "Unsubscribe" + + expect(page).to have_test_selector("storage-health-notifications-button", text: "Subscribe") + expect(page).to have_test_selector( + "storage-health-notifications-description", + text: "Health status email notifications for this storage have been turned off for all administrators." + ) + end + end + end + context "with Nextcloud Storage and not automatically managed" do let(:storage) { create(:nextcloud_storage, :as_not_automatically_managed, name: "Cloud Storage") } diff --git a/modules/storages/spec/models/storages/nextcloud_storage_spec.rb b/modules/storages/spec/models/storages/nextcloud_storage_spec.rb index 8d7c44de404..ec83f266aa7 100644 --- a/modules/storages/spec/models/storages/nextcloud_storage_spec.rb +++ b/modules/storages/spec/models/storages/nextcloud_storage_spec.rb @@ -65,6 +65,7 @@ RSpec.describe Storages::NextcloudStorage do aggregate_failures "configuration_checks" do expect(storage.configuration_checks) .to eq(host_name_configured: true, + nextcloud_audience_configured: true, storage_oauth_client_configured: true, openproject_oauth_application_configured: true) end @@ -84,6 +85,43 @@ RSpec.describe Storages::NextcloudStorage do end end + context "without nextcloud audience" do + let(:storage) do + build(:nextcloud_storage, + nextcloud_audience: "", + oauth_application: build(:oauth_application), + oauth_client: build(:oauth_client)) + end + + it "returns true" do + aggregate_failures do + expect(storage.configured?).to be(true) + aggregate_failures "configuration_checks" do + expect(storage.configuration_checks[:nextcloud_audience_configured]).to be(true) + end + end + end + + context "when storage authenticates through IDP" do + let(:storage) do + build(:nextcloud_storage, + authentication_method: "oauth2_sso", + nextcloud_audience: "", + oauth_application: build(:oauth_application), + oauth_client: build(:oauth_client)) + end + + it "returns false" do + aggregate_failures do + expect(storage.configured?).to be(false) + aggregate_failures "configuration_checks" do + expect(storage.configuration_checks[:nextcloud_audience_configured]).to be(false) + end + end + end + end + end + context "without openproject and storage integrations" do let(:storage) { build(:nextcloud_storage) } diff --git a/modules/storages/spec/services/storages/storages/create_service_spec.rb b/modules/storages/spec/services/storages/storages/create_service_spec.rb index 4ba04f9d56a..68345f40d3b 100644 --- a/modules/storages/spec/services/storages/storages/create_service_spec.rb +++ b/modules/storages/spec/services/storages/storages/create_service_spec.rb @@ -35,7 +35,7 @@ require "services/base_services/behaves_like_create_service" RSpec.describe Storages::Storages::CreateService, type: :model do it_behaves_like "BaseServices create service" do - let(:factory) { :storage } + let(:factory) { :nextcloud_storage } let!(:user) { create(:admin) } diff --git a/spec/contracts/composed_contract_spec.rb b/spec/contracts/composed_contract_spec.rb new file mode 100644 index 00000000000..94972beae2f --- /dev/null +++ b/spec/contracts/composed_contract_spec.rb @@ -0,0 +1,99 @@ +# 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" + +RSpec.describe ComposedContract do + subject(:composed_contract) do + # Binding methods to local variables so they are accessible inside the newly defined class + a = contract_class_a + b = contract_class_b + Class.new(described_class) do + include_contract a + include_contract b + end.new(model, user) + end + + let(:contract_class_a) do + Class.new(ModelContract) do + attribute :subject + validates :subject, format: /B/ + end + end + let(:contract_class_b) do + Class.new(ModelContract) do + attribute :description + validates :description, format: /E/ + end + end + + let(:model) do + create :work_package + end + let(:title) { "ABC" } + let(:description) { "DEF" } + + let(:user) { build(:user) } + + before do + model.save! + model.assign_attributes(subject: title, description:) + end + + it { is_expected.to be_valid } + + context "when contract A is invalid" do + let(:title) { "XYZ" } + + it { is_expected.not_to be_valid } + end + + context "when contract B is invalid" do + let(:description) { "XYZ" } + + it { is_expected.not_to be_valid } + end + + context "when changing an attribute not allowed by any subcontract" do + before do + model.done_ratio = 50 + end + + it { is_expected.not_to be_valid } + + context "and when the attribute was allowed on the composed contract" do + before do + composed_contract.writable_attributes << "done_ratio" + end + + it { is_expected.to be_valid } + end + end +end