@@ -49,17 +49,25 @@ def email_changed_event_data(stt_data_analyst):
4949 """Mock event data for email changed: includes old and new emails."""
5050 old_email = stt_data_analyst .email
5151 new_email = "new_email@example.com"
52- return {"subject" : {"email" : old_email , "subject_type" : new_email }}
52+ return {
53+ "subject" : {"email" : old_email , "subject_type" : "email" },
54+ "new-value" : new_email ,
55+ }
56+
57+
58+ @pytest .fixture
59+ def email_changed_event_data_without_new_value (stt_data_analyst ):
60+ """Mock Login.gov event data for email changed without a new email."""
61+ return {"subject" : {"email" : stt_data_analyst .email , "subject_type" : "email" }}
5362
5463
5564@pytest .fixture
5665def email_recycled_event_data (stt_data_analyst ):
5766 """Mock event data for email recycled: includes the recycled email."""
58- # Only the old email is relevant for handler logging; use a placeholder for subject_type
5967 return {
6068 "subject" : {
6169 "email" : stt_data_analyst .email ,
62- "subject_type" : "unused@example.com " ,
70+ "subject_type" : "email " ,
6371 }
6472 }
6573
@@ -222,6 +230,55 @@ def test_recovery_information_changed(
222230 decoded_jwt ["iat" ], tz = timezone .utc
223231 )
224232
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+
225282 @pytest .mark .django_db
226283 def test_mfa_locked (self , stt_data_analyst , event_data , decoded_jwt ):
227284 """Test handling of mfa-locked event."""
@@ -254,17 +311,19 @@ def test_mfa_locked(self, stt_data_analyst, event_data, decoded_jwt):
254311 def test_email_changed (
255312 self , stt_data_analyst , email_changed_event_data , decoded_jwt
256313 ):
257- """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+
258318 event_type = SecurityEventType .EMAIL_CHANGED
259319 SecurityEventHandler .handle_event (
260320 event_type , email_changed_event_data , decoded_jwt
261321 )
262322
263323 stt_data_analyst .refresh_from_db ()
264324
265- new_email = email_changed_event_data ["subject" ]["subject_type" ]
266- assert stt_data_analyst .email == new_email
267- assert stt_data_analyst .username == new_email
325+ assert stt_data_analyst .email == prev_email
326+ assert stt_data_analyst .username == prev_username
268327
269328 assert SecurityEventToken .objects .count () == 1
270329 token = SecurityEventToken .objects .first ()
@@ -278,6 +337,84 @@ def test_email_changed(
278337 decoded_jwt ["iat" ], tz = timezone .utc
279338 )
280339
340+ @pytest .mark .django_db
341+ def test_email_changed_without_new_value_does_not_update_user (
342+ self , stt_data_analyst , email_changed_event_data_without_new_value , decoded_jwt
343+ ):
344+ """Test Login.gov identifier-changed payload does not set email to subject_type."""
345+ prev_email = stt_data_analyst .email
346+ prev_username = stt_data_analyst .username
347+
348+ event_type = SecurityEventType .EMAIL_CHANGED
349+ SecurityEventHandler .handle_event (
350+ event_type , email_changed_event_data_without_new_value , decoded_jwt
351+ )
352+
353+ stt_data_analyst .refresh_from_db ()
354+
355+ assert stt_data_analyst .email == prev_email
356+ assert stt_data_analyst .username == prev_username
357+
358+ assert SecurityEventToken .objects .count () == 1
359+ token = SecurityEventToken .objects .first ()
360+ assert token .user == stt_data_analyst
361+ assert token .processed is True
362+ assert token .event_type == event_type
363+ assert token .event_data == email_changed_event_data_without_new_value
364+
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+
281418 @pytest .mark .django_db
282419 def test_email_recycled (
283420 self , stt_data_analyst , email_recycled_event_data , decoded_jwt
@@ -351,9 +488,9 @@ def test_handle_event_no_sub(self, caplog):
351488
352489 @pytest .mark .django_db
353490 def test_handle_event_user_not_found (self , caplog ):
354- """Test handling event when user is not found."""
491+ """Test handling user-mutating event when user is not found."""
355492
356- event_type = SecurityEventType .UNKNOWN_EVENT
493+ event_type = SecurityEventType .ACCOUNT_DISABLED
357494 login_gov_uuid = uuid .uuid4 ()
358495 event_data = {"subject" : {"sub" : str (login_gov_uuid )}}
359496 decoded_jwt = {}
0 commit comments