diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48e5177..ed6c919 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,5 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Run tests - run: bundle exec rspec spec + - name: Run all spec suites (default + engine + isolated) + run: bundle exec rake spec:all diff --git a/.gitignore b/.gitignore index 64a1e10..970778d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ # The dummy app's database.yml is checked in — override a global # ignore rule that excludes "database.yml" everywhere by default. !/spec/dummy/config/database.yml +!/spec/dummy_engine/config/database.yml +!/spec/dummy_isolated/config/database.yml diff --git a/.rspec b/.rspec index 7a2cc1a..28e9c03 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,8 @@ --require spec_helper --format documentation --color +# `bundle exec rspec` runs the default suite (spec/dummy). The engine- +# and isolated-engine suites each boot their own dummy Rails app and +# must run in separate processes — invoke them via `rake spec:engine`, +# `rake spec:isolated`, or all three with `rake spec:all`. +--exclude-pattern "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*" diff --git a/README.md b/README.md index 351eae6..5b6bc4b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ Without these, `/admin` is public to anyone and the utility navigation (includin ### 2. `app/models/admin_user.rb` +For SSO-only setups (recommended — no local password login at all): + +```ruby +class AdminUser < ApplicationRecord + devise :omniauthable, omniauth_providers: [:oidc] + + serialize :oidc_raw_info, coder: JSON +end +``` + +The engine auto-mounts `/admin/login` (SSO landing page) and `/admin/logout` so ActiveAdmin's login link still resolves, even though Devise's `:database_authenticatable` isn't installed. The `encrypted_password` column is **not** required. + +If you also want password login alongside SSO, add the usual modules and column: + ```ruby class AdminUser < ApplicationRecord devise :database_authenticatable, @@ -46,6 +60,23 @@ class AdminUser < ApplicationRecord end ``` +In mixed mode Devise's own `devise_for` already maps `GET /admin/login` to its session controller. Because host routes are drawn before the gem's `after_initialize` append, Devise wins recognition for that path and renders its default sessions view (with both password form and OmniAuth links). The gem's auto-mount is a silent no-op — keep your custom `app/views/active_admin/devise/sessions/new.html.erb` if you want a specific landing UI. + +### Engine-mounted Devise (yeti-web / pbx-api pattern) + +If `devise_for :admin_users` lives inside a Rails engine (not the main app routes), set `Devise.router_name = :` in `config/initializers/devise.rb`. The gem reads this and mounts its session routes inside that engine's route set so `.routes.url_helpers.new__session_path` resolves correctly. + +For **isolated** engines (`isolate_namespace ...`) mounted at a prefix (e.g. `mount AdminPanel::Engine => '/admin'`), the engine prepends its mount path to every internal route. The gem's default `login_path = '/admin/login'` would then become `/admin/admin/login`. Configure engine-relative paths in `config/initializers/activeadmin_oidc.rb`: + +```ruby +ActiveAdmin::Oidc.configure do |c| + c.login_path = '/login' + c.logout_path = '/logout' +end +``` + +Non-isolated engines don't need this override. + ### 3. `config/initializers/activeadmin_oidc.rb` (generated) Fill in at minimum `issuer`, `client_id`, and an `on_login` hook. Full reference below. @@ -57,6 +88,7 @@ The gem's Rails engine handles several things so host apps don't have to: * **OmniAuth strategy registration** — the engine registers the `:openid_connect` strategy with Devise automatically based on your `ActiveAdmin::Oidc` configuration. You do **not** need to add `config.omniauth` or `config.omniauth_path_prefix` to `devise.rb`. * **Callback controller** — the engine patches `ActiveAdmin::Devise.controllers` to route OmniAuth callbacks to the gem's controller. No manual `controllers: { omniauth_callbacks: ... }` needed in `routes.rb`. * **Login view override** — the engine prepends an SSO-only login page (no email/password fields) to the sessions controller's view path. If your host app ships its own `app/views/active_admin/devise/sessions/new.html.erb`, the gem detects it and backs off — your view wins. +* **Session routes** — the engine mounts `GET /admin/login` (renders the SSO landing page) and `DELETE /admin/logout` under the existing `devise_scope :admin_user`. Without these, hosts that omit `:database_authenticatable` would 404 on ActiveAdmin's redirect to `new_admin_user_session_path` (Devise only generates those routes as a side effect of `:database_authenticatable`). * **Path prefix** — the engine sets `Devise.omniauth_path_prefix` and `OmniAuth.config.path_prefix` to `/admin/auth` so the middleware intercepts requests under ActiveAdmin's mount point. Compatible with Rails 7.2+ and Rails 8's lazy route loading. * **Parameter filtering** — `code`, `id_token`, `access_token`, `refresh_token`, `state`, and `nonce` are added to `Rails.application.config.filter_parameters`. diff --git a/Rakefile b/Rakefile index 180946b..64ca094 100644 --- a/Rakefile +++ b/Rakefile @@ -4,7 +4,30 @@ require "bundler/gem_tasks" begin require "rspec/core/rake_task" - RSpec::Core::RakeTask.new(:spec) + + # Default spec suite — boots spec/dummy/ (main-app OIDC-only setup). + RSpec::Core::RakeTask.new(:spec) do |t| + t.exclude_pattern = "spec/{engine,isolated,dummy_engine,dummy_isolated}/**/*" + end + + namespace :spec do + # Each variant below boots a different dummy Rails app and therefore + # must run in its own process. CI invokes them as separate steps; + # locally `rake spec:all` runs them sequentially via `sh`. + desc "Run engine-mounted-Devise specs (boots spec/dummy_engine/)" + task :engine do + sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/engine spec/engine" + end + + desc "Run isolated-engine specs (boots spec/dummy_isolated/)" + task :isolated do + sh "bundle exec rspec --options /dev/null --require spec_helper -I spec/isolated spec/isolated" + end + + desc "Run every spec suite (default + engine + isolated)" + task all: [:spec, :engine, :isolated] + end + task default: :spec rescue LoadError # rspec not available diff --git a/activeadmin-oidc.gemspec b/activeadmin-oidc.gemspec index 917ccaa..5921b54 100644 --- a/activeadmin-oidc.gemspec +++ b/activeadmin-oidc.gemspec @@ -42,6 +42,7 @@ Gem::Specification.new do |spec| spec.add_dependency "rails", ">= 7.2" spec.add_development_dependency "rspec-rails", ">= 6.0" + spec.add_development_dependency "capybara", ">= 3.40" spec.add_development_dependency "webmock", ">= 3.19" spec.add_development_dependency "jwt", ">= 2.7" spec.add_development_dependency "sqlite3", ">= 1.7" diff --git a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb index f848a6a..8de1062 100644 --- a/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb +++ b/app/controllers/active_admin/oidc/devise/omniauth_callbacks_controller.rb @@ -73,6 +73,16 @@ def failure def after_sign_in_path_for(resource) stored_location_for(resource) || '/admin' end + + # Devise's default `after_omniauth_failure_path_for` calls + # `new_session_path(scope)`, a URL helper Devise only generates + # when :database_authenticatable mounts session routes. The + # engine mounts `new__session_path` itself (see + # `mount_oidc_sessions_routes` initializer) regardless of which + # modules are loaded, so we always route through that helper. + def after_omniauth_failure_path_for(scope) + public_send(:"new_#{scope}_session_path") + end end end end diff --git a/lib/activeadmin/oidc/configuration.rb b/lib/activeadmin/oidc/configuration.rb index 8533be4..5bdda41 100644 --- a/lib/activeadmin/oidc/configuration.rb +++ b/lib/activeadmin/oidc/configuration.rb @@ -11,12 +11,15 @@ class Configuration DEFAULT_ADMIN_USER_CLASS = 'AdminUser' DEFAULT_ACCESS_DENIED_MESSAGE = 'Your account has no permission to access this admin panel.' + DEFAULT_LOGIN_PATH = '/admin/login' + DEFAULT_LOGOUT_PATH = '/admin/logout' attr_accessor :issuer, :client_id, :client_secret, :scope, :redirect_uri, :login_button_label, :timeout, :identity_attribute, :identity_claim, - :access_denied_message, :on_login, :admin_user_class + :access_denied_message, :on_login, :admin_user_class, + :login_path, :logout_path def initialize reset! @@ -34,6 +37,8 @@ def reset! @identity_claim = DEFAULT_IDENTITY_CLAIM @access_denied_message = DEFAULT_ACCESS_DENIED_MESSAGE @admin_user_class = DEFAULT_ADMIN_USER_CLASS + @login_path = DEFAULT_LOGIN_PATH + @logout_path = DEFAULT_LOGOUT_PATH @on_login = nil @pkce_override = nil self diff --git a/lib/activeadmin/oidc/engine.rb b/lib/activeadmin/oidc/engine.rb index d3aeeaa..6c13e07 100644 --- a/lib/activeadmin/oidc/engine.rb +++ b/lib/activeadmin/oidc/engine.rb @@ -11,11 +11,30 @@ class Engine < ::Rails::Engine # Used to gate controller registration and view overrides so the # gem is a no-op when OIDC is not enabled on the model. def self.oidc_enabled? - admin_class = ActiveAdmin::Oidc.config.admin_user_class - klass = admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class + klass = admin_user_class klass.respond_to?(:devise_modules) && klass.devise_modules.include?(:omniauthable) end + def self.admin_user_class + admin_class = ActiveAdmin::Oidc.config.admin_user_class + admin_class.is_a?(String) ? admin_class.safe_constantize : admin_class + end + + # Returns the route set our session routes should be appended to. + # Follows `Devise.available_router_name` (set by the host via + # `Devise.router_name = :foo`) so that engine-mounted Devise + # setups see the helpers on the right engine's url_helpers. + def self.session_routes_target(app) + router_name = ::Devise.available_router_name + return app.routes if router_name.blank? || router_name.to_sym == :main_app + + engine_class = ::Rails::Engine.subclasses.find do |klass| + klass.engine_name.to_sym == router_name.to_sym + end + + engine_class ? engine_class.routes : app.routes + end + ControllersPatch = Module.new do def controllers result = super @@ -101,6 +120,44 @@ def controllers initializer 'activeadmin_oidc.filter_parameters' do |app| app.config.filter_parameters |= %i[code id_token access_token refresh_token state nonce] end + + # The gem is OIDC-first: mount our SSO landing page at /admin/login + # and a warden-based /admin/logout under the existing devise scope. + # Without these, hosts that omit :database_authenticatable have no + # session routes from Devise at all, so ActiveAdmin's redirect to + # `new_admin_user_session_path` 404s. Hosts that DO keep + # :database_authenticatable get our SSO landing on GET; Devise's + # POST /admin/login (password sign-in) is unaffected because it + # lives on the same path with a different verb. + # + # Mount target follows `Devise.available_router_name`: hosts that + # mount Devise inside an engine (pbx-api / yeti-web pattern) set + # `Devise.router_name = :admin_panel`, which pins Devise URL + # helpers to `AdminPanel::Engine.routes`. We mount in the same + # route set so `.routes.url_helpers.new_admin_user_session_path` + # resolves. Defaults to `Rails.application.routes` when unset. + initializer 'activeadmin_oidc.mount_oidc_sessions_routes' do |app| + # after_initialize fires once at boot. `RouteSet#clear!` deliberately + # preserves the append/prepend queues across reloads, so re-running + # this hook (as `to_prepare` would in dev) accumulates duplicate + # append callbacks and crashes the second draw with + # "Invalid route name, already in use: 'new_admin_user_session'". + app.config.after_initialize do + next unless Engine.oidc_enabled? + + cfg = ActiveAdmin::Oidc.config + login_path = cfg.login_path + logout_path = cfg.logout_path + scope_name = Engine.admin_user_class.model_name.singular.to_sym + + Engine.session_routes_target(app).append do + devise_scope scope_name do + get login_path, to: 'active_admin/devise/sessions#new', as: :"new_#{scope_name}_session" + delete logout_path, to: 'active_admin/devise/sessions#destroy', as: :"destroy_#{scope_name}_session" + end + end + end + end end end end diff --git a/lib/activeadmin/oidc/test_helpers.rb b/lib/activeadmin/oidc/test_helpers.rb index a086122..8b0b0f4 100644 --- a/lib/activeadmin/oidc/test_helpers.rb +++ b/lib/activeadmin/oidc/test_helpers.rb @@ -64,14 +64,28 @@ def reset_oidc_stubs end end - # RSpec support for oidc_mode tag filtering. - # Require this file in spec_helper or rails_helper to auto-configure: + # Opt-in RSpec support for the `oidc_mode` tag. + # + # Hosts where OIDC is OPTIONAL (some envs run without an IdP) call: + # + # require "activeadmin/oidc/test_helpers" + # ActiveAdmin::Oidc::RSpecSupport.install! + # + # That installs auto-include + skips `oidc_mode: true` specs when + # `:omniauthable` isn't loaded, and toggles `CI_RUN_OIDC=true` to + # run only OIDC specs in a focused CI job. + # + # Hosts where OIDC is MANDATORY (yeti-web / pbx-api / didww-rs) + # skip the helper entirely and just include the module directly: # # require "activeadmin/oidc/test_helpers" # - # Specs tagged `oidc_mode: true` will be skipped unless the AdminUser - # model has :omniauthable loaded. Set CI_RUN_OIDC=true in your CI job - # to run only OIDC-tagged specs. + # RSpec.configure do |c| + # c.include ActiveAdmin::Oidc::TestHelpers + # c.after { reset_oidc_stubs } + # end + # + # No filter, no skip logic, no CI env var to remember. module RSpecSupport def self.install! return unless defined?(RSpec) @@ -98,6 +112,3 @@ def self.install! end end end - -# Auto-install RSpec support when required during a test run. -ActiveAdmin::Oidc::RSpecSupport.install! diff --git a/spec/dummy/app/models/admin_user.rb b/spec/dummy/app/models/admin_user.rb index dd6ebaf..09e66eb 100644 --- a/spec/dummy/app/models/admin_user.rb +++ b/spec/dummy/app/models/admin_user.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AdminUser < ApplicationRecord - devise :database_authenticatable, :omniauthable, + devise :omniauthable, omniauth_providers: [:oidc] validates :email, presence: true diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index be519a3..0cab485 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -4,7 +4,6 @@ create_table :admin_users, force: :cascade do |t| t.string :email t.string :username - t.string :encrypted_password, default: "", null: false t.string :provider t.string :uid t.text :oidc_raw_info diff --git a/spec/dummy_engine/Rakefile b/spec/dummy_engine/Rakefile new file mode 100644 index 0000000..91e7ad7 --- /dev/null +++ b/spec/dummy_engine/Rakefile @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "config/application" +Rails.application.load_tasks diff --git a/spec/dummy_engine/app/admin/dashboard.rb b/spec/dummy_engine/app/admin/dashboard.rb new file mode 100644 index 0000000..15eb814 --- /dev/null +++ b/spec/dummy_engine/app/admin/dashboard.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +ActiveAdmin.register_page "Dashboard" do + menu priority: 1, label: proc { I18n.t("active_admin.dashboard") } + + content title: proc { I18n.t("active_admin.dashboard") } do + para "Engine dummy dashboard." + end +end diff --git a/spec/dummy_engine/app/assets/config/manifest.js b/spec/dummy_engine/app/assets/config/manifest.js new file mode 100644 index 0000000..7d52bcf --- /dev/null +++ b/spec/dummy_engine/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_directory ../javascripts .js diff --git a/spec/dummy_engine/app/assets/javascripts/active_admin.js b/spec/dummy_engine/app/assets/javascripts/active_admin.js new file mode 100644 index 0000000..32eb1ca --- /dev/null +++ b/spec/dummy_engine/app/assets/javascripts/active_admin.js @@ -0,0 +1 @@ +// No-op JS for dummy app diff --git a/spec/dummy_engine/app/assets/stylesheets/active_admin.css b/spec/dummy_engine/app/assets/stylesheets/active_admin.css new file mode 100644 index 0000000..f4a799f --- /dev/null +++ b/spec/dummy_engine/app/assets/stylesheets/active_admin.css @@ -0,0 +1 @@ +/* No-op stylesheet for dummy app */ diff --git a/spec/dummy_engine/app/controllers/application_controller.rb b/spec/dummy_engine/app/controllers/application_controller.rb new file mode 100644 index 0000000..7944f9f --- /dev/null +++ b/spec/dummy_engine/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base +end diff --git a/spec/dummy_engine/app/models/admin_user.rb b/spec/dummy_engine/app/models/admin_user.rb new file mode 100644 index 0000000..90f62a5 --- /dev/null +++ b/spec/dummy_engine/app/models/admin_user.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AdminUser < ApplicationRecord + devise :omniauthable, + omniauth_providers: [:oidc] + + validates :email, presence: true + + serialize :oidc_raw_info, coder: JSON +end diff --git a/spec/dummy_engine/app/models/application_record.rb b/spec/dummy_engine/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/spec/dummy_engine/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/spec/dummy_engine/config.ru b/spec/dummy_engine/config.ru new file mode 100644 index 0000000..345dbef --- /dev/null +++ b/spec/dummy_engine/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" +run Rails.application +Rails.application.load_server diff --git a/spec/dummy_engine/config/application.rb b/spec/dummy_engine/config/application.rb new file mode 100644 index 0000000..5b2bef9 --- /dev/null +++ b/spec/dummy_engine/config/application.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "boot" + +require "rails" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "action_mailer/railtie" +begin + require "sprockets/railtie" +rescue LoadError +end + +Bundler.require(*Rails.groups) + +require "devise" +require "activeadmin" +require "activeadmin-oidc" + +# Mimic pbx-api / yeti-web pattern: an in-tree engine (non-isolated) +# that mounts devise_for inside its own routes and pins Devise's URL +# helpers to itself via `router_name: :admin_panel`. +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "admin_panel" + +module Dummy + class Application < Rails::Application + rails_gem_version = Gem::Version.new(Rails.version) + config.load_defaults(rails_gem_version >= Gem::Version.new("8.0") ? 8.0 : 7.2) + config.eager_load = false + config.root = File.expand_path("..", __dir__) + + config.secret_key_base = "test-secret-key-base-#{"x" * 64}" + config.hosts.clear + + config.action_controller.allow_forgery_protection = false + config.session_store :cookie_store, key: "_dummy_engine_session" + + config.action_dispatch.show_exceptions = :none + config.consider_all_requests_local = true + config.active_support.to_time_preserves_timezone = :zone if config.active_support.respond_to?(:to_time_preserves_timezone=) + + config.logger = Logger.new($stdout) + config.log_level = ENV.fetch("DUMMY_LOG_LEVEL", "fatal").to_sym + end +end diff --git a/spec/dummy_engine/config/boot.rb b/spec/dummy_engine/config/boot.rb new file mode 100644 index 0000000..7865da2 --- /dev/null +++ b/spec/dummy_engine/config/boot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) + +require "bundler/setup" diff --git a/spec/dummy_engine/config/database.yml b/spec/dummy_engine/config/database.yml new file mode 100644 index 0000000..85b7ead --- /dev/null +++ b/spec/dummy_engine/config/database.yml @@ -0,0 +1,5 @@ +test: + adapter: sqlite3 + database: ":memory:" + pool: 5 + timeout: 5000 diff --git a/spec/dummy_engine/config/environment.rb b/spec/dummy_engine/config/environment.rb new file mode 100644 index 0000000..b3dafa1 --- /dev/null +++ b/spec/dummy_engine/config/environment.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "application" +Dummy::Application.initialize! diff --git a/spec/dummy_engine/config/environments/test.rb b/spec/dummy_engine/config/environments/test.rb new file mode 100644 index 0000000..ca39b0e --- /dev/null +++ b/spec/dummy_engine/config/environments/test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.cache_classes = true + config.eager_load = false + config.public_file_server.enabled = true + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = :none + config.action_controller.allow_forgery_protection = false + config.active_support.deprecation = :stderr + config.active_support.disallowed_deprecation = :raise + config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = { host: "www.example.com" } + config.i18n.raise_on_missing_translations = false + config.log_level = :fatal +end diff --git a/spec/dummy_engine/config/initializers/active_admin.rb b/spec/dummy_engine/config/initializers/active_admin.rb new file mode 100644 index 0000000..40ca057 --- /dev/null +++ b/spec/dummy_engine/config/initializers/active_admin.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +ActiveAdmin.setup do |config| + config.site_title = "Dummy Engine" + config.authentication_method = :authenticate_admin_user! + config.current_user_method = :current_admin_user + config.logout_link_path = :destroy_admin_user_session_path + + config.root_to = "dashboard#index" + config.comments = false + + config.default_namespace = :admin +end diff --git a/spec/dummy_engine/config/initializers/activeadmin_oidc.rb b/spec/dummy_engine/config/initializers/activeadmin_oidc.rb new file mode 100644 index 0000000..81345e0 --- /dev/null +++ b/spec/dummy_engine/config/initializers/activeadmin_oidc.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + c.on_login = ->(_admin_user, _claims) { true } +end diff --git a/spec/dummy_engine/config/initializers/devise.rb b/spec/dummy_engine/config/initializers/devise.rb new file mode 100644 index 0000000..34a3924 --- /dev/null +++ b/spec/dummy_engine/config/initializers/devise.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +Devise.setup do |config| + config.mailer_sender = 'please-change-me@example.com' + require 'devise/orm/active_record' + + config.case_insensitive_keys = [:email] + config.strip_whitespace_keys = [:email] + config.skip_session_storage = [:http_auth] + config.stretches = 1 + config.reconfirmable = true + config.expire_all_remember_me_on_sign_out = true + config.password_length = 6..128 + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + config.reset_password_within = 6.hours + config.sign_out_via = :delete + + # router_name pins Devise's URL helpers to AdminPanel::Engine instead + # of main_app. Without this, *_session_path helpers would be looked + # up on Rails.application.routes.url_helpers, not the engine's. + config.router_name = :admin_panel +end diff --git a/spec/dummy_engine/config/routes.rb b/spec/dummy_engine/config/routes.rb new file mode 100644 index 0000000..f2cee6a --- /dev/null +++ b/spec/dummy_engine/config/routes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Non-isolated AdminPanel::Engine: its routes (devise_for + + # ActiveAdmin) get drawn into AdminPanel::Engine.routes, which is + # itself a route set discovered by Rails::Engine railtie. No + # explicit `mount` needed — that would conflict because the engine + # registers itself once already. +end diff --git a/spec/dummy_engine/db/schema.rb b/spec/dummy_engine/db/schema.rb new file mode 100644 index 0000000..c65b726 --- /dev/null +++ b/spec/dummy_engine/db/schema.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.2].define(version: 0) do + create_table :admin_users, force: :cascade do |t| + t.string :email + t.string :username + t.string :provider + t.string :uid + t.text :oidc_raw_info + t.timestamps + end + + add_index :admin_users, :email, unique: true +end diff --git a/spec/dummy_engine/lib/admin_panel.rb b/spec/dummy_engine/lib/admin_panel.rb new file mode 100644 index 0000000..6102e75 --- /dev/null +++ b/spec/dummy_engine/lib/admin_panel.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "admin_panel/engine" diff --git a/spec/dummy_engine/lib/admin_panel/config/routes.rb b/spec/dummy_engine/lib/admin_panel/config/routes.rb new file mode 100644 index 0000000..5b46ac6 --- /dev/null +++ b/spec/dummy_engine/lib/admin_panel/config/routes.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Mirrors the pbx-api / yeti-web pattern: devise_for + ActiveAdmin +# routes live inside the engine, pinned to it via `router_name`. +AdminPanel::Engine.routes.draw do + devise_for :admin_users, ActiveAdmin::Devise.config.merge(router_name: :admin_panel) + ActiveAdmin.routes(self) +end diff --git a/spec/dummy_engine/lib/admin_panel/engine.rb b/spec/dummy_engine/lib/admin_panel/engine.rb new file mode 100644 index 0000000..dc59f65 --- /dev/null +++ b/spec/dummy_engine/lib/admin_panel/engine.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module AdminPanel + class Engine < ::Rails::Engine + # Intentionally NOT calling `isolate_namespace AdminPanel`: this + # mirrors the pbx-api / yeti-web setup where the admin engine + # shares route helpers and the main app's url table. + # + # Routes are auto-loaded from `/config/routes.rb`, + # i.e. `spec/dummy_engine/lib/admin_panel/config/routes.rb`. + engine_name "admin_panel" + end +end diff --git a/spec/dummy_isolated/Rakefile b/spec/dummy_isolated/Rakefile new file mode 100644 index 0000000..91e7ad7 --- /dev/null +++ b/spec/dummy_isolated/Rakefile @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "config/application" +Rails.application.load_tasks diff --git a/spec/dummy_isolated/app/admin/dashboard.rb b/spec/dummy_isolated/app/admin/dashboard.rb new file mode 100644 index 0000000..15eb814 --- /dev/null +++ b/spec/dummy_isolated/app/admin/dashboard.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +ActiveAdmin.register_page "Dashboard" do + menu priority: 1, label: proc { I18n.t("active_admin.dashboard") } + + content title: proc { I18n.t("active_admin.dashboard") } do + para "Engine dummy dashboard." + end +end diff --git a/spec/dummy_isolated/app/assets/config/manifest.js b/spec/dummy_isolated/app/assets/config/manifest.js new file mode 100644 index 0000000..7d52bcf --- /dev/null +++ b/spec/dummy_isolated/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_directory ../javascripts .js diff --git a/spec/dummy_isolated/app/assets/javascripts/active_admin.js b/spec/dummy_isolated/app/assets/javascripts/active_admin.js new file mode 100644 index 0000000..32eb1ca --- /dev/null +++ b/spec/dummy_isolated/app/assets/javascripts/active_admin.js @@ -0,0 +1 @@ +// No-op JS for dummy app diff --git a/spec/dummy_isolated/app/assets/stylesheets/active_admin.css b/spec/dummy_isolated/app/assets/stylesheets/active_admin.css new file mode 100644 index 0000000..f4a799f --- /dev/null +++ b/spec/dummy_isolated/app/assets/stylesheets/active_admin.css @@ -0,0 +1 @@ +/* No-op stylesheet for dummy app */ diff --git a/spec/dummy_isolated/app/controllers/application_controller.rb b/spec/dummy_isolated/app/controllers/application_controller.rb new file mode 100644 index 0000000..7944f9f --- /dev/null +++ b/spec/dummy_isolated/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base +end diff --git a/spec/dummy_isolated/app/models/admin_user.rb b/spec/dummy_isolated/app/models/admin_user.rb new file mode 100644 index 0000000..90f62a5 --- /dev/null +++ b/spec/dummy_isolated/app/models/admin_user.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AdminUser < ApplicationRecord + devise :omniauthable, + omniauth_providers: [:oidc] + + validates :email, presence: true + + serialize :oidc_raw_info, coder: JSON +end diff --git a/spec/dummy_isolated/app/models/application_record.rb b/spec/dummy_isolated/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/spec/dummy_isolated/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/spec/dummy_isolated/config.ru b/spec/dummy_isolated/config.ru new file mode 100644 index 0000000..345dbef --- /dev/null +++ b/spec/dummy_isolated/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" +run Rails.application +Rails.application.load_server diff --git a/spec/dummy_isolated/config/application.rb b/spec/dummy_isolated/config/application.rb new file mode 100644 index 0000000..5b2bef9 --- /dev/null +++ b/spec/dummy_isolated/config/application.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "boot" + +require "rails" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "action_mailer/railtie" +begin + require "sprockets/railtie" +rescue LoadError +end + +Bundler.require(*Rails.groups) + +require "devise" +require "activeadmin" +require "activeadmin-oidc" + +# Mimic pbx-api / yeti-web pattern: an in-tree engine (non-isolated) +# that mounts devise_for inside its own routes and pins Devise's URL +# helpers to itself via `router_name: :admin_panel`. +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "admin_panel" + +module Dummy + class Application < Rails::Application + rails_gem_version = Gem::Version.new(Rails.version) + config.load_defaults(rails_gem_version >= Gem::Version.new("8.0") ? 8.0 : 7.2) + config.eager_load = false + config.root = File.expand_path("..", __dir__) + + config.secret_key_base = "test-secret-key-base-#{"x" * 64}" + config.hosts.clear + + config.action_controller.allow_forgery_protection = false + config.session_store :cookie_store, key: "_dummy_engine_session" + + config.action_dispatch.show_exceptions = :none + config.consider_all_requests_local = true + config.active_support.to_time_preserves_timezone = :zone if config.active_support.respond_to?(:to_time_preserves_timezone=) + + config.logger = Logger.new($stdout) + config.log_level = ENV.fetch("DUMMY_LOG_LEVEL", "fatal").to_sym + end +end diff --git a/spec/dummy_isolated/config/boot.rb b/spec/dummy_isolated/config/boot.rb new file mode 100644 index 0000000..7865da2 --- /dev/null +++ b/spec/dummy_isolated/config/boot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) + +require "bundler/setup" diff --git a/spec/dummy_isolated/config/database.yml b/spec/dummy_isolated/config/database.yml new file mode 100644 index 0000000..85b7ead --- /dev/null +++ b/spec/dummy_isolated/config/database.yml @@ -0,0 +1,5 @@ +test: + adapter: sqlite3 + database: ":memory:" + pool: 5 + timeout: 5000 diff --git a/spec/dummy_isolated/config/environment.rb b/spec/dummy_isolated/config/environment.rb new file mode 100644 index 0000000..b3dafa1 --- /dev/null +++ b/spec/dummy_isolated/config/environment.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "application" +Dummy::Application.initialize! diff --git a/spec/dummy_isolated/config/environments/test.rb b/spec/dummy_isolated/config/environments/test.rb new file mode 100644 index 0000000..ca39b0e --- /dev/null +++ b/spec/dummy_isolated/config/environments/test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.cache_classes = true + config.eager_load = false + config.public_file_server.enabled = true + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.action_dispatch.show_exceptions = :none + config.action_controller.allow_forgery_protection = false + config.active_support.deprecation = :stderr + config.active_support.disallowed_deprecation = :raise + config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = { host: "www.example.com" } + config.i18n.raise_on_missing_translations = false + config.log_level = :fatal +end diff --git a/spec/dummy_isolated/config/initializers/active_admin.rb b/spec/dummy_isolated/config/initializers/active_admin.rb new file mode 100644 index 0000000..40ca057 --- /dev/null +++ b/spec/dummy_isolated/config/initializers/active_admin.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +ActiveAdmin.setup do |config| + config.site_title = "Dummy Engine" + config.authentication_method = :authenticate_admin_user! + config.current_user_method = :current_admin_user + config.logout_link_path = :destroy_admin_user_session_path + + config.root_to = "dashboard#index" + config.comments = false + + config.default_namespace = :admin +end diff --git a/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb b/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb new file mode 100644 index 0000000..819f45c --- /dev/null +++ b/spec/dummy_isolated/config/initializers/activeadmin_oidc.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +ActiveAdmin::Oidc.configure do |c| + c.issuer = "https://idp.example.com" + c.client_id = "client-abc" + c.on_login = ->(_admin_user, _claims) { true } + + # Isolated engine mounted at /admin: paths inside the engine route + # set are prefixed by the mount path, so the default `/admin/login` + # would become `/admin/admin/login`. Use relative paths instead. + c.login_path = "/login" + c.logout_path = "/logout" +end diff --git a/spec/dummy_isolated/config/initializers/devise.rb b/spec/dummy_isolated/config/initializers/devise.rb new file mode 100644 index 0000000..34a3924 --- /dev/null +++ b/spec/dummy_isolated/config/initializers/devise.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +Devise.setup do |config| + config.mailer_sender = 'please-change-me@example.com' + require 'devise/orm/active_record' + + config.case_insensitive_keys = [:email] + config.strip_whitespace_keys = [:email] + config.skip_session_storage = [:http_auth] + config.stretches = 1 + config.reconfirmable = true + config.expire_all_remember_me_on_sign_out = true + config.password_length = 6..128 + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + config.reset_password_within = 6.hours + config.sign_out_via = :delete + + # router_name pins Devise's URL helpers to AdminPanel::Engine instead + # of main_app. Without this, *_session_path helpers would be looked + # up on Rails.application.routes.url_helpers, not the engine's. + config.router_name = :admin_panel +end diff --git a/spec/dummy_isolated/config/routes.rb b/spec/dummy_isolated/config/routes.rb new file mode 100644 index 0000000..460f474 --- /dev/null +++ b/spec/dummy_isolated/config/routes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + # Routes are re-drawn during boot for isolated engines; guard the + # mount so the named helper isn't re-registered on the second pass. + unless Rails.application.routes.named_routes.key?(:admin_panel) + mount AdminPanel::Engine => "/admin" + end +end diff --git a/spec/dummy_isolated/db/schema.rb b/spec/dummy_isolated/db/schema.rb new file mode 100644 index 0000000..c65b726 --- /dev/null +++ b/spec/dummy_isolated/db/schema.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.2].define(version: 0) do + create_table :admin_users, force: :cascade do |t| + t.string :email + t.string :username + t.string :provider + t.string :uid + t.text :oidc_raw_info + t.timestamps + end + + add_index :admin_users, :email, unique: true +end diff --git a/spec/dummy_isolated/lib/admin_panel.rb b/spec/dummy_isolated/lib/admin_panel.rb new file mode 100644 index 0000000..6102e75 --- /dev/null +++ b/spec/dummy_isolated/lib/admin_panel.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "admin_panel/engine" diff --git a/spec/dummy_isolated/lib/admin_panel/config/routes.rb b/spec/dummy_isolated/lib/admin_panel/config/routes.rb new file mode 100644 index 0000000..678291b --- /dev/null +++ b/spec/dummy_isolated/lib/admin_panel/config/routes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Isolated engine variant: devise_for + ActiveAdmin live inside the +# engine. Mount-path prefixing applies — paths declared here become +# `/admin/` after `mount AdminPanel::Engine => '/admin'` in the +# main app routes. +AdminPanel::Engine.routes.draw do + devise_for :admin_users, ActiveAdmin::Devise.config.merge(router_name: :admin_panel) +end diff --git a/spec/dummy_isolated/lib/admin_panel/engine.rb b/spec/dummy_isolated/lib/admin_panel/engine.rb new file mode 100644 index 0000000..7c97c95 --- /dev/null +++ b/spec/dummy_isolated/lib/admin_panel/engine.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module AdminPanel + class Engine < ::Rails::Engine + # Isolated: URL helpers are namespaced under the engine + # (`admin_panel.foo_path`), engine routes do NOT merge into + # main_app helpers, and the engine's mount path PREFIXES every + # path declared inside. + # + # Hosts using this layout must set ActiveAdmin::Oidc.config.login_path + # relative to the engine mount (e.g. `'/login'` when the engine + # is mounted at `/admin` — otherwise the default `/admin/login` + # becomes `/admin/admin/login` after mount prefixing). + isolate_namespace AdminPanel + + engine_name "admin_panel" + end +end diff --git a/spec/engine/engine_rails_helper.rb b/spec/engine/engine_rails_helper.rb new file mode 100644 index 0000000..a28eb00 --- /dev/null +++ b/spec/engine/engine_rails_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" + +require "spec_helper" +require_relative "../dummy_engine/config/environment" + +abort("Rails not in test mode") if Rails.env.production? + +require "rspec/rails" +require "webmock/rspec" +require "capybara/rspec" + +ActiveRecord::Schema.verbose = false +load File.expand_path("../dummy_engine/db/schema.rb", __dir__) + +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! +end + +OmniAuth.config.test_mode = true +OmniAuth.config.logger = Logger.new(File::NULL) +OmniAuth.config.request_validation_phase = ->(_env) { } +OmniAuth.config.allowed_request_methods = %i[get post] +OmniAuth.config.silence_get_warning = true if OmniAuth.config.respond_to?(:silence_get_warning=) diff --git a/spec/engine/features/engine_mounted_login_spec.rb b/spec/engine/features/engine_mounted_login_spec.rb new file mode 100644 index 0000000..01a58bb --- /dev/null +++ b/spec/engine/features/engine_mounted_login_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "engine_rails_helper" + +# Engine-mounted Devise + OIDC scenario (pbx-api / yeti-web pattern): +# +# - AdminPanel::Engine is a non-isolated Rails engine +# - `devise_for :admin_users, ..., router_name: :admin_panel` lives +# inside the engine's routes +# - `Devise.router_name = :admin_panel` pins Devise's URL helpers to +# `AdminPanel::Engine.routes` +# +# With AdminUser also OIDC-only (`devise :omniauthable`, no +# `:database_authenticatable`), Devise generates no session routes. +# activeadmin-oidc has to mount `/admin/login` and `/admin/logout` +# *inside the engine's route set*, otherwise +# `AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path` +# stays undefined and host-side failure apps (Devise::FailureApp +# subclasses that redirect via the engine's helpers, e.g. pbx-api's +# `PbxDevise#redirect_url`) blow up with NoMethodError. +RSpec.describe "Engine-mounted OIDC sessions" do + it "AdminPanel::Engine.routes.url_helpers exposes new_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path + }.not_to raise_error + end + + it "AdminPanel::Engine.routes.url_helpers exposes destroy_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.destroy_admin_user_session_path + }.not_to raise_error + end + + it "the engine's route table contains GET /admin/login" do + paths = AdminPanel::Engine.routes.routes.map { |r| [r.verb, r.path.spec.to_s] } + expect(paths).to include(["GET", "/admin/login(.:format)"]) + end + + it "Rails.application.routes does NOT define the session helpers (they live on the engine)" do + # Sanity check: if the gem mounted on the wrong route set the + # helper would appear here instead of on the engine. + expect(Rails.application.routes.url_helpers).not_to respond_to(:new_admin_user_session_path) + end +end diff --git a/spec/isolated/features/isolated_engine_login_spec.rb b/spec/isolated/features/isolated_engine_login_spec.rb new file mode 100644 index 0000000..3d9f837 --- /dev/null +++ b/spec/isolated/features/isolated_engine_login_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "isolated_rails_helper" + +# Isolated-engine variant of the engine-mounted Devise scenario. +# +# AdminPanel::Engine uses `isolate_namespace AdminPanel`, mounted at +# `/admin` in main app routes. Inside the engine, devise_for registers +# `:admin_users` pinned to the engine via `router_name: :admin_panel`. +# +# Key difference from non-isolated: paths declared inside the engine +# get prefixed by the mount path, so a route declared as `/admin/login` +# inside the engine would become `/admin/admin/login` after mounting. +# Hosts in this layout must therefore configure +# `ActiveAdmin::Oidc.config.login_path = '/login'` (engine-relative); +# the gem then mounts at the correct effective path. +RSpec.describe "Isolated engine OIDC sessions" do + it "AdminPanel::Engine.routes.url_helpers exposes new_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.new_admin_user_session_path + }.not_to raise_error + end + + it "AdminPanel::Engine.routes.url_helpers exposes destroy_admin_user_session_path" do + expect { + AdminPanel::Engine.routes.url_helpers.destroy_admin_user_session_path + }.not_to raise_error + end + + it "the engine's route table contains GET /login (relative — mount prefix added later)" do + paths = AdminPanel::Engine.routes.routes.map { |r| [r.verb, r.path.spec.to_s] } + expect(paths).to include(["GET", "/login(.:format)"]) + end + + it "from the main app, GET /admin/login resolves through the mount prefix" do + main_paths = Rails.application.routes.routes.map { |r| r.path.spec.to_s } + expect(main_paths.any? { |p| p.include?("/admin") }).to be(true) + end + + it "main app routes resolve GET /admin/login through the mounted engine" do + request = ActionDispatch::Request.new("PATH_INFO" => "/admin/login", "REQUEST_METHOD" => "GET") + expect { Rails.application.routes.router.recognize(request) {} }.not_to raise_error + end +end diff --git a/spec/isolated/isolated_rails_helper.rb b/spec/isolated/isolated_rails_helper.rb new file mode 100644 index 0000000..f543376 --- /dev/null +++ b/spec/isolated/isolated_rails_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] ||= "test" + +require "spec_helper" +require_relative "../dummy_isolated/config/environment" + +abort("Rails not in test mode") if Rails.env.production? + +require "rspec/rails" +require "webmock/rspec" +require "capybara/rspec" + +ActiveRecord::Schema.verbose = false +load File.expand_path("../dummy_isolated/db/schema.rb", __dir__) + +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! +end + +OmniAuth.config.test_mode = true +OmniAuth.config.logger = Logger.new(File::NULL) +OmniAuth.config.request_validation_phase = ->(_env) { } +OmniAuth.config.allowed_request_methods = %i[get post] +OmniAuth.config.silence_get_warning = true if OmniAuth.config.respond_to?(:silence_get_warning=)