Merge pull request #10502 from opf/bug/41939-email-delivery-not-working-on-qa-edge

[#41939] Email delivery not working on qa-edge
pull/10583/head
Oliver Günther 3 years ago committed by GitHub
commit c90e7d3534
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      app/helpers/user_consent_helper.rb
  2. 1
      app/models/setting.rb
  3. 126
      config/constants/settings/definition.rb
  4. 2
      config/constants/settings/definitions.rb
  5. 8
      config/initializers/rack_timeout.rb
  6. 4
      lib/open_project/configuration/helpers.rb
  7. 3
      lib/redmine/plugin.rb
  8. 4
      modules/ldap_groups/lib/open_project/ldap_groups/engine.rb
  9. 12
      modules/recaptcha/app/controllers/recaptcha/request_controller.rb
  10. 6
      modules/recaptcha/app/views/recaptcha/admin/show.html.erb
  11. 8
      modules/recaptcha/app/views/recaptcha/request/perform.html.erb
  12. 4
      modules/recaptcha/lib/open_project/recaptcha/engine.rb
  13. 28
      modules/recaptcha/spec/controllers/request_controller_spec.rb
  14. 2
      modules/two_factor_authentication/app/models/two_factor_authentication/device/totp.rb
  15. 16
      modules/two_factor_authentication/app/views/two_factor_authentication/settings.html.erb
  16. 3
      modules/two_factor_authentication/lib/open_project/two_factor_authentication/engine.rb
  17. 2
      modules/two_factor_authentication/lib/open_project/two_factor_authentication/token_strategy/base.rb
  18. 2
      modules/two_factor_authentication/lib/open_project/two_factor_authentication/token_strategy/message_bird.rb
  19. 6
      modules/two_factor_authentication/lib/open_project/two_factor_authentication/token_strategy/restdt.rb
  20. 25
      modules/two_factor_authentication/lib/open_project/two_factor_authentication/token_strategy/sns.rb
  21. 42
      modules/two_factor_authentication/lib/open_project/two_factor_authentication/token_strategy_manager.rb
  22. 20
      modules/two_factor_authentication/spec/controllers/two_factor_authentication/authentication_controller_spec.rb
  23. 26
      modules/two_factor_authentication/spec/controllers/two_factor_authentication/forced_registration/two_factor_devices_controller_spec.rb
  24. 14
      modules/two_factor_authentication/spec/controllers/two_factor_authentication/my/two_factor_devices_controller_spec.rb
  25. 14
      modules/two_factor_authentication/spec/controllers/two_factor_authentication/users/two_factor_devices_controller_spec.rb
  26. 18
      modules/two_factor_authentication/spec/features/account_activation_spec.rb
  27. 12
      modules/two_factor_authentication/spec/features/admin_edit_two_factor_devices_spec.rb
  28. 10
      modules/two_factor_authentication/spec/features/backup_codes/login_with_backup_code_spec.rb
  29. 19
      modules/two_factor_authentication/spec/features/login/login_enforced_2fa_spec.rb
  30. 18
      modules/two_factor_authentication/spec/features/login/login_with_2fa_spec.rb
  31. 10
      modules/two_factor_authentication/spec/features/login/switch_available_devices_spec.rb
  32. 17
      modules/two_factor_authentication/spec/features/my_two_factor_devices_spec.rb
  33. 18
      modules/two_factor_authentication/spec/features/password_change_spec.rb
  34. 18
      modules/two_factor_authentication/spec/features/remember_cookie/login_with_remember_cookie_spec.rb
  35. 91
      modules/two_factor_authentication/spec/lib/token_strategy_manager_spec.rb
  36. 50
      modules/two_factor_authentication/spec/models/devices/totp_spec.rb
  37. 39
      modules/two_factor_authentication/spec/services/token_delivery/message_bird_spec.rb
  38. 23
      modules/two_factor_authentication/spec/services/token_delivery/restdt_spec.rb
  39. 34
      modules/two_factor_authentication/spec/services/token_delivery/sns_spec.rb
  40. 13
      modules/two_factor_authentication/spec/services/token_delivery/totp_spec.rb
  41. 42
      modules/two_factor_authentication/spec/services/token_service_spec.rb
  42. 233
      spec/constants/settings/definition_spec.rb
  43. 28
      spec/models/setting_spec.rb
  44. 50
      spec/support/shared/with_settings.rb
  45. 18
      spec/views/account/register.html.erb_spec.rb
  46. 2
      spec_legacy/support/legacy_assertions.rb
  47. 4
      spec_legacy/unit/mail_handler_spec.rb
  48. 2
      spec_legacy/unit/project_spec.rb

@ -47,7 +47,7 @@ module ::UserConsentHelper
def user_consent_instructions(_user, locale: I18n.locale)
all = Setting.consent_info
all.fetch(locale) { all.values.first }
all.fetch(locale.to_s) { all.values.first }
end
def consent_checkbox_label(locale: I18n.locale)

@ -326,6 +326,7 @@ class Setting < ApplicationRecord
if definition.serialized? && value.is_a?(String)
YAML::safe_load(value, permitted_classes: [Symbol, ActiveSupport::HashWithIndifferentAccess, Date, Time])
.tap { |maybe_hash| maybe_hash.try(:deep_stringify_keys!) }
elsif value != '' && !value.nil?
read_formatted_setting(value, definition.format)
else

@ -31,7 +31,8 @@ module Settings
ENV_PREFIX = 'OPENPROJECT_'.freeze
attr_accessor :name,
:format
:format,
:env_alias
attr_writer :value,
:allowed
@ -40,12 +41,14 @@ module Settings
value:,
format: nil,
writable: true,
allowed: nil)
allowed: nil,
env_alias: nil)
self.name = name.to_s
self.format = format ? format.to_sym : deduce_format(value)
self.value = value
self.value = value.is_a?(Hash) ? value.deep_stringify_keys : value
self.writable = writable
self.allowed = allowed
self.env_alias = env_alias
end
def value
@ -84,7 +87,7 @@ module Settings
def override_value(other_value)
if format == :hash
self.value = {} if value.nil?
value.deep_merge! other_value
value.deep_merge! other_value.deep_stringify_keys
else
self.value = other_value
end
@ -131,7 +134,8 @@ module Settings
value:,
format: nil,
writable: true,
allowed: nil)
allowed: nil,
env_alias: nil)
return if @by_name.present? && @by_name[name.to_s].present?
@by_name = nil
@ -140,7 +144,8 @@ module Settings
format: format,
value: value,
writable: writable,
allowed: allowed)
allowed: allowed,
env_alias: env_alias)
override_value(definition)
@ -234,32 +239,37 @@ module Settings
end
def override_config_values(definition)
value = ENV[env_name(definition)]
return unless value
definition.override_value(extract_value(definition.name.upcase, value))
find_env_var_override(definition) do |env_var_name, env_var_value|
value = extract_value_from_env(env_var_name, env_var_value)
definition.override_value(value)
end
end
def merge_hash_config(definition)
ENV.select { |k, _| k =~ /^#{env_name(definition)}/i }.each do |k, raw_value|
_, value = path_to_hash(*path(ENV_PREFIX, k),
extract_value(k, raw_value))
.first
# There might be ENV vars that match the OPENPROJECT_ prefix but are no OP instance
# settings, e.g. OPENPROJECT_DISABLE_DEV_ASSET_PROXY
definition.override_value(value)
merged_hash = {}
each_env_var_hash_override(definition) do |env_var_name, env_var_value, env_var_hash_part|
value = extract_hash_from_env(env_var_name, env_var_value, env_var_hash_part)
merged_hash.deep_merge!(value)
end
return if merged_hash.empty?
definition.override_value(merged_hash)
end
def extract_hash_from_env(env_var_name, env_var_value, env_var_hash_part)
value = extract_value_from_env(env_var_name, env_var_value)
path_to_hash(*hash_path(env_var_hash_part), value)
end
def path(prefix, env_var_name)
env_var_name
.sub(/^#{prefix}/, '')
.gsub(/([a-zA-Z0-9]|(__))+/)
# takes the hash part of an env variable and turn it into a path.
#
# e.g. hash_path('KEY_SUB__KEY_SUB__SUB__KEY') => ['key', 'sub_key', 'sub_sub_key']
def hash_path(env_var_hash_part)
env_var_hash_part
.scan(/(?:[a-zA-Z0-9]|__)+/)
.map do |seg|
unescape_underscores(seg.downcase)
end
unescape_underscores(seg.downcase)
end
end
# takes the path provided and transforms it into a deeply nested hash
@ -278,32 +288,80 @@ module Settings
path_segment.gsub '__', '_'
end
def env_name(definition)
def find_env_var_override(definition)
found_env_name = possible_env_names(definition).find { |name| ENV.key?(name) }
return unless found_env_name
if found_env_name == env_name_unprefixed(definition)
Rails.logger.warn(
"Using unprefixed environment variables is deprecated. " \
"Please use #{env_name(definition)} instead of #{env_name_unprefixed(definition)}"
)
end
yield found_env_name, ENV.fetch(found_env_name)
end
def each_env_var_hash_override(definition)
hash_override_matcher =
if definition.env_alias
/^(?:#{env_name(definition)}|#{env_name_legacy(definition)}|#{env_name_alias(definition)})_(.+)/i
else
/^(?:#{env_name(definition)}|#{env_name_legacy(definition)})_(.+)/i
end
ENV.each do |env_var_name, env_var_value|
env_var_name.match(hash_override_matcher) do |m|
yield env_var_name, env_var_value, m[1]
end
end
end
def possible_env_names(definition)
[
env_name_legacy(definition),
env_name(definition),
env_name_unprefixed(definition),
env_name_alias(definition)
].compact
end
def env_name_legacy(definition)
"#{ENV_PREFIX}#{definition.name.upcase.gsub('_', '__')}"
end
def env_name(definition)
"#{ENV_PREFIX}#{definition.name.upcase}"
end
def env_name_unprefixed(definition)
definition.name.upcase
end
def env_name_alias(definition)
"#{ENV_PREFIX}#{definition.env_alias.upcase}" if definition.env_alias
end
##
# Extract the configuration value from the given input
# Extract the configuration value from the given environment variable
# using YAML.
#
# @param key [String] The key of the input within the source hash.
# @param original_value [String] The string from which to extract the actual value.
# @param env_var_name [String] The environment variable name.
# @param env_var_value [String] The string from which to extract the actual value.
# @return A ruby object (e.g. Integer, Float, String, Hash, Boolean, etc.)
# @raise [ArgumentError] If the string could not be parsed.
def extract_value(key, original_value)
def extract_value_from_env(env_var_name, env_var_value)
# YAML parses '' as false, but empty ENV variables will be passed as that.
# To specify specific values, one can use !!str (-> '') or !!null (-> nil)
return original_value if original_value == ''
return env_var_value if env_var_value == ''
parsed = load_yaml(original_value)
parsed = load_yaml(env_var_value)
if parsed.is_a?(String)
original_value
env_var_value
else
parsed
end
rescue StandardError => e
raise ArgumentError, "Configuration value for '#{key}' is invalid: #{e.message}"
raise ArgumentError, "Configuration value for environment variable '#{env_var_name}' is invalid: #{e.message}"
end
def load_yaml(source)

@ -121,7 +121,7 @@ Settings::Definition.define do
writable: false
add :available_languages,
format: :hash,
format: :array,
value: %w[en de fr es pt pt-BR it zh-CN ko ru].freeze,
allowed: -> { Redmine::I18n.all_languages }

@ -1,15 +1,15 @@
# Use rack-timeout if we run in clustered mode with at least 2 workers
# so that workers, should a timeout occur, can be restarted without interruption.
if OpenProject::Configuration.web_workers >= 2
timeout = Integer(ENV['RACK_TIMEOUT_SERVICE_TIMEOUT'].presence || OpenProject::Configuration.web_timeout)
wait_timeout = Integer(ENV['RACK_TIMEOUT_WAIT_TIMEOUT'].presence || OpenProject::Configuration.web_wait_timeout)
service_timeout = OpenProject::Configuration.web_timeout
wait_timeout = OpenProject::Configuration.web_wait_timeout
Rails.logger.debug { "Enabling Rack::Timeout (service=#{timeout}s wait=#{wait_timeout}s)" }
Rails.logger.debug { "Enabling Rack::Timeout (service=#{service_timeout}s wait=#{wait_timeout}s)" }
Rails.application.config.middleware.insert_before(
::Rack::Runtime,
::Rack::Timeout,
service_timeout: timeout, # time after which a request being served times out
service_timeout: service_timeout, # time after which a request being served times out
wait_timeout: wait_timeout, # time after which a request waiting to be served times out
term_on_timeout: 1 # shut down worker (gracefully) right away on timeout to be restarted
)

@ -161,11 +161,11 @@ module OpenProject
end
def web_timeout
Integer(web['timeout'].presence)
Integer(ENV['RACK_TIMEOUT_SERVICE_TIMEOUT'].presence || web['timeout'].presence)
end
def web_wait_timeout
Integer(web['wait_timeout'].presence)
Integer(ENV['RACK_TIMEOUT_WAIT_TIMEOUT'].presence || web['wait_timeout'].presence)
end
def web_min_threads

@ -109,7 +109,8 @@ module Redmine #:nodoc:
if p.settings
Settings::Definition.add("plugin_#{id}",
value: p.settings[:default],
format: :hash)
format: :hash,
env_alias: p.settings[:env_alias])
end
# If there are plugins waiting for us to be loaded, we try loading those, again

@ -8,9 +8,7 @@ module OpenProject::LdapGroups
author_url: 'https://github.com/opf/openproject-ldap_groups',
bundled: true,
settings: {
default: {
name_attribute: 'cn'
}
default: {}
} do
menu :admin_menu,
:plugin_ldap_groups,

@ -28,7 +28,7 @@ module ::Recaptcha
def verify
if valid_recaptcha?
save_recpatcha_verification_success!
save_recaptcha_verification_success!
complete_stage_redirect
else
fail_recaptcha I18n.t('recaptcha.error_captcha')
@ -39,14 +39,14 @@ module ::Recaptcha
##
# Insert that the account was verified
def save_recpatcha_verification_success!
def save_recaptcha_verification_success!
# Remove all previous
::Recaptcha::Entry.where(user_id: @authenticated_user.id).delete_all
::Recaptcha::Entry.create!(user_id: @authenticated_user.id, version: recaptcha_version)
end
def recaptcha_version
case recaptcha_settings[:recaptcha_type]
case recaptcha_settings['recaptcha_type']
when ::OpenProject::Recaptcha::TYPE_DISABLED
0
when ::OpenProject::Recaptcha::TYPE_V2
@ -59,7 +59,7 @@ module ::Recaptcha
##
#
def valid_recaptcha?
call_args = { secret_key: recaptcha_settings[:secret_key] }
call_args = { secret_key: recaptcha_settings['secret_key'] }
if recaptcha_version == 3
call_args[:action] = 'login'
end
@ -88,7 +88,7 @@ module ::Recaptcha
end
def skip_if_disabled
if recaptcha_settings[:recaptcha_type] == ::OpenProject::Recaptcha::TYPE_DISABLED
if recaptcha_settings['recaptcha_type'] == ::OpenProject::Recaptcha::TYPE_DISABLED
complete_stage_redirect
end
end
@ -100,7 +100,7 @@ module ::Recaptcha
end
def skip_if_user_verified
if ::Recaptcha::Entry.where(user_id: @authenticated_user.id).exists?
if ::Recaptcha::Entry.exists?(user_id: @authenticated_user.id)
Rails.logger.debug { "User #{@authenticated_user.id} already provided recaptcha. Skipping. " }
complete_stage_redirect
end

@ -12,7 +12,7 @@
<label class="form--label" for='recaptcha_type'><%= t('recaptcha.settings.type') %></label>
<div class="form--field-container">
<%= styled_select_tag 'recaptcha_type',
options_for_select(recaptcha_available_options, Setting.plugin_openproject_recaptcha[:recaptcha_type]),
options_for_select(recaptcha_available_options, Setting.plugin_openproject_recaptcha['recaptcha_type']),
container_class: '-middle' %>
</div>
<div class="form--field-instructions">
@ -24,7 +24,7 @@
<label class="form--label" for='website_key'><%= t('recaptcha.settings.website_key') %></label>
<div class="form--field-container">
<%= styled_text_field_tag 'website_key',
Setting.plugin_openproject_recaptcha[:website_key] %>
Setting.plugin_openproject_recaptcha['website_key'] %>
</div>
<div class="form--field-instructions">
<%= I18n.t('recaptcha.settings.website_key_text') %>
@ -34,7 +34,7 @@
<label class="form--label" for='secret_key'><%= t('recaptcha.settings.secret_key') %></label>
<div class="form--field-container">
<%= styled_text_field_tag 'secret_key',
Setting.plugin_openproject_recaptcha[:secret_key] %>
Setting.plugin_openproject_recaptcha['secret_key'] %>
</div>
<div class="form--field-instructions">
<%= I18n.t('recaptcha.settings.secret_key_text') %>

@ -3,13 +3,13 @@
<div id="login-form" class="form -bordered">
<%= styled_form_tag({ action: :verify }, { :autocomplete => "off", :id => 'submit_captcha' }) do %>
<h2><%= t 'recaptcha.verify_account' %></h2>
<% if recaptcha_settings[:recaptcha_type] == ::OpenProject::Recaptcha::TYPE_V2 %>
<% if recaptcha_settings['recaptcha_type'] == ::OpenProject::Recaptcha::TYPE_V2 %>
<% input_name = "g-recaptcha-response" %>
<input type="hidden" name="<%= input_name %>" />
<%= recaptcha_tags(
nonce: content_security_policy_script_nonce,
callback: 'submitRecaptchaForm',
site_key: recaptcha_settings[:website_key]
site_key: recaptcha_settings['website_key']
) %>
<%= nonced_javascript_tag do %>
@ -28,11 +28,11 @@
document.getElementById('submit_captcha').submit();
}
<% end %>
<% elsif recaptcha_settings[:recaptcha_type] == ::OpenProject::Recaptcha::TYPE_V3 %>
<% elsif recaptcha_settings['recaptcha_type'] == ::OpenProject::Recaptcha::TYPE_V3 %>
<%= recaptcha_v3 action: 'login',
nonce: content_security_policy_script_nonce,
callback: 'submitRecaptchaForm',
site_key: recaptcha_settings[:website_key] %>
site_key: recaptcha_settings['website_key'] %>
<p><%= t('recaptcha.button_please_wait') %></p>
<%= nonced_javascript_tag do %>

@ -23,7 +23,7 @@ module OpenProject::Recaptcha
end
config.after_initialize do
SecureHeaders::Configuration.named_append(:recaptcha) do |request|
SecureHeaders::Configuration.named_append(:recaptcha) do
if OpenProject::Recaptcha.use_hcaptcha?
value = %w(https://*.hcaptcha.com)
keys = %i(frame_src script_src style_src connect_src)
@ -41,7 +41,7 @@ module OpenProject::Recaptcha
nil,
run_after_activation: true,
active: -> {
type = Setting.plugin_openproject_recaptcha[:recaptcha_type]
type = Setting.plugin_openproject_recaptcha['recaptcha_type']
type.present? && type.to_s != ::OpenProject::Recaptcha::TYPE_DISABLED
}

@ -2,11 +2,21 @@ require 'spec_helper'
describe ::Recaptcha::RequestController, type: :controller do
let(:user) { create :user }
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_recaptcha: {
'recaptcha_type' => 'v2',
'website_key' => 'A',
'secret_key' => 'B'
}
}
end
end
before do
login_as user
allow(Setting)
.to receive(:plugin_openproject_recaptcha)
.and_return(recaptcha_type: 'v2', website_key: 'A', secret_key: 'B')
session[:authenticated_user_id] = user.id
session[:stage_secrets] = { recaptcha: 'asdf' }
@ -21,7 +31,7 @@ describe ::Recaptcha::RequestController, type: :controller do
it 'skips if user is verified' do
allow(::Recaptcha::Entry)
.to receive_message_chain(:where, :exists?)
.to receive(:exists?).with(user_id: user.id)
.and_return true
get :perform
@ -32,25 +42,27 @@ describe ::Recaptcha::RequestController, type: :controller do
let(:user) { create :admin }
it 'skips the verification' do
expect(controller).not_to receive(:perform)
allow(controller).to receive(:perform)
get :perform
expect(response).to redirect_to stage_success_path(stage: :recaptcha, secret: 'asdf')
expect(controller).not_to have_received(:perform)
end
end
end
describe 'verify' do
it 'succeeds assuming verification works' do
allow(@controller).to receive(:valid_recaptcha?).and_return true
expect(@controller).to receive(:save_recpatcha_verification_success!)
allow(controller).to receive(:valid_recaptcha?).and_return true
allow(controller).to receive(:save_recaptcha_verification_success!)
post :verify
expect(flash[:error]).to be_nil
expect(response).to redirect_to stage_success_path(stage: :recaptcha, secret: 'asdf')
expect(controller).to have_received(:save_recaptcha_verification_success!)
end
it 'fails assuming verification fails' do
allow(@controller).to receive(:valid_recaptcha?).and_return false
allow(controller).to receive(:valid_recaptcha?).and_return false
post :verify
expect(flash[:error]).to be_present
expect(response).to redirect_to stage_failure_path(stage: :recaptcha)

@ -57,7 +57,7 @@ module TwoFactorAuthentication
end
def allowed_drift
self.class.manager.configuration.fetch :otp_drift_window, 60
self.class.manager.configuration['otp_drift_window'] || 60
end
def totp

@ -19,8 +19,8 @@
<div class="attributes-key-value--key"><%= t('two_factor_authentication.settings.label_active_strategies') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value">
<%= t(:label_none) if configuration[:active_strategies].empty? %>
<% configuration[:active_strategies].each do |key| %>
<%= t(:label_none) if configuration['active_strategies'].empty? %>
<% configuration['active_strategies'].each do |key| %>
<span>
<%= t("two_factor_authentication.strategies.#{key}") %>
</span>
@ -31,16 +31,16 @@
<div class="attributes-key-value--key"><%= t('two_factor_authentication.settings.label_enforced') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value">
<span><%= !!configuration[:enforced] %></span>
<span><%= !!configuration['enforced'] %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t('two_factor_authentication.settings.label_remember') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value">
<% if configuration[:allow_remember_for_days].to_i == 0 %>
<% if configuration['allow_remember_for_days'].to_i == 0 %>
<span><%= t(:label_disabled) %></span>
<% else %>
<span><%= configuration[:allow_remember_for_days] %> (<%= t(:label_day_plural) %>)</span>
<span><%= configuration['allow_remember_for_days'] %> (<%= t(:label_day_plural) %>)</span>
<% end %>
</div>
</div>
@ -53,8 +53,8 @@
<div class="form--field-container ">
<%= styled_check_box_tag 'settings[enforced]',
'1',
!!configuration[:enforced],
disabled: strategy_manager.enforced_by_configuration? || configuration[:active_strategies].empty?,
!!configuration['enforced'],
disabled: strategy_manager.enforced_by_configuration? || configuration['active_strategies'].empty?,
container_class: '-middle' %>
</div>
<div class="form--field-instructions">
@ -65,7 +65,7 @@
<label class="form--label" for='settings[allow_remember_for_days]'><%= t('two_factor_authentication.settings.label_remember') %></label>
<div class="form--field-container">
<%= styled_number_field_tag 'settings[allow_remember_for_days]',
configuration[:allow_remember_for_days],
configuration['allow_remember_for_days'],
min: '0',
max: '365',
step: '1',

@ -17,7 +17,8 @@ module OpenProject::TwoFactorAuthentication
enforced: false,
# Don't allow remember cookie
allow_remember_for_days: 0
}
},
env_alias: '2FA' # can override values with OPENPROJECT_2FA env var
},
bundled: true do
menu :my_menu,

