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.
This commit is contained in:
Jan Sandbrink
2025-01-22 09:16:09 +01:00
parent 8237ad601c
commit d385b0437f
32 changed files with 1071 additions and 93 deletions
+94
View File
@@ -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
@@ -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
@@ -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 }
@@ -69,6 +69,7 @@ module Storages
namespace("contracts") do
register(:storage, ::Storages::Storages::OneDriveContract)
register(:general_information, ::Storages::Storages::OneDriveContract)
end
namespace("models") do
@@ -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?
@@ -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?
@@ -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
%>
@@ -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
@@ -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
%>
@@ -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
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -45,7 +45,7 @@ class Storages::Admin::StoragesController < ApplicationController
# and set the @<controller_name> 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
@@ -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
@@ -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
@@ -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
@@ -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)
+13
View File
@@ -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
+1
View File
@@ -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
@@ -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
@@ -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
@@ -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",
@@ -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") }
@@ -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) }
@@ -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) }
+99
View File
@@ -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