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,265 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class TokensControllerTest < ActionDispatch::IntegrationTest
|
4
|
+
# BEHAVIOR-DRIVEN TESTING: NO MOCKS - Test Real Functionality
|
5
|
+
# These tests verify actual JWT authentication works with real database operations
|
6
|
+
# and HTTP requests to provide 100% confidence in production functionality
|
7
|
+
|
8
|
+
setup do
|
9
|
+
# Create real test data in the database - NO MOCKS
|
10
|
+
@organization = Organization.create!(
|
11
|
+
name: "Marvel",
|
12
|
+
website: "https://marvel.com"
|
13
|
+
)
|
14
|
+
@rival_organization = Organization.create!(
|
15
|
+
name: "DC Comics",
|
16
|
+
website: "https://dccomics.com"
|
17
|
+
)
|
18
|
+
|
19
|
+
@valid_user = User.create!(
|
20
|
+
email_address: "valid@example.com",
|
21
|
+
username: "validuser",
|
22
|
+
password: "securepassword123",
|
23
|
+
password_confirmation: "securepassword123",
|
24
|
+
organization: @organization,
|
25
|
+
first_name: "Valid",
|
26
|
+
last_name: "User"
|
27
|
+
)
|
28
|
+
|
29
|
+
@other_user = User.create!(
|
30
|
+
email_address: "other@example.com",
|
31
|
+
username: "otheruser",
|
32
|
+
password: "securepassword123",
|
33
|
+
password_confirmation: "securepassword123",
|
34
|
+
organization: @rival_organization,
|
35
|
+
first_name: "Other",
|
36
|
+
last_name: "User"
|
37
|
+
)
|
38
|
+
|
39
|
+
@inactive_user = User.create!(
|
40
|
+
email_address: "inactive@example.com",
|
41
|
+
username: "inactiveuser",
|
42
|
+
password: "password123",
|
43
|
+
password_confirmation: "password123",
|
44
|
+
organization: @organization,
|
45
|
+
first_name: "Inactive",
|
46
|
+
last_name: "User",
|
47
|
+
status: 1
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
test "POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/login with valid credentials returns JWT token and user data" do
|
52
|
+
# BEHAVIOR TEST: Verify login for a user in a different organization to ensure multi-tenancy.
|
53
|
+
post <%= login_path_helper %>, params: {
|
54
|
+
user: {
|
55
|
+
email_address: @other_user.email_address,
|
56
|
+
password: "securepassword123"
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
# VERIFY RESPONSE STRUCTURE: Basic validation
|
61
|
+
assert_response :ok, "Login should return OK status"
|
62
|
+
response_json = JSON.parse(response.body)
|
63
|
+
assert response_json.key?("token"), "Response should contain JWT token"
|
64
|
+
assert response_json.key?("user"), "Response should contain user data"
|
65
|
+
|
66
|
+
# VERIFY USER DATA ACCURACY: Check that the correct user and organization are returned.
|
67
|
+
user_data = response_json["user"]
|
68
|
+
assert_equal @other_user.id, user_data["id"], "Should return correct user ID"
|
69
|
+
assert_equal @other_user.email_address, user_data["email_address"], "Should return correct user email_address"
|
70
|
+
assert_equal @rival_organization.id, user_data["organization_id"], "Should return correct organization ID for the rival organization"
|
71
|
+
|
72
|
+
# VERIFY DATA ISOLATION: Ensure data from the other user/org is not returned.
|
73
|
+
assert_not_equal @valid_user.id, user_data["id"], "Should not return data for the wrong user"
|
74
|
+
assert_not_equal @organization.id, user_data["organization_id"], "Should not return data for the wrong organization"
|
75
|
+
end
|
76
|
+
|
77
|
+
test "POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/login with invalid credentials returns proper error" do
|
78
|
+
# BEHAVIOR TEST: Verify authentication rejection with real database validation
|
79
|
+
post <%= login_path_helper %>, params: {
|
80
|
+
user: {
|
81
|
+
email_address: @valid_user.email_address,
|
82
|
+
password: "wrongpassword"
|
83
|
+
}
|
84
|
+
}
|
85
|
+
|
86
|
+
# VERIFY ERROR RESPONSE: Check proper error handling
|
87
|
+
assert_response :unauthorized, "Invalid credentials should return unauthorized status"
|
88
|
+
response_json = JSON.parse(response.body)
|
89
|
+
assert response_json.key?("error"), "Error response should contain error message"
|
90
|
+
assert_equal "Invalid credentials", response_json["error"], "Should return proper error message"
|
91
|
+
assert_nil response_json["token"], "Should not return token on authentication failure"
|
92
|
+
assert_nil response_json["user"], "Should not return user data on authentication failure"
|
93
|
+
end
|
94
|
+
|
95
|
+
test "POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/login with nonexistent user returns proper error" do
|
96
|
+
# BEHAVIOR TEST: Verify handling of nonexistent users
|
97
|
+
post <%= login_path_helper %>, params: {
|
98
|
+
user: {
|
99
|
+
email_address: "nonexistent@example.com",
|
100
|
+
password: "somepassword"
|
101
|
+
}
|
102
|
+
}
|
103
|
+
|
104
|
+
# VERIFY ERROR RESPONSE: Check user not found handling
|
105
|
+
assert_response :unauthorized, "Nonexistent user should return unauthorized status"
|
106
|
+
response_json = JSON.parse(response.body)
|
107
|
+
assert_equal "Invalid credentials", response_json["error"], "Should return generic error message for security"
|
108
|
+
end
|
109
|
+
|
110
|
+
test "POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/login with inactive user returns proper error" do
|
111
|
+
# BEHAVIOR TEST: Verify inactive user access is properly denied
|
112
|
+
post <%= login_path_helper %>, params: {
|
113
|
+
user: {
|
114
|
+
email_address: @inactive_user.email_address,
|
115
|
+
password: "password123"
|
116
|
+
}
|
117
|
+
}
|
118
|
+
|
119
|
+
# VERIFY INACTIVE USER HANDLING: Check business logic enforcement
|
120
|
+
assert_response :unauthorized, "Inactive user should be denied access"
|
121
|
+
response_json = JSON.parse(response.body)
|
122
|
+
assert_equal "Account is inactive", response_json["error"], "Should return appropriate error for inactive account"
|
123
|
+
end
|
124
|
+
|
125
|
+
test "GET <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/me with valid JWT token returns current user" do
|
126
|
+
# BEHAVIOR TEST: Verify JWT authentication for a user from a different organization.
|
127
|
+
# First get a valid token by logging in as the user from the rival organization.
|
128
|
+
post <%= login_path_helper %>, params: {
|
129
|
+
user: {
|
130
|
+
email_address: @other_user.email_address,
|
131
|
+
password: "securepassword123"
|
132
|
+
}
|
133
|
+
}
|
134
|
+
|
135
|
+
token = JSON.parse(response.body)["token"]
|
136
|
+
|
137
|
+
# Now test the protected endpoint with the real token.
|
138
|
+
get <%= me_path_helper %>, headers: {
|
139
|
+
'Authorization' => "Bearer #{token}"
|
140
|
+
}
|
141
|
+
|
142
|
+
# VERIFY AUTHENTICATED RESPONSE: Check that the correct user data is returned.
|
143
|
+
assert_response :ok, "Valid token should allow access to protected endpoint"
|
144
|
+
response_json = JSON.parse(response.body)
|
145
|
+
user_data = response_json["user"]
|
146
|
+
|
147
|
+
# VERIFY USER DATA ACCURACY: Check that the data belongs to the authenticated user.
|
148
|
+
assert_equal @other_user.id, user_data["id"], "Should return correct authenticated user ID"
|
149
|
+
assert_equal @other_user.email_address, user_data["email_address"], "Should return correct authenticated user email_address"
|
150
|
+
assert_equal @rival_organization.id, user_data["organization_id"], "Should return correct organization relationship"
|
151
|
+
end
|
152
|
+
|
153
|
+
test "GET <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/me with invalid JWT token returns error" do
|
154
|
+
# BEHAVIOR TEST: Verify JWT validation rejects invalid tokens
|
155
|
+
get <%= me_path_helper %>, headers: {
|
156
|
+
'Authorization' => "Bearer invalid.jwt.token"
|
157
|
+
}
|
158
|
+
|
159
|
+
# VERIFY SECURITY VALIDATION: Check token validation properly rejects invalid tokens
|
160
|
+
assert_response :unauthorized, "Invalid token should be rejected"
|
161
|
+
response_json = JSON.parse(response.body)
|
162
|
+
assert_equal "Invalid token", response_json["error"], "Should return proper error message"
|
163
|
+
end
|
164
|
+
|
165
|
+
test "GET <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/me with expired JWT token returns error" do
|
166
|
+
# BEHAVIOR TEST: Verify expired tokens are properly rejected
|
167
|
+
# Create an expired token for testing
|
168
|
+
expired_payload = {
|
169
|
+
user_id: @valid_user.id,
|
170
|
+
exp: 1.hour.ago.to_i # Token expired 1 hour ago
|
171
|
+
}
|
172
|
+
expired_token = JWT.encode(expired_payload, Rails.application.credentials.secret_key_base, 'HS256')
|
173
|
+
|
174
|
+
get <%= me_path_helper %>, headers: {
|
175
|
+
'Authorization' => "Bearer #{expired_token}"
|
176
|
+
}
|
177
|
+
|
178
|
+
# VERIFY EXPIRATION HANDLING: Check expired tokens are rejected
|
179
|
+
assert_response :unauthorized, "Expired token should be rejected"
|
180
|
+
response_json = JSON.parse(response.body)
|
181
|
+
assert_equal "Token expired", response_json["error"], "Should return proper expiration error"
|
182
|
+
end
|
183
|
+
|
184
|
+
test "GET <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/me without authorization header returns error" do
|
185
|
+
# BEHAVIOR TEST: Verify endpoint protection when no token provided
|
186
|
+
get <%= me_path_helper %>
|
187
|
+
|
188
|
+
# VERIFY AUTHORIZATION REQUIREMENT: Check endpoint requires authentication
|
189
|
+
assert_response :unauthorized, "Missing authorization should be rejected"
|
190
|
+
response_json = JSON.parse(response.body)
|
191
|
+
assert_equal "No token provided", response_json["error"], "Should return proper missing token error"
|
192
|
+
end
|
193
|
+
|
194
|
+
test "DELETE <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/logout clears authentication state" do
|
195
|
+
# BEHAVIOR TEST: Verify logout functionality
|
196
|
+
# First login to get a token
|
197
|
+
post <%= login_path_helper %>, params: {
|
198
|
+
user: {
|
199
|
+
email_address: @valid_user.email_address,
|
200
|
+
password: "securepassword123"
|
201
|
+
}
|
202
|
+
}
|
203
|
+
|
204
|
+
token = JSON.parse(response.body)["token"]
|
205
|
+
|
206
|
+
# Now logout with the token
|
207
|
+
delete <%= logout_path_helper %>, headers: {
|
208
|
+
'Authorization' => "Bearer #{token}"
|
209
|
+
}
|
210
|
+
|
211
|
+
# VERIFY LOGOUT RESPONSE: Check logout confirmation
|
212
|
+
assert_response :ok, "Logout should return success status"
|
213
|
+
response_json = JSON.parse(response.body)
|
214
|
+
assert_equal "Logged out successfully", response_json["message"], "Should return logout confirmation"
|
215
|
+
end
|
216
|
+
|
217
|
+
test "POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/login validates required parameters" do
|
218
|
+
# BEHAVIOR TEST: Verify parameter validation
|
219
|
+
post <%= login_path_helper %>, params: {
|
220
|
+
user: {
|
221
|
+
email_address: @valid_user.email_address
|
222
|
+
# Missing password parameter
|
223
|
+
}
|
224
|
+
}
|
225
|
+
|
226
|
+
# VERIFY PARAMETER VALIDATION: Check required field enforcement
|
227
|
+
assert_response :unprocessable_entity, "Missing required parameters should return unprocessable_entity"
|
228
|
+
response_json = JSON.parse(response.body)
|
229
|
+
assert response_json["error"].present?, "Should return validation error message"
|
230
|
+
end
|
231
|
+
|
232
|
+
test "JWT token contains proper user context for multi-tenant application" do
|
233
|
+
# BEHAVIOR TEST: Verify JWT for a different organization contains correct context.
|
234
|
+
post <%= login_path_helper %>, params: {
|
235
|
+
user: {
|
236
|
+
email_address: @other_user.email_address,
|
237
|
+
password: "securepassword123"
|
238
|
+
}
|
239
|
+
}
|
240
|
+
|
241
|
+
token = JSON.parse(response.body)["token"]
|
242
|
+
decoded_token = JWT.decode(token, Rails.application.credentials.secret_key_base, true, { algorithm: 'HS256' })
|
243
|
+
payload = decoded_token[0]
|
244
|
+
|
245
|
+
# VERIFY MULTI-TENANT CONTEXT: Check that the correct organization context is in the token.
|
246
|
+
assert_equal @rival_organization.id, payload['organization_id'], "Token should contain the correct rival organization context"
|
247
|
+
assert_equal @other_user.id, payload['user_id'], "Token should contain the correct user ID"
|
248
|
+
assert payload['exp'].present?, "Token should have expiration"
|
249
|
+
assert payload['iat'].present?, "Token should have issued at time"
|
250
|
+
end
|
251
|
+
|
252
|
+
private
|
253
|
+
|
254
|
+
# Helper method to create authenticated request headers for testing
|
255
|
+
def authenticated_headers_for(user)
|
256
|
+
post <%= login_path_helper %>, params: {
|
257
|
+
user: {
|
258
|
+
email_address: user.email_address,
|
259
|
+
password: 'securepassword123'
|
260
|
+
}
|
261
|
+
}
|
262
|
+
token = JSON.parse(response.body)['token']
|
263
|
+
{ 'Authorization' => "Bearer #{token}" }
|
264
|
+
end
|
265
|
+
end
|
@@ -0,0 +1,216 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class AuthMailerTest < ActionMailer::TestCase
|
4
|
+
def setup
|
5
|
+
@organization = Organization.create!(name: "Test Organization")
|
6
|
+
@user = User.create!(
|
7
|
+
email_address: "auth-mailer-test@example.com",
|
8
|
+
username: "authmailertestuser",
|
9
|
+
password: "password123",
|
10
|
+
organization: @organization,
|
11
|
+
first_name: "Auth",
|
12
|
+
last_name: "Tester"
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
def teardown
|
17
|
+
User.destroy_all
|
18
|
+
Organization.destroy_all
|
19
|
+
end
|
20
|
+
|
21
|
+
# BEHAVIOR: Test password reset email generation and content
|
22
|
+
test "password reset email should contain proper content and structure" do
|
23
|
+
# SETUP: Generate reset token
|
24
|
+
reset_token = @user.generate_password_reset_token
|
25
|
+
reset_url = "http://localhost:3000/reset-password?token=#{reset_token}"
|
26
|
+
|
27
|
+
# EXECUTE: Generate password reset email
|
28
|
+
email = AuthMailer.with(
|
29
|
+
user: @user,
|
30
|
+
token: reset_token,
|
31
|
+
reset_url: reset_url
|
32
|
+
).password_reset
|
33
|
+
|
34
|
+
# VERIFY STRUCTURE: Basic email structure validation
|
35
|
+
assert_emails 1 do
|
36
|
+
email.deliver_now
|
37
|
+
end
|
38
|
+
|
39
|
+
# VERIFY FUNCTIONALITY: Email content and behavior
|
40
|
+
assert_equal [@user.email_address], email.to, "Email should be sent to user's email address"
|
41
|
+
assert_equal ["noreply@#{Rails.application.class.module_parent.name.downcase}.com"], email.from, "Email should have proper from address"
|
42
|
+
assert_match(/Reset your password/i, email.subject, "Subject should contain password reset text")
|
43
|
+
|
44
|
+
# VERIFY BUSINESS LOGIC: Email body content validation
|
45
|
+
email_body = email.body.to_s
|
46
|
+
assert_match(@user.first_name, email_body, "Email should contain user's first name for personalization")
|
47
|
+
assert_match(@user.email_address, email_body, "Email should display user's email address for verification")
|
48
|
+
assert_match(reset_url, email_body, "Email should contain the reset URL with token")
|
49
|
+
assert_match(/15 minutes/i, email_body, "Email should specify token expiration time")
|
50
|
+
assert_match(/security/i, email_body, "Email should contain security messaging")
|
51
|
+
|
52
|
+
# VERIFY EDGE CASES: Token format validation
|
53
|
+
assert_match(/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/, reset_token, "Reset token should be valid JWT format")
|
54
|
+
end
|
55
|
+
|
56
|
+
# BEHAVIOR: Test account unlock email generation and content
|
57
|
+
test "account unlock email should contain proper content and security messaging" do
|
58
|
+
# SETUP: Lock user account and generate unlock token
|
59
|
+
@user.lock_account!
|
60
|
+
unlock_token = @user.generate_unlock_token
|
61
|
+
unlock_url = "http://localhost:3000/unlock-account?token=#{unlock_token}"
|
62
|
+
|
63
|
+
# VERIFY PRECONDITION: User should be locked
|
64
|
+
assert @user.locked?, "User should be locked before sending unlock email"
|
65
|
+
|
66
|
+
# EXECUTE: Generate account unlock email
|
67
|
+
email = AuthMailer.with(
|
68
|
+
user: @user,
|
69
|
+
token: unlock_token,
|
70
|
+
unlock_url: unlock_url
|
71
|
+
).account_unlock
|
72
|
+
|
73
|
+
# VERIFY STRUCTURE: Basic email structure validation
|
74
|
+
assert_emails 1 do
|
75
|
+
email.deliver_now
|
76
|
+
end
|
77
|
+
|
78
|
+
# VERIFY FUNCTIONALITY: Email content and behavior
|
79
|
+
assert_equal [@user.email_address], email.to, "Email should be sent to user's email address"
|
80
|
+
assert_match(/Account locked/i, email.subject, "Subject should indicate account lock status")
|
81
|
+
|
82
|
+
# VERIFY BUSINESS LOGIC: Email body content validation
|
83
|
+
email_body = email.body.to_s
|
84
|
+
assert_match(@user.first_name, email_body, "Email should contain user's first name")
|
85
|
+
assert_match(/multiple failed login attempts/i, email_body, "Email should explain why account was locked")
|
86
|
+
assert_match(unlock_url, email_body, "Email should contain unlock URL with token")
|
87
|
+
assert_match(/1 hour/i, email_body, "Email should specify unlock token expiration")
|
88
|
+
assert_match(/security/i, email_body, "Email should contain security messaging")
|
89
|
+
assert_match(/contact support/i, email_body, "Email should provide support contact information")
|
90
|
+
|
91
|
+
# VERIFY EDGE CASES: Token format validation
|
92
|
+
assert_match(/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/, unlock_token, "Unlock token should be valid JWT format")
|
93
|
+
end
|
94
|
+
|
95
|
+
# BEHAVIOR: Test user invitation email generation and content
|
96
|
+
test "user invitation email should contain proper invitation content and onboarding" do
|
97
|
+
# SETUP: Create invitation
|
98
|
+
inviter = User.create!(
|
99
|
+
email_address: "inviter@example.com",
|
100
|
+
username: "inviter",
|
101
|
+
password: "password123",
|
102
|
+
organization: @organization,
|
103
|
+
first_name: "Inviter",
|
104
|
+
last_name: "User"
|
105
|
+
)
|
106
|
+
|
107
|
+
invitation = Invitation.create!(
|
108
|
+
email_address: "invited@example.com",
|
109
|
+
first_name: "Invited",
|
110
|
+
last_name: "User",
|
111
|
+
organization: @organization,
|
112
|
+
inviter: inviter
|
113
|
+
)
|
114
|
+
|
115
|
+
invitation_token = invitation.generate_invitation_token
|
116
|
+
invitation_url = "http://localhost:3000/accept-invitation?token=#{invitation_token}"
|
117
|
+
|
118
|
+
# EXECUTE: Generate invitation email
|
119
|
+
email = AuthMailer.with(
|
120
|
+
invitation: invitation,
|
121
|
+
inviter: inviter,
|
122
|
+
token: invitation_token,
|
123
|
+
invitation_url: invitation_url
|
124
|
+
).user_invitation
|
125
|
+
|
126
|
+
# VERIFY STRUCTURE: Basic email structure validation
|
127
|
+
assert_emails 1 do
|
128
|
+
email.deliver_now
|
129
|
+
end
|
130
|
+
|
131
|
+
# VERIFY FUNCTIONALITY: Email content and behavior
|
132
|
+
assert_equal [invitation.email_address], email.to, "Email should be sent to invitation email address"
|
133
|
+
assert_match(/invited/i, email.subject, "Subject should indicate invitation")
|
134
|
+
assert_match(@organization.name, email.subject, "Subject should contain organization name")
|
135
|
+
|
136
|
+
# VERIFY BUSINESS LOGIC: Email body content validation
|
137
|
+
email_body = email.body.to_s
|
138
|
+
assert_match(invitation.first_name, email_body, "Email should contain invitee's first name")
|
139
|
+
assert_match(inviter.first_name, email_body, "Email should contain inviter's name")
|
140
|
+
assert_match(@organization.name, email_body, "Email should contain organization name")
|
141
|
+
assert_match(invitation_url, email_body, "Email should contain invitation acceptance URL")
|
142
|
+
assert_match(/7 days/i, email_body, "Email should specify invitation expiration")
|
143
|
+
assert_match(/getting started/i, email_body, "Email should contain onboarding information")
|
144
|
+
|
145
|
+
# VERIFY EDGE CASES: Token format validation
|
146
|
+
assert_match(/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/, invitation_token, "Invitation token should be valid JWT format")
|
147
|
+
end
|
148
|
+
|
149
|
+
# BEHAVIOR: Test email delivery failure handling
|
150
|
+
test "should handle email delivery failures gracefully" do
|
151
|
+
# SETUP: Create user with invalid email to simulate delivery failure
|
152
|
+
user_with_invalid_email = User.create!(
|
153
|
+
email_address: "invalid-email-that-will-bounce@nonexistent-domain-12345.invalid",
|
154
|
+
username: "invalidemailuser",
|
155
|
+
password: "password123",
|
156
|
+
organization: @organization,
|
157
|
+
first_name: "Invalid",
|
158
|
+
last_name: "Email"
|
159
|
+
)
|
160
|
+
|
161
|
+
reset_token = user_with_invalid_email.generate_password_reset_token
|
162
|
+
|
163
|
+
# EXECUTE: Attempt to send email to invalid address
|
164
|
+
email = AuthMailer.with(
|
165
|
+
user: user_with_invalid_email,
|
166
|
+
token: reset_token,
|
167
|
+
reset_url: "http://localhost:3000/reset?token=#{reset_token}"
|
168
|
+
).password_reset
|
169
|
+
|
170
|
+
# VERIFY FUNCTIONALITY: Email should be generated even with invalid address
|
171
|
+
assert_not_nil email, "Email should be generated even with invalid email address"
|
172
|
+
assert_equal [user_with_invalid_email.email_address], email.to, "Email should contain the provided address"
|
173
|
+
|
174
|
+
# VERIFY BUSINESS LOGIC: System should not crash on delivery attempts
|
175
|
+
assert_nothing_raised "Email delivery should not raise exceptions in test environment" do
|
176
|
+
email.deliver_now
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# BEHAVIOR: Test email template rendering with missing data
|
181
|
+
test "should handle missing user data gracefully in email templates" do
|
182
|
+
# SETUP: Create user with minimal data
|
183
|
+
minimal_user = User.create!(
|
184
|
+
email_address: "minimal@example.com",
|
185
|
+
username: "minimaluser",
|
186
|
+
password: "password123",
|
187
|
+
organization: @organization
|
188
|
+
# No first_name, last_name provided
|
189
|
+
)
|
190
|
+
|
191
|
+
reset_token = minimal_user.generate_password_reset_token
|
192
|
+
|
193
|
+
# EXECUTE: Generate email for user with minimal data
|
194
|
+
email = AuthMailer.with(
|
195
|
+
user: minimal_user,
|
196
|
+
token: reset_token,
|
197
|
+
reset_url: "http://localhost:3000/reset?token=#{reset_token}"
|
198
|
+
).password_reset
|
199
|
+
|
200
|
+
# VERIFY FUNCTIONALITY: Email should be generated successfully
|
201
|
+
assert_not_nil email, "Email should be generated for user with minimal data"
|
202
|
+
|
203
|
+
# VERIFY BUSINESS LOGIC: Email should handle missing first name gracefully
|
204
|
+
email_body = email.body.to_s
|
205
|
+
assert_match(/Hello/i, email_body, "Email should have fallback greeting")
|
206
|
+
assert_match(minimal_user.email_address, email_body, "Email should display email address when name unavailable")
|
207
|
+
end
|
208
|
+
|
209
|
+
private
|
210
|
+
|
211
|
+
# Helper method to extract token from email body
|
212
|
+
def extract_token_from_email_body(email_body)
|
213
|
+
match = email_body.match(/token=([A-Za-z0-9\-_\.]+)/)
|
214
|
+
match ? match[1] : nil
|
215
|
+
end
|
216
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# Preview all authentication emails
|
2
|
+
# Visit http://localhost:3000/rails/mailers/auth_mailer to see email previews
|
3
|
+
class AuthMailerPreview < ActionMailer::Preview
|
4
|
+
|
5
|
+
# Preview email confirmation
|
6
|
+
# http://localhost:3000/rails/mailers/auth_mailer/email_confirmation
|
7
|
+
def email_confirmation
|
8
|
+
user = sample_user
|
9
|
+
user.confirmation_token = SecureRandom.hex(32)
|
10
|
+
user.confirmation_sent_at = Time.current
|
11
|
+
|
12
|
+
AuthMailer.email_confirmation(user)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Preview password reset email
|
16
|
+
# http://localhost:3000/rails/mailers/auth_mailer/password_reset
|
17
|
+
def password_reset
|
18
|
+
user = sample_user
|
19
|
+
token = JWT.encode(
|
20
|
+
{
|
21
|
+
user_id: user.id,
|
22
|
+
email_address: user.email_address,
|
23
|
+
type: 'password_reset',
|
24
|
+
exp: 15.minutes.from_now.to_i
|
25
|
+
},
|
26
|
+
Rails.application.secret_key_base
|
27
|
+
)
|
28
|
+
|
29
|
+
AuthMailer.with(
|
30
|
+
user: user,
|
31
|
+
token: token,
|
32
|
+
reset_url: "http://localhost:3000/auth/reset?token=#{token}"
|
33
|
+
).password_reset
|
34
|
+
end
|
35
|
+
|
36
|
+
# Preview account unlock email
|
37
|
+
# http://localhost:3000/rails/mailers/auth_mailer/account_unlock
|
38
|
+
def account_unlock
|
39
|
+
user = sample_user
|
40
|
+
user.locked_at = Time.current
|
41
|
+
user.failed_login_attempts = 5
|
42
|
+
|
43
|
+
token = JWT.encode(
|
44
|
+
{
|
45
|
+
user_id: user.id,
|
46
|
+
email_address: user.email_address,
|
47
|
+
type: 'account_unlock',
|
48
|
+
exp: 1.hour.from_now.to_i
|
49
|
+
},
|
50
|
+
Rails.application.secret_key_base
|
51
|
+
)
|
52
|
+
|
53
|
+
AuthMailer.with(
|
54
|
+
user: user,
|
55
|
+
token: token,
|
56
|
+
unlock_url: "http://localhost:3000/auth/unlock?token=#{token}"
|
57
|
+
).account_unlock
|
58
|
+
end
|
59
|
+
|
60
|
+
# Preview user invitation email
|
61
|
+
# http://localhost:3000/rails/mailers/auth_mailer/user_invitation
|
62
|
+
def user_invitation
|
63
|
+
invitation = sample_invitation
|
64
|
+
inviter = sample_user
|
65
|
+
|
66
|
+
token = JWT.encode(
|
67
|
+
{
|
68
|
+
invitation_id: invitation.id,
|
69
|
+
email_address: invitation.email_address,
|
70
|
+
type: 'invitation',
|
71
|
+
exp: 7.days.from_now.to_i
|
72
|
+
},
|
73
|
+
Rails.application.secret_key_base
|
74
|
+
)
|
75
|
+
|
76
|
+
AuthMailer.with(
|
77
|
+
invitation: invitation,
|
78
|
+
inviter: inviter,
|
79
|
+
token: token,
|
80
|
+
invitation_url: "http://localhost:3000/auth/invitation?token=#{token}"
|
81
|
+
).user_invitation
|
82
|
+
end
|
83
|
+
|
84
|
+
# Preview welcome email
|
85
|
+
# http://localhost:3000/rails/mailers/auth_mailer/welcome
|
86
|
+
def welcome
|
87
|
+
user = sample_user
|
88
|
+
user.confirmed_at = Time.current
|
89
|
+
|
90
|
+
AuthMailer.with(user: user).welcome
|
91
|
+
end
|
92
|
+
|
93
|
+
# Preview email change confirmation
|
94
|
+
# http://localhost:3000/rails/mailers/auth_mailer/email_change_confirmation
|
95
|
+
def email_change_confirmation
|
96
|
+
user = sample_user
|
97
|
+
new_email = 'newemail@example.com'
|
98
|
+
|
99
|
+
token = JWT.encode(
|
100
|
+
{
|
101
|
+
user_id: user.id,
|
102
|
+
email_address: new_email,
|
103
|
+
type: 'email_change',
|
104
|
+
exp: 24.hours.from_now.to_i
|
105
|
+
},
|
106
|
+
Rails.application.secret_key_base
|
107
|
+
)
|
108
|
+
|
109
|
+
AuthMailer.with(
|
110
|
+
user: user,
|
111
|
+
new_email_address: new_email,
|
112
|
+
token: token,
|
113
|
+
confirmation_url: "http://localhost:3000/auth/confirm-email?token=#{token}"
|
114
|
+
).email_change_confirmation
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def sample_user
|
120
|
+
# Create a sample user for preview purposes
|
121
|
+
organization = sample_organization
|
122
|
+
|
123
|
+
User.new(
|
124
|
+
id: 1,
|
125
|
+
email_address: 'john.doe@example.com',
|
126
|
+
username: 'johndoe',
|
127
|
+
first_name: 'John',
|
128
|
+
last_name: 'Doe',
|
129
|
+
phone_number: '+1-555-123-4567',
|
130
|
+
organization: organization,
|
131
|
+
created_at: Time.current,
|
132
|
+
updated_at: Time.current
|
133
|
+
)
|
134
|
+
end
|
135
|
+
|
136
|
+
def sample_organization
|
137
|
+
# Create a sample organization for preview purposes
|
138
|
+
Organization.new(
|
139
|
+
id: 1,
|
140
|
+
name: 'Acme Corporation',
|
141
|
+
domain: 'acme.com',
|
142
|
+
created_at: Time.current,
|
143
|
+
updated_at: Time.current
|
144
|
+
)
|
145
|
+
end
|
146
|
+
|
147
|
+
def sample_invitation
|
148
|
+
# Create a sample invitation for preview purposes
|
149
|
+
organization = sample_organization
|
150
|
+
|
151
|
+
Invitation.new(
|
152
|
+
id: 1,
|
153
|
+
email_address: 'newuser@example.com',
|
154
|
+
organization: organization,
|
155
|
+
role: 'user',
|
156
|
+
status: 'pending',
|
157
|
+
created_at: Time.current,
|
158
|
+
updated_at: Time.current
|
159
|
+
)
|
160
|
+
end
|
161
|
+
end
|