Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions lib/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -273,14 +273,8 @@ module Test
# PRIVATE CONFIGURATION

# Store scopes mappings.
mattr_accessor :mappings
@@mappings = {}
def self.mappings
# Starting from Rails 8.0, routes are lazy-loaded by default in test and development environments.
# However, Devise's mappings are built during the routes loading phase.
# To ensure it works correctly, we need to load the routes first before accessing @@mappings.
Rails.application.try(:reload_routes_unless_loaded)
@@mappings
end

# OmniAuth configurations.
mattr_reader :omniauth_configs
Expand Down Expand Up @@ -360,11 +354,29 @@ def self.mailer=(class_name)
end
self.mailer = "Devise::Mailer"

# Small method that adds a mapping to Devise.
# Registers a mapping with Devise. This initializes a mapping that only
# contains information about the model; routing information must be added
# later (typically while routes are loaded) via
# `Devise::Mapping#add_routes_options!`.
#
# This is now idempotent, so that `devise_for` can still create a mapping if
# it was not initialized before route loading, or use the existing one if it
# was previously initialized.
def self.add_mapping(resource, options)
_scoped_path, name = Devise::Mapping.mapping_name(resource, as: options[:as], singular: options[:singular])

if (mapping = @@mappings[name])
requested = (options[:class_name] || resource.to_s.classify).to_s
if options.key?(:class_name) && requested != mapping.class_name
raise ArgumentError, "conflicting class_name for the #{name.inspect} scope: " \
"already mapped to #{mapping.class_name.inspect} but got #{requested.inspect}"
end
return mapping
end

mapping = Devise::Mapping.new(resource, options)
@@mappings[mapping.name] = mapping
@@default_scope ||= mapping.name
@@mappings[name] = mapping
@@default_scope ||= name
@@helpers.each { |h| h.define_helpers(mapping) }
mapping
end
Expand Down
21 changes: 17 additions & 4 deletions lib/devise/mapping.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,26 @@ def self.find_scope!(obj)
end

def self.find_by_path!(path, path_type = :fullpath)
Devise.mappings.each_value { |m| return m if path.include?(m.send(path_type)) }
Devise.mappings.each_value { |m| return m if m.finalized? && path.include?(m.send(path_type)) }
raise "Could not find a valid mapping for path #{path.inspect}"
end

def self.mapping_name(resource, as: nil, singular: nil)
scoped_path = as ? "#{as}/#{resource}" : resource.to_s
singular = (singular || scoped_path.tr('/', '_').singularize).to_sym
[scoped_path, singular]
end

def initialize(name, options) #:nodoc:
@scoped_path = options[:as] ? "#{options[:as]}/#{name}" : name.to_s
@singular = (options[:singular] || @scoped_path.tr('/', '_').singularize).to_sym
@resource = name
@scoped_path, @singular = self.class.mapping_name(name, as: options[:as], singular: options[:singular])

@class_name = (options[:class_name] || name.to_s.classify).to_s
@klass = Devise.ref(@class_name)
end

@path = (options[:path] || name).to_s
def add_routes_options!(options)
@path = (options[:path] || @resource).to_s
@path_prefix = options[:path_prefix]

@sign_out_via = options[:sign_out_via] || Devise.sign_out_via
Expand All @@ -71,6 +79,11 @@ def initialize(name, options) #:nodoc:
default_path_names(options)
default_used_route(options)
default_used_helpers(options)
@finalized = true
end

def finalized?
!!@finalized
end

# Return modules for the mapping.
Expand Down
5 changes: 5 additions & 0 deletions lib/devise/rails.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'devise/rails/routes'
require 'devise/rails/devise_model'
require 'devise/rails/warden_compat'

module Devise
Expand All @@ -17,6 +18,10 @@ class Engine < ::Rails::Engine
app.reload_routes! if Devise.reload_routes
end

config.after_initialize do
Devise.configure_warden!
end

initializer "devise.deprecator" do |app|
app.deprecators[:devise] = Devise.deprecator if app.respond_to?(:deprecators)
end
Expand Down
25 changes: 25 additions & 0 deletions lib/devise/rails/devise_model.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Devise
ALLOWED_DEVISE_MODEL_OPTIONS = [:class_name, :singular, :as].freeze

# Registers the model phase of a Devise mapping during Rails initialization,
# before routes are loaded. This makes Devise.mappings[scope] available early
# (with its model, scope and strategies) even when routes are lazy-loaded,
# which Rails does by default in development and test starting from Rails 8.
#
# Devise.devise_model :user
# Devise.devise_model :admin, class_name: "Account", singular: :manager
def self.devise_model(resource, options = {})
options = options.symbolize_keys
invalid_options = options.keys - ALLOWED_DEVISE_MODEL_OPTIONS

unless invalid_options.empty?
raise ArgumentError, "Devise.devise_model only accepts model options " \
"(#{ALLOWED_DEVISE_MODEL_OPTIONS.join(', ')}); got: #{invalid_options.join(', ')}. " \
"Routing options must be given to devise_for."
end

add_mapping(resource, options)
end
end
2 changes: 1 addition & 1 deletion lib/devise/rails/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ def finalize!
" to :main_app as well in case you want to keep the current behavior."
end

Devise.configure_warden!
Devise.regenerate_helpers!
true
end
Expand Down Expand Up @@ -240,6 +239,7 @@ def devise_for(*resources)