@ -73,7 +73,7 @@ module OpenProject::TwoFactorAuthentication
end
def self.configuration_params
OpenProject::Configuration['2fa'][identifier]
Setting.plugin_openproject_two_factor_authentication[identifier.to_s]
end
protected

@ -63,7 +63,7 @@ module OpenProject::TwoFactorAuthentication
end
def message_bird_client
::MessageBird::Client.new(configuration_params[:apikey])
::MessageBird::Client.new(configuration_params['apikey'])
end
##

@ -27,10 +27,10 @@ module OpenProject::TwoFactorAuthentication
def service_params
{
url: URI(configuration_params[:service_url]),
url: URI(configuration_params['service_url']),
request_base: {
user: configuration_params[:username],
pass: configuration_params[:password],
user: configuration_params['username'],
pass: configuration_params['password'],
# The API supports XML and plain.
# In the latter case, only the status code is returned.
output: 'plain'

@ -23,6 +23,14 @@ module OpenProject::TwoFactorAuthentication
[:sms]
end
def self.validate_params
%w[access_key_id secret_access_key region].each do |key|
unless configuration_params[key]
raise ArgumentError, "Amazon SNS delivery settings is missing mandatory key :#{key}"
end
end
end
private
def send_sms
@ -54,8 +62,9 @@ module OpenProject::TwoFactorAuthentication
phone
end
# rubocop:disable Metric/AbcSize
def submit
aws_params = configuration_params.slice :region, :access_key_id, :secret_access_key
aws_params = configuration_params.slice 'region', 'access_key_id', 'secret_access_key'
sns = ::Aws::SNS::Client.new aws_params
sns.set_sms_attributes(
@ -65,7 +74,7 @@ module OpenProject::TwoFactorAuthentication
'DefaultSMSType' => 'Transactional',
# Set sender ID name (may not be supported in all countries)
'DefaultSenderID' => configuration_params.fetch(:sender_id, 'OpenProject')
'DefaultSenderID' => configuration_params.fetch('sender_id', 'OpenProject')
}
)
@ -82,20 +91,12 @@ module OpenProject::TwoFactorAuthentication
raise result
rescue StandardError => e
Rails.logger.error do
"[2FA] SNS delivery failed for user #{user.login} " \
"(Error: #{e})"
"[2FA] SNS delivery failed for user #{user.login} (Error: #{e})"
end
raise I18n.t('two_factor_authentication.sns.delivery_failed')
end
def self.validate_params
%i(access_key_id secret_access_key region).each do |key|
unless configuration_params[key]
raise ArgumentError, "Amazon SNS delivery settings is missing mandatory key :#{key}"
end
end
end
# rubocop:enable Metric/AbcSize
end
end
end

@ -25,7 +25,7 @@ module OpenProject::TwoFactorAuthentication
t = strategy.device_type
if types.key? t
raise ArgumentError, "Type #{t} already registered with strategy #{types[t].identifier}. " \
"You cannot register two strategies with the same types."
"You cannot register two strategies with the same types."
end
types[t] = strategy
@ -45,23 +45,23 @@ module OpenProject::TwoFactorAuthentication
##
# Determines whether admins can register devices on user's behalf
def admin_register_sms_strategy
enabled? && active_strategies.detect { |strategy_class| strategy_class.mobile_token? }
enabled? && active_strategies.detect(&:mobile_token?)
end
##
# Whether the system requires 2FA for all users
def enforced?
!!configuration[:enforced]
!!configuration['enforced']
end
##
# Determine whether the plugin settings can be changed from the UI
def configurable_by_ui?
!configuration[:hide_settings_menu_item]
!configuration['hide_settings_menu_item']
end
def allow_remember_for_days
configuration[:allow_remember_for_days].to_i
configuration['allow_remember_for_days'].to_i
end
##
@ -73,10 +73,10 @@ module OpenProject::TwoFactorAuthentication
##
# Fetch all active strategies
def active_strategies
configuration.fetch(:active_strategies, [])
.map(&:to_s)
.uniq
.map { |strategy| lookup_active_strategy strategy }
configuration.fetch('active_strategies', [])
.map(&:to_s)
.uniq
.map { |strategy| lookup_active_strategy strategy }
end
##
@ -93,40 +93,32 @@ module OpenProject::TwoFactorAuthentication
types << s.device_type
end
classes = types.map { |type| [type, ::TwoFactorAuthentication::Device.const_get(type.to_s.camelize)] }
Hash[classes]
types.index_with { |type| ::TwoFactorAuthentication::Device.const_get(type.to_s.camelize) }
end
##
# 2FA Plugin configuration
def configuration
config = OpenProject::Configuration['2fa'] || {}
settings = Setting.plugin_openproject_two_factor_authentication || {}
config = Setting.plugin_openproject_two_factor_authentication || {}
merge_with_settings! config, settings
merge_with_settings! config
config
end
def enforced_by_configuration?
enforced = (OpenProject::Configuration['2fa'] || {})[:enforced]
enforced = (OpenProject::Configuration['2fa'] || {})['enforced']
ActiveModel::Type::Boolean.new.cast enforced
end
def merge_with_settings!(config, settings)
predefined_strategies = config.fetch(:active_strategies, [])
additional_strategies = settings.fetch(:active_strategies, [])
config[:active_strategies] = predefined_strategies | additional_strategies
def merge_with_settings!(config)
config['active_strategies'] ||= []
# Always enable totp if nothing is enabled
config[:active_strategies] << :totp if add_default_strategy?(config)
# Allow enforcing from settings if not true in configuration
config[:enforced] ||= settings[:enforced]
config[:allow_remember_for_days] = config.fetch(:allow_remember_for_days, settings[:allow_remember_for_days])
config['active_strategies'] << :totp if add_default_strategy?(config)
end
def add_default_strategy?(config)
config[:active_strategies].empty? && !config[:disabled].present?
config['active_strategies'].empty? && config['disabled'].blank?
end
def available_strategies

@ -13,7 +13,7 @@ describe ::TwoFactorAuthentication::AuthenticationController, with_2fa_ee: true,
allow_any_instance_of(User).to receive(:any_active_memberships?).and_return(true)
end
describe 'with no active strategy', with_config: { '2fa' => {} } do
describe 'with no active strategy', with_settings: { 'plugin_openproject_two_factor_authentication' => {} } do
before do
session[:authenticated_user_id] = user.id
get :request_otp
@ -23,7 +23,7 @@ describe ::TwoFactorAuthentication::AuthenticationController, with_2fa_ee: true,
end
describe 'with no active strategy, but 2FA enforced as configuration',
with_config: { '2fa' => { active_strategies: [], enforced: true } } do
with_settings: { 'plugin_openproject_two_factor_authentication' => { active_strategies: [], enforced: true } } do
before do
allow(OpenProject::TwoFactorAuthentication::TokenStrategyManager)
.to receive(:add_default_strategy?)
@ -37,7 +37,9 @@ describe ::TwoFactorAuthentication::AuthenticationController, with_2fa_ee: true,
end
end
describe 'with one active strategy, enforced', with_config: { '2fa' => { active_strategies: [:developer], enforced: true } } do
describe 'with one active strategy, enforced', with_settings: {
'plugin_openproject_two_factor_authentication' => { active_strategies: [:developer], enforced: true }
} do
context 'with no device' do
before do
session[:authenticated_user_id] = user.id
@ -48,7 +50,8 @@ describe ::TwoFactorAuthentication::AuthenticationController, with_2fa_ee: true,
end
end
describe 'with one active strategy', with_config: { '2fa' => { active_strategies: [:developer] } } do
describe 'with one active strategy',
with_settings: { 'plugin_openproject_two_factor_authentication' => { active_strategies: [:developer] } } do
context 'with no device' do
before do
session[:authenticated_user_id] = user.id
@ -73,23 +76,28 @@ describe ::TwoFactorAuthentication::AuthenticationController, with_2fa_ee: true,
context 'with an invalid device' do
let!(:device) { create :two_factor_authentication_device_totp, user: user, channel: :totp }
it_behaves_like '2FA login request failure', I18n.t('two_factor_authentication.error_no_matching_strategy')
end
context 'with an active device' do
let!(:device) { create :two_factor_authentication_device_sms, user: user, channel: :sms }
it_behaves_like '2FA SMS request success'
end
end
describe 'with two active strategy', with_config: { '2fa' => { active_strategies: %i[developer totp] } } do
describe 'with two active strategy',
with_settings: { 'plugin_openproject_two_factor_authentication' => { active_strategies: %i[developer totp] } } do
context 'with a totp device' do
let!(:device) { create :two_factor_authentication_device_totp, user: user, channel: :totp }
it_behaves_like '2FA TOTP request success'
end
context 'with an sms device' do
let!(:device) { create :two_factor_authentication_device_sms, user: user, channel: :sms }
it_behaves_like '2FA SMS request success'
end
end
@ -99,7 +107,7 @@ describe ::TwoFactorAuthentication::AuthenticationController, with_2fa_ee: true,
get :confirm_otp
end
it "should receive a 405" do
it "receives a 405" do
expect(response.response_code).to eq(405)
end
end

@ -5,21 +5,26 @@ describe ::TwoFactorAuthentication::ForcedRegistration::TwoFactorDevicesControll
let(:user) { create(:user, login: 'foobar') }
let(:logged_in_user) { User.anonymous }
let(:active_strategies) { [] }
let(:config) { {} }
let(:authenticated_user_id) { user.id }
let(:user_force_2fa) { true }
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: {
'active_strategies' => active_strategies
}
}
end
end
before do
allow(User).to receive(:current).and_return(User.anonymous)
session[:authenticated_user_id] = authenticated_user_id
session[:authenticated_user_force_2fa] = user_force_2fa
session[:stage_secrets] = { two_factor_authentication: 'asdf' }
allow(OpenProject::Configuration).to receive(:[]).and_call_original
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return({ active_strategies: active_strategies }.merge(config).with_indifferent_access)
allow(OpenProject::TwoFactorAuthentication::TokenStrategyManager)
.to receive(:add_default_strategy?)
.and_return false
@ -41,17 +46,6 @@ describe ::TwoFactorAuthentication::ForcedRegistration::TwoFactorDevicesControll
end
end
context 'when authenticated_user present, but no registration' do
let(:active_strategies) { [:developer] }
let(:authenticated_user_id) { nil }
let(:user_force_2fa) { nil }
it 'does not give access' do
expect(response).to be_redirect
expect(response).to redirect_to stage_failure_path(stage: :two_factor_authentication)
end
end
context 'when logged in, but not enabled' do
it 'does not give access' do
expect(response.status).to eq 404

