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,327 @@
1
+ require 'test_helper'
2
+
3
+ class RecoverableTest < ActiveSupport::TestCase
4
+ def setup
5
+ @user = users(:john_user)
6
+ @organization = @user.organization
7
+ end
8
+
9
+ # Test basic password reset token functionality
10
+ test "should generate password reset token with proper structure" do
11
+ # BEHAVIOR: Verify password reset token contains required fields and structure
12
+
13
+ reset_token = @user.generate_password_reset_token
14
+
15
+ # VERIFY TOKEN STRUCTURE: Check JWT token is properly formatted
16
+ assert reset_token.present?, "Should generate password reset token"
17
+ assert_equal 3, reset_token.split('.').length, "JWT should have 3 parts separated by dots"
18
+
19
+ # VERIFY TOKEN CONTENT: Decode and validate payload
20
+ decoded = JWT.decode(reset_token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
21
+ payload = decoded.first
22
+
23
+ assert_equal @user.id, payload['user_id'], "Token should contain correct user ID"
24
+ assert_equal @user.email_address, payload['email_address'], "Token should contain user email_address"
25
+ assert_equal 'password_reset', payload['type'], "Token should be marked as password_reset type"
26
+ assert payload['iat'].present?, "Token should have issued at timestamp"
27
+ assert payload['exp'].present?, "Token should have expiration timestamp"
28
+ assert payload['password_hash'].present?, "Token should contain password hash fragment for invalidation"
29
+ end
30
+
31
+ test "should generate unique tokens for each request" do
32
+ # SECURITY: Verify each reset request generates unique tokens
33
+
34
+ token1 = @user.generate_password_reset_token
35
+ token2 = @user.generate_password_reset_token
36
+
37
+ assert_not_equal token1, token2, "Each reset request should generate unique tokens"
38
+
39
+ # Both should be valid but different
40
+ assert @user.valid_password_reset_token?(token1), "First token should be valid"
41
+ assert @user.valid_password_reset_token?(token2), "Second token should be valid"
42
+ end
43
+
44
+ test "should validate password reset tokens correctly" do
45
+ # BEHAVIOR: Verify token validation logic works correctly
46
+
47
+ reset_token = @user.generate_password_reset_token
48
+
49
+ # VERIFY VALIDATION: Valid token should pass validation
50
+ assert @user.valid_password_reset_token?(reset_token), "Valid token should pass validation"
51
+
52
+ # VERIFY TOKEN BINDING: Token should be bound to specific user
53
+ decoded = JWT.decode(reset_token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
54
+ payload = decoded.first
55
+ assert_equal @user.id, payload['user_id'], "Token should be bound to correct user"
56
+ end
57
+
58
+ test "should enforce password reset token expiration" do
59
+ # SECURITY: Verify tokens expire after configured time
60
+
61
+ # SETUP: Create token that should expire quickly for testing
62
+ expired_payload = {
63
+ user_id: @user.id,
64
+ email_address: @user.email_address,
65
+ type: 'password_reset',
66
+ iat: Time.now.to_i,
67
+ exp: 1.second.ago.to_i, # Already expired
68
+ password_hash: @user.password_digest[0..10]
69
+ }
70
+ expired_token = JWT.encode(expired_payload, PropelAuth.configuration.jwt_secret, 'HS256')
71
+
72
+ # VERIFY EXPIRATION: Expired token should be rejected
73
+ assert_not @user.valid_password_reset_token?(expired_token), "Expired token should be rejected"
74
+ end
75
+
76
+ test "should invalidate tokens when password changes" do
77
+ # SECURITY: Verify tokens become invalid if password already changed
78
+
79
+ reset_token = @user.generate_password_reset_token
80
+
81
+ # VERIFY INITIAL VALIDITY: Token should be valid initially
82
+ assert @user.valid_password_reset_token?(reset_token), "Token should be valid before password change"
83
+
84
+ # SIMULATE PASSWORD CHANGE: Update user password
85
+ @user.update!(password: "newpassword456")
86
+
87
+ # VERIFY INVALIDATION: Token should now be invalid due to password hash change
88
+ assert_not @user.valid_password_reset_token?(reset_token), "Token should be invalid after password change"
89
+ end
90
+
91
+ test "should reset password with valid token" do
92
+ # BEHAVIOR: Verify password reset functionality works end-to-end
93
+
94
+ reset_token = @user.generate_password_reset_token
95
+ new_password = "resetpassword789"
96
+
97
+ # VERIFY INITIAL STATE: User should authenticate with original password
98
+ assert @user.authenticate("password123"), "Should authenticate with original password"
99
+ assert_not @user.authenticate(new_password), "Should not authenticate with new password yet"
100
+
101
+ # EXECUTE PASSWORD RESET: Reset password using valid token
102
+ result = @user.reset_password_with_token!(reset_token, new_password)
103
+
104
+ # VERIFY RESET SUCCESS: Method should return true
105
+ assert result, "Password reset should succeed with valid token"
106
+
107
+ # VERIFY PASSWORD CHANGE: User should now authenticate with new password
108
+ @user.reload
109
+ assert @user.authenticate(new_password), "Should authenticate with new password"
110
+ assert_not @user.authenticate("password123"), "Should not authenticate with old password"
111
+ end
112
+
113
+ test "should reject password reset with invalid token" do
114
+ # SECURITY: Verify invalid tokens are rejected
115
+
116
+ invalid_token = "invalid.jwt.token"
117
+ new_password = "attemptedpassword"
118
+ original_password_digest = @user.password_digest
119
+
120
+ # EXECUTE WITH INVALID TOKEN: Attempt reset with invalid token
121
+ result = @user.reset_password_with_token!(invalid_token, new_password)
122
+
123
+ # VERIFY REJECTION: Reset should fail
124
+ assert_not result, "Password reset should fail with invalid token"
125
+
126
+ # VERIFY NO CHANGE: Password should remain unchanged
127
+ @user.reload
128
+ assert_equal original_password_digest, @user.password_digest, "Password should not change with invalid token"
129
+ assert @user.authenticate("password123"), "Should still authenticate with original password"
130
+ end
131
+
132
+ test "should reject password reset with wrong user token" do
133
+ # SECURITY: Verify tokens cannot be used across different users
134
+
135
+ other_user = User.create!(
136
+ email_address: "other-user@example.com",
137
+ username: "otheruser",
138
+ password: "otherpassword123",
139
+ organization: @organization
140
+ )
141
+
142
+ # GENERATE TOKEN FOR OTHER USER: Create token for different user
143
+ other_token = other_user.generate_password_reset_token
144
+ original_password_digest = @user.password_digest
145
+
146
+ # ATTEMPT CROSS-USER RESET: Try to use other user's token
147
+ result = @user.reset_password_with_token!(other_token, "hackedpassword")
148
+
149
+ # VERIFY REJECTION: Reset should fail
150
+ assert_not result, "Should reject token from different user"
151
+
152
+ # VERIFY NO CHANGE: Original user password should remain unchanged
153
+ @user.reload
154
+ assert_equal original_password_digest, @user.password_digest, "Password should not change with wrong user token"
155
+ end
156
+
157
+ test "should reject password reset with tampered token" do
158
+ # SECURITY: Verify JWT signature validation prevents tampering
159
+
160
+ valid_token = @user.generate_password_reset_token
161
+ tampered_token = valid_token[0..-10] + "tamperedXX" # Tamper with signature
162
+ original_password_digest = @user.password_digest
163
+
164
+ # ATTEMPT RESET WITH TAMPERED TOKEN: Try to use tampered token
165
+ result = @user.reset_password_with_token!(tampered_token, "hackedpassword")
166
+
167
+ # VERIFY REJECTION: Reset should fail
168
+ assert_not result, "Should reject tampered token"
169
+
170
+ # VERIFY NO CHANGE: Password should remain unchanged
171
+ @user.reload
172
+ assert_equal original_password_digest, @user.password_digest, "Password should not change with tampered token"
173
+ end
174
+
175
+ test "should enforce password requirements during reset" do
176
+ # VALIDATION: Verify password requirements are enforced
177
+
178
+ reset_token = @user.generate_password_reset_token
179
+ weak_password = "123" # Too short
180
+ original_password_digest = @user.password_digest
181
+
182
+ # ATTEMPT RESET WITH WEAK PASSWORD: Try to set weak password
183
+ result = @user.reset_password_with_token!(reset_token, weak_password)
184
+
185
+ # VERIFY REJECTION: Reset should fail due to weak password
186
+ assert_not result, "Should reject weak password during reset"
187
+
188
+ # VERIFY NO CHANGE: Password should remain unchanged
189
+ @user.reload
190
+ assert_equal original_password_digest, @user.password_digest, "Password should not change with weak password"
191
+ end
192
+
193
+ test "should handle validation failures gracefully during reset" do
194
+ # ERROR HANDLING: Verify graceful failure on validation errors
195
+
196
+ reset_token = @user.generate_password_reset_token
197
+
198
+ # ATTEMPT WITH INVALID USER STATE: Create invalid state that would fail save
199
+ @user.email_address = nil # Make user invalid (email_address is required)
200
+ new_password = "validnewpassword123"
201
+
202
+ # EXECUTE RESET: Attempt reset with invalid user state
203
+ result = @user.reset_password_with_token!(reset_token, new_password)
204
+
205
+ # VERIFY GRACEFUL FAILURE: Method should return false
206
+ assert_not result, "Should handle validation failure gracefully"
207
+
208
+ # VERIFY NO CHANGE: Password should remain unchanged
209
+ @user.reload
210
+ assert @user.authenticate("password123"), "Should still authenticate with original password"
211
+ end
212
+
213
+ test "should clear failed login attempts on successful password reset" do
214
+ # INTEGRATION: Verify password reset clears lockable state
215
+
216
+ # SETUP: Build up failed login attempts
217
+ @user.update!(failed_login_attempts: 5)
218
+ assert_equal 5, @user.failed_login_attempts, "Should have failed attempts"
219
+
220
+ # EXECUTE RESET: Reset password with valid token
221
+ reset_token = @user.generate_password_reset_token
222
+ result = @user.reset_password_with_token!(reset_token, "newpassword123")
223
+
224
+ # VERIFY INTEGRATION: Failed attempts should be cleared
225
+ assert result, "Password reset should succeed"
226
+ @user.reload
227
+ assert_equal 0, @user.failed_login_attempts, "Failed attempts should be cleared on password reset"
228
+ end
229
+
230
+ # Class method tests for token validation
231
+ test "should find user by valid password reset token" do
232
+ # BEHAVIOR: Verify class method for finding users by reset token
233
+
234
+ reset_token = @user.generate_password_reset_token
235
+
236
+ # EXECUTE LOOKUP: Find user by reset token
237
+ found_user = User.find_user_by_password_reset_token(reset_token)
238
+
239
+ # VERIFY LOOKUP: Should find correct user
240
+ assert_equal @user.id, found_user.id, "Should find user by valid reset token"
241
+ assert_equal @user.email_address, found_user.email_address, "Should return correct user"
242
+ end
243
+
244
+ test "should return nil for invalid password reset token lookup" do
245
+ # SECURITY: Verify invalid tokens return nil
246
+
247
+ invalid_token = "invalid.jwt.token"
248
+
249
+ # EXECUTE LOOKUP: Try to find user with invalid token
250
+ found_user = User.find_user_by_password_reset_token(invalid_token)
251
+
252
+ # VERIFY REJECTION: Should return nil
253
+ assert_nil found_user, "Should return nil for invalid token"
254
+ end
255
+
256
+ test "should return nil for expired password reset token lookup" do
257
+ # SECURITY: Verify expired tokens return nil
258
+
259
+ expired_payload = {
260
+ user_id: @user.id,
261
+ email_address: @user.email_address,
262
+ type: 'password_reset',
263
+ iat: Time.now.to_i,
264
+ exp: 1.second.ago.to_i, # Already expired
265
+ password_hash: @user.password_digest[0..10]
266
+ }
267
+ expired_token = JWT.encode(expired_payload, PropelAuth.configuration.jwt_secret, 'HS256')
268
+
269
+ # EXECUTE LOOKUP: Try to find user with expired token
270
+ found_user = User.find_user_by_password_reset_token(expired_token)
271
+
272
+ # VERIFY EXPIRATION: Should return nil
273
+ assert_nil found_user, "Should return nil for expired token"
274
+ end
275
+
276
+ test "should enforce reasonable token expiration times" do
277
+ # SECURITY: Verify token expiration is reasonable (not too long)
278
+
279
+ reset_token = @user.generate_password_reset_token
280
+ decoded = JWT.decode(reset_token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
281
+ payload = decoded.first
282
+
283
+ token_lifetime = payload['exp'] - payload['iat']
284
+
285
+ # VERIFY EXPIRATION BOUNDS: Token should expire reasonably soon
286
+ assert token_lifetime <= 1.hour, "Reset token should expire within 1 hour for security"
287
+ assert token_lifetime >= 5.minutes, "Reset token should be valid for at least 5 minutes for usability"
288
+ end
289
+
290
+ test "should validate token type to prevent reuse" do
291
+ # SECURITY: Verify only password_reset type tokens are accepted
292
+
293
+ # CREATE UNLOCK TOKEN: Generate token with different type
294
+ unlock_payload = {
295
+ user_id: @user.id,
296
+ email_address: @user.email_address,
297
+ type: 'unlock', # Wrong type
298
+ iat: Time.now.to_i,
299
+ exp: 1.hour.from_now.to_i,
300
+ password_hash: @user.password_digest[0..10]
301
+ }
302
+ unlock_token = JWT.encode(unlock_payload, PropelAuth.configuration.jwt_secret, 'HS256')
303
+
304
+ # VERIFY TYPE VALIDATION: Unlock token should not work for password reset
305
+ assert_not @user.valid_password_reset_token?(unlock_token), "Should reject wrong token type"
306
+
307
+ # VERIFY FAILED RESET: Password reset should fail with wrong token type
308
+ result = @user.reset_password_with_token!(unlock_token, "newpassword")
309
+ assert_not result, "Password reset should fail with wrong token type"
310
+ end
311
+
312
+ test "should bind token to current password hash" do
313
+ # SECURITY: Verify token includes password hash binding
314
+
315
+ reset_token = @user.generate_password_reset_token
316
+ decoded = JWT.decode(reset_token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
317
+ payload = decoded.first
318
+
319
+ # VERIFY BINDING: Token should contain password hash fragment
320
+ assert payload['password_hash'].present?, "Token should contain password hash binding"
321
+ assert payload['password_hash'].length > 0, "Password hash binding should not be empty"
322
+
323
+ # VERIFY CURRENT BINDING: Should match current password hash
324
+ expected_hash_fragment = @user.password_digest[0..10]
325
+ assert_equal expected_hash_fragment, payload['password_hash'], "Token should be bound to current password hash"
326
+ end
327
+ end
@@ -0,0 +1,196 @@
1
+ require 'test_helper'
2
+
3
+ class <%= controller_namespace %>::LockableIntegrationTest < ActionDispatch::IntegrationTest
4
+ def setup
5
+ @organization = Organization.create!(name: "Test Organization")
6
+ @user = User.create!(
7
+ email_address: "lockable-integration@example.com",
8
+ username: "lockableintegration",
9
+ password: "correctpassword123",
10
+ organization: @organization
11
+ )
12
+ end
13
+
14
+ # CRITICAL: Real API integration with lockable functionality
15
+
16
+ test "should track failed attempts during actual login API calls" do
17
+ # BEHAVIOR: Verify failed login attempts are tracked through real API
18
+ 9.times do |i|
19
+ post <%= login_path_helper %>, params: {
20
+ user: { email_address: @user.email_address, password: "wrongpassword" }
21
+ }
22
+
23
+ assert_response :unauthorized, "Failed login should return unauthorized"
24
+ @user.reload
25
+ assert_equal i + 1, @user.failed_login_attempts, "Should track failed attempt #{i + 1}"
26
+ assert_not @user.locked?, "Should not be locked until max attempts reached"
27
+ end
28
+ end
29
+
30
+ test "should lock account and return 423 after max failed login attempts" do
31
+ # BEHAVIOR: Verify account locks after exactly 10 failed attempts via API
32
+
33
+ # First 9 attempts should not lock
34
+ 9.times do
35
+ post <%= login_path_helper %>, params: {
36
+ user: { email_address: @user.email_address, password: "wrongpassword" }
37
+ }
38
+ assert_response :unauthorized
39
+ end
40
+
41
+ # 10th attempt should lock the account
42
+ post <%= login_path_helper %>, params: {
43
+ user: { email_address: @user.email_address, password: "wrongpassword" }
44
+ }
45
+
46
+ @user.reload
47
+ assert @user.locked?, "User should be locked after 10 failed attempts"
48
+ assert_equal 10, @user.failed_login_attempts, "Should have exactly 10 failed attempts"
49
+ end
50
+
51
+ test "should return 423 locked status when attempting to login to locked account" do
52
+ # BEHAVIOR: Verify locked accounts return proper HTTP status and error
53
+ @user.lock_account!
54
+
55
+ post <%= login_path_helper %>, params: {
56
+ user: { email_address: @user.email_address, password: "correctpassword123" }
57
+ }
58
+
59
+ assert_response :locked, "Should return 423 locked status"
60
+
61
+ json = JSON.parse(response.body)
62
+ assert_match(/locked.*too many failed attempts/i, json['error'], "Should return descriptive error message")
63
+ assert_nil json['token'], "Should not return authentication token"
64
+ assert_nil json['user'], "Should not return user data"
65
+ end
66
+
67
+ test "should prevent login even with correct password when account is locked" do
68
+ # SECURITY: Verify locked accounts cannot authenticate regardless of password
69
+ @user.lock_account!
70
+
71
+ # Try with correct password
72
+ post <%= login_path_helper %>, params: {
73
+ user: { email_address: @user.email_address, password: "correctpassword123" }
74
+ }
75
+
76
+ assert_response :locked, "Should reject even correct password when locked"
77
+
78
+ # Verify no login tracking occurs
79
+ @user.reload
80
+ assert @user.locked?, "Should remain locked after correct password attempt"
81
+ end
82
+
83
+ test "should reset failed attempts counter on successful login" do
84
+ # BEHAVIOR: Verify successful login clears failed attempt tracking
85
+
86
+ # Build up some failed attempts
87
+ 5.times do
88
+ post <%= login_path_helper %>, params: {
89
+ user: { email_address: @user.email_address, password: "wrongpassword" }
90
+ }
91
+ end
92
+
93
+ @user.reload
94
+ assert_equal 5, @user.failed_login_attempts, "Should have 5 failed attempts"
95
+
96
+ # Successful login should reset counter
97
+ post <%= login_path_helper %>, params: {
98
+ user: { email_address: @user.email_address, password: "correctpassword123" }
99
+ }
100
+
101
+ assert_response :ok, "Successful login should return 200"
102
+
103
+ @user.reload
104
+ assert_equal 0, @user.failed_login_attempts, "Failed attempts should reset to 0"
105
+ assert_not @user.locked?, "User should not be locked"
106
+ assert @user.last_login_at.present?, "Should update last login timestamp"
107
+ end
108
+
109
+ test "should handle unlock endpoint with valid unlock token" do
110
+ # API INTEGRATION: Verify unlock endpoint works with JWT tokens
111
+ @user.lock_account!
112
+ unlock_token = @user.generate_unlock_token
113
+
114
+ post <%= unlock_path_helper %>, params: { token: unlock_token }
115
+
116
+ assert_response :ok, "Valid unlock token should return 200"
117
+
118
+ json = JSON.parse(response.body)
119
+ assert_match(/unlocked successfully/i, json['message'], "Should return success message")
120
+ assert_equal @user.id, json['user']['id'], "Should return user data"
121
+
122
+ @user.reload
123
+ assert_not @user.locked?, "User should be unlocked"
124
+ assert_equal 0, @user.failed_login_attempts, "Failed attempts should be reset"
125
+ end
126
+
127
+ test "should reject unlock endpoint with invalid token" do
128
+ # API INTEGRATION: Verify unlock endpoint properly validates tokens
129
+ @user.lock_account!
130
+
131
+ post <%= unlock_path_helper %>, params: { token: "invalid_token_12345" }
132
+
133
+ assert_response :unauthorized, "Invalid token should return 401"
134
+
135
+ json = JSON.parse(response.body)
136
+ assert_match(/invalid.*expired/i, json['error'], "Should return error message")
137
+
138
+ @user.reload
139
+ assert @user.locked?, "User should remain locked with invalid token"
140
+ end
141
+
142
+ test "should require unlock token parameter" do
143
+ # API VALIDATION: Verify unlock endpoint validates required parameters
144
+ @user.lock_account!
145
+
146
+ post <%= unlock_path_helper %>, params: {}
147
+
148
+ assert_response :unprocessable_entity, "Missing token should return 422"
149
+
150
+ json = JSON.parse(response.body)
151
+ assert_match(/token.*required/i, json['error'], "Should indicate token is required")
152
+ end
153
+
154
+ test "should not increment attempts when user does not exist" do
155
+ # SECURITY: Verify failed attempts are not tracked for nonexistent users
156
+ post <%= login_path_helper %>, params: {
157
+ user: { email_address: "nonexistent@example.com", password: "anypassword" }
158
+ }
159
+
160
+ assert_response :unauthorized, "Nonexistent user should return unauthorized"
161
+
162
+ # Verify no user was created or attempts tracked
163
+ assert_nil User.find_by(email_address: "nonexistent@example.com"), "Should not create user record"
164
+ end
165
+
166
+ test "should handle graceful error responses for authentication failures" do
167
+ # ERROR HANDLING: Verify system handles authentication failures gracefully
168
+
169
+ post <%= login_path_helper %>, params: {
170
+ user: { email_address: "nonexistent@example.com", password: "wrongpassword" }
171
+ }
172
+
173
+ # Should still return unauthorized in a controlled way, not crash
174
+ assert_response :unauthorized, "Should handle authentication failure gracefully"
175
+
176
+ json = JSON.parse(response.body)
177
+ assert json['error'].present?, "Should return error message"
178
+ assert_match(/invalid credentials/i, json['error'], "Should return appropriate error")
179
+ end
180
+
181
+ test "should properly update last_login_at on successful authentication" do
182
+ # BEHAVIOR: Verify login timestamp tracking
183
+ start_time = Time.current
184
+
185
+ post <%= login_path_helper %>, params: {
186
+ user: { email_address: @user.email_address, password: "correctpassword123" }
187
+ }
188
+
189
+ assert_response :ok
190
+
191
+ @user.reload
192
+ assert @user.last_login_at.present?, "Should set last login timestamp"
193
+ assert @user.last_login_at >= start_time, "Login timestamp should be after start time"
194
+ assert @user.last_login_at > 1.minute.ago, "Login timestamp should be recent"
195
+ end
196
+ end