@@ -230,6 +230,55 @@ def test_recovery_information_changed(
230230 decoded_jwt ["iat" ], tz = timezone .utc
231231 )
232232
233+ @pytest .mark .django_db
234+ @pytest .mark .parametrize (
235+ "event_type" ,
236+ [
237+ SecurityEventType .MFA_LOCKED ,
238+ SecurityEventType .EMAIL_CHANGED ,
239+ SecurityEventType .EMAIL_RECYCLED ,
240+ SecurityEventType .PASSWORD_RESET ,
241+ SecurityEventType .RECOVERY_ACTIVATED ,
242+ SecurityEventType .RECOVERY_INFORMATION_CHANGED ,
243+ SecurityEventType .REPROOF_COMPLETE ,
244+ ],
245+ )
246+ def test_non_mutating_events_do_not_update_user (
247+ self , stt_data_analyst , event_type , decoded_jwt
248+ ):
249+ """Test only account status and purge events mutate the user model."""
250+ prev_email = stt_data_analyst .email
251+ prev_username = stt_data_analyst .username
252+ prev_active = stt_data_analyst .is_active
253+ prev_login_gov_uuid = str (stt_data_analyst .login_gov_uuid )
254+ prev_status = stt_data_analyst .account_approval_status
255+ event_data = {"subject" : {"sub" : str (stt_data_analyst .login_gov_uuid )}}
256+
257+ if event_type in {
258+ SecurityEventType .EMAIL_CHANGED ,
259+ SecurityEventType .EMAIL_RECYCLED ,
260+ }:
261+ event_data = {
262+ "subject" : {
263+ "email" : stt_data_analyst .email ,
264+ "subject_type" : "email" ,
265+ }
266+ }
267+
268+ SecurityEventHandler .handle_event (event_type , event_data , decoded_jwt )
269+
270+ stt_data_analyst .refresh_from_db ()
271+
272+ assert stt_data_analyst .email == prev_email
273+ assert stt_data_analyst .username == prev_username
274+ assert stt_data_analyst .is_active == prev_active
275+ assert str (stt_data_analyst .login_gov_uuid ) == prev_login_gov_uuid
276+ assert stt_data_analyst .account_approval_status == prev_status
277+
278+ token = SecurityEventToken .objects .first ()
279+ assert token .processed is True
280+ assert token .event_type == event_type
281+
233282 @pytest .mark .django_db
234283 def test_mfa_locked (self , stt_data_analyst , event_data , decoded_jwt ):
235284 """Test handling of mfa-locked event."""
@@ -262,17 +311,19 @@ def test_mfa_locked(self, stt_data_analyst, event_data, decoded_jwt):
262311 def test_email_changed (
263312 self , stt_data_analyst , email_changed_event_data , decoded_jwt
264313 ):
265- """Test handling email-changed event updates user's email and username."""
314+ """Test handling email-changed event does not update user's email and username."""
315+ prev_email = stt_data_analyst .email
316+ prev_username = stt_data_analyst .username
317+
266318 event_type = SecurityEventType .EMAIL_CHANGED
267319 SecurityEventHandler .handle_event (
268320 event_type , email_changed_event_data , decoded_jwt
269321 )
270322
271323 stt_data_analyst .refresh_from_db ()
272324
273- new_email = email_changed_event_data ["new-value" ]
274- assert stt_data_analyst .email == new_email
275- assert stt_data_analyst .username == new_email
325+ assert stt_data_analyst .email == prev_email
326+ assert stt_data_analyst .username == prev_username
276327
277328 assert SecurityEventToken .objects .count () == 1
278329 token = SecurityEventToken .objects .first ()
@@ -311,6 +362,59 @@ def test_email_changed_without_new_value_does_not_update_user(
311362 assert token .event_type == event_type
312363 assert token .event_data == email_changed_event_data_without_new_value
313364
365+ @pytest .mark .django_db
366+ def test_email_changed_for_unknown_email_records_processed_event (
367+ self , caplog , decoded_jwt
368+ ):
369+ """Test Login.gov identifier-changed can arrive with a new unknown email."""
370+ event_type = SecurityEventType .EMAIL_CHANGED
371+ event_data = {
372+ "subject" : {
373+ "email" : "new_login_email@example.com" ,
374+ "subject_type" : "email" ,
375+ }
376+ }
377+
378+ with caplog .at_level (logging .WARNING ):
379+ SecurityEventHandler .handle_event (event_type , event_data , decoded_jwt )
380+
381+ assert "No user found with the provided 'email'" not in caplog .text
382+ assert "unmatched subject" in caplog .text
383+
384+ assert SecurityEventToken .objects .count () == 1
385+ token = SecurityEventToken .objects .first ()
386+ assert token .user is None
387+ assert token .email == "new_login_email@example.com"
388+ assert token .processed is True
389+ assert token .processed_at is not None
390+ assert token .event_type == event_type
391+ assert token .event_data == event_data
392+ assert token .jwt_id == decoded_jwt ["jti" ]
393+ assert token .issuer == decoded_jwt ["iss" ]
394+
395+ @pytest .mark .django_db
396+ def test_non_mutating_event_for_unknown_sub_records_processed_event (
397+ self , caplog , decoded_jwt
398+ ):
399+ """Test non-mutating events with an unknown sub are recorded for audit."""
400+ event_type = SecurityEventType .PASSWORD_RESET
401+ event_data = {"subject" : {"sub" : str (uuid .uuid4 ())}}
402+
403+ with caplog .at_level (logging .WARNING ):
404+ SecurityEventHandler .handle_event (event_type , event_data , decoded_jwt )
405+
406+ assert "No user found with login_gov_uuid" not in caplog .text
407+ assert "unmatched subject" in caplog .text
408+
409+ assert SecurityEventToken .objects .count () == 1
410+ token = SecurityEventToken .objects .first ()
411+ assert token .user is None
412+ assert token .email is None
413+ assert token .processed is True
414+ assert token .processed_at is not None
415+ assert token .event_type == event_type
416+ assert token .event_data == event_data
417+
314418 @pytest .mark .django_db
315419 def test_email_recycled (
316420 self , stt_data_analyst , email_recycled_event_data , decoded_jwt
@@ -384,9 +488,9 @@ def test_handle_event_no_sub(self, caplog):
384488
385489 @pytest .mark .django_db
386490 def test_handle_event_user_not_found (self , caplog ):
387- """Test handling event when user is not found."""
491+ """Test handling user-mutating event when user is not found."""
388492
389- event_type = SecurityEventType .UNKNOWN_EVENT
493+ event_type = SecurityEventType .ACCOUNT_DISABLED
390494 login_gov_uuid = uuid .uuid4 ()
391495 event_data = {"subject" : {"sub" : str (login_gov_uuid )}}
392496 decoded_jwt = {}
0 commit comments