@ -8,12 +8,18 @@ describe ::TwoFactorAuthentication::My::TwoFactorDevicesController, with_2fa_ee:
let(:active_strategies) { [] }
let(:config) { {} }
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: {
'active_strategies' => active_strategies
}.merge(config)
}
end
end
before do
allow(User).to receive(:current).and_return(logged_in_user)
allow(OpenProject::Configuration).to receive(:[]).and_call_original
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return({ active_strategies: active_strategies }.merge(config).with_indifferent_access)
allow(OpenProject::TwoFactorAuthentication::TokenStrategyManager)
.to receive(:add_default_strategy?)
.and_return false

@ -9,12 +9,18 @@ describe ::TwoFactorAuthentication::Users::TwoFactorDevicesController, with_2fa_
let(:active_strategies) { [:developer] }
let(:config) { {} }
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: {
'active_strategies' => active_strategies
}.merge(config)
}
end
end
before do
allow(User).to receive(:current).and_return(logged_in_user)
allow(OpenProject::Configuration).to receive(:[]).and_call_original
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return({ active_strategies: active_strategies }.merge(config).with_indifferent_access)
allow(OpenProject::TwoFactorAuthentication::TokenStrategyManager)
.to receive(:add_default_strategy?)
.and_return false

@ -5,7 +5,9 @@ describe 'activating an invited account',
with_2fa_ee: true,
type: :feature,
js: true,
with_config: { '2fa': { active_strategies: [:developer] } } do
with_settings: {
plugin_openproject_two_factor_authentication: { 'active_strategies' => [:developer] }
} do
let(:user) do
user = build :user, first_login: true
UserInvitation.invite_user! user
@ -20,7 +22,7 @@ describe 'activating an invited account',
token: token.value,
only_path: true)
expect(current_path).to eql account_register_path
expect(page).to have_current_path account_register_path
fill_in I18n.t('attributes.password'), with: 'Password1234'
fill_in I18n.t('activerecord.attributes.user.password_confirmation'), with: 'Password1234'
@ -42,10 +44,12 @@ describe 'activating an invited account',
it 'requests a OTP' do
sms_token = nil
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::Developer)
.to receive(:create_mobile_otp).and_wrap_original do |m|
sms_token = m.call
end
# rubocop:enable RSpec/AnyInstance
activate!
@ -67,12 +71,18 @@ describe 'activating an invited account',
fill_in I18n.t(:field_otp), with: 'asdf' # faulty token
click_button I18n.t(:button_login)
expect(current_path).to eql signin_path
expect(page).to have_current_path signin_path
expect(page).to have_content(I18n.t(:notice_account_otp_invalid))
end
end
context 'when enforced', with_config: { '2fa': { active_strategies: [:developer], enforced: true } } do
context 'when enforced',
with_settings: {
plugin_openproject_two_factor_authentication: {
'active_strategies' => [:developer],
'enforced' => true
}
} do
before do
activate!
end

