Ejemplo n.º 1
0
    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,
        )
Ejemplo n.º 2
0
 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")
Ejemplo n.º 3
0
 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}),
     )
Ejemplo n.º 4
0
    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)
Ejemplo n.º 5
0
    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.")
Ejemplo n.º 6
0
    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.")
Ejemplo n.º 7
0
 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)",
     )
Ejemplo n.º 8
0
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
Ejemplo n.º 9
0
    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)
Ejemplo n.º 10
0
    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)
Ejemplo n.º 11
0
    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)