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

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

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