@ -1,8 +1,12 @@
require_relative '../spec_helper'
describe 'Admin 2FA management', with_2fa_ee: true, type: :feature,
with_config: { '2fa': { active_strategies: %i[developer totp] } },
js: true do
describe 'Admin 2FA management',
with_2fa_ee: true,
type: :feature,
with_settings: {
plugin_openproject_two_factor_authentication: { 'active_strategies' => %i[developer totp] }
},
js: true do
let(:dialog) { ::Components::PasswordConfirmationDialog.new }
let(:user_password) { 'admin!' * 4 }
let(:other_user) { create :user, login: 'bob' }
@ -24,7 +28,7 @@ describe 'Admin 2FA management', with_2fa_ee: true, type: :feature,
page.find('.admin--edit-section a').click
expect(page).to have_selector('.generic-table--empty-row')
expect(current_path).to eq my_2fa_devices_path
expect(page).to have_current_path my_2fa_devices_path
end
it 'allows 2FA device management of the user' do

@ -2,7 +2,9 @@ require_relative '../../spec_helper'
require_relative '../shared_2fa_examples'
describe 'Login with 2FA backup code', with_2fa_ee: true, type: :feature,
with_config: { '2fa': { active_strategies: [:developer] } },
with_settings: {
plugin_openproject_two_factor_authentication: { 'active_strategies' => [:developer] }
},
js: true do
let(:user_password) { 'bob!' * 4 }
let(:user) do
@ -13,7 +15,7 @@ describe 'Login with 2FA backup code', with_2fa_ee: true, type: :feature,
end
let!(:device) { create :two_factor_authentication_device_sms, user: user, active: true, default: true }
context 'user has no backup code' do
context 'when user has no backup code' do
it 'does not show the backup code link' do
first_login_step
@ -23,7 +25,7 @@ describe 'Login with 2FA backup code', with_2fa_ee: true, type: :feature,
end
end
context 'user has backup codes' do
context 'when user has backup codes' do
let!(:valid_backup_codes) { ::TwoFactorAuthentication::BackupCode.regenerate! user }
it 'allows entering a backup code' do
@ -51,7 +53,7 @@ describe 'Login with 2FA backup code', with_2fa_ee: true, type: :feature,
# Expect failure
expect(page).to have_selector('.flash.error', text: I18n.t('two_factor_authentication.error_invalid_backup_code'))
expect(current_path).to eql signin_path
expect(page).to have_current_path signin_path
# Try again!
first_login_step