resources.each do |resource|
mapping = Devise.add_mapping(resource, options)
mapping.add_routes_options!(options)

begin
raise_no_devise_method_error!(mapping.class_name) unless mapping.to.respond_to?(:devise)
Expand Down
56 changes: 56 additions & 0 deletions test/mapping_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,60 @@ def user.devise_scope; :special_scope; end
Devise::Mapping.find_by_path!('/accounts/facebook/callback', :path)
end
end

test 'mapping_name computes the scoped path and scope from the resource' do
assert_equal ["users", :user], Devise::Mapping.mapping_name(:users)
assert_equal ["user", :user], Devise::Mapping.mapping_name(:user)
end

test 'mapping_name honors the :singular option' do
assert_equal ["accounts", :manager], Devise::Mapping.mapping_name(:accounts, singular: :manager)
end

test 'mapping_name honors the :as option' do
assert_equal ["publisher/account", :publisher_account], Devise::Mapping.mapping_name(:account, as: "publisher")
end

test 'a mapping built from devise_for is finalized' do
assert Devise.mappings[:user].finalized?
end

test 'a partial mapping exposes the model phase but is not finalized' do
mapping = Devise::Mapping.new(:partial_admin, class_name: "Admin", singular: :partial_admin)

assert_not mapping.finalized?
assert_equal Admin, mapping.to
assert_equal :partial_admin, mapping.name
assert_equal Admin.devise_modules, mapping.modules
assert_equal [:database_authenticatable], mapping.strategies
end

test 'add_routes_options! finalizes a partial mapping with the routing options' do
mapping = Devise::Mapping.new(:partial_admin, class_name: "Admin")
mapping.add_routes_options!(path: "admins_area", sign_out_via: :get)

assert mapping.finalized?
assert_equal "admins_area", mapping.path
assert_equal :get, mapping.sign_out_via
assert_not_nil mapping.used_routes
end

test 'add_routes_options! defaults the path from the resource, not the singular' do
mapping = Devise::Mapping.new(:accounts, class_name: "Admin", singular: :manager)
mapping.add_routes_options!({})

assert_equal "accounts", mapping.path
assert_equal :manager, mapping.name
end

test 'find_by_path! ignores mappings that are not finalized' do
partial = Devise::Mapping.new(:partial_admin, class_name: "Admin")
Devise.mappings[:partial_admin] = partial

assert_raise RuntimeError do
Devise::Mapping.find_by_path!("/partial_admin/sign_in")
end
ensure
Devise.mappings.delete(:partial_admin)
end
end
6 changes: 6 additions & 0 deletions test/rails_app/config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
config.secret_key = "d9eb5171c59a4c817f68b0de27b8c1e340c2341b52cdbc60d3083d4e8958532" \
"18dcc5f589cafde048faec956b61f864b9b5513ff9ce29bf9e5d58b0f234f8e3b"

# An example of configuring a devise-enabled model via early initialization,
# before route loading. This same resource name still needs to be configured
# again in the routes, via `devise_for`, in order to register its routes and
# finalize the mapping configuration.
config.devise_model :users

# ==> Mailer Configuration
# Configure the e-mail address which will be shown in Devise::Mailer,
# note that it will be overwritten if you use your own mailer class with default "from" parameter.
Expand Down
51 changes: 51 additions & 0 deletions test/routes_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,54 @@ class ScopedRoutingTest < ActionController::TestCase
assert_equal '/publisher/accounts/get_in', @routes.url_helpers.new_publisher_account_session_path
end
end

class DeviseModelTest < ActiveSupport::TestCase
test 'registers a partial mapping that is not finalized' do
mapping = Devise.devise_model :registered_admin, class_name: "Admin"

assert_equal mapping, Devise.mappings[:registered_admin]
assert_not mapping.finalized?
assert_equal Admin, mapping.to
ensure
Devise.mappings.delete(:registered_admin)
end

test 'derives the class_name from the resource name when not given' do
mapping = Devise.devise_model :registered_admin

assert_equal "RegisteredAdmin", mapping.class_name
ensure
Devise.mappings.delete(:registered_admin)
end

test 'is idempotent for an already registered scope' do
first = Devise.devise_model :registered_admin, class_name: "Admin"
second = Devise.devise_model :registered_admin, class_name: "Admin"

assert_same first, second
ensure
Devise.mappings.delete(:registered_admin)
end

test 'rejects routing options' do
e = assert_raise ArgumentError do
Devise.devise_model :registered_admin, path: "admins_area"
end
assert_match "only accepts model options", e.message
assert_nil Devise.mappings[:registered_admin]
end

test 'devise_for raises on a class_name conflicting with the partial mapping' do
Devise.devise_model :registered_admin, class_name: "Admin"

e = assert_raise ArgumentError do
routes = ActionDispatch::Routing::RouteSet.new
routes.draw do
devise_for :registered_admin, class_name: "User"
end
end
assert_match "conflicting class_name", e.message
ensure
Devise.mappings.delete(:registered_admin)
end
end
5 changes: 5 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
require "rails/test_help"
require "orm/#{DEVISE_ORM}"

# Routes are lazy-loaded by default in development and test from Rails 8, so the
# Devise mappings declared by devise_for are not built until something loads the
# routes. Load them now so unit tests can rely on Devise.mappings being present.
Rails.application.reload_routes_unless_loaded

I18n.load_path.concat Dir["#{File.dirname(__FILE__)}/support/locale/*.yml"]

require 'mocha/minitest'
Expand Down