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,471 @@
1
+ require 'test_helper'
2
+
3
+ class <%= controller_namespace %>::PasswordResetIntegrationTest < ActionDispatch::IntegrationTest
4
+ def setup
5
+ @organization = Organization.create!(name: "Test Organization")
6
+ @user = User.create!(
7
+ email_address: "password-reset@example.com",
8
+ username: "passwordresetuser",
9
+ password: "originalpassword123",
10
+ organization: @organization,
11
+ first_name: "Password",
12
+ last_name: "User"
13
+ )
14
+ end
15
+
16
+ def teardown
17
+ User.destroy_all
18
+ Organization.destroy_all
19
+ end
20
+
21
+ # POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/reset - Request Password Reset Tests
22
+ test "should initiate password reset with valid email_address" do
23
+ # BEHAVIOR: Verify password reset request works with valid email_address
24
+
25
+ post <%= reset_path_helper %>, params: { email_address: @user.email_address }
26
+
27
+ # VERIFY API RESPONSE: Should return success status
28
+ assert_response :ok, "Valid email_address should initiate password reset"
29
+
30
+ response_json = JSON.parse(response.body)
31
+ assert_match(/reset.*sent/i, response_json['message'], "Should confirm reset email sent")
32
+ assert_not response_json.key?('token'), "Should not expose reset token in response"
33
+
34
+ # VERIFY EMAIL DELIVERY: Email should be sent (will test this when we add mailer)
35
+ # For now, verify the request was processed successfully
36
+ end
37
+
38
+ test "should reject password reset with invalid email_address format" do
39
+ # VALIDATION: Verify email_address format validation
40
+
41
+ post <%= reset_path_helper %>, params: { email_address: "invalid-email-format" }
42
+
43
+ # VERIFY VALIDATION ERROR: Should return validation error
44
+ assert_response :unprocessable_entity, "Invalid email_address format should be rejected"
45
+
46
+ response_json = JSON.parse(response.body)
47
+ assert_match(/email.*invalid/i, response_json['error'], "Should indicate email_address format error")
48
+ end
49
+
50
+ test "should handle password reset for nonexistent email_address safely" do
51
+ # SECURITY: Verify nonexistent email_addresses don't reveal user information
52
+
53
+ post <%= reset_path_helper %>, params: { email_address: "nonexistent@example.com" }
54
+
55
+ # VERIFY SECURITY RESPONSE: Should return same response as valid email_address (prevent enumeration)
56
+ assert_response :ok, "Nonexistent email_address should return same response as valid email_address"
57
+
58
+ response_json = JSON.parse(response.body)
59
+ assert_match(/reset.*sent/i, response_json['message'], "Should return same success message")
60
+
61
+ # VERIFY NO EMAIL SENT: No actual email should be sent for nonexistent user
62
+ end
63
+
64
+ test "should require email_address parameter for password reset request" do
65
+ # VALIDATION: Verify email_address parameter is required
66
+
67
+ post <%= reset_path_helper %>, params: {}
68
+
69
+ # VERIFY PARAMETER VALIDATION: Should return parameter error
70
+ assert_response :unprocessable_entity, "Missing email_address should return validation error"
71
+
72
+ response_json = JSON.parse(response.body)
73
+ assert_match(/email.*required/i, response_json['error'], "Should indicate email_address is required")
74
+ end
75
+
76
+ test "should rate limit password reset requests" do
77
+ # SECURITY: Verify rate limiting prevents abuse
78
+
79
+ # EXECUTE MULTIPLE REQUESTS: Send multiple reset requests rapidly
80
+ 5.times do
81
+ post <%= reset_path_helper %>, params: { email_address: @user.email_address }
82
+ end
83
+
84
+ # Make one more request that should be rate limited
85
+ post <%= reset_path_helper %>, params: { email_address: @user.email_address }
86
+
87
+ # VERIFY RATE LIMITING: Should eventually rate limit (implementation dependent)
88
+ # For now, verify the endpoint is accessible (rate limiting will be added later)
89
+ assert_response :ok, "Rate limiting test - endpoint should be accessible"
90
+ end
91
+
92
+ # PUT <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/reset - Confirm Password Reset Tests
93
+ test "should reset password with valid token and new password" do
94
+ # BEHAVIOR: Verify password reset confirmation works end-to-end
95
+
96
+ # SETUP: Generate valid reset token
97
+ reset_token = @user.generate_password_reset_token
98
+ new_password = "newpassword456"
99
+
100
+ # EXECUTE RESET: Confirm password reset with token
101
+ put <%= reset_path_helper %>, params: {
102
+ token: reset_token,
103
+ password: new_password,
104
+ password_confirmation: new_password
105
+ }
106
+
107
+ # VERIFY API RESPONSE: Should return success
108
+ assert_response :ok, "Valid token should allow password reset"
109
+
110
+ response_json = JSON.parse(response.body)
111
+ assert_match(/password.*reset.*successfully/i, response_json['message'], "Should confirm password reset")
112
+ assert response_json.key?('user'), "Should return user data"
113
+ assert_equal @user.id, response_json['user']['id'], "Should return correct user"
114
+
115
+ # VERIFY PASSWORD CHANGE: User should authenticate with new password
116
+ @user.reload
117
+ assert @user.authenticate(new_password), "Should authenticate with new password"
118
+ assert_not @user.authenticate("originalpassword123"), "Should not authenticate with old password"
119
+ end
120
+
121
+ test "should reject password reset with invalid token" do
122
+ # SECURITY: Verify invalid tokens are rejected
123
+
124
+ invalid_token = "invalid.jwt.token"
125
+ new_password = "attemptedpassword"
126
+
127
+ # EXECUTE WITH INVALID TOKEN: Try to reset with invalid token
128
+ put <%= reset_path_helper %>, params: {
129
+ token: invalid_token,
130
+ password: new_password,
131
+ password_confirmation: new_password
132
+ }
133
+
134
+ # VERIFY REJECTION: Should return unauthorized
135
+ assert_response :unauthorized, "Invalid token should be rejected"
136
+
137
+ response_json = JSON.parse(response.body)
138
+ assert_match(/invalid.*expired.*token/i, response_json['error'], "Should indicate token error")
139
+
140
+ # VERIFY NO CHANGE: Password should remain unchanged
141
+ @user.reload
142
+ assert @user.authenticate("originalpassword123"), "Password should remain unchanged"
143
+ end
144
+
145
+ test "should reject password reset with expired token" do
146
+ # SECURITY: Verify expired tokens are rejected
147
+
148
+ # CREATE EXPIRED TOKEN: Generate token that's already expired
149
+ expired_payload = {
150
+ user_id: @user.id,
151
+ email_address: @user.email_address,
152
+ type: 'password_reset',
153
+ iat: Time.now.to_i,
154
+ exp: 1.second.ago.to_i, # Already expired
155
+ password_hash: @user.password_digest[0..10]
156
+ }
157
+ expired_token = JWT.encode(expired_payload, PropelAuth.configuration.jwt_secret, 'HS256')
158
+
159
+ # EXECUTE WITH EXPIRED TOKEN: Try to reset with expired token
160
+ put <%= reset_path_helper %>, params: {
161
+ token: expired_token,
162
+ password: "newpassword",
163
+ password_confirmation: "newpassword"
164
+ }
165
+
166
+ # VERIFY EXPIRATION: Should return unauthorized
167
+ assert_response :unauthorized, "Expired token should be rejected"
168
+
169
+ response_json = JSON.parse(response.body)
170
+ assert_match(/invalid.*expired.*token/i, response_json['error'], "Should indicate token expiration")
171
+ end
172
+
173
+ test "should validate password confirmation matches" do
174
+ # VALIDATION: Verify password confirmation is enforced
175
+
176
+ reset_token = @user.generate_password_reset_token
177
+
178
+ # EXECUTE WITH MISMATCHED PASSWORDS: Password and confirmation don't match
179
+ put <%= reset_path_helper %>, params: {
180
+ token: reset_token,
181
+ password: "newpassword123",
182
+ password_confirmation: "differentpassword456"
183
+ }
184
+
185
+ # VERIFY VALIDATION: Should return validation error
186
+ assert_response :unprocessable_entity, "Mismatched passwords should be rejected"
187
+
188
+ response_json = JSON.parse(response.body)
189
+ assert_match(/password.*confirmation.*match/i, response_json['error'], "Should indicate password mismatch")
190
+
191
+ # VERIFY NO CHANGE: Password should remain unchanged
192
+ @user.reload
193
+ assert @user.authenticate("originalpassword123"), "Password should remain unchanged"
194
+ end
195
+
196
+ test "should enforce password requirements during reset" do
197
+ # VALIDATION: Verify password requirements are enforced
198
+
199
+ reset_token = @user.generate_password_reset_token
200
+ weak_password = "123" # Too short
201
+
202
+ # EXECUTE WITH WEAK PASSWORD: Try to set weak password
203
+ put <%= reset_path_helper %>, params: {
204
+ token: reset_token,
205
+ password: weak_password,
206
+ password_confirmation: weak_password
207
+ }
208
+
209
+ # VERIFY VALIDATION: Should return validation error
210
+ assert_response :unprocessable_entity, "Weak password should be rejected"
211
+
212
+ response_json = JSON.parse(response.body)
213
+ assert_match(/password.*too short/i, response_json['error'], "Should indicate password strength issue")
214
+
215
+ # VERIFY NO CHANGE: Password should remain unchanged
216
+ @user.reload
217
+ assert @user.authenticate("originalpassword123"), "Password should remain unchanged"
218
+ end
219
+
220
+ test "should require all parameters for password reset confirmation" do
221
+ # VALIDATION: Verify all required parameters are present
222
+
223
+ reset_token = @user.generate_password_reset_token
224
+
225
+ # TEST MISSING TOKEN
226
+ put <%= reset_path_helper %>, params: {
227
+ password: "newpassword123",
228
+ password_confirmation: "newpassword123"
229
+ }
230
+
231
+ assert_response :unprocessable_entity, "Missing token should be rejected"
232
+
233
+ # TEST MISSING PASSWORD
234
+ put <%= reset_path_helper %>, params: {
235
+ token: reset_token,
236
+ password_confirmation: "newpassword123"
237
+ }
238
+
239
+ assert_response :unprocessable_entity, "Missing password should be rejected"
240
+
241
+ # TEST MISSING CONFIRMATION
242
+ put <%= reset_path_helper %>, params: {
243
+ token: reset_token,
244
+ password: "newpassword123"
245
+ }
246
+
247
+ assert_response :unprocessable_entity, "Missing password confirmation should be rejected"
248
+ end
249
+
250
+ test "should clear failed login attempts on successful password reset" do
251
+ # INTEGRATION: Verify password reset clears lockable state
252
+
253
+ # SETUP: Build up failed login attempts
254
+ @user.update!(failed_login_attempts: 8)
255
+ assert_equal 8, @user.failed_login_attempts, "Should have failed attempts"
256
+
257
+ # EXECUTE RESET: Reset password successfully
258
+ reset_token = @user.generate_password_reset_token
259
+ put <%= reset_path_helper %>, params: {
260
+ token: reset_token,
261
+ password: "newpassword123",
262
+ password_confirmation: "newpassword123"
263
+ }
264
+
265
+ # VERIFY SUCCESS: Should reset password
266
+ assert_response :ok, "Password reset should succeed"
267
+
268
+ # VERIFY FAILED ATTEMPTS CLEARED: Should clear lockable state
269
+ @user.reload
270
+ assert_equal 0, @user.failed_login_attempts, "Failed attempts should be cleared"
271
+ end
272
+
273
+ test "should not affect locked account status during password reset" do
274
+ # INTEGRATION: Verify password reset doesn't unlock account automatically
275
+
276
+ # SETUP: Lock the account
277
+ @user.lock_account!
278
+ assert @user.locked?, "Account should be locked"
279
+
280
+ # EXECUTE RESET: Reset password while locked
281
+ reset_token = @user.generate_password_reset_token
282
+ put <%= reset_path_helper %>, params: {
283
+ token: reset_token,
284
+ password: "newpassword123",
285
+ password_confirmation: "newpassword123"
286
+ }
287
+
288
+ # VERIFY PASSWORD RESET: Should succeed
289
+ assert_response :ok, "Password reset should succeed even for locked account"
290
+
291
+ # VERIFY LOCK STATUS: Account should remain locked (require explicit unlock)
292
+ @user.reload
293
+ assert @user.locked?, "Account should remain locked after password reset"
294
+ assert @user.authenticate("newpassword123"), "Should authenticate with new password"
295
+ end
296
+
297
+ # GET <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/reset/verify - Token Verification Tests
298
+ test "should verify valid password reset token" do
299
+ # BEHAVIOR: Verify token verification endpoint works
300
+
301
+ reset_token = @user.generate_password_reset_token
302
+
303
+ # EXECUTE VERIFICATION: Verify token is valid
304
+ get <%= reset_path_helper %>, params: { token: reset_token }
305
+
306
+ # VERIFY API RESPONSE: Should confirm token validity
307
+ assert_response :ok, "Valid token should pass verification"
308
+
309
+ response_json = JSON.parse(response.body)
310
+ assert response_json['valid'], "Should indicate token is valid"
311
+ assert response_json.key?('user'), "Should return user data for valid token"
312
+ assert_equal @user.email_address, response_json['user']['email_address'], "Should return correct user"
313
+ assert_not response_json['user'].key?('password_digest'), "Should not expose password"
314
+ end
315
+
316
+ test "should reject invalid token during verification" do
317
+ # SECURITY: Verify invalid tokens are rejected during verification
318
+
319
+ invalid_token = "invalid.jwt.token"
320
+
321
+ # EXECUTE VERIFICATION: Verify invalid token
322
+ get <%= reset_path_helper %>, params: { token: invalid_token }
323
+
324
+ # VERIFY REJECTION: Should indicate token is invalid
325
+ assert_response :unauthorized, "Invalid token should be rejected"
326
+
327
+ response_json = JSON.parse(response.body)
328
+ assert_not response_json['valid'], "Should indicate token is invalid"
329
+ assert_match(/invalid.*expired.*token/i, response_json['error'], "Should indicate token error")
330
+ end
331
+
332
+ test "should reject expired token during verification" do
333
+ # SECURITY: Verify expired tokens are rejected during verification
334
+
335
+ expired_payload = {
336
+ user_id: @user.id,
337
+ email_address: @user.email_address,
338
+ type: 'password_reset',
339
+ iat: Time.now.to_i,
340
+ exp: 1.second.ago.to_i, # Already expired
341
+ password_hash: @user.password_digest[0..10]
342
+ }
343
+ expired_token = JWT.encode(expired_payload, PropelAuth.configuration.jwt_secret, 'HS256')
344
+
345
+ # EXECUTE VERIFICATION: Verify expired token
346
+ get <%= reset_path_helper %>, params: { token: expired_token }
347
+
348
+ # VERIFY EXPIRATION: Should indicate token is expired
349
+ assert_response :unauthorized, "Expired token should be rejected"
350
+
351
+ response_json = JSON.parse(response.body)
352
+ assert_not response_json['valid'], "Should indicate token is invalid"
353
+ end
354
+
355
+ test "should require token parameter for verification" do
356
+ # VALIDATION: Verify token parameter is required for verification
357
+
358
+ get <%= reset_path_helper %>, params: {}
359
+
360
+ # VERIFY PARAMETER VALIDATION: Should return parameter error
361
+ assert_response :unprocessable_entity, "Missing token should return validation error"
362
+
363
+ response_json = JSON.parse(response.body)
364
+ assert_match(/token.*required/i, response_json['error'], "Should indicate token is required")
365
+ end
366
+
367
+ # Full Workflow Integration Tests
368
+ test "should complete full password reset workflow" do
369
+ # INTEGRATION: Test complete password reset workflow from start to finish
370
+
371
+ # STEP 1: REQUEST RESET - Send password reset request
372
+ post <%= reset_path_helper %>, params: { email_address: @user.email_address }
373
+ assert_response :ok, "Reset request should succeed"
374
+
375
+ # STEP 2: VERIFY TOKEN - Check that we can verify tokens (simulate email link click)
376
+ reset_token = @user.generate_password_reset_token # Simulate token from email
377
+ get <%= reset_path_helper %>, params: { token: reset_token }
378
+ assert_response :ok, "Token verification should succeed"
379
+
380
+ verification_json = JSON.parse(response.body)
381
+ assert verification_json['valid'], "Token should be valid"
382
+
383
+ # STEP 3: RESET PASSWORD - Complete password reset
384
+ new_password = "completeworkflow123"
385
+ put <%= reset_path_helper %>, params: {
386
+ token: reset_token,
387
+ password: new_password,
388
+ password_confirmation: new_password
389
+ }
390
+ assert_response :ok, "Password reset should succeed"
391
+
392
+ # STEP 4: VERIFY NEW PASSWORD - Test login with new password
393
+ post <%= login_path_helper %>, params: {
394
+ user: { email_address: @user.email_address, password: new_password }
395
+ }
396
+ assert_response :ok, "Should login with new password"
397
+
398
+ login_json = JSON.parse(response.body)
399
+ assert login_json['token'].present?, "Should receive JWT token"
400
+
401
+ # STEP 5: VERIFY OLD PASSWORD INVALID - Test old password doesn't work
402
+ post <%= login_path_helper %>, params: {
403
+ user: { email_address: @user.email_address, password: "originalpassword123" }
404
+ }
405
+ assert_response :unauthorized, "Should not login with old password"
406
+ end
407
+
408
+ test "should prevent token reuse after successful password reset" do
409
+ # SECURITY: Verify tokens can't be reused after successful reset
410
+
411
+ reset_token = @user.generate_password_reset_token
412
+
413
+ # FIRST RESET: Use token successfully
414
+ put <%= reset_path_helper %>, params: {
415
+ token: reset_token,
416
+ password: "firstnewpassword123",
417
+ password_confirmation: "firstnewpassword123"
418
+ }
419
+ assert_response :ok, "First password reset should succeed"
420
+
421
+ # ATTEMPTED REUSE: Try to use same token again
422
+ put <%= reset_path_helper %>, params: {
423
+ token: reset_token,
424
+ password: "secondnewpassword456",
425
+ password_confirmation: "secondnewpassword456"
426
+ }
427
+
428
+ # VERIFY PREVENTION: Second attempt should fail
429
+ assert_response :unauthorized, "Token reuse should be prevented"
430
+
431
+ # VERIFY PASSWORD UNCHANGED: Password should remain as first reset
432
+ @user.reload
433
+ assert @user.authenticate("firstnewpassword123"), "Password should remain from first reset"
434
+ assert_not @user.authenticate("secondnewpassword456"), "Second password should not be set"
435
+ end
436
+
437
+ test "should handle concurrent password reset attempts safely" do
438
+ # CONCURRENCY: Verify system handles concurrent reset attempts
439
+
440
+ # GENERATE MULTIPLE TOKENS: Simulate multiple reset requests
441
+ token1 = @user.generate_password_reset_token
442
+ sleep(0.1) # Small delay to ensure different timestamps
443
+ token2 = @user.generate_password_reset_token
444
+
445
+ # VERIFY BOTH VALID: Both tokens should be valid initially
446
+ assert @user.valid_password_reset_token?(token1), "First token should be valid"
447
+ assert @user.valid_password_reset_token?(token2), "Second token should be valid"
448
+
449
+ # USE FIRST TOKEN: Reset password with first token
450
+ put <%= reset_path_helper %>, params: {
451
+ token: token1,
452
+ password: "concurrentpassword1",
453
+ password_confirmation: "concurrentpassword1"
454
+ }
455
+ assert_response :ok, "First reset should succeed"
456
+
457
+ # TRY SECOND TOKEN: Attempt reset with second token (should fail due to password change)
458
+ put <%= reset_path_helper %>, params: {
459
+ token: token2,
460
+ password: "concurrentpassword2",
461
+ password_confirmation: "concurrentpassword2"
462
+ }
463
+
464
+ # VERIFY CONCURRENCY HANDLING: Second token should be invalid due to password change
465
+ assert_response :unauthorized, "Second token should be invalid after password change"
466
+
467
+ # VERIFY FINAL STATE: Password should be from first successful reset
468
+ @user.reload
469
+ assert @user.authenticate("concurrentpassword1"), "Password should be from first reset"
470
+ end
471
+ end