@ -1,9 +1,16 @@
require_relative '../../spec_helper'
require_relative '../shared_2fa_examples'
describe 'Login with enforced 2FA', with_2fa_ee: true, type: :feature,
with_config: { '2fa': { active_strategies: [:developer], enforced: true } },
js: true do
describe 'Login with enforced 2FA',
with_2fa_ee: true,
type: :feature,
with_settings: {
plugin_openproject_two_factor_authentication: {
'active_strategies' => [:developer],
'enforced' => true
}
},
js: true do
let(:user_password) { 'bob!' * 4 }
let(:user) do
create(:user,
@ -17,10 +24,12 @@ describe 'Login with enforced 2FA', with_2fa_ee: true, type: :feature,
it 'requests a 2FA' do
sms_token = nil
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::Developer)
.to receive(:create_mobile_otp).and_wrap_original do |m|
.to receive(:create_mobile_otp).and_wrap_original do |m|
sms_token = m.call
end
# rubocop:enable RSpec/AnyInstance
first_login_step
two_factor_step(sms_token)
@ -32,7 +41,7 @@ describe 'Login with enforced 2FA', with_2fa_ee: true, type: :feature,
two_factor_step('whatever')
expect(page).to have_selector('.flash.error', text: I18n.t(:notice_account_otp_invalid))
expect(current_path).to eql signin_path
expect(page).to have_current_path signin_path
end
end

@ -1,9 +1,15 @@
require_relative '../../spec_helper'
require_relative '../shared_2fa_examples'
describe 'Login with 2FA device', with_2fa_ee: true, type: :feature,
with_config: { '2fa': { active_strategies: [:developer] } },
js: true do
describe 'Login with 2FA device',
with_2fa_ee: true,
type: :feature,
with_settings: {
plugin_openproject_two_factor_authentication: {
'active_strategies' => [:developer]
}
},
js: true do
let(:user_password) { 'bob!' * 4 }
let(:user) do
create(:user,
@ -17,10 +23,12 @@ describe 'Login with 2FA device', with_2fa_ee: true, type: :feature,
it 'requests a 2FA' do
sms_token = nil
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::Developer)
.to receive(:create_mobile_otp).and_wrap_original do |m|
.to receive(:create_mobile_otp).and_wrap_original do |m|
sms_token = m.call
end
# rubocop:enable RSpec/AnyInstance
first_login_step
two_factor_step(sms_token)
@ -32,7 +40,7 @@ describe 'Login with 2FA device', with_2fa_ee: true, type: :feature,
two_factor_step('whatever')
expect(page).to have_selector('.flash.error', text: I18n.t(:notice_account_otp_invalid))
expect(current_path).to eql signin_path
expect(page).to have_current_path signin_path
end
end
end

@ -1,9 +1,13 @@
require_relative '../../spec_helper'
require_relative '../shared_2fa_examples'
describe 'Login by switching 2FA device', with_2fa_ee: true, type: :feature,
with_config: { '2fa': { active_strategies: %i[developer totp] } },
js: true do
describe 'Login by switching 2FA device',
with_2fa_ee: true,
type: :feature,
with_settings: {
plugin_openproject_two_factor_authentication: { 'active_strategies' => %i[developer totp] }
},
js: true do
let(:user_password) { 'bob!' * 4 }
let(:user) do
create(:user,

@ -1,9 +1,12 @@
require_relative '../spec_helper'
describe 'My Account 2FA configuration', with_2fa_ee: true,
type: :feature,
with_config: { '2fa': { active_strategies: %i[developer totp] } },
js: true do
describe 'My Account 2FA configuration',
with_2fa_ee: true,
type: :feature,
with_settings: {
plugin_openproject_two_factor_authentication: { 'active_strategies' => %i[developer totp] }
},
js: true do
let(:dialog) { ::Components::PasswordConfirmationDialog.new }
let(:user_password) { 'boB!4' * 4 }
let(:user) do
@ -26,7 +29,7 @@ describe 'My Account 2FA configuration', with_2fa_ee: true,
# Visit inline create
find('.wp-inline-create--add-link').click
expect(page).to have_selector('h2', text: I18n.t('two_factor_authentication.devices.add_new'))
expect(current_path).to eq new_my_2fa_device_path
expect(page).to have_current_path new_my_2fa_device_path
# Select SMS
find('.mobile-otp-new-device-sms .button').click
@ -45,10 +48,12 @@ describe 'My Account 2FA configuration', with_2fa_ee: true,
# Log token for next access
sms_token = nil
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::Developer)
.to receive(:create_mobile_otp).and_wrap_original do |m|
.to receive(:create_mobile_otp).and_wrap_original do |m|
sms_token = m.call
end
# rubocop:enable RSpec/AnyInstance
click_button I18n.t(:button_continue)

@ -1,8 +1,14 @@
require_relative '../spec_helper'
describe 'Password change with OTP', with_2fa_ee: true, type: :feature,
with_config: { '2fa': { active_strategies: [:developer] } },
js: true do
describe 'Password change with OTP',
with_2fa_ee: true,
type: :feature,
with_settings: {
plugin_openproject_two_factor_authentication: {
'active_strategies' => [:developer]
}
},
js: true do
let(:user_password) { 'boB&' * 4 }
let(:new_user_password) { '%obB' * 4 }
let(:user) do
@ -22,10 +28,12 @@ describe 'Password change with OTP', with_2fa_ee: true, type: :feature,
end
sms_token = nil
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::Developer)
.to receive(:create_mobile_otp).and_wrap_original do |m|
.to receive(:create_mobile_otp).and_wrap_original do |m|
sms_token = m.call
end
# rubocop:enable RSpec/AnyInstance
expect(page).to have_selector('h2', text: I18n.t(:button_change_password))
within('#content') do
@ -43,7 +51,7 @@ describe 'Password change with OTP', with_2fa_ee: true, type: :feature,
click_button I18n.t(:button_login)
end
expect(current_path).to eql expected_path_after_login
expect(page).to have_current_path(expected_path_after_login, ignore_query: true)
end
context 'when password is expired',

@ -4,7 +4,12 @@ require_relative '../shared_2fa_examples'
describe 'Login with 2FA remember cookie',
type: :feature,
with_2fa_ee: true,
with_config: { '2fa': { active_strategies: [:developer], allow_remember_for_days: 30 } },
with_settings: {
plugin_openproject_two_factor_authentication: {
active_strategies: [:developer],
allow_remember_for_days: 30
}
},
js: true do
let(:user_password) do
"user!user!"
@ -40,15 +45,20 @@ describe 'Login with 2FA remember cookie',
expect_not_logged_in
end
context 'not enabled',
with_config: { '2fa': { active_strategies: [:developer], allow_remember_for_days: 0 } } do
context 'when not enabled',
with_settings: {
plugin_openproject_two_factor_authentication: {
active_strategies: [:developer],
allow_remember_for_days: 0
}
} do
it 'does not show the save form' do
first_login_step
expect(page).to have_no_selector('input#remember_me')
end
end
context 'user has no remember cookie' do
context 'when user has no remember cookie' do
it 'can remove the autologin cookie after login' do
login_with_cookie
visit my_2fa_devices_path

