diff --git a/Gemfile b/Gemfile index 35b9e21a8add..ea8c8d5f4dd0 100644 --- a/Gemfile +++ b/Gemfile @@ -210,6 +210,7 @@ gem "validate_url" # Storages support code gem "dry-container" +gem "dry-monads" # ActiveRecord extension which adds typecasting to store accessors gem "store_attribute", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index 008b0185db80..ead74d2345ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -476,6 +476,10 @@ GEM concurrent-ruby (~> 1.0) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) + dry-monads (1.6.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) dry-types (1.7.2) bigdecimal (~> 3.0) concurrent-ruby (~> 1.0) @@ -1195,6 +1199,7 @@ DEPENDENCIES doorkeeper (~> 5.7.0) dotenv-rails dry-container + dry-monads email_validator (~> 2.2.3) equivalent-xml (~> 0.6) erb_lint diff --git a/modules/storages/app/common/storages/peripherals/one_drive_connection_validator.rb b/modules/storages/app/common/storages/peripherals/one_drive_connection_validator.rb new file mode 100644 index 000000000000..0d53bc9cb960 --- /dev/null +++ b/modules/storages/app/common/storages/peripherals/one_drive_connection_validator.rb @@ -0,0 +1,162 @@ +# frozen_string_literal:true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + module Peripherals + class OneDriveConnectionValidator + include Dry::Monads[:maybe] + + using ServiceResultRefinements + + def initialize(storage:) + @storage = storage + end + + def validate + maybe_is_not_configured + .or { tenant_id_wrong } + .or { client_id_wrong } + .or { client_secret_wrong } + .or { drive_id_wrong } + .or { request_failed_with_unknown_error } + .or { drive_with_unexpected_content } + .value_or(ConnectionValidation.new(type: :healthy, + error_code: :none, + timestamp: Time.current, + description: nil)) + end + + private + + def query + @query ||= Peripherals::Registry + .resolve("#{@storage.short_provider_type}.queries.files") + .call(storage: @storage, auth_strategy:, folder: root_folder) + end + + def maybe_is_not_configured + return None() if @storage.configured? + + Some(ConnectionValidation.new(type: :none, + error_code: :wrn_not_configured, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.not_configured"))) + end + + def drive_id_wrong + return None() if query.result != :not_found + + Some(ConnectionValidation.new(type: :error, + error_code: :err_drive_invalid, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.drive_id_wrong"))) + end + + def tenant_id_wrong + return None() if query.result != :unauthorized + + payload = JSON.parse(query.error_payload) + return None() if payload["error"] != "invalid_request" + + tenant_id_string = "Tenant '#{@storage.tenant_id}' not found." + return None() unless payload["error_description"].include?(tenant_id_string) + + Some(ConnectionValidation.new(type: :error, + error_code: :err_tenant_invalid, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.tenant_id_wrong"))) + end + + def client_id_wrong + return None() if query.result != :unauthorized + + payload = JSON.parse(query.error_payload) + return None() if payload["error"] != "unauthorized_client" + + Some(ConnectionValidation.new(type: :error, + error_code: :err_client_invalid, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.client_id_wrong"))) + end + + def client_secret_wrong + return None() if query.result != :unauthorized + + payload = JSON.parse(query.error_payload) + return None() if payload["error"] != "invalid_client" + + Some(ConnectionValidation.new(type: :error, + error_code: :err_client_invalid, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.client_secret_wrong"))) + end + + # rubocop:disable Metrics/AbcSize + def drive_with_unexpected_content + return None() if query.failure? + return None() unless @storage.automatic_management_enabled? + + expected_folder_ids = @storage.project_storages + .where(project_folder_mode: "automatic") + .map(&:project_folder_id) + + unexpected_files = query.result.files.reject { |file| expected_folder_ids.include?(file.id) } + return None() if unexpected_files.empty? + + Some(ConnectionValidation.new(type: :warning, + error_code: :wrn_unexpected_content, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.unexpected_content"))) + end + + # rubocop:enable Metrics/AbcSize + + def request_failed_with_unknown_error + return None() if query.success? + + Rails.logger.error("Connection validation failed with unknown error:\n\t" \ + "status: #{query.result}\n\tresponse: #{query.error_payload}") + + Some(ConnectionValidation.new(type: :error, + error_code: :err_unknown, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.unknown_error"))) + end + + def root_folder + Peripherals::ParentFolder.new("/") + end + + def auth_strategy + Peripherals::Registry.resolve("#{@storage.short_provider_type}.authentication.userless").call + end + end + end +end diff --git a/modules/storages/app/components/storages/admin/sidebar/health_status_component.html.erb b/modules/storages/app/components/storages/admin/sidebar/health_status_component.html.erb index 013a12725891..81159ee25382 100644 --- a/modules/storages/app/components/storages/admin/sidebar/health_status_component.html.erb +++ b/modules/storages/app/components/storages/admin/sidebar/health_status_component.html.erb @@ -31,34 +31,53 @@ See COPYRIGHT and LICENSE files for more details. component_wrapper do flex_layout do |health_status_container| health_status_container.with_row do - flex_layout do |heading| - heading.with_row do - render(Primer::Beta::Heading.new(tag: :h4)) { I18n.t("storages.health.title") } - end + render(Primer::Beta::Heading.new(tag: :h4)) { I18n.t("storages.health.title") } + end - heading.with_row(mt: 2) do - render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t("storages.health.subtitle") } + if @storage.provider_type_one_drive? + health_status_container.with_row(mt: 2) do + render(Storages::Admin::Sidebar::ValidationResultComponent.new(result: validation_result_placeholder)) + end + + health_status_container.with_row(mt: 2) do + primer_form_with( + model: @storage, + url: validate_connection_admin_settings_storage_connection_validation_path(@storage), + method: :post, + data: { turbo: true } + ) do + render(Primer::Beta::Button.new( + scheme: :link, + color: :default, + font_weight: :bold, + type: :submit, + )) do |button| + button.with_leading_visual_icon(icon: "meter") + I18n.t("storages.health.connection_validation.action") + end end end end - health_status_container.with_row(mt: 2) do - flex_layout do |health_status_label| - health_status_label.with_row do - concat(render(Primer::Beta::Text.new(pr: 2, test_selector: "storage-health-checked-at")) do - I18n.t('storages.health.checked', datetime: helpers.format_time(@storage.health_checked_at)) - end) + if @storage.automatic_management_enabled? + health_status_container.with_row(mt: 2) do + render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t("storages.health.project_folders.subtitle") } + end - concat(render(Primer::Beta::Label.new(scheme: health_status_indicator[:scheme], test_selector: "storage-health-status")) do - health_status_indicator[:label] - end) - end + health_status_container.with_row(mt: 2) do + concat(render(Primer::Beta::Text.new(pr: 2, test_selector: "storage-health-checked-at")) do + I18n.t('storages.health.checked', datetime: helpers.format_time(@storage.health_checked_at)) + end) + + concat(render(Primer::Beta::Label.new(scheme: health_status_indicator[:scheme], test_selector: "storage-health-status")) do + health_status_indicator[:label] + end) + end - if @storage.health_unhealthy? - health_status_label.with_row(mt: 2) do - render(Primer::Beta::Text.new(color: :muted, test_selector: "storage-health-error")) do - formatted_health_reason - end + if @storage.health_unhealthy? + health_status_container.with_row(mt: 2) do + render(Primer::Beta::Text.new(color: :muted, test_selector: "storage-health-error")) do + formatted_health_reason end end end diff --git a/modules/storages/app/components/storages/admin/sidebar/health_status_component.rb b/modules/storages/app/components/storages/admin/sidebar/health_status_component.rb index 42e05f958ca9..97a3383375a9 100644 --- a/modules/storages/app/components/storages/admin/sidebar/health_status_component.rb +++ b/modules/storages/app/components/storages/admin/sidebar/health_status_component.rb @@ -27,36 +27,47 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -# -module Storages::Admin - class Sidebar::HealthStatusComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - def initialize(storage:) - super(storage) - @storage = storage - end +module Storages + module Admin + module Sidebar + class HealthStatusComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers - private + def initialize(storage:) + super(storage) + @storage = storage + end - def health_status_indicator - case @storage.health_status - when "healthy" - { scheme: :success, label: I18n.t("storages.health.label_healthy") } - when "unhealthy" - { scheme: :danger, label: I18n.t("storages.health.label_error") } - else - { scheme: :attention, label: I18n.t("storages.health.label_pending") } - end - end + private - # This method returns the health identifier, description and the time since when the error occurs in a - # formatted manner. e.g. "Not found: Outbound request destination not found since 12/07/2023 03:45 PM" - def formatted_health_reason - "#{@storage.health_reason_identifier.tr('_', ' ').strip.capitalize}: #{@storage.health_reason_description} " + - I18n.t("storages.health.since", datetime: helpers.format_time(@storage.health_changed_at)) + def health_status_indicator + case @storage.health_status + when "healthy" + { scheme: :success, label: I18n.t("storages.health.label_healthy") } + when "unhealthy" + { scheme: :danger, label: I18n.t("storages.health.label_error") } + else + { scheme: :attention, label: I18n.t("storages.health.label_pending") } + end + end + + # This method returns the health identifier, description and the time since when the error occurs in a + # formatted manner. e.g. "Not found: Outbound request destination not found since 12/07/2023 03:45 PM" + def formatted_health_reason + "#{@storage.health_reason_identifier.tr('_', ' ').strip.capitalize}: #{@storage.health_reason_description} " + + I18n.t("storages.health.since", datetime: helpers.format_time(@storage.health_changed_at)) + end + + def validation_result_placeholder + ConnectionValidation.new(type: :none, + error_code: :none, + timestamp: Time.current, + description: I18n.t("storages.health.connection_validation.placeholder")) + end + end end end end diff --git a/modules/storages/app/components/storages/admin/sidebar/validation_result_component.html.erb b/modules/storages/app/components/storages/admin/sidebar/validation_result_component.html.erb new file mode 100644 index 000000000000..b50ef2442045 --- /dev/null +++ b/modules/storages/app/components/storages/admin/sidebar/validation_result_component.html.erb @@ -0,0 +1,61 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 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 do + flex_layout do |container| + container.with_row do + render(Primer::Beta::Text.new(font_weight: :bold, test_selector: "validation-result--subtitle")) do + I18n.t("storages.health.connection_validation.subtitle") + end + end + + if @result.validation_result_exists? + container.with_row(mt: 2) do + status = status_indicator + + concat(render(Primer::Beta::Text.new(pr: 2, test_selector: "validation-result--timestamp")) do + I18n.t('storages.health.checked', datetime: helpers.format_time(@result.timestamp)) + end) + concat(render(Primer::Beta::Label.new(scheme: status[:scheme])) { status[:label] }) + end + end + + if @result.description.present? + prefix = @result.error_code? ? "#{@result.error_code.upcase}: " : "" + + container.with_row(mt: 2) do + render(Primer::Beta::Text.new(color: :muted, test_selector: "validation-result--description")) do + prefix + @result.description + end + end + end + end + end +%> diff --git a/modules/storages/app/components/storages/admin/sidebar/validation_result_component.rb b/modules/storages/app/components/storages/admin/sidebar/validation_result_component.rb new file mode 100644 index 000000000000..f145051b2929 --- /dev/null +++ b/modules/storages/app/components/storages/admin/sidebar/validation_result_component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 + module Sidebar + class ValidationResultComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(result:) + super(result) + @result = result + end + + private + + def status_indicator + case @result.type + when :healthy + { scheme: :success, label: I18n.t("storages.health.label_healthy") } + when :warning + { scheme: :attention, label: I18n.t("storages.health.label_warning") } + when :error + { scheme: :danger, label: I18n.t("storages.health.label_error") } + else + raise ArgumentError, "Unknown validation result type for status indicator: #{@result.type}" + end + end + end + end + end +end diff --git a/modules/storages/app/components/storages/admin/sidebar_component.html.erb b/modules/storages/app/components/storages/admin/sidebar_component.html.erb index 3b19f1e32f1b..918d19b50396 100644 --- a/modules/storages/app/components/storages/admin/sidebar_component.html.erb +++ b/modules/storages/app/components/storages/admin/sidebar_component.html.erb @@ -2,7 +2,10 @@ component_wrapper do render(Primer::OpenProject::BorderGrid.new) do |border_grid| border_grid.with_row { render(Storages::Admin::Sidebar::HealthStatusComponent.new(storage: @storage)) } - border_grid.with_row { render(Storages::Admin::Sidebar::HealthNotificationsComponent.new(storage: @storage)) } + + if @storage.automatic_management_enabled? + border_grid.with_row { render(Storages::Admin::Sidebar::HealthNotificationsComponent.new(storage: @storage)) } + end end end %> diff --git a/modules/storages/app/controllers/storages/admin/connection_validation_controller.rb b/modules/storages/app/controllers/storages/admin/connection_validation_controller.rb new file mode 100644 index 000000000000..fd758c0ea165 --- /dev/null +++ b/modules/storages/app/controllers/storages/admin/connection_validation_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 ConnectionValidationController < ApplicationController + include OpTurbo::ComponentStream + + layout "admin" + + before_action :require_admin + + model_object OneDriveStorage + + before_action :find_model_object, only: %i[validate_connection] + + def validate_connection + @result = Peripherals::OneDriveConnectionValidator + .new(storage: @storage) + .validate + update_via_turbo_stream(component: Sidebar::ValidationResultComponent.new(result: @result)) + respond_to_with_turbo_streams + end + + private + + def find_model_object(object_id = :storage_id) + super + @storage = @object + end + end + end +end diff --git a/modules/storages/app/models/storages/connection_validation.rb b/modules/storages/app/models/storages/connection_validation.rb new file mode 100644 index 000000000000..a9ad82f087d8 --- /dev/null +++ b/modules/storages/app/models/storages/connection_validation.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 + ConnectionValidation = Data.define(:type, :error_code, :description, :timestamp) do + def validation_result_exists? + type.present? && type != :none + end + + def error_code? + error_code.present? && error_code != :none + end + end +end diff --git a/modules/storages/app/views/storages/admin/storages/edit.html.erb b/modules/storages/app/views/storages/admin/storages/edit.html.erb index ecda5f36208a..b5d320e8349a 100644 --- a/modules/storages/app/views/storages/admin/storages/edit.html.erb +++ b/modules/storages/app/views/storages/admin/storages/edit.html.erb @@ -64,7 +64,8 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> -<% if @storage.automatic_management_enabled? %> +<% display_sidebar = @storage.provider_type_one_drive? || @storage.automatic_management_enabled? %> +<% if display_sidebar %> <%= render(Primer::Alpha::Layout.new(stacking_breakpoint: :lg)) do |component| %> <% component.with_main() do %> <%= render(::Storages::Admin::StorageViewComponent.new(@storage)) %> diff --git a/modules/storages/config/locales/en.yml b/modules/storages/config/locales/en.yml index 8e0acddddc4b..a127c89109ec 100644 --- a/modules/storages/config/locales/en.yml +++ b/modules/storages/config/locales/en.yml @@ -103,11 +103,24 @@ en: storage_provider: Storage provider health: checked: Last checked %{datetime} + connection_validation: + action: Recheck connection + client_id_wrong: The configured OAuth 2 client id is invalid. Please check the configuration. + client_secret_wrong: The configured OAuth 2 client secret is invalid. Please check the configuration. + drive_id_wrong: The configured drive id could not be found. Please check the configuration. + not_configured: The connection could not be validated. Please finish configuration first. + placeholder: Check your connection against the server. + subtitle: Connection validation + tenant_id_wrong: The configured directory (tenant) id is invalid. Please check the configuration. + unexpected_content: Unexpected content found in the drive. + unknown_error: The connection could not be validated. An unknown error occurred. Please check the server logs for further information. label_error: Error label_healthy: Healthy label_pending: Pending + label_warning: Warning + project_folders: + subtitle: Automatically managed project folders since: since %{datetime} - subtitle: Automatically managed project folders title: Health status health_email_notifications: description_subscribed: All administrators receive health status email notifications for this storage. diff --git a/modules/storages/config/routes.rb b/modules/storages/config/routes.rb index 30617b2ca52b..04b2d533977b 100644 --- a/modules/storages/config/routes.rb +++ b/modules/storages/config/routes.rb @@ -38,11 +38,18 @@ post :finish_setup end - resource :automatically_managed_project_folders, controller: "/storages/admin/automatically_managed_project_folders", - only: %i[new create edit update] + resource :automatically_managed_project_folders, + controller: "/storages/admin/automatically_managed_project_folders", + only: %i[new create edit update] resource :access_management, controller: "/storages/admin/access_management", only: %i[new create edit update] + resource :connection_validation, + controller: "/storages/admin/connection_validation", + only: [] do + post :validate_connection, on: :member + end + get :select_provider, on: :collection member do diff --git a/modules/storages/spec/common/storages/peripherals/one_drive_connection_validator_spec.rb b/modules/storages/spec/common/storages/peripherals/one_drive_connection_validator_spec.rb new file mode 100644 index 000000000000..6aed3e2cf694 --- /dev/null +++ b/modules/storages/spec/common/storages/peripherals/one_drive_connection_validator_spec.rb @@ -0,0 +1,172 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe Storages::Peripherals::OneDriveConnectionValidator do + let(:storage) { create(:one_drive_storage, oauth_client: create(:oauth_client)) } + + before do + Storages::Peripherals::Registry.stub("#{storage.short_provider_type}.queries.files", ->(_) { response }) + end + + subject { described_class.new(storage:).validate } + + context "if storage is not yet configured" do + let(:storage) { create(:one_drive_storage) } + + it "returns a validation failure" do + expect(subject.type).to eq(:none) + expect(subject.error_code).to eq(:wrn_not_configured) + expect(subject.description).to eq("The connection could not be validated. Please finish configuration first.") + end + end + + context "if the storage's tenant id could not be found" do + let(:error_payload) do + { + error: "invalid_request", + error_description: "There is an error. Tenant '#{storage.tenant_id}' not found. This is VERY bad." + }.to_json + end + let(:response) { build_failure(code: :unauthorized, payload: error_payload) } + + it "returns a validation failure" do + expect(subject.type).to eq(:error) + expect(subject.error_code).to eq(:err_tenant_invalid) + expect(subject.description) + .to eq("The configured directory (tenant) id is invalid. Please check the configuration.") + end + end + + context "if the storage's client id could not be found" do + let(:error_payload) { { error: "unauthorized_client" }.to_json } + let(:response) { build_failure(code: :unauthorized, payload: error_payload) } + + it "returns a validation failure" do + expect(subject.type).to eq(:error) + expect(subject.error_code).to eq(:err_client_invalid) + expect(subject.description).to eq("The configured OAuth 2 client id is invalid. Please check the configuration.") + end + end + + context "if the storage's client secret is wrong" do + let(:error_payload) { { error: "invalid_client" }.to_json } + let(:response) { build_failure(code: :unauthorized, payload: error_payload) } + + it "returns a validation failure" do + expect(subject.type).to eq(:error) + expect(subject.error_code).to eq(:err_client_invalid) + expect(subject.description) + .to eq("The configured OAuth 2 client secret is invalid. Please check the configuration.") + end + end + + context "if the storage's drive id could not be found" do + let(:response) { build_failure(code: :not_found, payload: nil) } + + it "returns a validation failure" do + expect(subject.type).to eq(:error) + expect(subject.error_code).to eq(:err_drive_invalid) + expect(subject.description).to eq("The configured drive id could not be found. Please check the configuration.") + end + end + + context "if the request fails with an unknown error" do + let(:response) { build_failure(code: :error, payload: nil) } + + before do + allow(Rails.logger).to receive(:error) + end + + it "returns a validation failure" do + expect(subject.type).to eq(:error) + expect(subject.error_code).to eq(:err_unknown) + expect(subject.description) + .to eq("The connection could not be validated. An unknown error occurred. " \ + "Please check the server logs for further information.") + end + + it "logs the error message" do + described_class.new(storage:).validate + expect(Rails.logger).to have_received(:error) + end + end + + context "if the request returns unexpected files" do + let(:storage) { create(:one_drive_storage, :as_automatically_managed, oauth_client: create(:oauth_client)) } + let(:project_folder_id) { "1337" } + let(:project_storage) do + create(:project_storage, + :as_automatically_managed, + project_folder_id:, + storage:, + project: create(:project)) + end + let(:files_result) do + Storages::StorageFiles.new( + [ + Storages::StorageFile.new(id: project_folder_id, name: "I am your father"), + Storages::StorageFile.new(id: "noooooooooo", name: "testimony_of_luke_skywalker.md") + ], + Storages::StorageFile.new(id: "root", name: "root"), + [] + ) + end + let(:response) { ServiceResult.success(result: files_result) } + + before do + project_storage + end + + it "returns a validation failure" do + expect(subject.type).to eq(:warning) + expect(subject.error_code).to eq(:wrn_unexpected_content) + expect(subject.description).to eq("Unexpected content found in the drive.") + end + end + + context "if everything was fine" do + let(:response) { ServiceResult.success } + + it "returns a validation success" do + expect(subject.type).to eq(:healthy) + expect(subject.error_code).to eq(:none) + expect(subject.description).to be_nil + end + end + + private + + def build_failure(code:, payload:) + data = Storages::StorageErrorData.new(source: "query", payload:) + error = Storages::StorageError.new(code:, data:) + ServiceResult.failure(result: code, errors: error) + end +end diff --git a/modules/storages/spec/requests/storages/admin/connection_validation_spec.rb b/modules/storages/spec/requests/storages/admin/connection_validation_spec.rb new file mode 100644 index 000000000000..b3b0cfe7b9de --- /dev/null +++ b/modules/storages/spec/requests/storages/admin/connection_validation_spec.rb @@ -0,0 +1,142 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 "connection validation", :skip_csrf do + describe "POST /admin/settings/storages/:id/connection_validation/validate_connection" do + let(:storage) { create(:one_drive_storage) } + let(:user) { create(:admin) } + let(:validator) do + double = instance_double(Storages::Peripherals::OneDriveConnectionValidator) + allow(double).to receive_messages(validate: validation_result) + double + end + + current_user { user } + + before do + allow(Storages::Peripherals::OneDriveConnectionValidator).to receive(:new).and_return(validator) + end + + subject do + post validate_connection_admin_settings_storage_connection_validation_path(storage.id, format: :turbo_stream) + end + + shared_examples_for "a validation result template" do |show_timestamp:, label:, description:| + it "returns a turbo update template" do + expect(subject.status).to eq(200) + + doc = Nokogiri::HTML(subject.body) + expect(doc.xpath(xpath_for_subtitle).text).to eq("Connection validation") + + if show_timestamp + expect(doc.xpath(xpath_for_timestamp)).not_to be_empty + else + expect(doc.xpath(xpath_for_timestamp)).to be_empty + end + + if label.present? + expect(doc.xpath(xpath_for_label).text).to eq(label) + else + expect(doc.xpath(xpath_for_label).text).to be_empty + end + + if description.present? + expect(doc.xpath(xpath_for_description).text).to eq(description) + else + expect(doc.xpath(xpath_for_description).text).to be_empty + end + end + end + + context "if the a validation result of type :none (no validation executed) is returned" do + let(:validation_result) do + Storages::ConnectionValidation.new(type: :none, + error_code: :none, + timestamp: Time.current, + description: "not configured") + end + + it_behaves_like "a validation result template", show_timestamp: false, label: nil, description: "not configured" + end + + context "if validator returns an error" do + let(:validation_result) do + Storages::ConnectionValidation.new(type: :error, + error_code: :my_err, + timestamp: Time.current, + description: "An error occurred") + end + + it_behaves_like "a validation result template", + show_timestamp: true, label: "Error", description: "MY_ERR: An error occurred" + end + + context "if validator returns a warning" do + let(:validation_result) do + Storages::ConnectionValidation.new(type: :warning, + error_code: :my_wrn, + timestamp: Time.current, + description: "There is something weird...") + end + + it_behaves_like "a validation result template", + show_timestamp: true, label: "Warning", description: "MY_WRN: There is something weird..." + end + + context "if validator returns a success" do + let(:validation_result) do + Storages::ConnectionValidation.new(type: :healthy, error_code: :none, timestamp: Time.current, description: nil) + end + + it_behaves_like "a validation result template", + show_timestamp: true, label: "Healthy", description: nil + end + end + + private + + def xpath_for_subtitle + "#{xpath_for_turbo_target}/div/div/span[@data-test-selector='validation-result--subtitle']" + end + + def xpath_for_timestamp + "#{xpath_for_turbo_target}/div/div/span[@data-test-selector='validation-result--timestamp']" + end + + def xpath_for_label + "#{xpath_for_turbo_target}/div/div/span[contains(@class, 'Label')]" + end + + def xpath_for_description + "#{xpath_for_turbo_target}/div/div/span[@data-test-selector='validation-result--description']" + end + + def xpath_for_turbo_target = "//turbo-stream[@target='storages-admin-sidebar-validation-result-component']/template" +end