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