propel_authentication 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +290 -0
  4. data/Rakefile +12 -0
  5. data/lib/generators/propel_auth/install_generator.rb +486 -0
  6. data/lib/generators/propel_auth/pack_generator.rb +277 -0
  7. data/lib/generators/propel_auth/templates/agency.rb +7 -0
  8. data/lib/generators/propel_auth/templates/agent.rb +7 -0
  9. data/lib/generators/propel_auth/templates/auth/base_passwords_controller.rb.tt +99 -0
  10. data/lib/generators/propel_auth/templates/auth/base_tokens_controller.rb.tt +90 -0
  11. data/lib/generators/propel_auth/templates/auth/passwords_controller.rb.tt +126 -0
  12. data/lib/generators/propel_auth/templates/auth_mailer.rb +180 -0
  13. data/lib/generators/propel_auth/templates/authenticatable.rb +38 -0
  14. data/lib/generators/propel_auth/templates/concerns/confirmable.rb +145 -0
  15. data/lib/generators/propel_auth/templates/concerns/lockable.rb +123 -0
  16. data/lib/generators/propel_auth/templates/concerns/propel_authentication.rb +44 -0
  17. data/lib/generators/propel_auth/templates/concerns/rack_session_disable.rb +19 -0
  18. data/lib/generators/propel_auth/templates/concerns/recoverable.rb +124 -0
  19. data/lib/generators/propel_auth/templates/config/environments/development_email.rb +43 -0
  20. data/lib/generators/propel_auth/templates/db/migrate/create_agencies.rb +20 -0
  21. data/lib/generators/propel_auth/templates/db/migrate/create_agents.rb +11 -0
  22. data/lib/generators/propel_auth/templates/db/migrate/create_invitations.rb +28 -0
  23. data/lib/generators/propel_auth/templates/db/migrate/create_organizations.rb +18 -0
  24. data/lib/generators/propel_auth/templates/db/migrate/create_users.rb +43 -0
  25. data/lib/generators/propel_auth/templates/db/seeds.rb +29 -0
  26. data/lib/generators/propel_auth/templates/invitation.rb +133 -0
  27. data/lib/generators/propel_auth/templates/lib/propel_auth.rb +84 -0
  28. data/lib/generators/propel_auth/templates/organization.rb +7 -0
  29. data/lib/generators/propel_auth/templates/propel_auth.rb +132 -0
  30. data/lib/generators/propel_auth/templates/services/auth_notification_service.rb +89 -0
  31. data/lib/generators/propel_auth/templates/test/concerns/confirmable_test.rb.tt +247 -0
  32. data/lib/generators/propel_auth/templates/test/concerns/lockable_test.rb.tt +282 -0
  33. data/lib/generators/propel_auth/templates/test/concerns/propel_authentication_test.rb.tt +75 -0
  34. data/lib/generators/propel_auth/templates/test/concerns/recoverable_test.rb.tt +327 -0
  35. data/lib/generators/propel_auth/templates/test/controllers/auth/lockable_integration_test.rb.tt +196 -0
  36. data/lib/generators/propel_auth/templates/test/controllers/auth/password_reset_integration_test.rb.tt +471 -0
  37. data/lib/generators/propel_auth/templates/test/controllers/auth/tokens_controller_test.rb.tt +265 -0
  38. data/lib/generators/propel_auth/templates/test/mailers/auth_mailer_test.rb.tt +216 -0
  39. data/lib/generators/propel_auth/templates/test/mailers/previews/auth_mailer_preview.rb +161 -0
  40. data/lib/generators/propel_auth/templates/tokens_controller.rb.tt +96 -0
  41. data/lib/generators/propel_auth/templates/user.rb +21 -0
  42. data/lib/generators/propel_auth/templates/user_test.rb.tt +81 -0
  43. data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.html.erb +213 -0
  44. data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.text.erb +56 -0
  45. data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.html.erb +213 -0
  46. data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.text.erb +32 -0
  47. data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.html.erb +166 -0
  48. data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.text.erb +32 -0
  49. data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.html.erb +194 -0
  50. data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.text.erb +51 -0
  51. data/lib/generators/propel_auth/test/dummy/Dockerfile +72 -0
  52. data/lib/generators/propel_auth/test/dummy/Gemfile +63 -0
  53. data/lib/generators/propel_auth/test/dummy/Gemfile.lock +394 -0
  54. data/lib/generators/propel_auth/test/dummy/README.md +24 -0
  55. data/lib/generators/propel_auth/test/dummy/Rakefile +6 -0
  56. data/lib/generators/propel_auth/test/dummy/app/assets/stylesheets/application.css +10 -0
  57. data/lib/generators/propel_auth/test/dummy/app/controllers/application_controller.rb +4 -0
  58. data/lib/generators/propel_auth/test/dummy/app/helpers/application_helper.rb +2 -0
  59. data/lib/generators/propel_auth/test/dummy/app/jobs/application_job.rb +7 -0
  60. data/lib/generators/propel_auth/test/dummy/app/mailers/application_mailer.rb +4 -0
  61. data/lib/generators/propel_auth/test/dummy/app/models/application_record.rb +3 -0
  62. data/lib/generators/propel_auth/test/dummy/app/views/layouts/application.html.erb +27 -0
  63. data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  64. data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  65. data/lib/generators/propel_auth/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  66. data/lib/generators/propel_auth/test/dummy/app/views/pwa/service-worker.js +26 -0
  67. data/lib/generators/propel_auth/test/dummy/bin/brakeman +7 -0
  68. data/lib/generators/propel_auth/test/dummy/bin/dev +2 -0
  69. data/lib/generators/propel_auth/test/dummy/bin/docker-entrypoint +14 -0
  70. data/lib/generators/propel_auth/test/dummy/bin/rails +4 -0
  71. data/lib/generators/propel_auth/test/dummy/bin/rake +4 -0
  72. data/lib/generators/propel_auth/test/dummy/bin/rubocop +8 -0
  73. data/lib/generators/propel_auth/test/dummy/bin/setup +34 -0
  74. data/lib/generators/propel_auth/test/dummy/bin/thrust +5 -0
  75. data/lib/generators/propel_auth/test/dummy/config/application.rb +42 -0
  76. data/lib/generators/propel_auth/test/dummy/config/boot.rb +4 -0
  77. data/lib/generators/propel_auth/test/dummy/config/cable.yml +10 -0
  78. data/lib/generators/propel_auth/test/dummy/config/credentials.yml.enc +1 -0
  79. data/lib/generators/propel_auth/test/dummy/config/database.yml +41 -0
  80. data/lib/generators/propel_auth/test/dummy/config/environment.rb +5 -0
  81. data/lib/generators/propel_auth/test/dummy/config/environments/development.rb +72 -0
  82. data/lib/generators/propel_auth/test/dummy/config/environments/production.rb +89 -0
  83. data/lib/generators/propel_auth/test/dummy/config/environments/test.rb +53 -0
  84. data/lib/generators/propel_auth/test/dummy/config/initializers/assets.rb +10 -0
  85. data/lib/generators/propel_auth/test/dummy/config/initializers/content_security_policy.rb +25 -0
  86. data/lib/generators/propel_auth/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  87. data/lib/generators/propel_auth/test/dummy/config/initializers/inflections.rb +16 -0
  88. data/lib/generators/propel_auth/test/dummy/config/locales/en.yml +31 -0
  89. data/lib/generators/propel_auth/test/dummy/config/master.key +1 -0
  90. data/lib/generators/propel_auth/test/dummy/config/puma.rb +41 -0
  91. data/lib/generators/propel_auth/test/dummy/config/routes.rb +2 -0
  92. data/lib/generators/propel_auth/test/dummy/config/storage.yml +34 -0
  93. data/lib/generators/propel_auth/test/dummy/config.ru +6 -0
  94. data/lib/generators/propel_auth/test/dummy/db/schema.rb +14 -0
  95. data/lib/generators/propel_auth/test/generators/authentication/controllers/tokens_controller_test.rb +230 -0
  96. data/lib/generators/propel_auth/test/generators/authentication/install_generator_test.rb +490 -0
  97. data/lib/generators/propel_auth/test/generators/authentication/uninstall_generator_test.rb +408 -0
  98. data/lib/generators/propel_auth/test/integration/generator_integration_test.rb +158 -0
  99. data/lib/generators/propel_auth/test/integration/multi_version_generator_test.rb +125 -0
  100. data/lib/generators/propel_auth/unpack_generator.rb +345 -0
  101. data/lib/propel_auth.rb +3 -0
  102. metadata +195 -0
