diff --git a/openwisp_users/base/models.py b/openwisp_users/base/models.py index ef5a0dec..3f5c9662 100644 --- a/openwisp_users/base/models.py +++ b/openwisp_users/base/models.py @@ -129,7 +129,7 @@ def _get_pk(obj): return str(pk) def set_password(self, *args, **kwargs): - self.password_updated = timezone.now().date() + self.password_updated = localdate() return super().set_password(*args, **kwargs) def has_password_expired(self): @@ -145,7 +145,7 @@ def has_password_expired(self): ) else: return False - return expiry_date < timezone.now().date() + return expiry_date < localdate() def is_member(self, organization): return self._get_pk(organization) in self.organizations_dict diff --git a/openwisp_users/tasks.py b/openwisp_users/tasks.py index a29d0645..02e9a7c9 100644 --- a/openwisp_users/tasks.py +++ b/openwisp_users/tasks.py @@ -6,7 +6,7 @@ from django.template.loader import render_to_string from django.urls import reverse from django.utils import translation -from django.utils.timezone import now, timedelta +from django.utils.timezone import localdate, timedelta from django.utils.translation import gettext_lazy as _ from swapper import load_model @@ -30,7 +30,7 @@ def password_expiration_email(): ): # The password expiration feature is not enabled return - expiry_date = now().date() + timedelta(days=7) + expiry_date = localdate() + timedelta(days=7) query = Q() if app_settings.USER_PASSWORD_EXPIRATION: query |= Q( @@ -54,6 +54,9 @@ def password_expiration_email(): emailaddress__verified=True, ) .filter(query) + # a user can own more than one verified email address: + # without distinct() the join would send one email per address + .distinct() ) email_count = 0 for user in qs.iterator(): diff --git a/openwisp_users/tests/test_models.py b/openwisp_users/tests/test_models.py index 5c02b3c6..628e19ed 100644 --- a/openwisp_users/tests/test_models.py +++ b/openwisp_users/tests/test_models.py @@ -416,7 +416,7 @@ def test_has_password_expired(self): # User.objects.create_user does not call User.set_password. # Therefore, we set the password_updated field manually. - User.objects.update(password_updated=now().date()) + User.objects.update(password_updated=localdate()) staff_user.refresh_from_db() end_user.refresh_from_db() @@ -437,7 +437,7 @@ def test_has_password_expired(self): self.assertEqual(end_user.has_password_expired(), False) with self.subTest("Test password is expired"): - User.objects.update(password_updated=now().date() - timedelta(days=180)) + User.objects.update(password_updated=localdate() - timedelta(days=180)) staff_user.refresh_from_db() end_user.refresh_from_db() with ( @@ -459,10 +459,10 @@ def test_has_password_expired(self): @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) @patch.object(app_settings, "STAFF_USER_PASSWORD_EXPIRATION", 90) def test_password_expiration_mail(self): - user_expiry_date = now().date() - timedelta( + user_expiry_date = localdate() - timedelta( days=(app_settings.USER_PASSWORD_EXPIRATION - 7) ) - staff_user_expiry_date = now().date() - timedelta( + staff_user_expiry_date = localdate() - timedelta( days=(app_settings.STAFF_USER_PASSWORD_EXPIRATION - 7) ) staff_user = self._create_operator() @@ -498,7 +498,7 @@ def test_password_expiration_mail(self): password_updated=user_expiry_date ) password_expiration_email.delay() - expected_date = localize((now() + timedelta(days=7)).date()) + expected_date = localize(localdate() + timedelta(days=7)) self.assertEqual(len(mail.outbox), 1) email = mail.outbox.pop() self.assertEqual(email.to, [verified_email_user.email]) @@ -525,7 +525,7 @@ def test_password_expiration_mail(self): @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) @patch("openwisp_users.utils.sleep") def test_password_expiration_mail_sleep(self, mocked_sleep): - user_expiry_date = now().date() - timedelta( + user_expiry_date = localdate() - timedelta( days=(app_settings.USER_PASSWORD_EXPIRATION - 7) ) for i in range(10): @@ -547,7 +547,7 @@ def test_password_expiration_mail_settings_disabled(self): self.assertEqual(app_settings.USER_PASSWORD_EXPIRATION, 0) self.assertEqual(app_settings.STAFF_USER_PASSWORD_EXPIRATION, 0) self._create_user() - User.objects.update(password_updated=now().date() - timedelta(days=180)) + User.objects.update(password_updated=localdate() - timedelta(days=180)) with patch("openwisp_users.tasks.send_email") as mocked_send_email: password_expiration_email.delay() mocked_send_email.assert_not_called() @@ -1195,3 +1195,90 @@ def test_expiration_reminder_email_recipient_selection(self): expiration_reminder_email, None, ) + + @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) + def test_password_expiration_mail_multiple_verified_emails(self): + user = self._create_user() + for index in range(2): + EmailAddress.objects.create( + user=user, + email=f"alias{index}@tester.com", + verified=True, + ) + User.objects.update( + password_updated=localdate() + - timedelta(days=app_settings.USER_PASSWORD_EXPIRATION - 7) + ) + password_expiration_email.delay() + self.assertEqual( + len(mail.outbox), + 1, + msg=( + f"Expected 1 password expiry notice, got {len(mail.outbox)}:" + " the notice is duplicated once per verified email address" + " of the same user" + ), + ) + self.assertEqual(mail.outbox[0].to, [user.email]) + + @freeze_time("2026-03-29 23:30:00") + @override_settings(TIME_ZONE="Europe/Rome") + @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) + def test_password_expiration_local_date(self): + """ + 23:30 UTC is already the next day in Europe/Rome (UTC+2), so password + expiration dates must follow the local calendar day. + """ + user = self._create_user() + + with self.subTest("password_updated is stamped with the local date"): + user.set_password("tester") + self.assertEqual( + user.password_updated, + localdate(), + msg=( + f"Expected password_updated {localdate()} (local date)," + f" got {user.password_updated} (UTC date)" + ), + ) + + expiration = app_settings.USER_PASSWORD_EXPIRATION + + with self.subTest("password is valid on the expiration date"): + user.password_updated = localdate() - timedelta(days=expiration) + self.assertFalse( + user.has_password_expired(), + msg="Expected password to remain valid on its expiration date", + ) + + with self.subTest("password is expired after the expiration date"): + user.password_updated = localdate() - timedelta(days=expiration + 1) + self.assertTrue( + user.has_password_expired(), + msg=( + "Expected the password to be expired: it expired on" + f" {user.password_updated + timedelta(days=expiration)}," + f" while the local date is {localdate()}" + ), + ) + + @freeze_time("2026-03-29 23:30:00") + @override_settings(TIME_ZONE="Europe/Rome") + @patch.object(app_settings, "USER_PASSWORD_EXPIRATION", 30) + def test_password_expiration_mail_local_date(self): + self._create_user() + User.objects.update( + password_updated=localdate() + - timedelta(days=app_settings.USER_PASSWORD_EXPIRATION - 7) + ) + password_expiration_email.delay() + self.assertEqual( + len(mail.outbox), + 1, + msg=( + f"Expected 1 password expiry notice, got {len(mail.outbox)}:" + " the task looks for passwords expiring 7 days after the UTC" + f" date ({now().date()}) instead of the local date" + f" ({localdate()})" + ), + )