@ -1,5 +1,6 @@
require_relative '../spec_helper'
# rubocop:disable RSpec/NestedGroups
describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
let(:dev_strategy) { ::OpenProject::TwoFactorAuthentication::TokenStrategy::Developer }
let(:totp_strategy) { ::OpenProject::TwoFactorAuthentication::TokenStrategy::Totp }
@ -12,10 +13,14 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
let(:enforced) { false }
context 'without EE' do
before do
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return(active_strategies: [:developer])
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: {
'active_strategies' => %i[developer]
}
}
end
end
it 'is not enabled' do
@ -24,10 +29,12 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
end
context 'with EE', with_2fa_ee: true do
before do
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return(configuration)
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: configuration
}
end
end
describe '#find_matching_strategy' do
@ -35,6 +42,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
context 'when no strategy is set' do
let(:active_strategies) { [] }
it 'returns nil' do
expect(subject).to be_nil
end
@ -52,7 +60,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
let(:active_strategies) { [:totp] }
it 'returns the strategy' do
expect(subject).to eq(nil)
expect(subject).to be_nil
end
end
end
@ -71,64 +79,19 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
end
end
describe 'with additional settings given' do
let(:active_strategies) { [:developer] }
let(:enforced) { false }
before do
allow(Setting).to receive(:plugin_openproject_two_factor_authentication).and_return(settings)
end
context 'when nothing given' do
let(:settings) { nil }
it 'uses the configuration' do
expect(described_class.active_strategies).to eq([dev_strategy])
expect(described_class).not_to be_enforced
end
end
context 'when additional strategy given' do
let(:settings) { { active_strategies: [:totp] } }
it 'merges configuration and settings' do
expect(described_class.active_strategies).to eq([dev_strategy, totp_strategy])
expect(described_class).not_to be_enforced
end
end
context 'when enforced set' do
context 'when true and config is false' do
let(:enforced) { false }
let(:settings) { { enforced: true } }
it 'does override the configuration' do
expect(described_class).to be_enforced
end
end
context 'when false and config is true' do
let(:enforced) { true }
let(:settings) { { enforced: false } }
it 'does not override the configuration' do
expect(described_class).to be_enforced
end
end
end
end
describe '#validate_active_strategies!' do
subject { described_class.validate_active_strategies! }
context 'when no strategy is set' do
let(:active_strategies) { [] }
before do
allow(OpenProject::TwoFactorAuthentication::TokenStrategyManager)
.to receive(:add_default_strategy?)
.and_return false
.and_return false
end
context 'and enforced is false' do
context 'when enforced is false' do
let(:enforced) { false }
it 'accepts that' do
@ -138,7 +101,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
end
end
context 'and enforced is true' do
context 'when enforced is true' do
let(:enforced) { true }
it 'raises and error that a strategy is needed' do
@ -152,7 +115,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
context 'when a strategy is set' do
let(:active_strategies) { [:developer] }
context 'and it is valid' do
context 'when it is valid' do
it 'returns that' do
expect { subject }.not_to raise_error
expect(described_class.active_strategies).to eq([dev_strategy])
@ -161,11 +124,10 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
end
end
context 'and it is invalid' do
before do
expect(dev_strategy).to receive(:validate!).and_raise 'Error!'
end
context 'when it is invalid' do
it 'raises' do
allow(dev_strategy).to receive(:validate!).and_raise 'Error!'
expect { subject }.to raise_error 'Error!'
expect(described_class).to be_enabled
expect(described_class).not_to be_enforced
@ -175,3 +137,4 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategyManager do
end
end
end
# rubocop:enable RSpec/NestedGroups

@ -4,6 +4,7 @@ require 'timecop'
describe ::TwoFactorAuthentication::Device::Totp, with_2fa_ee: true, type: :model do
let(:user) { create :user }
let(:channel) { :totp }
subject { described_class.new identifier: 'foo', channel: channel, user: user, active: true }
describe 'validations' do
@ -27,40 +28,47 @@ describe ::TwoFactorAuthentication::Device::Totp, with_2fa_ee: true, type: :mode
describe 'token validation' do
let(:totp) { subject.send :totp }
context 'drift', with_config: { '2fa': { otp_drift_window: 30 } } do
context 'when setting drift',
with_settings: {
plugin_openproject_two_factor_authentication: {
'otp_drift_window' => 30
}
} do
it 'uses the drift window from configuration' do
expect(subject.allowed_drift).to eq 30
end
end
context 'no drift set' do
context 'when no drift set' do
it 'uses the default drift window' do
expect(subject.allowed_drift).to eq 60
end
it 'uses the drift value for verification' do
# Assume never used
# rubocop:disable RSpec/SubjectStub
allow(subject).to receive(:last_used_at).and_return nil
allow(subject).to receive(:update_column).with(:last_used_at, any_args)
# rubocop:enable RSpec/SubjectStub
Timecop.freeze(Time.now) do
old_valid = totp.at(Time.now - 30.seconds)
old_valid2 = totp.at(Time.now - 60.seconds)
new_valid = totp.at(Time.now + 30.seconds)
new_valid2 = totp.at(Time.now + 60.seconds)
Timecop.freeze(Time.current) do
old_valid = totp.at(30.seconds.ago)
old_valid2 = totp.at(60.seconds.ago)
new_valid = totp.at(30.seconds.from_now)
new_valid2 = totp.at(60.seconds.from_now)
# Next iteration at interval is 90
# which should be invalid
old_invalid = totp.at(Time.now - 90.seconds)
new_invalid = totp.at(Time.now + 90.seconds)
old_invalid = totp.at(90.seconds.ago)
new_invalid = totp.at(90.seconds.from_now)
expect(subject.verify_token(old_valid)).to eq true
expect(subject.verify_token(old_valid2)).to eq true
expect(subject.verify_token(new_valid)).to eq true
expect(subject.verify_token(new_valid2)).to eq true
expect(subject.verify_token(old_valid)).to be true
expect(subject.verify_token(old_valid2)).to be true
expect(subject.verify_token(new_valid)).to be true
expect(subject.verify_token(new_valid2)).to be true
expect(subject.verify_token(old_invalid)).to eq false
expect(subject.verify_token(new_invalid)).to eq false
expect(subject.verify_token(old_invalid)).to be false
expect(subject.verify_token(new_invalid)).to be false
end
end
end
@ -68,18 +76,18 @@ describe ::TwoFactorAuthentication::Device::Totp, with_2fa_ee: true, type: :mode
it 'avoids double verification' do
subject.save!
valid = totp.at(Time.now)
expect(subject.verify_token(valid)).to eq true
valid = totp.at(Time.current)
expect(subject.verify_token(valid)).to be true
last_date = subject.last_used_at
expect(last_date).to be_present
expect(subject.verify_token(valid)).to eq false
expect(subject.verify_token(valid)).to be false
future = Time.now + 1.minute
future = 1.minute.from_now
Timecop.freeze(future) do
valid = totp.now
expect(subject.verify_token(valid)).to eq true
expect(subject.verify_token(valid)).to eq false
expect(subject.verify_token(valid)).to be true
expect(subject.verify_token(valid)).to be false
end
end
end

@ -8,17 +8,29 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::MessageBird, wit
let!(:device) { create :two_factor_authentication_device_sms, user: user, channel: channel }
let(:service_url) { 'https://example.org/foobar' }
let(:apikey) { 'whatever' }
let(:params) do
{
apikey: 'whatever'
apikey: apikey
}
end
before do
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return(active_strategies: [:message_bird], message_bird: params)
let(:result) { subject.request }
subject { ::TwoFactorAuthentication::TokenService.new user: user }
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: {
'active_strategies' => [:message_bird],
'message_bird' => params
}
}
end
end
before do
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::MessageBird)
.to receive(:create_mobile_otp)
.and_return('1234')
@ -34,12 +46,21 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::MessageBird, wit
end
end
subject { ::TwoFactorAuthentication::TokenService.new user: user }
let(:result) { subject.request }
describe 'calling a mocked test API' do
let(:channel) { :sms }
before do
allow(::MessageBird::Client).to receive(:new)
end
it 'uses the api key defined in the settings' do
result
expect(::MessageBird::Client).to have_received(:new).with(apikey)
end
end
describe 'calling the test API' do
describe 'calling the real test API' do
let(:apikey) { ENV['MESSAGEBIRD_TEST_APIKEY'] }
let(:params) { { apikey: apikey } }
before do
skip 'Missing MESSAGEBIRD_TEST_APIKEY environment variable' unless apikey.present?

@ -25,11 +25,22 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Restdt, with_2fa
}
end
before do
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return(active_strategies: [:restdt], restdt: params)
let(:result) { subject.request }
subject { ::TwoFactorAuthentication::TokenService.new user: user }
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: {
'active_strategies' => [:restdt],
'restdt' => params
}
}
end
end
before do
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::Restdt)
.to receive(:create_mobile_otp)
.and_return('1234')
@ -45,9 +56,6 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Restdt, with_2fa
end
end
subject { ::TwoFactorAuthentication::TokenService.new user: user }
let(:result) { subject.request }
describe 'calling a mocked API', webmock: true do
let(:response_code) { '200' }
let(:channel) { :sms }
@ -74,6 +82,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Restdt, with_2fa
describe 'request body' do
context 'with SMS' do
let(:expected_params) { { onlycall: '0' } }
it_behaves_like 'API response', true
end

@ -7,7 +7,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Sns, with_2fa_ee
let!(:device) { create :two_factor_authentication_device_sms, user: user, channel: channel }
let(:channel) { :sms }
let(:params) do
let(:sns_params) do
{
region: 'eu-west-1',
access_key_id: 'foobar',
@ -15,22 +15,38 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Sns, with_2fa_ee
}
end
before do
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return(active_strategies: [:sns], sns: params)
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: {
'active_strategies' => [:sns],
'sns' => sns_params
}
}
end
end
before do
allow_any_instance_of(::OpenProject::TwoFactorAuthentication::TokenStrategy::Sns)
.to receive(:create_mobile_otp)
.and_return('1234')
end
describe '#setup' do
let(:params) { { region: nil } }
context 'for valid params' do
it 'validates without errors' do
expect { described_class.validate! }
.not_to raise_exception
end
end
it 'raises an exception for incomplete params' do
expect { described_class.validate! }
.to raise_exception(ArgumentError)
context 'for incomplete params' do
let(:sns_params) { { region: nil } }
it 'raises an exception' do
expect { described_class.validate! }
.to raise_exception(ArgumentError)
end
end
end

@ -5,17 +5,12 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Totp, with_2fa_e
let!(:user) { create :user }
let!(:device) { create :two_factor_authentication_device_totp, user: user, default: true }
before do
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return(active_strategies: [:totp])
end
describe '#verify' do
subject { ::TwoFactorAuthentication::TokenService.new user: user }
let(:result) { subject.verify token }
context '#valid current token' do
context 'with valid current token' do
let(:token) { device.totp.now }
it 'is validated' do
@ -30,7 +25,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Totp, with_2fa_e
end
end
context 'invalid token' do
context 'with invalid token' do
let(:token) { 'definitely invalid' }
it 'is not validated' do
@ -39,7 +34,7 @@ describe ::OpenProject::TwoFactorAuthentication::TokenStrategy::Totp, with_2fa_e
end
end
context 'assuming internal error' do
context 'with internal error' do
let(:token) { 1234 }
before do

