diff --git a/Gemfile b/Gemfile index 6113637..6fab3d2 100644 --- a/Gemfile +++ b/Gemfile @@ -34,9 +34,8 @@ gem "jwt", "~> 3.1" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 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_queue" gem "solid_cable" # Reduces boot times through caching; required in config/boot.rb @@ -68,6 +67,9 @@ end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" + + # Preview emails in browser instead of sending them + gem "letter_opener" end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 2aa1354..57f0586 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,6 +100,8 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + childprocess (5.1.0) + logger (~> 1.5) chunky_png (1.4.0) concurrent-ruby (1.3.5) connection_pool (2.5.4) @@ -113,8 +115,6 @@ GEM ed25519 (1.4.0) erb (5.1.1) erubi (1.13.1) - et-orbi (1.4.0) - tzinfo ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) @@ -122,9 +122,6 @@ GEM ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) - fugit (1.12.1) - et-orbi (~> 1.4) - raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) i18n (1.14.7) @@ -159,6 +156,12 @@ GEM thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) 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) logger (1.7.0) loofah (2.24.1) @@ -225,7 +228,6 @@ GEM public_suffix (6.0.2) puma (7.1.0) nio4r (~> 2.0) - raabro (1.4.0) racc (1.8.1) rack (3.2.3) rack-session (2.1.1) @@ -329,13 +331,6 @@ GEM activejob (>= 7.2) activerecord (>= 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-musl) sqlite3 (2.7.4-arm-linux-gnu) @@ -416,6 +411,7 @@ DEPENDENCIES jbuilder jwt (~> 3.1) kamal + letter_opener propshaft puma (>= 5.0) rails (~> 8.1.0) @@ -425,7 +421,6 @@ DEPENDENCIES selenium-webdriver solid_cable solid_cache - solid_queue sqlite3 (>= 2.1) stimulus-rails tailwindcss-rails diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..87ad2f6 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,22 @@ 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 diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index c12955b..66a020a 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -8,6 +8,39 @@ +<% unless smtp_configured? %> +
+ <% if Rails.env.development? %> + Emails are being delivered using <%= email_delivery_method %> and will open in your browser. + <% else %> + SMTP settings are not configured. Invitation emails and other notifications will not be sent. + <% end %> +
++ <% if Rails.env.development? %> + To configure SMTP for production, set environment variables like SMTP_ADDRESS, SMTP_PORT, SMTP_USERNAME, etc. + <% else %> + Configure SMTP settings by setting environment variables: SMTP_ADDRESS, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, etc. + <% end %> +
+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)) %>.
- 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.
diff --git a/config/application.rb b/config/application.rb index 3893418..155551c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,18 @@ module Clinch # # config.time_zone = "Central Time (US & Canada)" # 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 diff --git a/config/environments/development.rb b/config/environments/development.rb index da13670..b8f693a 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -31,8 +31,9 @@ Rails.application.configure do # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false + # Preview emails in browser using letter_opener + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true # Make template changes take effect immediately. config.action_mailer.perform_caching = false @@ -58,9 +59,8 @@ Rails.application.configure do # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true - # Use Solid Queue for background jobs (same as production). - config.active_job.queue_adapter = :solid_queue - config.solid_queue.connects_to = { database: { writing: :queue } } + # Use async processor for background jobs in development + config.active_job.queue_adapter = :async # Highlight code that triggered redirect in logs. diff --git a/config/environments/production.rb b/config/environments/production.rb index f893475..321abf1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -49,9 +49,8 @@ Rails.application.configure do # Replace the default in-process memory cache store with a durable alternative. config.cache_store = :solid_cache_store - # Replace the default in-process and non-durable queuing backend for Active Job. - config.active_job.queue_adapter = :solid_queue - config.solid_queue.connects_to = { database: { writing: :queue } } + # Use async processor for background jobs (modify as needed for production) + config.active_job.queue_adapter = :async # 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. diff --git a/config/puma.rb b/config/puma.rb index 38c4b86..5771f2b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -34,8 +34,7 @@ port ENV.fetch("PORT", 3000) # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart -# Run the Solid Queue supervisor inside of Puma for single-server deployments. -plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] +# Solid Queue plugin removed - now using async processor # Specify the PID file. Defaults to tmp/pids/server.pid in development. # In other environments, only set the PID file if requested. diff --git a/config/recurring.yml b/config/recurring.yml deleted file mode 100644 index b4207f9..0000000 --- a/config/recurring.yml +++ /dev/null @@ -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 diff --git a/test/controllers/api/forward_auth_controller_test.rb b/test/controllers/api/forward_auth_controller_test.rb new file mode 100644 index 0000000..f919d08 --- /dev/null +++ b/test/controllers/api/forward_auth_controller_test.rb @@ -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 \ No newline at end of file diff --git a/test/integration/forward_auth_integration_test.rb b/test/integration/forward_auth_integration_test.rb new file mode 100644 index 0000000..cdb7716 --- /dev/null +++ b/test/integration/forward_auth_integration_test.rb @@ -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 \ No newline at end of file diff --git a/test/models/forward_auth_rule_test.rb b/test/models/forward_auth_rule_test.rb index b5eea93..1923c15 100644 --- a/test/models/forward_auth_rule_test.rb +++ b/test/models/forward_auth_rule_test.rb @@ -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 diff --git a/test/simple_role_test.rb b/test/simple_role_test.rb new file mode 100644 index 0000000..fe88d4d --- /dev/null +++ b/test/simple_role_test.rb @@ -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 + diff --git a/test/system/forward_auth_system_test.rb b/test/system/forward_auth_system_test.rb new file mode 100644 index 0000000..7897e21 --- /dev/null +++ b/test/system/forward_auth_system_test.rb @@ -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 \ No newline at end of file