Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions openwisp_users/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions openwisp_users/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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():
Expand Down
101 changes: 94 additions & 7 deletions openwisp_users/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 (
Expand All @@ -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()
Expand Down Expand Up @@ -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])
Expand All @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()})"
),
)
Loading