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:
Dan Milne
2025-10-26 16:30:02 +11:00
parent 88428bfd97
commit d98f777e7d
15 changed files with 1459 additions and 48 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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">
<% if user.pending_invitation? %> <div class="flex justify-end space-x-3">
<%= button_to "Resend", resend_invitation_admin_user_path(user), method: :post, class: "text-yellow-600 hover:text-yellow-900 mr-4" %> <% if user.pending_invitation? %>
<% end %> <%= link_to "Resend", resend_invitation_admin_user_path(user),
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %> data: { turbo_method: :post },
<%= 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" %> class: "text-yellow-600 hover:text-yellow-900" %>
<% end %>
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-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 %>

View File

@@ -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>

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View 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

View 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

View File

@@ -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
View 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

View 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