def test_token_is_for_a_different_user(self): """If you clicked an unsubscribe link for a different participant, it should still work.""" participant = factories.ParticipantFactory.create() token = unsubscribe.generate_unsubscribe_token(participant) self.client.force_login(factories.ParticipantFactory().user) with self._spy_on_add_message() as add_message: response = self.client.get(f'/preferences/email/unsubscribe/{token}/') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/preferences/email/') add_message.assert_has_calls( [ mock.call( mock.ANY, messages.SUCCESS, 'Successfully unsubscribed', ), mock.call( mock.ANY, messages.WARNING, 'Note that the unsubscribe token was for a different participant! ' 'You may edit your own mail preferences below.', ), ], any_order=False, )
def test_participant_since_deleted(self): par = factories.ParticipantFactory.create() token = unsubscribe.generate_unsubscribe_token(par) par.delete() with self.assertRaises(unsubscribe.InvalidToken) as cm: unsubscribe.unsubscribe_from_token(token) self.assertEqual(str(cm.exception), "Participant no longer exists")
def test_generate_token(self): par = factories.ParticipantFactory.build(pk=22) token = unsubscribe.generate_unsubscribe_token(par) # A bit circular, but show that we extract the signed data self.assertEqual( unsubscribe.unsign_token(token), (22, {unsubscribe.EmailType.membership_renewal}), )
def test_works_even_if_already_unsubscribed(self): par = factories.ParticipantFactory.create( send_membership_reminder=False) token = unsubscribe.generate_unsubscribe_token(par) returned_par = unsubscribe.unsubscribe_from_token(token) self.assertEqual(returned_par, par) par.refresh_from_db() self.assertFalse(par.send_membership_reminder)
def test_real_token_wrong_secret(self): par = factories.ParticipantFactory.build(pk=42) with self.settings(UNSUBSCRIBE_SECRET_KEY='different-secret'): token = unsubscribe.generate_unsubscribe_token(par) with self.assertRaises(unsubscribe.InvalidToken) as cm: unsubscribe.unsubscribe_from_token(token) self.assertEqual(str(cm.exception), "Invalid token, cannot unsubscribe automatically.")
def test_token_expired(self): par = factories.ParticipantFactory.create() with freeze_time("2021-09-01 12:00:00 EST"): token = unsubscribe.generate_unsubscribe_token(par) with freeze_time("2021-10-03 12:00:00 EST"): with self.assertRaises(unsubscribe.InvalidToken) as cm: unsubscribe.unsubscribe_from_token(token) self.assertEqual(str(cm.exception), "Token expired, cannot unsubscribe automatically.")
def test_participant_since_deleted(self): """We handle the case of a valid token for a since-deleted participant.""" par = factories.ParticipantFactory.create() token = unsubscribe.generate_unsubscribe_token(par) par.delete() soup = self._get(f'/preferences/email/unsubscribe/{token}/') self.assertEqual( ['Participant no longer exists'], [alert.text.strip() for alert in soup.find_all(class_='alert')], ) self.assertTrue(soup.find('a', href='/preferences/email/')) self.assertEqual( strip_whitespace(soup.find('p', class_="lead").text), "Edit your email preferences (login required)", )
def send_email_reminding_to_renew(participant): """Send a (one-time, will not be repeated) reminder when dues are nearly up. These emails are meant to be opt-in (i.e. participants must willingly *ask* to get reminders when it's time to renew). """ par = f'{participant.email} ({participant.pk})' today = date_utils.local_date() membership: Optional[models.Membership] = participant.membership # For (hopefully) obvious reasons, you *must* have an old membership to renew. # The trips database is *not* the source of truth for membership. # However, we very frequently query the gear database for membership status. if not (membership and membership.membership_expires): raise ValueError(f"Can't email {par} about renewal (no membership on file!)") # Language in our email assumes that the membership is still active. # Accordingly, never send a reminder if membership has expired already. if today > membership.membership_expires: raise ValueError(f"Membership has already expired for {par}") renewal_date = membership.date_when_renewal_is_recommended(report_past_dates=True) # We should never remind people to renew before it's actually possible. if today < renewal_date: # (Buying a new membership today won't credit them the remaining days) raise ValueError(f"We don't yet recommend renewal for {par}") context = { 'participant': participant, 'discounts': participant.discounts.all().order_by('name'), 'expiry_if_renewing': membership.membership_expires + timedelta(days=365), 'unsubscribe_token': unsubscribe.generate_unsubscribe_token(participant), } text_content = get_template('email/membership/renew.txt').render(context) html_content = get_template('email/membership/renew.html').render(context) msg = mail.EmailMultiAlternatives( subject="[Action required] Renew your MITOC membership", body=text_content, to=[participant.email], reply_to=['*****@*****.**'], ) msg.attach_alternative(html_content, "text/html") msg.send() logger.info("Reminded %s to renew their membership", par) return msg
def test_success_logged_in(self): """The token still works when logged in!.""" par = factories.ParticipantFactory.create() self.client.force_login(par.user) token = unsubscribe.generate_unsubscribe_token(par) with self._spy_on_add_message() as add_message: response = self.client.get(f'/preferences/email/unsubscribe/{token}/') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/preferences/email/') add_message.assert_called_once_with( mock.ANY, messages.SUCCESS, "Successfully unsubscribed" ) par.refresh_from_db() self.assertFalse(par.send_membership_reminder)
def test_expired_token(self): """Tokens are only valid for so long.""" par = factories.ParticipantFactory.build(pk=22) with freeze_time("2021-09-01 12:00:00 EST"): token = unsubscribe.generate_unsubscribe_token(par) self.assertEqual( unsubscribe.unsign_token(token).participant_pk, 22) # 28 days later, still works with freeze_time("2021-09-29 12:00:00 EST"): self.assertEqual( unsubscribe.unsign_token(token).participant_pk, 22) # >30 days later, expired with freeze_time("2021-10-03 12:00:00 EST"): with self.assertRaises(signing.SignatureExpired): unsubscribe.unsign_token(token)
def test_tokens_are_salted(self): """We use a namespaced salt to avoid token re-use. This test is a bit circular in nature, but mostly is documentation as to *why* we're using the same salt for all participants (feels a bit weird). # > Using salt in this way puts the different signatures into different # > namespaces. A signature that comes from one namespace (a particular salt # > value) cannot be used to validate the same plaintext string in a different # > namespace that is using a different salt setting. https://docs.djangoproject.com/en/3.2/topics/signing/ We *could* use a nonce to avoid re-used the same tokens, but there's no real reason these tokens can't be shown again and again. """ def _sha_256_signed(payload, salt, key='sooper-secret') -> str: signer = TimestampSigner(key=key, salt=salt, algorithm='sha256') return signer.sign_object(payload) par = factories.ParticipantFactory.build(pk=22) payload = {'pk': 22, 'emails': [0]} with self.settings(UNSUBSCRIBE_SECRET_KEY='sooper-secret'): real_token = unsubscribe.generate_unsubscribe_token(par) # First, demonstrate that our token uses a salt salted_token = _sha_256_signed(payload, salt='ws.email.unsubscribe') self.assertEqual(salted_token, real_token) # Then, show that the unsalted version of the same payload doesn't match unsalted_token = _sha_256_signed(payload, salt=None) self.assertNotEqual(salted_token, unsalted_token) # Finally, other signers which might use the same secret, but a different salt don't match other_salt_token = _sha_256_signed(payload, salt='some.other.module') self.assertNotEqual(salted_token, other_salt_token)