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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +290 -0
- data/Rakefile +12 -0
- data/lib/generators/propel_auth/install_generator.rb +486 -0
- data/lib/generators/propel_auth/pack_generator.rb +277 -0
- data/lib/generators/propel_auth/templates/agency.rb +7 -0
- data/lib/generators/propel_auth/templates/agent.rb +7 -0
- data/lib/generators/propel_auth/templates/auth/base_passwords_controller.rb.tt +99 -0
- data/lib/generators/propel_auth/templates/auth/base_tokens_controller.rb.tt +90 -0
- data/lib/generators/propel_auth/templates/auth/passwords_controller.rb.tt +126 -0
- data/lib/generators/propel_auth/templates/auth_mailer.rb +180 -0
- data/lib/generators/propel_auth/templates/authenticatable.rb +38 -0
- data/lib/generators/propel_auth/templates/concerns/confirmable.rb +145 -0
- data/lib/generators/propel_auth/templates/concerns/lockable.rb +123 -0
- data/lib/generators/propel_auth/templates/concerns/propel_authentication.rb +44 -0
- data/lib/generators/propel_auth/templates/concerns/rack_session_disable.rb +19 -0
- data/lib/generators/propel_auth/templates/concerns/recoverable.rb +124 -0
- data/lib/generators/propel_auth/templates/config/environments/development_email.rb +43 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_agencies.rb +20 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_agents.rb +11 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_invitations.rb +28 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_organizations.rb +18 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_users.rb +43 -0
- data/lib/generators/propel_auth/templates/db/seeds.rb +29 -0
- data/lib/generators/propel_auth/templates/invitation.rb +133 -0
- data/lib/generators/propel_auth/templates/lib/propel_auth.rb +84 -0
- data/lib/generators/propel_auth/templates/organization.rb +7 -0
- data/lib/generators/propel_auth/templates/propel_auth.rb +132 -0
- data/lib/generators/propel_auth/templates/services/auth_notification_service.rb +89 -0
- data/lib/generators/propel_auth/templates/test/concerns/confirmable_test.rb.tt +247 -0
- data/lib/generators/propel_auth/templates/test/concerns/lockable_test.rb.tt +282 -0
- data/lib/generators/propel_auth/templates/test/concerns/propel_authentication_test.rb.tt +75 -0
- data/lib/generators/propel_auth/templates/test/concerns/recoverable_test.rb.tt +327 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/lockable_integration_test.rb.tt +196 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/password_reset_integration_test.rb.tt +471 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/tokens_controller_test.rb.tt +265 -0
- data/lib/generators/propel_auth/templates/test/mailers/auth_mailer_test.rb.tt +216 -0
- data/lib/generators/propel_auth/templates/test/mailers/previews/auth_mailer_preview.rb +161 -0
- data/lib/generators/propel_auth/templates/tokens_controller.rb.tt +96 -0
- data/lib/generators/propel_auth/templates/user.rb +21 -0
- data/lib/generators/propel_auth/templates/user_test.rb.tt +81 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.html.erb +213 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.text.erb +56 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.html.erb +213 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.text.erb +32 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.html.erb +166 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.text.erb +32 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.html.erb +194 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.text.erb +51 -0
- data/lib/generators/propel_auth/test/dummy/Dockerfile +72 -0
- data/lib/generators/propel_auth/test/dummy/Gemfile +63 -0
- data/lib/generators/propel_auth/test/dummy/Gemfile.lock +394 -0
- data/lib/generators/propel_auth/test/dummy/README.md +24 -0
- data/lib/generators/propel_auth/test/dummy/Rakefile +6 -0
- data/lib/generators/propel_auth/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/lib/generators/propel_auth/test/dummy/app/controllers/application_controller.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/app/helpers/application_helper.rb +2 -0
- data/lib/generators/propel_auth/test/dummy/app/jobs/application_job.rb +7 -0
- data/lib/generators/propel_auth/test/dummy/app/mailers/application_mailer.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/app/models/application_record.rb +3 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/application.html.erb +27 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/lib/generators/propel_auth/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/lib/generators/propel_auth/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/lib/generators/propel_auth/test/dummy/bin/brakeman +7 -0
- data/lib/generators/propel_auth/test/dummy/bin/dev +2 -0
- data/lib/generators/propel_auth/test/dummy/bin/docker-entrypoint +14 -0
- data/lib/generators/propel_auth/test/dummy/bin/rails +4 -0
- data/lib/generators/propel_auth/test/dummy/bin/rake +4 -0
- data/lib/generators/propel_auth/test/dummy/bin/rubocop +8 -0
- data/lib/generators/propel_auth/test/dummy/bin/setup +34 -0
- data/lib/generators/propel_auth/test/dummy/bin/thrust +5 -0
- data/lib/generators/propel_auth/test/dummy/config/application.rb +42 -0
- data/lib/generators/propel_auth/test/dummy/config/boot.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/config/cable.yml +10 -0
- data/lib/generators/propel_auth/test/dummy/config/credentials.yml.enc +1 -0
- data/lib/generators/propel_auth/test/dummy/config/database.yml +41 -0
- data/lib/generators/propel_auth/test/dummy/config/environment.rb +5 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/development.rb +72 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/production.rb +89 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/test.rb +53 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/assets.rb +10 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/content_security_policy.rb +25 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/inflections.rb +16 -0
- data/lib/generators/propel_auth/test/dummy/config/locales/en.yml +31 -0
- data/lib/generators/propel_auth/test/dummy/config/master.key +1 -0
- data/lib/generators/propel_auth/test/dummy/config/puma.rb +41 -0
- data/lib/generators/propel_auth/test/dummy/config/routes.rb +2 -0
- data/lib/generators/propel_auth/test/dummy/config/storage.yml +34 -0
- data/lib/generators/propel_auth/test/dummy/config.ru +6 -0
- data/lib/generators/propel_auth/test/dummy/db/schema.rb +14 -0
- data/lib/generators/propel_auth/test/generators/authentication/controllers/tokens_controller_test.rb +230 -0
- data/lib/generators/propel_auth/test/generators/authentication/install_generator_test.rb +490 -0
- data/lib/generators/propel_auth/test/generators/authentication/uninstall_generator_test.rb +408 -0
- data/lib/generators/propel_auth/test/integration/generator_integration_test.rb +158 -0
- data/lib/generators/propel_auth/test/integration/multi_version_generator_test.rb +125 -0
- data/lib/generators/propel_auth/unpack_generator.rb +345 -0
- data/lib/propel_auth.rb +3 -0
- 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
|
data/lib/generators/propel_auth/templates/test/controllers/auth/lockable_integration_test.rb.tt
ADDED
@@ -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
|