@@ -0,0 +1,247 @@
1
+ require 'test_helper'
2
+
3
+ class ConfirmableTest < ActiveSupport::TestCase
4
+ def setup
5
+ @user = users(:john_user)
6
+ @organization = @user.organization
7
+ end
8
+
9
+ # TOKEN GENERATION TESTS
10
+
11
+ test "should generate confirmation token on user creation" do
12
+ user = User.new(
13
+ email_address: 'jane@example.com',
14
+ username: 'jane_doe',
15
+ password: 'securepassword123',
16
+ password_confirmation: 'securepassword123',
17
+ organization: @organization
18
+ )
19
+
20
+ assert_nil user.confirmation_token, "Confirmation token should be nil before save"
21
+ assert_nil user.confirmation_sent_at, "Confirmation sent at should be nil before save"
22
+
23
+ user.save!
24
+
25
+ assert_not_nil user.confirmation_token, "Confirmation token should be generated on save"
26
+ assert_not_nil user.confirmation_sent_at, "Confirmation sent at should be set on save"
27
+ assert user.confirmation_token.length >= 32, "Confirmation token should be at least 32 characters"
28
+ end
29
+
30
+ test "should not generate confirmation token for already confirmed users" do
31
+ @user.update!(confirmed_at: Time.current)
32
+ original_token = @user.confirmation_token
33
+
34
+ @user.save!
35
+
36
+ assert_equal original_token, @user.confirmation_token, "Confirmation token should not change for confirmed users"
37
+ end
38
+
39
+ test "should generate new confirmation token when resending confirmation" do
40
+ original_token = @user.confirmation_token
41
+ original_sent_at = @user.confirmation_sent_at
42
+
43
+ sleep 0.001 # Ensure time difference
44
+ @user.send_confirmation_instructions
45
+
46
+ assert_not_equal original_token, @user.confirmation_token, "Should generate new confirmation token"
47
+ assert @user.confirmation_sent_at > original_sent_at, "Should update confirmation sent time"
48
+ end
49
+
50
+ # CONFIRMATION STATUS TESTS
51
+
52
+ test "should not be confirmed by default" do
53
+ assert_not @user.confirmed?, "User should not be confirmed by default"
54
+ assert_nil @user.confirmed_at, "Confirmed at should be nil by default"
55
+ end
56
+
57
+ test "should be confirmed after setting confirmed_at" do
58
+ @user.update!(confirmed_at: Time.current)
59
+
60
+ assert @user.confirmed?, "User should be confirmed after setting confirmed_at"
61
+ assert_not_nil @user.confirmed_at, "Confirmed at should be set"
62
+ end
63
+
64
+ test "should identify confirmation as pending" do
65
+ assert @user.confirmation_pending?, "Confirmation should be pending for unconfirmed user"
66
+
67
+ @user.update!(confirmed_at: Time.current)
68
+ assert_not @user.confirmation_pending?, "Confirmation should not be pending for confirmed user"
69
+ end
70
+
71
+ # CONFIRMATION TOKEN VALIDATION
72
+
73
+ test "should confirm user with valid token" do
74
+ token = @user.confirmation_token
75
+
76
+ result = @user.confirm_by_token(token)
77
+
78
+ assert result, "Should successfully confirm with valid token"
79
+ assert @user.confirmed?, "User should be confirmed after token confirmation"
80
+ assert_not_nil @user.confirmed_at, "Confirmed at should be set"
81
+ assert_nil @user.confirmation_token, "Confirmation token should be cleared"
82
+ end
83
+
84
+ test "should not confirm user with invalid token" do
85
+ result = @user.confirm_by_token('invalid_token')
86
+
87
+ assert_not result, "Should not confirm with invalid token"
88
+ assert_not @user.confirmed?, "User should remain unconfirmed"
89
+ assert_nil @user.confirmed_at, "Confirmed at should remain nil"
90
+ assert_not_nil @user.confirmation_token, "Confirmation token should remain"
91
+ end
92
+
93
+ test "should not confirm user with expired token" do
94
+ @user.update!(confirmation_sent_at: 25.hours.ago) # Expired (24h default)
95
+ token = @user.confirmation_token
96
+
97
+ result = @user.confirm_by_token(token)
98
+
99
+ assert_not result, "Should not confirm with expired token"
100
+ assert_not @user.confirmed?, "User should remain unconfirmed"
101
+ assert @user.confirmation_expired?, "Confirmation should be expired"
102
+ end
103
+
104
+ test "should detect expired confirmation tokens" do
105
+ @user.update!(confirmation_sent_at: 25.hours.ago)
106
+ assert @user.confirmation_expired?, "Confirmation should be expired after 24 hours"
107
+
108
+ @user.update!(confirmation_sent_at: 23.hours.ago)
109
+ assert_not @user.confirmation_expired?, "Confirmation should not be expired within 24 hours"
110
+ end
111
+
112
+ # EMAIL INTEGRATION TESTS
113
+
114
+ test "should send confirmation email on user creation" do
115
+ assert_emails 1 do
116
+ User.create!(
117
+ email_address: 'newuser@example.com',
118
+ username: 'newuser',
119
+ password: 'securepassword123',
120
+ password_confirmation: 'securepassword123',
121
+ organization: @organization
122
+ )
123
+ end
124
+
125
+ email = ActionMailer::Base.deliveries.last
126
+ assert_equal 'newuser@example.com', email.to.first, "Email should be sent to user's email address"
127
+ assert_match(/confirm/i, email.subject, "Email subject should mention confirmation")
128
+ assert_match(/confirm/i, email.body.to_s, "Email body should contain confirmation instructions")
129
+ end
130
+
131
+ test "should send confirmation email when resending instructions" do
132
+ ActionMailer::Base.deliveries.clear
133
+
134
+ assert_emails 1 do
135
+ @user.send_confirmation_instructions
136
+ end
137
+
138
+ email = ActionMailer::Base.deliveries.last
139
+ assert_equal @user.email_address, email.to.first, "Email should be sent to user's email address"
140
+ assert_match(@user.confirmation_token, email.body.to_s, "Email should contain confirmation token")
141
+ end
142
+
143
+ test "should not send confirmation email to already confirmed users" do
144
+ @user.update!(confirmed_at: Time.current)
145
+ ActionMailer::Base.deliveries.clear
146
+
147
+ assert_emails 0 do
148
+ @user.send_confirmation_instructions
149
+ end
150
+ end
151
+
152
+ # BUSINESS LOGIC TESTS
153
+
154
+ test "should prevent login for unconfirmed users" do
155
+ assert_not @user.can_login?, "Unconfirmed users should not be able to login"
156
+
157
+ @user.update!(confirmed_at: Time.current)
158
+ assert @user.can_login?, "Confirmed users should be able to login"
159
+ end
160
+
161
+ test "should allow confirmation resend with rate limiting" do
162
+ @user.send_confirmation_instructions
163
+ first_sent_at = @user.confirmation_sent_at
164
+
165
+ # Try to resend immediately (should be rate limited)
166
+ @user.send_confirmation_instructions
167
+ assert_equal first_sent_at, @user.confirmation_sent_at, "Should not resend immediately"
168
+
169
+ # Simulate time passing (1 minute)
170
+ @user.update!(confirmation_sent_at: 2.minutes.ago)
171
+ @user.send_confirmation_instructions
172
+ assert @user.confirmation_sent_at > first_sent_at, "Should allow resend after time limit"
173
+ end
174
+
175
+ test "should handle email address changes requiring reconfirmation" do
176
+ @user.update!(confirmed_at: Time.current)
177
+ original_email = @user.email_address
178
+
179
+ assert_emails 1 do
180
+ @user.update!(email_address: 'newemail@example.com')
181
+ end
182
+
183
+ @user.reload
184
+ assert_not @user.confirmed?, "Should require reconfirmation after email change"
185
+ assert_equal 'newemail@example.com', @user.email_address, "Email should be updated"
186
+ assert_not_nil @user.confirmation_token, "Should generate new confirmation token"
187
+ end
188
+
189
+ # SECURITY TESTS
190
+
191
+ test "should use secure random tokens" do
192
+ tokens = []
193
+
194
+ 10.times do
195
+ user = User.create!(
196
+ email_address: "user#{SecureRandom.hex(4)}@example.com",
197
+ username: "user#{SecureRandom.hex(4)}",
198
+ password: 'securepassword123',
199
+ password_confirmation: 'securepassword123',
200
+ organization: @organization
201
+ )
202
+ tokens << user.confirmation_token
203
+ end
204
+
205
+ # All tokens should be unique
206
+ assert_equal tokens.length, tokens.uniq.length, "All confirmation tokens should be unique"
207
+
208
+ # All tokens should be proper length
209
+ tokens.each do |token|
210
+ assert token.length >= 32, "Token should be at least 32 characters: #{token}"
211
+ assert token.match?(/\A[a-f0-9]+\z/), "Token should be hexadecimal: #{token}"
212
+ end
213
+ end
214
+
215
+ test "should clear sensitive data after confirmation" do
216
+ token = @user.confirmation_token
217
+
218
+ @user.confirm_by_token(token)
219
+
220
+ assert_nil @user.confirmation_token, "Confirmation token should be cleared"
221
+ assert_not_nil @user.confirmed_at, "Confirmed at should be preserved"
222
+ end
223
+
224
+ # EDGE CASES
225
+
226
+ test "should handle nil confirmation token gracefully" do
227
+ @user.update_column(:confirmation_token, nil)
228
+
229
+ result = @user.confirm_by_token('any_token')
230
+ assert_not result, "Should not confirm when token is nil"
231
+ end
232
+
233
+ test "should handle missing confirmation_sent_at gracefully" do
234
+ @user.update_column(:confirmation_sent_at, nil)
235
+
236
+ assert_not @user.confirmation_expired?, "Should not be expired when sent_at is nil"
237
+ end
238
+
239
+ private
240
+
241
+ def assert_emails(number, &block)
242
+ original_count = ActionMailer::Base.deliveries.count
243
+ yield
244
+ new_count = ActionMailer::Base.deliveries.count
245
+ assert_equal original_count + number, new_count, "Expected #{number} emails to be sent"
246
+ end
247
+ end
@@ -0,0 +1,282 @@
1
+ require 'test_helper'
2
+
3
+ class LockableTest < ActiveSupport::TestCase
4
+ def setup
5
+ @user = users(:jane_user)
6
+ @organization = @user.organization
7
+ end
8
+
9
+ # Test basic lockable functionality
10
+ test "should not be locked initially" do
11
+ assert_not @user.locked?, "User should not be locked initially"
12
+ assert_equal 0, @user.failed_login_attempts, "Failed login attempts should be 0 initially"
13
+ assert_nil @user.locked_at, "Locked at should be nil initially"
14
+ end
15
+
16
+ test "should increment failed login attempts" do
17
+ @user.increment_failed_attempts
18
+ assert_equal 1, @user.failed_login_attempts, "Failed attempts should increment to 1"
19
+ assert_not @user.locked?, "User should not be locked after 1 failed attempt"
20
+ end
21
+
22
+ test "should lock account after max failed attempts" do
23
+ # Simulate 10 failed attempts
24
+ PropelAuth.configuration.max_failed_attempts.times do
25
+ @user.increment_failed_attempts
26
+ end
27
+
28
+ assert @user.locked?, "User should be locked after #{PropelAuth.configuration.max_failed_attempts} failed attempts"
29
+ assert @user.locked_at.present?, "Locked at timestamp should be set"
30
+ assert_equal PropelAuth.configuration.max_failed_attempts, @user.failed_login_attempts, "Failed attempts should equal max"
31
+ end
32
+
33
+ test "should reset failed attempts on successful login" do
34
+ # Set some failed attempts
35
+ @user.update!(failed_login_attempts: 5)
36
+
37
+ # Simulate successful login
38
+ @user.reset_failed_attempts
39
+
40
+ assert_equal 0, @user.failed_login_attempts, "Failed attempts should reset to 0 on successful login"
41
+ end
42
+
43
+ test "should unlock account manually" do
44
+ # Lock the account
45
+ @user.lock_account!
46
+ assert @user.locked?, "User should be locked"
47
+
48
+ # Unlock manually
49
+ @user.unlock_account!
50
+ assert_not @user.locked?, "User should be unlocked"
51
+ assert_nil @user.locked_at, "Locked at should be nil after unlock"
52
+ assert_equal 0, @user.failed_login_attempts, "Failed attempts should reset after unlock"
53
+ end
54
+
55
+ test "should automatically unlock after lockout duration" do
56
+ # Lock the account with a past timestamp
57
+ past_time = (PropelAuth.configuration.lockout_duration + 1.minute).ago
58
+ @user.update!(
59
+ locked_at: past_time,
60
+ failed_login_attempts: PropelAuth.configuration.max_failed_attempts
61
+ )
62
+
63
+ # Check if automatically unlocked
64
+ assert_not @user.locked?, "User should be automatically unlocked after lockout duration"
65
+ end
66
+
67
+ test "should remain locked within lockout duration" do
68
+ # Lock the account recently
69
+ recent_time = 5.minutes.ago
70
+ @user.update!(
71
+ locked_at: recent_time,
72
+ failed_login_attempts: PropelAuth.configuration.max_failed_attempts
73
+ )
74
+
75
+ # Should still be locked
76
+ assert @user.locked?, "User should remain locked within lockout duration"
77
+ end
78
+
79
+ test "should prevent authentication when locked" do
80
+ @user.lock_account!
81
+
82
+ # Even with correct password, should not authenticate when locked
83
+ assert_not @user.can_authenticate?, "Locked user should not be able to authenticate"
84
+ end
85
+
86
+ test "should allow authentication when not locked" do
87
+ assert @user.can_authenticate?, "Unlocked user should be able to authenticate"
88
+ end
89
+
90
+ test "should provide unlock token for self-service unlock" do
91
+ @user.lock_account!
92
+
93
+ unlock_token = @user.generate_unlock_token
94
+ assert unlock_token.present?, "Should generate unlock token"
95
+ assert_equal 3, unlock_token.split('.').length, "Unlock token should be a JWT"
96
+
97
+ # Verify token can be used to find user
98
+ found_user = User.find_by_unlock_token(unlock_token)
99
+ assert_equal @user.id, found_user.id, "Should find user by unlock token"
100
+ end
101
+
102
+ test "should unlock with valid unlock token" do
103
+ @user.lock_account!
104
+ unlock_token = @user.generate_unlock_token
105
+
106
+ result = @user.unlock_with_token!(unlock_token)
107
+ assert result, "Should unlock with valid token"
108
+ assert_not @user.locked?, "User should be unlocked after valid token"
109
+ end
110
+
111
+ test "should not unlock with invalid unlock token" do
112
+ @user.lock_account!
113
+
114
+ result = @user.unlock_with_token!("invalid_token")
115
+ assert_not result, "Should not unlock with invalid token"
116
+ assert @user.locked?, "User should remain locked with invalid token"
117
+ end
118
+
119
+ test "should track time of lock for security auditing" do
120
+ # BEHAVIOR: Verify lock timestamp is recorded within reasonable time
121
+ start_time = Time.current
122
+ @user.lock_account!
123
+ end_time = Time.current
124
+
125
+ # Should be locked within the time range
126
+ assert @user.locked_at >= start_time, "Lock time should be after start time"
127
+ assert @user.locked_at <= end_time, "Lock time should be before end time"
128
+ assert @user.locked_at.present?, "Lock timestamp should be recorded"
129
+ end
130
+
131
+ test "should integrate with failed login attempt configuration" do
132
+ # Test with different configuration
133
+ original_max = PropelAuth.configuration.max_failed_attempts
134
+ PropelAuth.configuration.max_failed_attempts = 3
135
+
136
+ # Should lock after 3 attempts with new config
137
+ 3.times { @user.increment_failed_attempts }
138
+ assert @user.locked?, "Should respect updated configuration"
139
+
140
+ # Restore original config
141
+ PropelAuth.configuration.max_failed_attempts = original_max
142
+ end
143
+
144
+ # CRITICAL EDGE CASE TESTS - Missing from initial implementation
145
+
146
+ test "should not increment attempts beyond max when already locked" do
147
+ # SECURITY: Prevent attempt counter overflow on locked accounts
148
+ @user.lock_account!
149
+ original_attempts = @user.failed_login_attempts
150
+
151
+ @user.increment_failed_attempts # Try to increment past lock
152
+ assert_equal original_attempts, @user.reload.failed_login_attempts, "Should not increment attempts when already locked"
153
+ end
154
+
155
+ test "should reject expired unlock tokens" do
156
+ # SECURITY: Verify token expiration is enforced
157
+ @user.lock_account!
158
+
159
+ # Create token that expires immediately
160
+ expired_payload = {
161
+ user_id: @user.id,
162
+ email_address: @user.email_address,
163
+ type: 'unlock',
164
+ iat: Time.now.to_i,
165
+ exp: 1.second.ago.to_i
166
+ }
167
+ expired_token = JWT.encode(expired_payload, PropelAuth.configuration.jwt_secret, 'HS256')
168
+
169
+ assert_not @user.unlock_with_token!(expired_token), "Should reject expired unlock token"
170
+ assert @user.locked?, "Should remain locked with expired token"
171
+ end
172
+
173
+ test "should reject unlock tokens for different users" do
174
+ # SECURITY: Prevent token reuse across users
175
+ other_user = User.create!(
176
+ email_address: "other-user@example.com",
177
+ username: "otheruser",
178
+ password: "password123",
179
+ organization: @organization
180
+ )
181
+
182
+ @user.lock_account!
183
+ other_user.lock_account!
184
+
185
+ # Generate token for other user
186
+ other_token = other_user.generate_unlock_token
187
+
188
+ # Try to use other user's token
189
+ assert_not @user.unlock_with_token!(other_token), "Should reject token from different user"
190
+ assert @user.locked?, "Should remain locked with wrong user token"
191
+ end
192
+
193
+ test "should reject tampered unlock tokens" do
194
+ # SECURITY: Verify JWT signature validation
195
+ @user.lock_account!
196
+
197
+ valid_token = @user.generate_unlock_token
198
+ tampered_token = valid_token[0..-10] + "tamperedXX" # Tamper with signature
199
+
200
+ assert_not @user.unlock_with_token!(tampered_token), "Should reject tampered token"
201
+ assert @user.locked?, "Should remain locked with tampered token"
202
+ end
203
+
204
+ test "should handle validation failures gracefully during locking" do
205
+ # ERROR HANDLING: Verify graceful failure on validation errors
206
+
207
+ # Create invalid state that would fail validation
208
+ @user.email_address = nil # Make user invalid (email_address is required)
209
+
210
+ # Attempt to lock - should fail due to validation
211
+ assert_raises(ActiveRecord::RecordInvalid) { @user.lock_account! }
212
+
213
+ # Verify state remains consistent after failure
214
+ @user.reload
215
+ assert_not @user.locked?, "Should not be locked if validation failed"
216
+ end
217
+
218
+ test "should handle validation failures gracefully during unlock" do
219
+ # ERROR HANDLING: Verify graceful failure on validation errors
220
+ @user.lock_account!
221
+
222
+ # Create invalid state that would fail validation
223
+ @user.email_address = nil # Make user invalid (email_address is required)
224
+
225
+ # Attempt to unlock - should fail due to validation
226
+ assert_raises(ActiveRecord::RecordInvalid) { @user.unlock_account! }
227
+
228
+ # Verify state remains consistent after failure
229
+ @user.reload
230
+ assert @user.locked?, "Should remain locked if validation failed"
231
+ end
232
+
233
+ test "should persist failed attempts across multiple increment calls" do
234
+ # DATA INTEGRITY: Verify attempts are properly persisted
235
+ 5.times do |i|
236
+ @user.increment_failed_attempts
237
+ @user.reload # Simulate fresh load from database
238
+ assert_equal i + 1, @user.failed_login_attempts, "Failed attempts should persist across reloads"
239
+ end
240
+ end
241
+
242
+ test "should not allow double-locking with race conditions" do
243
+ # CONCURRENCY: Verify locking is idempotent
244
+ @user.lock_account!
245
+ first_locked_at = @user.locked_at
246
+
247
+ # Try to lock again (simulating race condition)
248
+ @user.lock_account!
249
+ @user.reload
250
+
251
+ assert_equal first_locked_at.to_i, @user.locked_at.to_i, "Locked timestamp should not change on double-lock"
252
+ end
253
+
254
+ test "should validate unlock token structure and content" do
255
+ # JWT VALIDATION: Verify token contains required fields
256
+ @user.lock_account!
257
+
258
+ unlock_token = @user.generate_unlock_token
259
+ decoded = JWT.decode(unlock_token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
260
+ payload = decoded.first
261
+
262
+ assert_equal @user.id, payload['user_id'], "Token should contain correct user ID"
263
+ assert_equal @user.email_address, payload['email_address'], "Token should contain user email_address"
264
+ assert_equal 'unlock', payload['type'], "Token should be marked as unlock type"
265
+ assert payload['iat'].present?, "Token should have issued at timestamp"
266
+ assert payload['exp'].present?, "Token should have expiration timestamp"
267
+ assert payload['exp'] > Time.now.to_i, "Token should not be expired immediately"
268
+ end
269
+
270
+ test "should enforce maximum unlock token lifetime" do
271
+ # SECURITY: Verify unlock tokens have reasonable expiration
272
+ @user.lock_account!
273
+
274
+ unlock_token = @user.generate_unlock_token
275
+ decoded = JWT.decode(unlock_token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
276
+ payload = decoded.first
277
+
278
+ token_lifetime = payload['exp'] - payload['iat']
279
+ assert token_lifetime <= 1.hour, "Unlock token should expire within 1 hour"
280
+ assert token_lifetime > 30.minutes, "Unlock token should be valid for at least 30 minutes"
281
+ end
282
+ end
@@ -0,0 +1,75 @@
1
+ require 'test_helper'
2
+
3
+ # A dummy controller to test the concern
4
+ class PropelAuthenticationTestController < ActionController::Base
5
+ include PropelAuthentication
6
+ before_action :authenticate_user
7
+
8
+ def index
9
+ render json: { user_id: current_user.id }
10
+ end
11
+ end
12
+
13
+ class PropelAuthenticationTest < ActionDispatch::IntegrationTest
14
+ setup do
15
+ @user = users(:john_user)
16
+ @organization = @user.organization
17
+ @token = @user.generate_jwt_token
18
+ end
19
+
20
+ def with_test_routes
21
+ Rails.application.routes.draw do
22
+ get '/test_auth', to: 'propel_authentication_test#index'
23
+ yield
24
+ end
25
+ ensure
26
+ Rails.application.reload_routes!
27
+ end
28
+
29
+ test "should get index with valid token" do
30
+ with_test_routes do
31
+ get '/test_auth', headers: { 'Authorization' => "Bearer #{@token}" }
32
+ assert_response :success
33
+ json_response = JSON.parse(response.body)
34
+ assert_equal @user.id, json_response['user_id']
35
+ end
36
+ end
37
+
38
+ test "should return unauthorized with invalid token" do
39
+ with_test_routes do
40
+ get '/test_auth', headers: { 'Authorization' => 'Bearer invalidtoken' }
41
+ assert_response :unauthorized
42
+ assert_equal 'Invalid token', JSON.parse(response.body)['error']
43
+ end
44
+ end
45
+
46
+ test "should return unauthorized with expired token" do
47
+ payload = {
48
+ user_id: @user.id,
49
+ exp: 1.hour.ago.to_i
50
+ }
51
+ expired_token = JWT.encode(payload, PropelAuth.configuration.jwt_secret, 'HS256')
52
+
53
+ with_test_routes do
54
+ get '/test_auth', headers: { 'Authorization' => "Bearer #{expired_token}" }
55
+ assert_response :unauthorized
56
+ assert_equal 'Token expired', JSON.parse(response.body)['error']
57
+ end
58
+ end
59
+
60
+ test "should return unauthorized with no token" do
61
+ with_test_routes do
62
+ get '/test_auth'
63
+ assert_response :unauthorized
64
+ assert_equal 'No token provided', JSON.parse(response.body)['error']
65
+ end
66
+ end
67
+
68
+ test "should return unauthorized with incorrectly formatted token header" do
69
+ with_test_routes do
70
+ get '/test_auth', headers: { 'Authorization' => @token }
71
+ assert_response :unauthorized
72
+ assert_equal 'No token provided', JSON.parse(response.body)['error']
73
+ end
74
+ end
75
+ end