@ -6,34 +6,38 @@ describe ::TwoFactorAuthentication::TokenService, with_2fa_ee: true do
let(:dev_strategy) { ::OpenProject::TwoFactorAuthentication::TokenStrategy::Developer }
let(:configuration) do
{
active_strategies: active_strategies,
enforced: enforced
'active_strategies' => active_strategies,
'enforced' => enforced
}
end
let(:enforced) { false }
before do
allow(OpenProject::Configuration)
.to receive(:[]).with('2fa')
.and_return(configuration)
end
let(:result) { subject.request }
subject { described_class.new user: user }
let(:result) { subject.request }
include_context 'with settings' do
let(:settings) do
{
plugin_openproject_two_factor_authentication: configuration
}
end
end
context 'when no strategy is set' do
let(:active_strategies) { [] }
context 'when enforced' do
let(:enforced) { true }
before do
allow(OpenProject::TwoFactorAuthentication::TokenStrategyManager)
.to receive(:add_default_strategy?)
.and_return false
end
let(:enforced) { true }
it 'requires a token' do
expect(subject.requires_token?).to be_truthy
expect(subject).to be_requires_token
end
it 'returns error when requesting' do
@ -44,6 +48,7 @@ describe ::TwoFactorAuthentication::TokenService, with_2fa_ee: true do
context 'when not enforced' do
let(:enforced) { false }
before do
allow(OpenProject::TwoFactorAuthentication::TokenStrategyManager)
.to receive(:add_default_strategy?)
@ -51,7 +56,7 @@ describe ::TwoFactorAuthentication::TokenService, with_2fa_ee: true do
end
it 'requires no token' do
expect(subject.requires_token?).to be_falsey
expect(subject).not_to be_requires_token
end
it 'returns error when requesting' do
@ -64,28 +69,28 @@ describe ::TwoFactorAuthentication::TokenService, with_2fa_ee: true do
context 'when developer strategy is set' do
let(:active_strategies) { [:developer] }
context 'but no device exists' do
context 'when no device exists' do
it 'returns an error' do
expect(result).not_to be_success
expect(result.errors.full_messages).to eq [I18n.t('two_factor_authentication.error_no_device')]
end
end
context 'and matching device exists' do
context 'when matching device exists' do
let!(:device) { create :two_factor_authentication_device_sms, user: user, default: true }
it 'submits the request' do
expect(subject.requires_token?).to be_truthy
expect(subject).to be_requires_token
expect(result).to be_success
expect(result.errors).to be_empty
end
end
context 'and non-matching device exists' do
context 'when non-matching device exists' do
let!(:device) { create :two_factor_authentication_device_totp, user: user, default: true }
it 'submits the request' do
expect(subject.requires_token?).to be_truthy
expect(subject).to be_requires_token
expect(result).not_to be_success
expect(result.errors.full_messages).to eq [I18n.t('two_factor_authentication.error_no_matching_strategy')]
end
@ -103,7 +108,7 @@ describe ::TwoFactorAuthentication::TokenService, with_2fa_ee: true do
let(:use_device) { nil }
it 'uses the totp device' do
expect(subject.requires_token?).to be_truthy
expect(subject).to be_requires_token
expect(result).to be_success
expect(result.errors).to be_empty
@ -114,8 +119,9 @@ describe ::TwoFactorAuthentication::TokenService, with_2fa_ee: true do
context 'with overridden device' do
let(:use_device) { sms_device }
it 'uses the overridden device' do
expect(subject.requires_token?).to be_truthy
expect(subject).to be_requires_token
expect(result).to be_success
expect(result.errors).to be_empty

