diff --git a/modules/auth_saml/app/components/saml/providers/metadata_changes/row_component.rb b/modules/auth_saml/app/components/saml/providers/metadata_changes/row_component.rb new file mode 100644 index 000000000000..36013c1b9867 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/metadata_changes/row_component.rb @@ -0,0 +1,59 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Saml + module Providers + module MetadataChanges + class RowComponent < ::OpPrimer::BorderBoxRowComponent + delegate :provider, to: :table + + def attribute + I18n.t("activerecord.attributes.saml/provider.#{key}") + end + + def current_value + current = provider.options_in_database[key] + return I18n.t(:label_none_parentheses) if current.blank? + + simple_format(h(current.to_s)) + end + + def new_value + simple_format(h(value.to_s)) + end + + private + + def key = row.first + + def value = row.last + end + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/metadata_changes/table_component.rb b/modules/auth_saml/app/components/saml/providers/metadata_changes/table_component.rb new file mode 100644 index 000000000000..f795510e7ff5 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/metadata_changes/table_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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Saml + module Providers + module MetadataChanges + class TableComponent < ::OpPrimer::BorderBoxTableComponent + options :provider + + columns :attribute, :current_value, :new_value + main_column :attribute, :current_value, :new_value + mobile_labels :current_value, :new_value + + def row_class + RowComponent + end + + def mobile_title + t(".mobile_title") + end + + def headers + [ + [:attribute, { caption: I18n.t(:label_attribute) }], + [:current_value, { caption: t(".current_value") }], + [:new_value, { caption: t(".new_value") }] + ] + end + end + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.html.erb new file mode 100644 index 000000000000..7054fc93a315 --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.html.erb @@ -0,0 +1,14 @@ +<%= + render(Primer::OpenProject::SidePanel::Section.new(test_selector: "saml_provider_refresh_metadata")) do |section| + section.with_title { I18n.t("saml.providers.label_metadata") } + section.with_description { t(".description") } + + render( + Primer::Beta::Button.new( + tag: :a, + href: refresh_metadata_saml_provider_path(provider), + data: { turbo_prefetch: false } + ) + ) { t(".button") } + end +%> diff --git a/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.rb b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.rb new file mode 100644 index 000000000000..07bdcff57dbb --- /dev/null +++ b/modules/auth_saml/app/components/saml/providers/side_panel/refresh_metadata_component.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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Saml::Providers + module SidePanel + class RefreshMetadataComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + alias_method :provider, :model + + def render? + provider.persisted? && provider.metadata_url.present? && !provider.seeded_from_env? + end + end + end +end diff --git a/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb index aff99061d6a4..7f9af5d435c2 100644 --- a/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb +++ b/modules/auth_saml/app/components/saml/providers/side_panel_component.html.erb @@ -3,7 +3,8 @@ render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel| [ Saml::Providers::SidePanel::MetadataComponent.new(@provider), - Saml::Providers::SidePanel::InformationComponent.new(@provider) + Saml::Providers::SidePanel::InformationComponent.new(@provider), + Saml::Providers::SidePanel::RefreshMetadataComponent.new(@provider) ].each do |component| panel.with_section(component) end diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index cf1a8b61de86..bbec77323588 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -36,8 +36,10 @@ class ProvidersController < ::ApplicationController before_action :require_admin before_action :check_ee, except: %i[index] - before_action :find_provider, only: %i[show edit import_metadata update confirm_destroy destroy] - before_action :check_provider_writable, only: %i[update import_metadata] + before_action :find_provider, + only: %i[show edit import_metadata refresh_metadata apply_metadata update confirm_destroy destroy] + before_action :check_provider_writable, only: %i[update import_metadata refresh_metadata apply_metadata] + before_action :check_metadata_url, only: %i[refresh_metadata apply_metadata] before_action :set_edit_state, only: %i[create edit update import_metadata] def index @@ -97,6 +99,29 @@ def create end end + def refresh_metadata + call = Saml::UpdateMetadataService.new(provider: @provider, user: User.current).call + + if call.success? + prepare_metadata_preview + else + flash[:error] = call.message + redirect_to saml_provider_path(@provider) + end + end + + def apply_metadata + preview = metadata_verifier.verified(params[:metadata_token], purpose: metadata_token_purpose) + + if (error = metadata_preview_error(preview)) + flash[:error] = error + else + apply_metadata_options(preview["options"]) + end + + redirect_to saml_provider_path(@provider) + end + def update call = Saml::Providers::UpdateService .new(model: @provider, user: User.current) @@ -131,6 +156,50 @@ def destroy private + def prepare_metadata_preview + @metadata_changes = @provider.options.filter do |key, value| + key != "last_metadata_update" && value != @provider.options_in_database[key] + end + @metadata_token = metadata_verifier.generate( + { + "provider_version" => @provider.cache_key_with_version, + "options" => @metadata_changes.merge("last_metadata_update" => @provider.last_metadata_update) + }, + purpose: metadata_token_purpose, + expires_in: 15.minutes + ) + end + + def metadata_preview_error(preview) + if preview.nil? + t(".expired") + elsif preview["provider_version"] != @provider.cache_key_with_version + t(".provider_changed") + end + end + + def apply_metadata_options(options) + call = Saml::Providers::UpdateService.new(model: @provider, user: User.current).call(options:) + + if call.success? + flash[:notice] = I18n.t("saml.metadata_parser.success") + else + flash[:error] = call.message + end + end + + def check_metadata_url + render_404 if @provider.metadata_url.blank? + end + + def metadata_verifier + Rails.application.message_verifier("saml_metadata_refresh") + end + + def metadata_token_purpose + "apply_metadata/#{@provider.id}" + end + def successful_save_response if @new_mode && !@next_edit_state flash[:notice] = I18n.t("saml.providers.notice_created") diff --git a/modules/auth_saml/app/services/saml/update_metadata_service.rb b/modules/auth_saml/app/services/saml/update_metadata_service.rb index fcd2c17c3965..babab556ace8 100644 --- a/modules/auth_saml/app/services/saml/update_metadata_service.rb +++ b/modules/auth_saml/app/services/saml/update_metadata_service.rb @@ -55,12 +55,11 @@ def call # rubocop:disable Metrics/AbcSize private def apply_metadata(metadata) - new_options = provider.options.merge(metadata) last_metadata_update = metadata.blank? ? nil : Time.current Saml::Providers::SetAttributesService .new(model: @provider, user: User.current, contract_class: Saml::Providers::UpdateContract) - .call({ options: new_options, last_metadata_update: }) + .call({ options: metadata, last_metadata_update: }) end def fetch_metadata diff --git a/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb b/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb new file mode 100644 index 000000000000..6ccc45741c0b --- /dev/null +++ b/modules/auth_saml/app/views/saml/providers/refresh_metadata.html.erb @@ -0,0 +1,34 @@ +<% html_title(t(:label_administration), t(".title"), @provider.display_name) %> + +<% content_for :header_tags do %> + +<% end %> + +<%= render(Primer::OpenProject::PageHeader.new) do |header| %> + <% header.with_title { t(".title") } %> + <% header.with_breadcrumbs( + [{ href: admin_index_path, text: t(:label_administration) }, + { href: saml_providers_path, text: t("saml.providers.plural") }, + { href: saml_provider_path(@provider), text: @provider.display_name }, + t(".title")] + ) %> + <% header.with_description { t(".description") } %> +<% end %> + +<% if @metadata_changes.empty? %> + <%= render(Primer::Alpha::Banner.new) { t(".no_changes") } %> +<% else %> + <%= render(Saml::Providers::MetadataChanges::TableComponent.new(rows: @metadata_changes.to_a, provider: @provider)) %> +<% end %> + +<%= form_with(url: apply_metadata_saml_provider_path(@provider), method: :post) do %> + <%= hidden_field_tag :metadata_token, @metadata_token %> + <%= flex_layout(align_items: :center, mt: 3) do |actions| + actions.with_column(mr: 2) do + render(Primer::Beta::Button.new(type: :submit, scheme: :primary)) { t(".apply") } + end + actions.with_column do + render(Primer::Beta::Button.new(tag: :a, href: saml_provider_path(@provider))) { t(:button_cancel) } + end + end %> +<% end %> diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 3af301e1fadf..1d220f892ed7 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -13,6 +13,7 @@ en: icon: "Custom icon" identifier: Identifier idp_cert: Public certificate of identity provider + idp_cert_fingerprint: Identity provider certificate fingerprint idp_entity_id: Identity provider entity ID idp_slo_service_url: Identity provider logout endpoint idp_sso_service_url: Identity provider login endpoint @@ -128,6 +129,9 @@ en: metadata_too_large: "The metadata file exceeds the maximum allowed size." success: "Successfully updated the configuration using the identity provider metadata." providers: + apply_metadata: + expired: "This metadata preview has expired or is invalid. Refresh the metadata again to review the latest configuration." + provider_changed: "The provider configuration has changed since this preview. Refresh the metadata again to review the latest configuration." attribute_mapping: Attribute mapping attribute_mapping_text: > The following fields control which attributes provided by the SAML identity provider @@ -147,10 +151,20 @@ en: label_uid: Internal user id metadata: dialog: "This is the URL where the OpenProject SAML metadata is available. Optionally use it to configure your identity provider:" + metadata_changes: + table_component: + current_value: "Current value" + mobile_title: "Metadata changes" + new_value: "New value" no_results_table: No SAML identity providers have been defined yet. notice_created: A new SAML identity provider was successfully created. plural: SAML identity providers + refresh_metadata: + apply: "Apply changes" + description: "Review the configuration fetched from your identity provider. Changes are saved only when you apply them." + no_changes: "The metadata matches the current configuration." + title: "Review metadata changes" request_attributes: format: 'Attribute format' legend: > @@ -174,6 +188,10 @@ en: metadata_form_banner: "Editing the metadata may override existing values in other sections. " requested_attributes: "Define the set of attributes to be requested in the SAML request sent to your identity provider." seeded_from_env: "This provider was seeded from the environment configuration. It cannot be edited." + side_panel: + refresh_metadata_component: + button: "Refresh metadata" + description: "You can check if the configuration fetched from your identity provider has changed." singular: SAML identity provider upsell: description: Connect OpenProject to a SAML identity provider diff --git a/modules/auth_saml/config/routes.rb b/modules/auth_saml/config/routes.rb index 133ea54d6eca..149a10305933 100644 --- a/modules/auth_saml/config/routes.rb +++ b/modules/auth_saml/config/routes.rb @@ -33,6 +33,8 @@ resources :providers do member do post :import_metadata + get :refresh_metadata + post :apply_metadata get :confirm_destroy end end diff --git a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb index 5151987d3a3b..0f8f2ef7074f 100644 --- a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb +++ b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb @@ -186,6 +186,37 @@ provider.reload expect(provider.limit_self_registration).to be true end + + it "can review, cancel, and apply refreshed metadata", :webmock do + url = "https://example.com/metadata" + provider.update!(metadata_url: url, last_metadata_update: 1.day.ago) + original_options = provider.options.deep_dup + metadata = Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read + stub_request(:get, url).to_return(status: 200, body: metadata) + + visit saml_provider_path(provider) + within_test_selector("saml_provider_refresh_metadata") do + expect(page).to have_text("You can check if the configuration fetched from your identity provider has changed.") + click_on "Refresh metadata" + end + + expect(page).to have_heading(text: "Review metadata changes") + expect(page).to have_text("https://example.com/sso") + expect(page).to have_text("https://example.com/login") + expect(provider.reload.options).to eq(original_options) + + click_on "Cancel" + expect(page).to have_heading(text: "My provider") + expect(provider.reload.options).to eq(original_options) + + click_on "Refresh metadata" + click_on "Apply changes" + + expect(page).to have_text("Successfully updated the configuration using the identity provider metadata.") + expect(page).to have_heading(text: "My provider") + expect(provider.reload.idp_sso_service_url).to eq("https://example.com/login") + expect(provider.last_metadata_update).to be > 1.minute.ago + end end end diff --git a/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb new file mode 100644 index 000000000000..29ccf3027053 --- /dev/null +++ b/modules/auth_saml/spec/requests/saml_metadata_refresh_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe "SAML metadata refresh", :skip_csrf, :webmock, + type: :rails_request, with_ee: %i[sso_auth_providers] do + let(:admin) { create(:admin) } + let(:metadata_url) { "https://example.com/metadata" } + let(:metadata) { Rails.root.join("modules/auth_saml/spec/fixtures/idp_metadata.xml").read } + let(:provider) { create(:saml_provider, :with_encryption, metadata_url:, last_metadata_update: 1.day.ago) } + let(:metadata_token) { page.find("input[name='metadata_token']", visible: :all).value } + + before do + login_as admin + allow(OpenProject::SsrfProtection).to receive(:safe_ip?).with("example.com").and_return(IPAddr.new("93.184.216.34")) + stub_request(:get, metadata_url).to_return(status: 200, body: metadata) + end + + it "offers a refresh action in the side panel" do + get saml_provider_path(provider) + + expect(page.find(:test_id, "saml_provider_refresh_metadata")) + .to have_link("Refresh metadata", href: refresh_metadata_saml_provider_path(provider)) + expect(page.find(:test_id, "saml_provider_metadata")).to have_no_link("Refresh metadata") + end + + it "previews the fetched changes without saving them" do + original_options = provider.options.deep_dup + + get refresh_metadata_saml_provider_path(provider) + + expect(response).to have_http_status(:ok) + expect(page).to have_heading("Review metadata changes") + expect(page).to have_text("https://example.com/sso") + expect(page).to have_text("https://example.com/login") + expect(page).to have_text("Public certificate of identity provider") + expect(page).to have_button("Apply changes") + expect(page).to have_link("Cancel", href: saml_provider_path(provider)) + expect(provider.reload.options).to eq(original_options) + end + + it "applies the reviewed endpoints and certificate without fetching metadata again" do + original_options = provider.options.deep_dup + get refresh_metadata_saml_provider_path(provider) + token = metadata_token + stub_request(:get, metadata_url).to_return(status: 200, body: metadata.gsub("/login", "/other-login")) + + post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } + + expect(response).to redirect_to(saml_provider_path(provider)) + expect(flash[:notice]).to eq(I18n.t("saml.metadata_parser.success")) + provider.reload + expect(provider.idp_sso_service_url).to eq("https://example.com/login") + expect(provider.idp_slo_service_url).to eq("https://example.com/logout") + expect(provider.idp_entity_id).to eq("urn:dev-example.com") + certificate = Nokogiri::XML(metadata).at_xpath("//*[local-name()='X509Certificate']").text + expect(provider.idp_cert).to eq(OneLogin::RubySaml::Utils.format_cert(certificate)) + expect(provider.last_metadata_update).to be > 1.minute.ago + expect(provider.metadata_url).to eq(metadata_url) + expect(provider.options.slice("mapping_login", "mapping_mail", "certificate", "private_key", "authn_requests_signed")) + .to eq(original_options.slice("mapping_login", "mapping_mail", "certificate", "private_key", "authn_requests_signed")) + expect(WebMock).to have_requested(:get, metadata_url).once + end + + it "leaves the configuration unchanged when cancelling" do + original_options = provider.options.deep_dup + get refresh_metadata_saml_provider_path(provider) + + get saml_provider_path(provider) + + expect(provider.reload.options).to eq(original_options) + end + + it "reports when the fetched metadata matches the current configuration" do + get refresh_metadata_saml_provider_path(provider) + post apply_metadata_saml_provider_path(provider), params: { metadata_token: } + + get refresh_metadata_saml_provider_path(provider) + + expect(page).to have_text("The metadata matches the current configuration.") + end + + it "leaves the configuration unchanged when fetching metadata fails" do + original_options = provider.options.deep_dup + stub_request(:get, metadata_url).to_return(status: 500) + + get refresh_metadata_saml_provider_path(provider) + + expect(response).to redirect_to(saml_provider_path(provider)) + expect(flash[:error]).to include("Failed to retrieve the identity provider metadata") + expect(provider.reload.options).to eq(original_options) + end + + it "rejects malformed metadata without changing the configuration" do + original_options = provider.options.deep_dup + stub_request(:get, metadata_url).to_return(status: 200, body: "") + + get refresh_metadata_saml_provider_path(provider) + + expect(response).to redirect_to(saml_provider_path(provider)) + expect(flash[:error]).to include("Failed to retrieve the identity provider metadata") + expect(provider.reload.options).to eq(original_options) + end + + it "blocks unsafe metadata hosts" do + allow(OpenProject::SsrfProtection).to receive(:safe_ip?).with("example.com").and_return(nil) + + get refresh_metadata_saml_provider_path(provider) + + expect(response).to redirect_to(saml_provider_path(provider)) + expect(flash[:error]).to include("MetadataHostNotAllowedError") + expect(WebMock).not_to have_requested(:get, metadata_url) + end + + it "rejects a modified preview token" do + get refresh_metadata_saml_provider_path(provider) + token = "#{metadata_token}invalid" + original_options = provider.reload.options.deep_dup + + post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } + + expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired")) + expect(provider.reload.options).to eq(original_options) + end + + it "rejects a missing preview token" do + post apply_metadata_saml_provider_path(provider) + + expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired")) + end + + it "rejects an expired preview" do + get refresh_metadata_saml_provider_path(provider) + token = metadata_token + original_options = provider.reload.options.deep_dup + + travel 16.minutes do + post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } + end + + expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired")) + expect(provider.reload.options).to eq(original_options) + end + + it "rejects a preview if the provider has since changed" do + get refresh_metadata_saml_provider_path(provider) + token = metadata_token + provider.update!(idp_sso_service_url: "https://example.com/changed") + + post apply_metadata_saml_provider_path(provider), params: { metadata_token: token } + + expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.provider_changed")) + expect(provider.reload.idp_sso_service_url).to eq("https://example.com/changed") + end + + it "rejects a preview for a different provider" do + get refresh_metadata_saml_provider_path(provider) + token = metadata_token + other_provider = create(:saml_provider, metadata_url:) + + post apply_metadata_saml_provider_path(other_provider), params: { metadata_token: token } + + expect(flash[:error]).to eq(I18n.t("saml.providers.apply_metadata.expired")) + expect(other_provider.reload.idp_sso_service_url).to eq("https://example.com/sso") + end + + context "without a metadata URL" do + let(:provider) { create(:saml_provider, metadata_xml: metadata) } + + it "does not offer or allow refreshing" do + get saml_provider_path(provider) + expect(page).to have_no_link("Refresh metadata") + + get refresh_metadata_saml_provider_path(provider) + expect(response).to have_http_status(:not_found) + + post apply_metadata_saml_provider_path(provider) + expect(response).to have_http_status(:not_found) + end + end + + context "with an environment-managed provider" do + before do + allow(Setting).to receive(:seed_saml_provider).and_return({ provider.slug => {} }) + end + + it "does not offer or allow refreshing" do + get saml_provider_path(provider) + expect(page).to have_no_link("Refresh metadata") + + get refresh_metadata_saml_provider_path(provider) + expect(response).to redirect_to(saml_provider_path(provider)) + + post apply_metadata_saml_provider_path(provider) + expect(response).to redirect_to(saml_provider_path(provider)) + expect(WebMock).not_to have_requested(:get, metadata_url) + end + end + + context "without admin permissions" do + let(:admin) { create(:user) } + + it "does not allow previewing or applying metadata" do + get refresh_metadata_saml_provider_path(provider) + expect(response).to have_http_status(:forbidden) + + post apply_metadata_saml_provider_path(provider) + expect(response).to have_http_status(:forbidden) + expect(WebMock).not_to have_requested(:get, metadata_url) + end + end + + context "without Enterprise", with_ee: [] do + it "does not allow previewing or applying metadata" do + get refresh_metadata_saml_provider_path(provider) + expect(response).to redirect_to(saml_providers_path) + + post apply_metadata_saml_provider_path(provider) + expect(response).to redirect_to(saml_providers_path) + expect(WebMock).not_to have_requested(:get, metadata_url) + end + end +end