Refactor email delivery and background jobs system
- Switch from SolidQueue to async job processor for simpler background job handling - Remove SolidQueue gem and related configuration files - Add letter_opener gem for development email preview - Fix invitation email template issues (invitation_login_token method and route helper) - Configure SMTP settings via environment variables in application.rb - Add email delivery configuration banner on admin users page - Improve admin users page with inline action buttons and SMTP configuration warnings - Update development and production environments to use async processor - Add helper methods to detect SMTP configuration and filter out localhost settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
6
Gemfile
6
Gemfile
@@ -34,9 +34,8 @@ gem "jwt", "~> 3.1"
|
|||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|
||||||
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
|
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||||
gem "solid_cache"
|
gem "solid_cache"
|
||||||
gem "solid_queue"
|
|
||||||
gem "solid_cable"
|
gem "solid_cable"
|
||||||
|
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
@@ -68,6 +67,9 @@ end
|
|||||||
group :development do
|
group :development do
|
||||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||||
gem "web-console"
|
gem "web-console"
|
||||||
|
|
||||||
|
# Preview emails in browser instead of sending them
|
||||||
|
gem "letter_opener"
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
|
|||||||
23
Gemfile.lock
23
Gemfile.lock
@@ -100,6 +100,8 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
|
childprocess (5.1.0)
|
||||||
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.4)
|
||||||
@@ -113,8 +115,6 @@ GEM
|
|||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (5.1.1)
|
erb (5.1.1)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
|
||||||
tzinfo
|
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-musl)
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
ffi (1.17.2-arm-linux-gnu)
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
@@ -122,9 +122,6 @@ GEM
|
|||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-musl)
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
fugit (1.12.1)
|
|
||||||
et-orbi (~> 1.4)
|
|
||||||
raabro (~> 1.4)
|
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
@@ -159,6 +156,12 @@ GEM
|
|||||||
thor (~> 1.3)
|
thor (~> 1.3)
|
||||||
zeitwerk (>= 2.6.18, < 3.0)
|
zeitwerk (>= 2.6.18, < 3.0)
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
|
launchy (3.1.1)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
childprocess (~> 5.0)
|
||||||
|
logger (~> 1.6)
|
||||||
|
letter_opener (1.10.0)
|
||||||
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.24.1)
|
loofah (2.24.1)
|
||||||
@@ -225,7 +228,6 @@ GEM
|
|||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.3)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.1)
|
||||||
@@ -329,13 +331,6 @@ GEM
|
|||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_queue (1.2.2)
|
|
||||||
activejob (>= 7.1)
|
|
||||||
activerecord (>= 7.1)
|
|
||||||
concurrent-ruby (>= 1.3.1)
|
|
||||||
fugit (~> 1.11)
|
|
||||||
railties (>= 7.1)
|
|
||||||
thor (>= 1.3.1)
|
|
||||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||||
sqlite3 (2.7.4-arm-linux-gnu)
|
sqlite3 (2.7.4-arm-linux-gnu)
|
||||||
@@ -416,6 +411,7 @@ DEPENDENCIES
|
|||||||
jbuilder
|
jbuilder
|
||||||
jwt (~> 3.1)
|
jwt (~> 3.1)
|
||||||
kamal
|
kamal
|
||||||
|
letter_opener
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.0)
|
rails (~> 8.1.0)
|
||||||
@@ -425,7 +421,6 @@ DEPENDENCIES
|
|||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
solid_queue
|
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
|
|||||||
@@ -1,2 +1,22 @@
|
|||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
|
def smtp_configured?
|
||||||
|
return true if Rails.env.test?
|
||||||
|
|
||||||
|
smtp_address = ENV["SMTP_ADDRESS"]
|
||||||
|
smtp_port = ENV["SMTP_PORT"]
|
||||||
|
|
||||||
|
smtp_address.present? &&
|
||||||
|
smtp_port.present? &&
|
||||||
|
smtp_address != "localhost" &&
|
||||||
|
!smtp_address.start_with?("127.0.0.1") &&
|
||||||
|
!smtp_address.start_with?("localhost")
|
||||||
|
end
|
||||||
|
|
||||||
|
def email_delivery_method
|
||||||
|
if Rails.env.development?
|
||||||
|
ActionMailer::Base.delivery_method
|
||||||
|
else
|
||||||
|
:smtp
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% unless smtp_configured? %>
|
||||||
|
<div class="mt-6 rounded-md bg-yellow-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">
|
||||||
|
Email delivery not configured
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>
|
||||||
|
<% if Rails.env.development? %>
|
||||||
|
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
|
||||||
|
<% else %>
|
||||||
|
SMTP settings are not configured. Invitation emails and other notifications will not be sent.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
<% if Rails.env.development? %>
|
||||||
|
To configure SMTP for production, set environment variables like <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, etc.
|
||||||
|
<% else %>
|
||||||
|
Configure SMTP settings by setting environment variables: <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, <span class="font-mono">SMTP_PASSWORD</span>, etc.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-8 flow-root">
|
<div class="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
@@ -66,11 +99,17 @@
|
|||||||
<%= user.groups.count %>
|
<%= user.groups.count %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
<% if user.pending_invitation? %>
|
<% if user.pending_invitation? %>
|
||||||
<%= button_to "Resend", resend_invitation_admin_user_path(user), method: :post, class: "text-yellow-600 hover:text-yellow-900 mr-4" %>
|
<%= link_to "Resend", resend_invitation_admin_user_path(user),
|
||||||
|
data: { turbo_method: :post },
|
||||||
|
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
<%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %>
|
<%= link_to "Delete", admin_user_path(user),
|
||||||
|
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
||||||
|
class: "text-red-600 hover:text-red-900" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<p>
|
<p>
|
||||||
You've been invited to join Clinch! To set up your account and create your password, please visit
|
You've been invited to join Clinch! To set up your account and create your password, please visit
|
||||||
<%= link_to "this invitation page", invite_url(@user.invitation_login_token) %>.
|
<%= link_to "this invitation page", invitation_url(@user.generate_token_for(:invitation_login)) %>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
This invitation link will expire in <%= distance_of_time_in_words(0, @user.invitation_login_token_expires_in) %>.
|
This invitation link will expire in 24 hours.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -23,5 +23,18 @@ module Clinch
|
|||||||
#
|
#
|
||||||
# config.time_zone = "Central Time (US & Canada)"
|
# config.time_zone = "Central Time (US & Canada)"
|
||||||
# config.eager_load_paths << Rails.root.join("extras")
|
# config.eager_load_paths << Rails.root.join("extras")
|
||||||
|
|
||||||
|
# Configure SMTP settings using environment variables
|
||||||
|
config.action_mailer.delivery_method = :smtp
|
||||||
|
config.action_mailer.smtp_settings = {
|
||||||
|
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
||||||
|
port: ENV.fetch('SMTP_PORT', 587),
|
||||||
|
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
||||||
|
user_name: ENV.fetch('SMTP_USER_NAME', nil),
|
||||||
|
password: ENV.fetch('SMTP_PASSWORD', nil),
|
||||||
|
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
||||||
|
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
||||||
|
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ Rails.application.configure do
|
|||||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||||
config.active_storage.service = :local
|
config.active_storage.service = :local
|
||||||
|
|
||||||
# Don't care if the mailer can't send.
|
# Preview emails in browser using letter_opener
|
||||||
config.action_mailer.raise_delivery_errors = false
|
config.action_mailer.delivery_method = :letter_opener
|
||||||
|
config.action_mailer.perform_deliveries = true
|
||||||
|
|
||||||
# Make template changes take effect immediately.
|
# Make template changes take effect immediately.
|
||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
@@ -58,9 +59,8 @@ Rails.application.configure do
|
|||||||
# Highlight code that enqueued background job in logs.
|
# Highlight code that enqueued background job in logs.
|
||||||
config.active_job.verbose_enqueue_logs = true
|
config.active_job.verbose_enqueue_logs = true
|
||||||
|
|
||||||
# Use Solid Queue for background jobs (same as production).
|
# Use async processor for background jobs in development
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :async
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
||||||
|
|
||||||
|
|
||||||
# Highlight code that triggered redirect in logs.
|
# Highlight code that triggered redirect in logs.
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ Rails.application.configure do
|
|||||||
# Replace the default in-process memory cache store with a durable alternative.
|
# Replace the default in-process memory cache store with a durable alternative.
|
||||||
config.cache_store = :solid_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
|
|
||||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
# Use async processor for background jobs (modify as needed for production)
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :async
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# Ignore bad email addresses and do not raise email delivery errors.
|
||||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ port ENV.fetch("PORT", 3000)
|
|||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
# Solid Queue plugin removed - now using async processor
|
||||||
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
|
||||||
|
|
||||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||||
# In other environments, only set the PID file if requested.
|
# In other environments, only set the PID file if requested.
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# examples:
|
|
||||||
# periodic_cleanup:
|
|
||||||
# class: CleanSoftDeletedRecordsJob
|
|
||||||
# queue: background
|
|
||||||
# args: [ 1000, { batch_size: 500 } ]
|
|
||||||
# schedule: every hour
|
|
||||||
# periodic_cleanup_with_command:
|
|
||||||
# command: "SoftDeletedRecord.due.delete_all"
|
|
||||||
# priority: 2
|
|
||||||
# schedule: at 5am every day
|
|
||||||
|
|
||||||
production:
|
|
||||||
clear_solid_queue_finished_jobs:
|
|
||||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
||||||
schedule: every hour at minute 12
|
|
||||||
275
test/controllers/api/forward_auth_controller_test.rb
Normal file
275
test/controllers/api/forward_auth_controller_test.rb
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class ForwardAuthControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:one)
|
||||||
|
@admin_user = users(:two)
|
||||||
|
@inactive_user = users(:three)
|
||||||
|
@group = groups(:one)
|
||||||
|
@rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true)
|
||||||
|
@inactive_rule = ForwardAuthRule.create!(domain_pattern: "inactive.example.com", active: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Authentication Tests
|
||||||
|
test "should redirect to login when no session cookie" do
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
assert_equal "No session cookie", response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect when session cookie is invalid" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=invalid_session_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect when session is expired" do
|
||||||
|
expired_session = @user.sessions.create!(created_at: 1.year.ago)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=#{expired_session.id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
assert_equal "Session expired", response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect when user is inactive" do
|
||||||
|
sign_in_as(@inactive_user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_equal "User account is not active", response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return 200 when user is authenticated" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Rule Matching Tests
|
||||||
|
test "should return 200 when matching rule exists" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return 200 with default headers when no rule matches" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "unknown.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "X-Remote-User", response.headers["X-Remote-User"]
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return 403 when rule exists but is inactive" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "inactive.example.com" }
|
||||||
|
|
||||||
|
assert_response 403
|
||||||
|
assert_equal "No authentication rule configured for this domain", response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return 403 when rule exists but user not in allowed groups" do
|
||||||
|
@rule.allowed_groups << @group
|
||||||
|
sign_in_as(@user) # User not in group
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 403
|
||||||
|
assert_match %r{permission to access this domain}, response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return 200 when user is in allowed groups" do
|
||||||
|
@rule.allowed_groups << @group
|
||||||
|
@user.groups << @group
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Domain Pattern Tests
|
||||||
|
test "should match wildcard domains correctly" do
|
||||||
|
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "other.com" }
|
||||||
|
assert_response 200 # Falls back to default behavior
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should match exact domains correctly" do
|
||||||
|
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app.api.example.com" }
|
||||||
|
assert_response 200 # Falls back to default behavior
|
||||||
|
end
|
||||||
|
|
||||||
|
# Header Configuration Tests
|
||||||
|
test "should return default headers when rule has no custom config" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
|
||||||
|
assert_equal "X-Remote-Email", response.headers.keys.find { |k| k.include?("Email") }
|
||||||
|
assert_equal "X-Remote-Name", response.headers.keys.find { |k| k.include?("Name") }
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return custom headers when configured" do
|
||||||
|
custom_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "custom.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: {
|
||||||
|
user: "X-WEBAUTH-USER",
|
||||||
|
email: "X-WEBAUTH-EMAIL",
|
||||||
|
groups: "X-WEBAUTH-ROLES"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
|
||||||
|
assert_equal "X-WEBAUTH-EMAIL", response.headers.keys.find { |k| k.include?("EMAIL") }
|
||||||
|
assert_equal @user.email_address, response.headers["X-WEBAUTH-USER"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return no headers when all headers disabled" do
|
||||||
|
no_headers_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "noheaders.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||||
|
)
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||||
|
assert_empty auth_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should include groups header when user has groups" do
|
||||||
|
@user.groups << @group
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @group.name, response.headers["X-Remote-Groups"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not include groups header when user has no groups" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_nil response.headers["X-Remote-Groups"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should include admin header correctly" do
|
||||||
|
sign_in_as(@admin_user) # Assuming users(:two) is admin
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "true", response.headers["X-Remote-Admin"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should include multiple groups when user has multiple groups" do
|
||||||
|
group2 = groups(:two)
|
||||||
|
@user.groups << @group
|
||||||
|
@user.groups << group2
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
groups_header = response.headers["X-Remote-Groups"]
|
||||||
|
assert_includes groups_header, @group.name
|
||||||
|
assert_includes groups_header, group2.name
|
||||||
|
end
|
||||||
|
|
||||||
|
# Header Fallback Tests
|
||||||
|
test "should fall back to Host header when X-Forwarded-Host is missing" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "Host" => "test.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle requests without any host headers" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify"
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "User #{@user.email_address} authenticated (no domain specified)",
|
||||||
|
request.env["action_dispatch.instance"].instance_variable_get(:@logged_messages)&.last
|
||||||
|
end
|
||||||
|
|
||||||
|
# Security Tests
|
||||||
|
test "should handle malformed session IDs gracefully" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=malformed_session_id_with_special_chars!@#$%"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle very long domain names" do
|
||||||
|
long_domain = "a" * 250 + ".example.com"
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => long_domain }
|
||||||
|
|
||||||
|
assert_response 200 # Should fall back to default behavior
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle case insensitive domain matching" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "TEST.Example.COM" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
322
test/integration/forward_auth_integration_test.rb
Normal file
322
test/integration/forward_auth_integration_test.rb
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:one)
|
||||||
|
@admin_user = users(:two)
|
||||||
|
@group = groups(:one)
|
||||||
|
@group2 = groups(:two)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Basic Authentication Flow Tests
|
||||||
|
test "complete authentication flow: unauthenticated to authenticated" do
|
||||||
|
# Step 1: Unauthenticated request should redirect
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 302
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
assert_equal "No session cookie", response.headers["X-Auth-Reason"]
|
||||||
|
|
||||||
|
# Step 2: Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
assert_redirected_to "/"
|
||||||
|
assert cookies[:session_id]
|
||||||
|
|
||||||
|
# Step 3: Authenticated request should succeed
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "session persistence across multiple requests" do
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
session_cookie = cookies[:session_id]
|
||||||
|
assert session_cookie
|
||||||
|
|
||||||
|
# Multiple requests should work with same session
|
||||||
|
3.times do |i|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "session expiration handling" do
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
# Manually expire the session
|
||||||
|
session = Session.find_by(id: cookies.signed[:session_id])
|
||||||
|
session.update!(created_at: 1.year.ago)
|
||||||
|
|
||||||
|
# Request should fail and redirect to login
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 302
|
||||||
|
assert_equal "Session expired", response.headers["X-Auth-Reason"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Domain and Rule Integration Tests
|
||||||
|
test "different domain patterns with same session" do
|
||||||
|
# Create test rules
|
||||||
|
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
|
||||||
|
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
|
||||||
|
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
# Test wildcard domain
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
|
||||||
|
# Test exact domain
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
|
||||||
|
# Test non-matching domain (should use defaults)
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "other.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "group-based access control integration" do
|
||||||
|
# Create restricted rule
|
||||||
|
restricted_rule = ForwardAuthRule.create!(domain_pattern: "restricted.example.com", active: true)
|
||||||
|
restricted_rule.allowed_groups << @group
|
||||||
|
|
||||||
|
# Sign in user without group
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
# Should be denied access
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
|
||||||
|
assert_response 403
|
||||||
|
assert_match %r{permission to access this domain}, response.headers["X-Auth-Reason"]
|
||||||
|
|
||||||
|
# Add user to group
|
||||||
|
@user.groups << @group
|
||||||
|
|
||||||
|
# Should now be allowed
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Header Configuration Integration Tests
|
||||||
|
test "different header configurations with same user" do
|
||||||
|
# Create rules with different header configs
|
||||||
|
default_rule = ForwardAuthRule.create!(domain_pattern: "default.example.com", active: true)
|
||||||
|
custom_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "custom.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" }
|
||||||
|
)
|
||||||
|
no_headers_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "noheaders.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add user to groups
|
||||||
|
@user.groups << @group
|
||||||
|
@user.groups << @group2
|
||||||
|
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
# Test default headers
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "default.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
|
||||||
|
assert_equal "X-Remote-Groups", response.headers.keys.find { |k| k.include?("Groups") }
|
||||||
|
|
||||||
|
# Test custom headers
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
|
||||||
|
assert_equal "X-WEBAUTH-ROLES", response.headers.keys.find { |k| k.include?("ROLES") }
|
||||||
|
|
||||||
|
# Test no headers
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||||
|
assert_empty auth_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redirect URL Integration Tests
|
||||||
|
test "redirect URL preserves original request information" do
|
||||||
|
# Test with various redirect parameters
|
||||||
|
test_cases = [
|
||||||
|
{ rd: "https://app.example.com/", rm: "GET" },
|
||||||
|
{ rd: "https://grafana.example.com/dashboard", rm: "POST" },
|
||||||
|
{ rd: "https://metube.example.com/videos", rm: "PUT" }
|
||||||
|
]
|
||||||
|
|
||||||
|
test_cases.each do |params|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, params: params
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
location = response.location
|
||||||
|
|
||||||
|
# Should contain the original redirect URL
|
||||||
|
assert_includes location, params[:rd]
|
||||||
|
assert_includes location, params[:rm]
|
||||||
|
assert_includes location, "/signin"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "return URL functionality after authentication" do
|
||||||
|
# Initial request should set return URL
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"X-Forwarded-Uri" => "/admin"
|
||||||
|
}, params: { rd: "https://app.example.com/admin" }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
location = response.location
|
||||||
|
|
||||||
|
# Extract return URL from location
|
||||||
|
assert_match /rd=([^&]+)/, location
|
||||||
|
return_url = CGI.unescape($1)
|
||||||
|
assert_equal "https://app.example.com/admin", return_url
|
||||||
|
|
||||||
|
# Store session return URL
|
||||||
|
return_to_after_authenticating = session[:return_to_after_authenticating]
|
||||||
|
assert_equal "https://app.example.com/admin", return_to_after_authenticating
|
||||||
|
end
|
||||||
|
|
||||||
|
# Multiple User Scenarios Integration Tests
|
||||||
|
test "multiple users with different access levels" do
|
||||||
|
regular_user = users(:one)
|
||||||
|
admin_user = users(:two)
|
||||||
|
|
||||||
|
# Create restricted rule
|
||||||
|
admin_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "admin.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test regular user
|
||||||
|
post "/signin", params: { email_address: regular_user.email_address, password: "password" }
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal regular_user.email_address, response.headers["X-Admin-User"]
|
||||||
|
|
||||||
|
# Sign out
|
||||||
|
delete "/session"
|
||||||
|
|
||||||
|
# Test admin user
|
||||||
|
post "/signin", params: { email_address: admin_user.email_address, password: "password" }
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal admin_user.email_address, response.headers["X-Admin-User"]
|
||||||
|
assert_equal "true", response.headers["X-Admin-Flag"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Security Integration Tests
|
||||||
|
test "session hijacking prevention" do
|
||||||
|
# User A signs in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
user_a_session = cookies[:session_id]
|
||||||
|
|
||||||
|
# User B signs in
|
||||||
|
delete "/session"
|
||||||
|
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
|
||||||
|
user_b_session = cookies[:session_id]
|
||||||
|
|
||||||
|
# User A's session should still work
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=#{user_a_session}"
|
||||||
|
}
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
|
||||||
|
# User B's session should work
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=#{user_b_session}"
|
||||||
|
}
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @admin_user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "concurrent requests with same session" do
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
session_cookie = cookies[:session_id]
|
||||||
|
|
||||||
|
# Simulate concurrent requests
|
||||||
|
threads = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
5.times do |i|
|
||||||
|
threads << Thread.new do
|
||||||
|
# Create a new integration test instance for this thread
|
||||||
|
test_instance = self.class.new
|
||||||
|
test_instance.setup_controller_request_and_response
|
||||||
|
|
||||||
|
test_instance.get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "app#{i}.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
||||||
|
}
|
||||||
|
|
||||||
|
results << {
|
||||||
|
thread_id: i,
|
||||||
|
status: test_instance.response.status,
|
||||||
|
user: test_instance.response.headers["X-Remote-User"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
threads.each(&:join)
|
||||||
|
|
||||||
|
# All requests should succeed
|
||||||
|
results.each do |result|
|
||||||
|
assert_equal 200, result[:status], "Thread #{result[:thread_id]} failed"
|
||||||
|
assert_equal @user.email_address, result[:user], "Thread #{result[:thread_id]} has wrong user"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Performance Integration Tests
|
||||||
|
test "response times are reasonable" do
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
# Test multiple requests
|
||||||
|
start_time = Time.current
|
||||||
|
|
||||||
|
10.times do |i|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
end_time = Time.current
|
||||||
|
total_time = end_time - start_time
|
||||||
|
average_time = total_time / 10
|
||||||
|
|
||||||
|
# Each request should take less than 100ms on average
|
||||||
|
assert average_time < 0.1, "Average response time #{average_time}s is too slow"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Error Handling Integration Tests
|
||||||
|
test "graceful handling of malformed headers" do
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
# Test various malformed header combinations
|
||||||
|
test_cases = [
|
||||||
|
{ "X-Forwarded-Host" => nil },
|
||||||
|
{ "X-Forwarded-Host" => "" },
|
||||||
|
{ "X-Forwarded-Host" => " " },
|
||||||
|
{ "Host" => nil },
|
||||||
|
{ "Host" => "" }
|
||||||
|
]
|
||||||
|
|
||||||
|
test_cases.each_with_index do |headers, i|
|
||||||
|
get "/api/verify", headers: headers
|
||||||
|
assert_response 200, "Failed on test case #{i}: #{headers.inspect}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -124,4 +124,272 @@ class ForwardAuthRuleTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
assert_not @rule.user_allowed?(user)
|
assert_not @rule.user_allowed?(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Header Configuration Tests
|
||||||
|
test "effective_headers should return default headers when no custom config" do
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
expected = ForwardAuthRule::DEFAULT_HEADERS
|
||||||
|
assert_equal expected, @rule.effective_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "effective_headers should merge custom headers with defaults" do
|
||||||
|
@rule.save!
|
||||||
|
@rule.update!(headers_config: { user: "X-Forwarded-User", email: "X-Forwarded-Email" })
|
||||||
|
|
||||||
|
expected = ForwardAuthRule::DEFAULT_HEADERS.merge(
|
||||||
|
user: "X-Forwarded-User",
|
||||||
|
email: "X-Forwarded-Email"
|
||||||
|
)
|
||||||
|
assert_equal expected, @rule.effective_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_for_user should generate correct headers for user with groups" do
|
||||||
|
group = groups(:one)
|
||||||
|
user = users(:one)
|
||||||
|
user.groups << group
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
|
||||||
|
assert_equal user.email_address, headers["X-Remote-User"]
|
||||||
|
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||||
|
assert_equal user.email_address, headers["X-Remote-Name"]
|
||||||
|
assert_equal group.name, headers["X-Remote-Groups"]
|
||||||
|
assert_equal "true", headers["X-Remote-Admin"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_for_user should generate correct headers for user without groups" do
|
||||||
|
user = users(:one)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
|
||||||
|
assert_equal user.email_address, headers["X-Remote-User"]
|
||||||
|
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||||
|
assert_equal user.email_address, headers["X-Remote-Name"]
|
||||||
|
assert_nil headers["X-Remote-Groups"] # No groups, no header
|
||||||
|
assert_equal "true", headers["X-Remote-Admin"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_for_user should work with custom headers" do
|
||||||
|
user = users(:one)
|
||||||
|
@rule.update!(headers_config: {
|
||||||
|
user: "X-Forwarded-User",
|
||||||
|
groups: "X-Custom-Groups"
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
|
||||||
|
assert_equal user.email_address, headers["X-Forwarded-User"]
|
||||||
|
assert_nil headers["X-Remote-User"] # Should be overridden
|
||||||
|
assert_equal user.email_address, headers["X-Remote-Email"] # Default preserved
|
||||||
|
assert_nil headers["X-Custom-Groups"] # User has no groups
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_for_user should return empty hash when all headers disabled" do
|
||||||
|
user = users(:one)
|
||||||
|
@rule.update!(headers_config: {
|
||||||
|
user: "",
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
groups: "",
|
||||||
|
admin: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
assert_empty headers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_disabled? should correctly identify disabled headers" do
|
||||||
|
@rule.save!
|
||||||
|
assert_not @rule.headers_disabled?
|
||||||
|
|
||||||
|
@rule.update!(headers_config: { user: "X-Custom-User" })
|
||||||
|
assert_not @rule.headers_disabled?
|
||||||
|
|
||||||
|
@rule.update!(headers_config: { user: "", email: "", name: "", groups: "", admin: "" })
|
||||||
|
assert @rule.headers_disabled?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Additional Domain Pattern Tests
|
||||||
|
test "matches_domain? should handle complex patterns" do
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
# Test multiple wildcards
|
||||||
|
@rule.update!(domain_pattern: "*.*.example.com")
|
||||||
|
assert @rule.matches_domain?("app.dev.example.com")
|
||||||
|
assert @rule.matches_domain?("api.staging.example.com")
|
||||||
|
assert_not @rule.matches_domain?("example.com")
|
||||||
|
assert_not @rule.matches_domain?("app.example.org")
|
||||||
|
|
||||||
|
# Test exact domain with dots
|
||||||
|
@rule.update!(domain_pattern: "api.v2.example.com")
|
||||||
|
assert @rule.matches_domain?("api.v2.example.com")
|
||||||
|
assert_not @rule.matches_domain?("api.v3.example.com")
|
||||||
|
assert_not @rule.matches_domain?("v2.api.example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches_domain? should handle case insensitivity" do
|
||||||
|
@rule.update!(domain_pattern: "*.EXAMPLE.COM")
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert @rule.matches_domain?("app.example.com")
|
||||||
|
assert @rule.matches_domain?("APP.EXAMPLE.COM")
|
||||||
|
assert @rule.matches_domain?("App.Example.Com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matches_domain? should handle empty and nil domains" do
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert_not @rule.matches_domain?("")
|
||||||
|
assert_not @rule.matches_domain?(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Advanced Header Configuration Tests
|
||||||
|
test "headers_for_user should handle partial header configuration" do
|
||||||
|
user = users(:one)
|
||||||
|
user.groups << groups(:one)
|
||||||
|
@rule.update!(headers_config: {
|
||||||
|
user: "X-Custom-User",
|
||||||
|
email: "", # Disabled
|
||||||
|
groups: "X-Custom-Groups"
|
||||||
|
})
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
|
||||||
|
# Should include custom user header
|
||||||
|
assert_equal "X-Custom-User", headers.keys.find { |k| k.include?("User") }
|
||||||
|
assert_equal user.email_address, headers["X-Custom-User"]
|
||||||
|
|
||||||
|
# Should include default email header (not overridden)
|
||||||
|
assert_equal "X-Remote-Email", headers.keys.find { |k| k.include?("Email") }
|
||||||
|
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||||
|
|
||||||
|
# Should include custom groups header
|
||||||
|
assert_equal "X-Custom-Groups", headers.keys.find { |k| k.include?("Groups") }
|
||||||
|
assert_equal groups(:one).name, headers["X-Custom-Groups"]
|
||||||
|
|
||||||
|
# Should include default name header (not overridden)
|
||||||
|
assert_equal "X-Remote-Name", headers.keys.find { |k| k.include?("Name") }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_for_user should handle user without groups when groups header configured" do
|
||||||
|
user = users(:one)
|
||||||
|
user.groups.clear # No groups
|
||||||
|
@rule.update!(headers_config: { groups: "X-Custom-Groups" })
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
|
||||||
|
# Should not include groups header for user with no groups
|
||||||
|
assert_nil headers["X-Custom-Groups"]
|
||||||
|
assert_nil headers["X-Remote-Groups"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_for_user should handle non-admin user correctly" do
|
||||||
|
user = users(:one)
|
||||||
|
# Ensure user is not admin
|
||||||
|
user.update!(admin: false)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
|
||||||
|
assert_equal "false", headers["X-Remote-Admin"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "headers_for_user should work with nil headers_config" do
|
||||||
|
user = users(:one)
|
||||||
|
@rule.update!(headers_config: nil)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
headers = @rule.headers_for_user(user)
|
||||||
|
|
||||||
|
# Should use default headers
|
||||||
|
assert_equal "X-Remote-User", headers.keys.find { |k| k.include?("User") }
|
||||||
|
assert_equal user.email_address, headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "effective_headers should handle symbol keys in headers_config" do
|
||||||
|
@rule.update!(headers_config: { user: "X-Symbol-User", email: "X-Symbol-Email" })
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
effective = @rule.effective_headers
|
||||||
|
|
||||||
|
assert_equal "X-Symbol-User", effective[:user]
|
||||||
|
assert_equal "X-Symbol-Email", effective[:email]
|
||||||
|
assert_equal "X-Remote-Name", effective[:name] # Default
|
||||||
|
end
|
||||||
|
|
||||||
|
test "effective_headers should handle string keys in headers_config" do
|
||||||
|
@rule.update!(headers_config: { "user" => "X-String-User", "email" => "X-String-Email" })
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
effective = @rule.effective_headers
|
||||||
|
|
||||||
|
assert_equal "X-String-User", effective[:user]
|
||||||
|
assert_equal "X-String-Email", effective[:email]
|
||||||
|
assert_equal "X-Remote-Name", effective[:name] # Default
|
||||||
|
end
|
||||||
|
|
||||||
|
# Policy and Access Control Tests
|
||||||
|
test "policy_for_user should handle user with TOTP enabled" do
|
||||||
|
user = users(:one)
|
||||||
|
user.update!(totp_secret: "test_secret")
|
||||||
|
@rule.allowed_groups << groups(:one)
|
||||||
|
user.groups << groups(:one)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
policy = @rule.policy_for_user(user)
|
||||||
|
assert_equal "two_factor", policy
|
||||||
|
end
|
||||||
|
|
||||||
|
test "policy_for_user should handle user without TOTP" do
|
||||||
|
user = users(:one)
|
||||||
|
user.update!(totp_secret: nil)
|
||||||
|
@rule.allowed_groups << groups(:one)
|
||||||
|
user.groups << groups(:one)
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
policy = @rule.policy_for_user(user)
|
||||||
|
assert_equal "one_factor", policy
|
||||||
|
end
|
||||||
|
|
||||||
|
test "policy_for_user should handle user with multiple groups" do
|
||||||
|
user = users(:one)
|
||||||
|
group1 = groups(:one)
|
||||||
|
group2 = groups(:two)
|
||||||
|
@rule.allowed_groups << group1
|
||||||
|
@rule.allowed_groups << group2
|
||||||
|
user.groups << group1
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
policy = @rule.policy_for_user(user)
|
||||||
|
assert_equal "one_factor", policy
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user_allowed? should handle user with multiple groups, one allowed" do
|
||||||
|
user = users(:one)
|
||||||
|
allowed_group = groups(:one)
|
||||||
|
other_group = groups(:two)
|
||||||
|
@rule.allowed_groups << allowed_group
|
||||||
|
user.groups << allowed_group
|
||||||
|
user.groups << other_group
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert @rule.user_allowed?(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user_allowed? should handle user with multiple groups, none allowed" do
|
||||||
|
user = users(:one)
|
||||||
|
group1 = groups(:one)
|
||||||
|
group2 = groups(:two)
|
||||||
|
# Don't add any groups to allowed_groups
|
||||||
|
user.groups << group1
|
||||||
|
user.groups << group2
|
||||||
|
@rule.save!
|
||||||
|
|
||||||
|
assert_not @rule.user_allowed?(user)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
96
test/simple_role_test.rb
Normal file
96
test/simple_role_test.rb
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
# Simple test script to verify role mapping functionality
|
||||||
|
# Run with: ruby test/simple_role_test.rb
|
||||||
|
|
||||||
|
require_relative "../config/environment"
|
||||||
|
|
||||||
|
puts "🧪 Testing OIDC Role Mapping functionality..."
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Create test user
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "test#{Time.current.to_i}@example.com",
|
||||||
|
password: "password123",
|
||||||
|
admin: false,
|
||||||
|
status: :active
|
||||||
|
)
|
||||||
|
puts "✅ Created test user: #{user.email_address}"
|
||||||
|
|
||||||
|
# Create test application
|
||||||
|
application = Application.create!(
|
||||||
|
name: "Test Role App",
|
||||||
|
slug: "test-role-app-#{Time.current.to_i}",
|
||||||
|
app_type: "oidc",
|
||||||
|
role_mapping_mode: "oidc_managed"
|
||||||
|
)
|
||||||
|
puts "✅ Created test application: #{application.name}"
|
||||||
|
|
||||||
|
# Create role
|
||||||
|
role = application.application_roles.create!(
|
||||||
|
name: "admin",
|
||||||
|
display_name: "Administrator",
|
||||||
|
description: "Full access role"
|
||||||
|
)
|
||||||
|
puts "✅ Created role: #{role.name}"
|
||||||
|
|
||||||
|
# Test role assignment
|
||||||
|
application.assign_role_to_user!(user, "admin", source: 'manual')
|
||||||
|
puts "✅ Assigned role to user"
|
||||||
|
|
||||||
|
# Verify role assignment
|
||||||
|
unless application.user_has_role?(user, "admin")
|
||||||
|
raise "Role should be assigned to user"
|
||||||
|
end
|
||||||
|
puts "✅ Verified role assignment"
|
||||||
|
|
||||||
|
# Test role mapping engine
|
||||||
|
claims = { "roles" => ["admin", "editor"] }
|
||||||
|
RoleMappingEngine.sync_user_roles!(user, application, claims)
|
||||||
|
puts "✅ Synced roles from OIDC claims"
|
||||||
|
|
||||||
|
# Test JWT generation with roles
|
||||||
|
token = OidcJwtService.generate_id_token(user, application)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
unless decoded["roles"]&.include?("admin")
|
||||||
|
raise "JWT should contain roles"
|
||||||
|
end
|
||||||
|
puts "✅ JWT includes roles claim"
|
||||||
|
|
||||||
|
# Test custom claim name
|
||||||
|
application.update!(role_claim_name: "user_roles")
|
||||||
|
token = OidcJwtService.generate_id_token(user, application)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
unless decoded["user_roles"]&.include?("admin")
|
||||||
|
raise "JWT should use custom claim name"
|
||||||
|
end
|
||||||
|
puts "✅ Custom claim name works"
|
||||||
|
|
||||||
|
# Test role prefix filtering
|
||||||
|
application.update!(role_prefix: "app-")
|
||||||
|
role.update!(name: "app-admin")
|
||||||
|
application.assign_role_to_user!(user, "app-admin", source: 'manual')
|
||||||
|
|
||||||
|
claims = { "roles" => ["app-admin", "external-role"] }
|
||||||
|
RoleMappingEngine.sync_user_roles!(user, application, claims)
|
||||||
|
unless application.user_has_role?(user, "app-admin")
|
||||||
|
raise "Prefixed role should be assigned"
|
||||||
|
end
|
||||||
|
if application.user_has_role?(user, "external-role")
|
||||||
|
raise "Non-prefixed role should be filtered"
|
||||||
|
end
|
||||||
|
puts "✅ Role prefix filtering works"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
user.destroy
|
||||||
|
application.destroy
|
||||||
|
puts "🧹 Cleaned up test data"
|
||||||
|
|
||||||
|
puts "\n🎉 All tests passed! OIDC Role Mapping is working correctly."
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
puts "❌ Test failed: #{e.message}"
|
||||||
|
puts e.backtrace.first(5)
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
398
test/system/forward_auth_system_test.rb
Normal file
398
test/system/forward_auth_system_test.rb
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
||||||
|
driven_by :rack_test
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@user = users(:one)
|
||||||
|
@admin_user = users(:two)
|
||||||
|
@group = groups(:one)
|
||||||
|
@group2 = groups(:two)
|
||||||
|
end
|
||||||
|
|
||||||
|
# End-to-End Authentication Flow Tests
|
||||||
|
test "complete forward auth flow with default headers" do
|
||||||
|
# Create a rule with default headers
|
||||||
|
rule = ForwardAuthRule.create!(domain_pattern: "app.example.com", active: true)
|
||||||
|
|
||||||
|
# Step 1: Unauthenticated request to protected resource
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "app.example.com",
|
||||||
|
"X-Forwarded-Uri" => "/dashboard"
|
||||||
|
}, params: { rd: "https://app.example.com/dashboard" }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
location = response.location
|
||||||
|
assert_match %r{/signin}, location
|
||||||
|
assert_match %r{rd=https://app.example.com/dashboard}, location
|
||||||
|
|
||||||
|
# Step 2: Extract return URL from session
|
||||||
|
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
||||||
|
|
||||||
|
# Step 3: Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_redirected_to "https://app.example.com/dashboard"
|
||||||
|
|
||||||
|
# Step 4: Authenticated request to protected resource
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-Email"]
|
||||||
|
assert_equal "false", response.headers["X-Remote-Admin"] unless @user.admin?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multiple domain access with single session" do
|
||||||
|
# Create rules for different applications
|
||||||
|
app_rule = ForwardAuthRule.create!(domain_pattern: "app.example.com", active: true)
|
||||||
|
grafana_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "grafana.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: { user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL" }
|
||||||
|
)
|
||||||
|
metube_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "metube.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sign in once
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
assert_response 302
|
||||||
|
assert_redirected_to "/"
|
||||||
|
|
||||||
|
# Test access to different applications
|
||||||
|
# App with default headers
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
|
||||||
|
|
||||||
|
# Grafana with custom headers
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "grafana.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
|
||||||
|
|
||||||
|
# Metube with no headers
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "metube.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||||
|
assert_empty auth_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
# Group-Based Access Control System Tests
|
||||||
|
test "group-based access control with multiple groups" do
|
||||||
|
# Create restricted rule
|
||||||
|
restricted_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "admin.example.com",
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
restricted_rule.allowed_groups << @group
|
||||||
|
restricted_rule.allowed_groups << @group2
|
||||||
|
|
||||||
|
# Add user to first group only
|
||||||
|
@user.groups << @group
|
||||||
|
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
assert_response 302
|
||||||
|
|
||||||
|
# Should have access (in allowed group)
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @group.name, response.headers["X-Remote-Groups"]
|
||||||
|
|
||||||
|
# Add user to second group
|
||||||
|
@user.groups << @group2
|
||||||
|
|
||||||
|
# Should show multiple groups
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
groups_header = response.headers["X-Remote-Groups"]
|
||||||
|
assert_includes groups_header, @group.name
|
||||||
|
assert_includes groups_header, @group2.name
|
||||||
|
|
||||||
|
# Remove user from all groups
|
||||||
|
@user.groups.clear
|
||||||
|
|
||||||
|
# Should be denied
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||||
|
assert_response 403
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bypass mode when no groups assigned to rule" do
|
||||||
|
# Create bypass rule (no groups)
|
||||||
|
bypass_rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: "public.example.com",
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user with no groups
|
||||||
|
@user.groups.clear
|
||||||
|
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
assert_response 302
|
||||||
|
|
||||||
|
# Should have access (bypass mode)
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "public.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Security System Tests
|
||||||
|
test "session security and isolation" do
|
||||||
|
# User A signs in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
user_a_session = cookies[:session_id]
|
||||||
|
|
||||||
|
# User B signs in
|
||||||
|
delete "/session"
|
||||||
|
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
|
||||||
|
user_b_session = cookies[:session_id]
|
||||||
|
|
||||||
|
# User A should still be able to access resources
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=#{user_a_session}"
|
||||||
|
}
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
|
||||||
|
# User B should be able to access resources
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=#{user_b_session}"
|
||||||
|
}
|
||||||
|
assert_response 200
|
||||||
|
assert_equal @admin_user.email_address, response.headers["X-Remote-User"]
|
||||||
|
|
||||||
|
# Sessions should be independent
|
||||||
|
assert_not_equal user_a_session, user_b_session
|
||||||
|
end
|
||||||
|
|
||||||
|
test "session expiration and cleanup" do
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
session_id = cookies[:session_id]
|
||||||
|
|
||||||
|
# Should work initially
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
# Manually expire session
|
||||||
|
session = Session.find(session_id)
|
||||||
|
session.update!(created_at: 1.year.ago)
|
||||||
|
|
||||||
|
# Should redirect to login
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 302
|
||||||
|
assert_equal "Session expired", response.headers["X-Auth-Reason"]
|
||||||
|
|
||||||
|
# Session should be cleaned up
|
||||||
|
assert_nil Session.find_by(id: session_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "concurrent access with rate limiting considerations" do
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
session_cookie = cookies[:session_id]
|
||||||
|
|
||||||
|
# Simulate multiple concurrent requests from different IPs
|
||||||
|
threads = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
10.times do |i|
|
||||||
|
threads << Thread.new do
|
||||||
|
start_time = Time.current
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "app#{i}.example.com",
|
||||||
|
"X-Forwarded-For" => "192.168.1.#{100 + i}",
|
||||||
|
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
||||||
|
}
|
||||||
|
|
||||||
|
end_time = Time.current
|
||||||
|
|
||||||
|
results << {
|
||||||
|
thread_id: i,
|
||||||
|
status: response.status,
|
||||||
|
user: response.headers["X-Remote-User"],
|
||||||
|
duration: end_time - start_time
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
threads.each(&:join)
|
||||||
|
|
||||||
|
# All requests should succeed
|
||||||
|
results.each do |result|
|
||||||
|
assert_equal 200, result[:status], "Thread #{result[:thread_id]} failed"
|
||||||
|
assert_equal @user.email_address, result[:user], "Thread #{result[:thread_id]} has wrong user"
|
||||||
|
assert result[:duration] < 1.0, "Thread #{result[:thread_id]} was too slow"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Complex Scenario System Tests
|
||||||
|
test "complex multi-application scenario" do
|
||||||
|
# Setup multiple applications with different requirements
|
||||||
|
apps = [
|
||||||
|
{
|
||||||
|
domain: "dashboard.example.com",
|
||||||
|
headers_config: { user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS" },
|
||||||
|
groups: [@group]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: "api.example.com",
|
||||||
|
headers_config: { user: "X-API-USER", email: "X-API-EMAIL" },
|
||||||
|
groups: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: "logs.example.com",
|
||||||
|
headers_config: { user: "", email: "", name: "", groups: "", admin: "" },
|
||||||
|
groups: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create rules for each app
|
||||||
|
rules = apps.map do |app|
|
||||||
|
rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: app[:domain],
|
||||||
|
active: true,
|
||||||
|
headers_config: app[:headers_config]
|
||||||
|
)
|
||||||
|
app[:groups].each { |group| rule.allowed_groups << group }
|
||||||
|
rule
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add user to required groups
|
||||||
|
@user.groups << @group
|
||||||
|
|
||||||
|
# Sign in once
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
assert_response 302
|
||||||
|
|
||||||
|
# Test access to each application
|
||||||
|
apps.each do |app|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => app[:domain] }
|
||||||
|
assert_response 200, "Failed for #{app[:domain]}"
|
||||||
|
|
||||||
|
# Verify headers are correct
|
||||||
|
if app[:headers_config][:user].present?
|
||||||
|
assert_equal app[:headers_config][:user],
|
||||||
|
response.headers.keys.find { |k| k.include?("USER") },
|
||||||
|
"Wrong user header for #{app[:domain]}"
|
||||||
|
assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
|
||||||
|
else
|
||||||
|
# Should have no auth headers
|
||||||
|
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||||
|
assert_empty auth_headers, "Should have no headers for #{app[:domain]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "domain pattern edge cases" do
|
||||||
|
# Test various domain patterns
|
||||||
|
patterns = [
|
||||||
|
{ pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"] },
|
||||||
|
{ pattern: "api.*.com", domains: ["api.example.com", "api.test.com"] },
|
||||||
|
{ pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"] }
|
||||||
|
]
|
||||||
|
|
||||||
|
patterns.each do |pattern_config|
|
||||||
|
rule = ForwardAuthRule.create!(
|
||||||
|
domain_pattern: pattern_config[:pattern],
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
|
||||||
|
# Test each domain
|
||||||
|
pattern_config[:domains].each do |domain|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => domain }
|
||||||
|
assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clean up for next test
|
||||||
|
delete "/session"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Performance System Tests
|
||||||
|
test "system performance under load" do
|
||||||
|
# Create test rule
|
||||||
|
rule = ForwardAuthRule.create!(domain_pattern: "loadtest.example.com", active: true)
|
||||||
|
|
||||||
|
# Sign in
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
session_cookie = cookies[:session_id]
|
||||||
|
|
||||||
|
# Performance test
|
||||||
|
start_time = Time.current
|
||||||
|
request_count = 50
|
||||||
|
results = []
|
||||||
|
|
||||||
|
request_count.times do |i|
|
||||||
|
request_start = Time.current
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "app#{i}.loadtest.example.com",
|
||||||
|
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
||||||
|
}
|
||||||
|
|
||||||
|
request_end = Time.current
|
||||||
|
|
||||||
|
results << {
|
||||||
|
request_id: i,
|
||||||
|
status: response.status,
|
||||||
|
duration: request_end - request_start
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
total_time = Time.current - start_time
|
||||||
|
average_duration = results.map { |r| r[:duration] }.sum / request_count
|
||||||
|
|
||||||
|
# Performance assertions
|
||||||
|
assert total_time < 5.0, "Total time #{total_time}s is too slow"
|
||||||
|
assert average_duration < 0.1, "Average request time #{average_duration}s is too slow"
|
||||||
|
assert results.all? { |r| r[:status] == 200 }, "Some requests failed"
|
||||||
|
|
||||||
|
# Calculate requests per second
|
||||||
|
rps = request_count / total_time
|
||||||
|
assert rps > 10, "Requests per second #{rps} is too low"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Error Recovery System Tests
|
||||||
|
test "graceful degradation with database issues" do
|
||||||
|
# Sign in first
|
||||||
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
|
assert_response 302
|
||||||
|
|
||||||
|
# Simulate database connection issue by mocking
|
||||||
|
original_method = Session.method(:find_by)
|
||||||
|
|
||||||
|
# Mock database failure
|
||||||
|
Session.define_singleton_method(:find_by) do |id|
|
||||||
|
raise ActiveRecord::ConnectionNotEstablished, "Database connection lost"
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Request should handle the error gracefully
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
|
||||||
|
# Should return 302 (redirect to login) rather than 500 error
|
||||||
|
assert_response 302, "Should gracefully handle database issues"
|
||||||
|
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||||
|
ensure
|
||||||
|
# Restore original method
|
||||||
|
Session.define_singleton_method(:find_by, original_method)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Normal operation should still work
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user