@ -73,11 +73,46 @@ describe Settings::Definition do
context 'when overriding from ENV' do
include_context 'with clean definitions'
it 'allows overriding configuration from ENV' do
stub_const('ENV', { 'OPENPROJECT_EDITION' => 'bim' })
def value_for(name)
all.detect { |d| d.name == name }.value
end
expect(all.detect { |d| d.name == 'edition' }.value)
.to eql 'bim'
it 'allows overriding configuration from ENV with OPENPROJECT_ prefix with double underscore case (legacy)' do
stub_const('ENV',
{
'OPENPROJECT_EDITION' => 'bim',
'OPENPROJECT_DEFAULT__LANGUAGE' => 'de'
})
expect(value_for('edition')).to eql 'bim'
expect(value_for('default_language')).to eql 'de'
end
it 'allows overriding configuration from ENV with OPENPROJECT_ prefix with single underscore case' do
stub_const('ENV', { 'OPENPROJECT_DEFAULT_LANGUAGE' => 'de' })
expect(value_for('default_language')).to eql 'de'
end
it 'allows overriding configuration from ENV without OPENPROJECT_ prefix' do
stub_const('ENV',
{
'EDITION' => 'bim',
'DEFAULT_LANGUAGE' => 'de'
})
expect(value_for('edition')).to eql 'bim'
expect(value_for('default_language')).to eql 'de'
end
it 'logs a deprecation warning when overriding configuration from ENV without OPENPROJECT_ prefix' do
allow(Rails.logger).to receive(:warn)
stub_const('ENV', { 'DEFAULT_LANGUAGE' => 'de' })
expect(value_for('default_language')).to eql 'de'
expect(Rails.logger)
.to have_received(:warn)
.with(a_string_including("use OPENPROJECT_DEFAULT_LANGUAGE instead of DEFAULT_LANGUAGE"))
end
it 'overriding boolean configuration from ENV will cast the value' do
@ -118,21 +153,181 @@ describe Settings::Definition do
it 'allows overriding settings hash partially from ENV' do
stub_const('ENV', { 'OPENPROJECT_REPOSITORY__CHECKOUT__DATA_GIT_ENABLED' => '1' })
expect(all.detect { |d| d.name == 'repository_checkout_data' }.value)
expect(value_for('repository_checkout_data'))
.to eql({
'git' => { 'enabled' => 1 },
'subversion' => { 'enabled' => 0 }
})
end
it 'ENV vars for which no definition exists will not be handled' do
it 'allows overriding settings hash partially from ENV with single underscore name' do
stub_const('ENV', { 'OPENPROJECT_REPOSITORY_CHECKOUT_DATA_GIT_ENABLED' => '1' })
expect(value_for('repository_checkout_data'))
.to eql({
'git' => { 'enabled' => 1 },
'subversion' => { 'enabled' => 0 }
})
end
it 'allows overriding settings hash partially from ENV with yaml data' do
stub_const('ENV', { 'OPENPROJECT_REPOSITORY_CHECKOUT_DATA' => '{git: {enabled: 1}}' })
expect(value_for('repository_checkout_data'))
.to eql({
'git' => { 'enabled' => 1 },
'subversion' => { 'enabled' => 0 }
})
end
it 'allows overriding settings hash fully from repeated ENV values' do
stub_const(
'ENV',
{
'OPENPROJECT_REPOSITORY__CHECKOUT__DATA' => '{hg: {enabled: 0}}',
'OPENPROJECT_REPOSITORY__CHECKOUT__DATA_CVS_ENABLED' => '0',
'OPENPROJECT_REPOSITORY_CHECKOUT_DATA_GIT_ENABLED' => '1',
'OPENPROJECT_REPOSITORY_CHECKOUT_DATA_GIT_MINIMUM__VERSION' => '42',
'OPENPROJECT_REPOSITORY_CHECKOUT_DATA_SUBVERSION_ENABLED' => '1'
}
)
expect(value_for('repository_checkout_data'))
.to eql({
'cvs' => { 'enabled' => 0 },
'git' => { 'enabled' => 1, 'minimum_version' => 42 },
'hg' => { 'enabled' => 0 },
'subversion' => { 'enabled' => 1 }
})
end
it 'allows overriding settings hash fully from ENV with yaml data' do
stub_const(
'ENV',
{
'OPENPROJECT_REPOSITORY_CHECKOUT_DATA' => '{git: {enabled: 1, key: "42"}, cvs: {enabled: 0}}'
}
)
expect(all.detect { |d| d.name == 'repository_checkout_data' }.value)
.to eql({
'git' => { 'enabled' => 1, 'key' => '42' },
'cvs' => { 'enabled' => 0 },
'subversion' => { 'enabled' => 0 }
})
end
it 'allows overriding settings hash fully from ENV with yaml data multiline' do
stub_const(
'ENV',
{
'OPENPROJECT_REPOSITORY_CHECKOUT_DATA' => <<~YML
---
git:
enabled: 1
key: "42"
cvs:
enabled: 0
YML
}
)
expect(all.detect { |d| d.name == 'repository_checkout_data' }.value)
.to eql({
'git' => { 'enabled' => 1, 'key' => '42' },
'cvs' => { 'enabled' => 0 },
'subversion' => { 'enabled' => 0 }
})
end
it 'allows overriding settings hash fully from ENV with json data' do
stub_const(
'ENV',
{
'OPENPROJECT_REPOSITORY_CHECKOUT_DATA' => '{"git": {"enabled": 1, "key": "42"}, "cvs": {"enabled": 0}}'
}
)
expect(all.detect { |d| d.name == 'repository_checkout_data' }.value)
.to eql({
'git' => { 'enabled' => 1, 'key' => '42' },
'cvs' => { 'enabled' => 0 },
'subversion' => { 'enabled' => 0 }
})
end
it 'allows overriding configuration array from ENV with yaml/json data' do
stub_const(
'ENV',
{
'OPENPROJECT_BLACKLISTED_ROUTES' => '["admin/info", "admin/plugins"]'
}
)
expect(value_for('blacklisted_routes'))
.to eq(['admin/info', 'admin/plugins'])
end
it 'allows overriding configuration array from ENV with space separated string' do
stub_const(
'ENV',
{
'OPENPROJECT_BLACKLISTED_ROUTES' => 'admin/info admin/plugins'
}
)
# works for OpenProject::Configuration thanks to OpenProject::Configuration::Helper mixin
expect(OpenProject::Configuration.blacklisted_routes)
.to eq(['admin/info', 'admin/plugins'])
# sadly behaves differently for Setting
expect(Setting.blacklisted_routes)
.to eq('admin/info admin/plugins')
end
context 'with definitions from plugins' do
let(:definition_2fa) { definitions_before.find { _1.name == 'plugin_openproject_two_factor_authentication' }.dup }
before do
# hack to have access to Setting.plugin_openproject_two_factor_authentication after
# having done
described_class.all << definition_2fa
end
it 'allows overriding settings hash partially from ENV with aliased env name' do
stub_const(
'ENV',
{
'OPENPROJECT_2FA_ENFORCED' => 'true',
'OPENPROJECT_2FA_ALLOW__REMEMBER__FOR__DAYS' => '15'
}
)
described_class.send(:override_value, definition_2fa) # override from env manually after changing ENV
expect(value_for('plugin_openproject_two_factor_authentication'))
.to eq('active_strategies' => [:totp], 'enforced' => true, 'allow_remember_for_days' => 15)
end
it 'allows overriding settings hash from ENV with aliased env name' do
stub_const(
'ENV',
{
'OPENPROJECT_2FA' => '{"enforced": true, "allow_remember_for_days": 15}'
}
)
described_class.send(:override_value, definition_2fa) # override from env manually after changing ENV
expect(value_for('plugin_openproject_two_factor_authentication'))
.to eq({ 'active_strategies' => [:totp], 'enforced' => true, 'allow_remember_for_days' => 15 })
end
end
it 'will not handle ENV vars for which no definition exists' do
stub_const('ENV', { 'OPENPROJECT_BOGUS' => '1' })
expect(all.detect { |d| d.name == 'bogus' })
.to be_nil
end
it 'ENV vars for which a definition has been added after #all was called first (e.g. in a module)' do
it 'will handle ENV vars for definitions added after #all was called (e.g. in a module)' do
stub_const('ENV', { 'OPENPROJECT_BOGUS' => '1' })
all
@ -356,18 +551,18 @@ describe Settings::Definition do
end
before do
instance.override_value({ abc: { a: 5 }, xyz: 2 })
instance.override_value({ abc: { 'a' => 5 }, xyz: 2 })
end
it 'deep merges' do
it 'deep merges and transforms keys to string' do
expect(instance.value)
.to eql({
abc: {
a: 5,
b: 2
'abc' => {
'a' => 5,
'b' => 2
},
cde: 1,
xyz: 2
'cde' => 1,
'xyz' => 2
})
end
@ -504,7 +699,7 @@ describe Settings::Definition do
context 'with the minimal attributes (hash value)' do
let(:instance) do
described_class.new 'bogus',
value: { a: 'b' }
value: { a: 'b', c: { d: 'e' } }
end
it 'has the format (in symbol) deduced' do
@ -516,6 +711,14 @@ describe Settings::Definition do
expect(instance)
.to be_serialized
end
it 'transforms keys to string' do
expect(instance.value)
.to eq({
'a' => 'b',
'c' => { 'd' => 'e' }
})
end
end
context 'with the minimal attributes (array value)' do

@ -269,9 +269,8 @@ describe Setting, type: :model do
end
# tests the serialization feature to store complex data types like arrays in settings
describe 'serialized settings' do
describe 'serialized array settings' do
before do
# note: default_projects_modules is marked as serialized in settings.yml (no type-based automagic here)
described_class.default_projects_modules = ['some_input']
end
@ -279,9 +278,28 @@ describe Setting, type: :model do
expect(described_class.default_projects_modules).to eq ['some_input']
expect(described_class.find_by(name: 'default_projects_modules').value).to eq ['some_input']
end
end
after do
described_class.find_by(name: 'default_projects_modules').destroy
# tests the serialization feature to store complex data types like arrays in settings
describe 'serialized hash settings' do
before do
setting = described_class.create!(name: 'repository_checkout_data')
setting.update_columns(
value: {
git: { enabled: 0 },
subversion: { enabled: 0 }
}.to_yaml
)
end
it 'deserializes hashes stored with symbol keys as string keys' do
expected_value = {
"git" => { "enabled" => 0 },
"subversion" => { "enabled" => 0 }
}
expect(described_class.repository_checkout_data).to eq(expected_value)
expect(described_class.find_by(name: 'repository_checkout_data').value).to eq(expected_value)
end
end
@ -298,7 +316,7 @@ describe Setting, type: :model do
Rails.cache.clear
end
context 'cache is empty' do
context 'when cache is empty' do
it 'requests the settings once from database' do
expect(Setting).to receive(:pluck).with(:name, :value)
.once

@ -55,7 +55,33 @@ RSpec.shared_context 'with settings reset' do
end
end
module WithSettingsMixin
module_function
def with_settings(settings)
allow(Setting).to receive(:[]).and_call_original
settings.each do |k, v|
name = k.to_s.sub(/\?\Z/, '') # remove trailing question mark if present to get setting name
raise "#{k} is not a valid setting" unless Setting.respond_to?(name)
expect(name).not_to start_with("localized_"), -> do
base = name[10..]
"Don't use `#{name}` in `with_settings`. Do this: `with_settings: { #{base}: { \"en\" => \"#{v}\" } }`"
end
v = v.deep_stringify_keys if v.is_a?(Hash)
allow(Setting).to receive(:[]).with(name).and_return v
allow(Setting).to receive(:[]).with(name.to_sym).and_return v
end
end
end
RSpec.configure do |config|
config.include WithSettingsMixin
# examples tagged with `:settings_reset` will automatically have the settings
# reset before the example, and restored after.
config.include_context "with settings reset", :settings_reset
@ -64,23 +90,13 @@ RSpec.configure do |config|
settings = example.metadata[:with_settings]
if settings.present?
settings = aggregate_mocked_settings(example, settings)
allow(Setting).to receive(:[]).and_call_original
settings.each do |k, v|
name = k.to_s.sub(/\?\Z/, '') # remove trailing question mark if present to get setting name
raise "#{k} is not a valid setting" unless Setting.respond_to?(name)
expect(name).not_to start_with("localized_"), -> do
base = name[10..-1]
"Don't use `#{name}` in `with_settings`. Do this: `with_settings: { #{base}: { \"en\" => \"#{v}\" } }`"
end
allow(Setting).to receive(:[]).with(name).and_return v
allow(Setting).to receive(:[]).with(name.to_sym).and_return v
end
with_settings(settings)
end
end
end
RSpec.shared_context 'with settings' do
before do
with_settings(settings)
end
end

@ -43,13 +43,13 @@ describe 'account/register', type: :view do
let(:auth_source) { create :auth_source }
let(:user) { build :user, auth_source: auth_source }
it 'should not show a login field' do
it 'does not show a login field' do
expect(rendered).not_to include('user[login]')
end
end
context 'without auth source' do
it 'should show a login field' do
it 'shows a login field' do
expect(rendered).to include('user[login]')
end
end
@ -67,21 +67,21 @@ describe 'account/register', type: :view do
let(:auth_source) { create :auth_source }
let(:user) { build :user, auth_source: auth_source }
it 'should not show a login field' do
it 'does not show a login field' do
expect(rendered).not_to include('user[login]')
end
it 'should show an email field' do
it 'shows an email field' do
expect(rendered).to include('user[mail]')
end
end
context 'without auth source' do
it 'should not show a login field' do
it 'does not show a login field' do
expect(rendered).not_to include('user[login]')
end
it 'should show an email field' do
it 'shows an email field' do
expect(rendered).to include('user[mail]')
end
end
@ -96,7 +96,7 @@ describe 'account/register', type: :view do
assign(:user, user)
end
it 'should render the registration footer from the settings' do
it 'renders the registration footer from the settings' do
render
expect(rendered).to include(footer)
@ -106,8 +106,8 @@ describe 'account/register', type: :view do
context "with consent required", with_settings: {
consent_required: true,
consent_info: {
en: "You must consent!",
de: "Du musst zustimmen!"
'en' => "You must consent!",
'de' => "Du musst zustimmen!"
}
} do
let(:locale) { raise "you have to define the locale" }

@ -82,7 +82,7 @@ module LegacyAssertionsAndHelpers
file
end
def with_settings(options, &_block)
def with_legacy_settings(options, &_block)
saved_settings = options.keys.inject({}) { |h, k| h[k] = Setting[k].dup; h }
options.each { |k, v| Setting[k] = v }
yield

@ -175,7 +175,7 @@ describe MailHandler, type: :model do
assert_equal false, submit_email('ticket_without_from_header.eml')
end
context 'without default start_date', with_settings: { work_package_startdate_is_adddate: false } do
context 'without default start_date', with_legacy_settings: { work_package_startdate_is_adddate: false } do
it 'should add work package with invalid attributes' do
issue = submit_email('ticket_with_invalid_attributes.eml', allow_override: 'type,category,priority')
assert issue.is_a?(WorkPackage)
@ -309,7 +309,7 @@ describe MailHandler, type: :model do
end
context 'with min password length',
with_settings: { password_min_length: 15 } do
with_legacy_settings: { password_min_length: 15 } do
it 'should new user from attributes should respect minimum password length' do
user = MailHandler.new_user_from_attributes('jsmith@example.net')
assert user.valid?

@ -140,7 +140,7 @@ describe Project, type: :model do
end
context 'with modules',
with_settings: { default_projects_modules: ['work_package_tracking', 'repository'] } do
with_legacy_settings: { default_projects_modules: ['work_package_tracking', 'repository'] } do
it 'should enabled module names' do
project = Project.new

Loading…
Cancel
Save