From 63de6706811936070b4979353c371a849b46af85 Mon Sep 17 00:00:00 2001 From: Abhilash Raj Date: Sat, 30 Jul 2016 21:06:57 -0700 Subject: Intermediate Commit --- src/mailman/app/events.py | 4 +- src/mailman/app/registrar.py | 105 ------ src/mailman/app/subscriptions.py | 2 +- src/mailman/app/tests/test_moderation.py | 7 +- src/mailman/app/tests/test_registrar.py | 269 -------------- src/mailman/app/tests/test_subscriptions.py | 4 +- src/mailman/app/tests/test_unsubscriptions.py | 412 +++++++++++++++++++++ src/mailman/app/tests/test_workflowmanager.py | 271 ++++++++++++++ src/mailman/app/unsubscriptions.py | 255 +++++++++++++ src/mailman/app/workflowmanager.py | 147 ++++++++ src/mailman/commands/eml_confirm.py | 6 +- src/mailman/commands/eml_membership.py | 18 +- src/mailman/commands/tests/test_confirm.py | 12 +- src/mailman/config/configure.zcml | 12 +- .../448a93984c35_unsubscription_workflow.py | 42 +++ src/mailman/interfaces/mailinglist.py | 2 + src/mailman/interfaces/registrar.py | 112 ------ src/mailman/interfaces/workflowmanager.py | 113 ++++++ src/mailman/model/mailinglist.py | 31 +- src/mailman/model/tests/test_mailinglist.py | 13 + src/mailman/model/tests/test_workflow.py | 2 +- src/mailman/rest/members.py | 6 +- src/mailman/rest/sub_moderation.py | 8 +- src/mailman/rest/tests/test_membership.py | 8 +- src/mailman/rest/tests/test_moderation.py | 7 +- src/mailman/runners/tests/test_confirm.py | 7 +- src/mailman/runners/tests/test_join.py | 7 +- src/mailman/runners/tests/test_leave.py | 81 ++++ src/mailman/styles/base.py | 1 + 29 files changed, 1422 insertions(+), 542 deletions(-) delete mode 100644 src/mailman/app/registrar.py delete mode 100644 src/mailman/app/tests/test_registrar.py create mode 100644 src/mailman/app/tests/test_unsubscriptions.py create mode 100644 src/mailman/app/tests/test_workflowmanager.py create mode 100644 src/mailman/app/unsubscriptions.py create mode 100644 src/mailman/app/workflowmanager.py create mode 100644 src/mailman/database/alembic/versions/448a93984c35_unsubscription_workflow.py delete mode 100644 src/mailman/interfaces/registrar.py create mode 100644 src/mailman/interfaces/workflowmanager.py create mode 100644 src/mailman/runners/tests/test_leave.py diff --git a/src/mailman/app/events.py b/src/mailman/app/events.py index 8b95bd4be..f3a4d2f3b 100644 --- a/src/mailman/app/events.py +++ b/src/mailman/app/events.py @@ -19,7 +19,7 @@ from mailman import public from mailman.app import ( - domain, membership, moderator, registrar, subscriptions) + domain, membership, moderator, workflowmanager, subscriptions) from mailman.core import i18n, switchboard from mailman.languages import manager as language_manager from mailman.styles import manager as style_manager @@ -37,7 +37,7 @@ def initialize(): membership.handle_SubscriptionEvent, moderator.handle_ListDeletingEvent, passwords.handle_ConfigurationUpdatedEvent, - registrar.handle_ConfirmationNeededEvent, + workflowmanager.handle_ConfirmationNeededEvent, style_manager.handle_ConfigurationUpdatedEvent, subscriptions.handle_ListDeletingEvent, switchboard.handle_ConfigurationUpdatedEvent, diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py deleted file mode 100644 index 7cefbd518..000000000 --- a/src/mailman/app/registrar.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright (C) 2007-2016 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see . - -"""Implementation of the IRegistrar interface.""" - -import logging - -from mailman import public -from mailman.app.subscriptions import SubscriptionWorkflow -from mailman.database.transaction import flush -from mailman.email.message import UserNotification -from mailman.interfaces.pending import IPendable, IPendings -from mailman.interfaces.registrar import ConfirmationNeededEvent, IRegistrar -from mailman.interfaces.template import ITemplateLoader -from mailman.interfaces.workflow import IWorkflowStateManager -from mailman.utilities.string import expand -from zope.component import getUtility -from zope.interface import implementer - - -log = logging.getLogger('mailman.error') - - -@implementer(IPendable) -class PendableRegistration(dict): - PEND_TYPE = 'registration' - - -@public -@implementer(IRegistrar) -class Registrar: - """Handle registrations and confirmations for subscriptions.""" - - def __init__(self, mlist): - self._mlist = mlist - - def register(self, subscriber=None, *, - pre_verified=False, pre_confirmed=False, pre_approved=False): - """See `IRegistrar`.""" - workflow = SubscriptionWorkflow( - self._mlist, subscriber, - pre_verified=pre_verified, - pre_confirmed=pre_confirmed, - pre_approved=pre_approved) - list(workflow) - return workflow.token, workflow.token_owner, workflow.member - - def confirm(self, token): - """See `IRegistrar`.""" - workflow = SubscriptionWorkflow(self._mlist) - workflow.token = token - workflow.restore() - list(workflow) - return workflow.token, workflow.token_owner, workflow.member - - def discard(self, token): - """See `IRegistrar`.""" - with flush(): - getUtility(IPendings).confirm(token) - getUtility(IWorkflowStateManager).discard( - SubscriptionWorkflow.__name__, token) - - -@public -def handle_ConfirmationNeededEvent(event): - if not isinstance(event, ConfirmationNeededEvent): - return - # There are three ways for a user to confirm their subscription. They - # can reply to the original message and let the VERP'd return address - # encode the token, they can reply to the robot and keep the token in - # the Subject header, or they can click on the URL in the body of the - # message and confirm through the web. - subject = 'confirm {}'.format(event.token) - confirm_address = event.mlist.confirm_address(event.token) - email_address = event.email - # Send a verification email to the address. - template = getUtility(ITemplateLoader).get( - 'list:user:action:confirm', event.mlist) - text = expand(template, event.mlist, dict( - token=event.token, - subject=subject, - confirm_email=confirm_address, - user_email=email_address, - # For backward compatibility. - confirm_address=confirm_address, - email_address=email_address, - domain_name=event.mlist.domain.mail_host, - contact_address=event.mlist.owner_address, - )) - msg = UserNotification(email_address, confirm_address, subject, text) - msg.send(event.mlist, add_precedence=False) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index a397c4fde..0cd056b99 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -33,7 +33,7 @@ from mailman.interfaces.listmanager import ListDeletingEvent from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.member import MembershipIsBannedError from mailman.interfaces.pending import IPendable, IPendings -from mailman.interfaces.registrar import ConfirmationNeededEvent +from mailman.interfaces.workflowmanager import ConfirmationNeededEvent from mailman.interfaces.subscriptions import ( ISubscriptionService, SubscriptionPendingError, TokenOwner) from mailman.interfaces.template import ITemplateLoader diff --git a/src/mailman/app/tests/test_moderation.py b/src/mailman/app/tests/test_moderation.py index 931b85fd0..a7fc9e1b4 100644 --- a/src/mailman/app/tests/test_moderation.py +++ b/src/mailman/app/tests/test_moderation.py @@ -24,7 +24,7 @@ from mailman.app.moderator import ( handle_message, handle_unsubscription, hold_message, hold_unsubscription) from mailman.interfaces.action import Action from mailman.interfaces.messages import IMessageStore -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.requests import IListRequests from mailman.interfaces.usermanager import IUserManager from mailman.runners.incoming import IncomingRunner @@ -35,7 +35,7 @@ from mailman.testing.helpers import ( specialized_message_from_string as mfs) from mailman.testing.layers import SMTPLayer from mailman.utilities.datetime import now -from zope.component import getUtility +from zope.component import getUtility, getAdapter class TestModeration(unittest.TestCase): @@ -153,7 +153,8 @@ class TestUnsubscription(unittest.TestCase): def setUp(self): self._mlist = create_list('test@example.com') - self._registrar = IRegistrar(self._mlist) + self._registrar = getAdapter( + self._mlist, IWorkflowManager, name='subscribe') def test_unsubscribe_defer(self): # When unsubscriptions must be approved by the moderator, but the diff --git a/src/mailman/app/tests/test_registrar.py b/src/mailman/app/tests/test_registrar.py deleted file mode 100644 index 2107f9648..000000000 --- a/src/mailman/app/tests/test_registrar.py +++ /dev/null @@ -1,269 +0,0 @@ -# Copyright (C) 2012-2016 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see . - -"""Test email address registration.""" - -import unittest - -from mailman.app.lifecycle import create_list -from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.member import MemberRole -from mailman.interfaces.pending import IPendings -from mailman.interfaces.registrar import IRegistrar -from mailman.interfaces.subscriptions import TokenOwner -from mailman.interfaces.usermanager import IUserManager -from mailman.testing.helpers import get_queue_messages -from mailman.testing.layers import ConfigLayer -from mailman.utilities.datetime import now -from zope.component import getUtility - - -class TestRegistrar(unittest.TestCase): - """Test registration.""" - - layer = ConfigLayer - - def setUp(self): - self._mlist = create_list('ant@example.com') - self._registrar = IRegistrar(self._mlist) - self._pendings = getUtility(IPendings) - self._anne = getUtility(IUserManager).create_address( - 'anne@example.com') - - def test_initial_conditions(self): - # Registering a subscription request provides a unique token associated - # with a pendable, and the owner of the token. - self.assertEqual(self._pendings.count, 0) - token, token_owner, member = self._registrar.register(self._anne) - self.assertIsNotNone(token) - self.assertEqual(token_owner, TokenOwner.subscriber) - self.assertIsNone(member) - self.assertEqual(self._pendings.count, 1) - record = self._pendings.confirm(token, expunge=False) - self.assertEqual(record['list_id'], self._mlist.list_id) - self.assertEqual(record['email'], 'anne@example.com') - - def test_subscribe(self): - # Registering a subscription request where no confirmation or - # moderation steps are needed, leaves us with no token or owner, since - # there's nothing more to do. - self._mlist.subscription_policy = SubscriptionPolicy.open - self._anne.verified_on = now() - token, token_owner, rmember = self._registrar.register(self._anne) - self.assertIsNone(token) - self.assertEqual(token_owner, TokenOwner.no_one) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertEqual(rmember, member) - self.assertEqual(member.address, self._anne) - # There's nothing to confirm. - record = self._pendings.confirm(token, expunge=False) - self.assertIsNone(record) - - def test_no_such_token(self): - # Given a token which is not in the database, a LookupError is raised. - self._registrar.register(self._anne) - self.assertRaises(LookupError, self._registrar.confirm, 'not-a-token') - - def test_confirm_because_verify(self): - # We have a subscription request which requires the user to confirm - # (because she does not have a verified address), but not the moderator - # to approve. Running the workflow gives us a token. Confirming the - # token subscribes the user. - self._mlist.subscription_policy = SubscriptionPolicy.open - token, token_owner, rmember = self._registrar.register(self._anne) - self.assertIsNotNone(token) - self.assertEqual(token_owner, TokenOwner.subscriber) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # Now confirm the subscription. - token, token_owner, rmember = self._registrar.confirm(token) - self.assertIsNone(token) - self.assertEqual(token_owner, TokenOwner.no_one) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertEqual(rmember, member) - self.assertEqual(member.address, self._anne) - - def test_confirm_because_confirm(self): - # We have a subscription request which requires the user to confirm - # (because of list policy), but not the moderator to approve. Running - # the workflow gives us a token. Confirming the token subscribes the - # user. - self._mlist.subscription_policy = SubscriptionPolicy.confirm - self._anne.verified_on = now() - token, token_owner, rmember = self._registrar.register(self._anne) - self.assertIsNotNone(token) - self.assertEqual(token_owner, TokenOwner.subscriber) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # Now confirm the subscription. - token, token_owner, rmember = self._registrar.confirm(token) - self.assertIsNone(token) - self.assertEqual(token_owner, TokenOwner.no_one) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertEqual(rmember, member) - self.assertEqual(member.address, self._anne) - - def test_confirm_because_moderation(self): - # We have a subscription request which requires the moderator to - # approve. Running the workflow gives us a token. Confirming the - # token subscribes the user. - self._mlist.subscription_policy = SubscriptionPolicy.moderate - self._anne.verified_on = now() - token, token_owner, rmember = self._registrar.register(self._anne) - self.assertIsNotNone(token) - self.assertEqual(token_owner, TokenOwner.moderator) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # Now confirm the subscription. - token, token_owner, rmember = self._registrar.confirm(token) - self.assertIsNone(token) - self.assertEqual(token_owner, TokenOwner.no_one) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertEqual(rmember, member) - self.assertEqual(member.address, self._anne) - - def test_confirm_because_confirm_then_moderation(self): - # We have a subscription request which requires the user to confirm - # (because she does not have a verified address) and the moderator to - # approve. Running the workflow gives us a token. Confirming the - # token runs the workflow a little farther, but still gives us a - # token. Confirming again subscribes the user. - self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) - self._anne.verified_on = now() - # Runs until subscription confirmation. - token, token_owner, rmember = self._registrar.register(self._anne) - self.assertIsNotNone(token) - self.assertEqual(token_owner, TokenOwner.subscriber) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # Now confirm the subscription, and wait for the moderator to approve - # the subscription. She is still not subscribed. - new_token, token_owner, rmember = self._registrar.confirm(token) - # The new token, used for the moderator to approve the message, is not - # the same as the old token. - self.assertNotEqual(new_token, token) - self.assertIsNotNone(new_token) - self.assertEqual(token_owner, TokenOwner.moderator) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # Confirm once more, this time as the moderator approving the - # subscription. Now she's a member. - token, token_owner, rmember = self._registrar.confirm(new_token) - self.assertIsNone(token) - self.assertEqual(token_owner, TokenOwner.no_one) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertEqual(rmember, member) - self.assertEqual(member.address, self._anne) - - def test_confirm_then_moderate_with_different_tokens(self): - # Ensure that the confirmation token the user sees when they have to - # confirm their subscription is different than the token the moderator - # sees when they approve the subscription. This prevents the user - # from using a replay attack to subvert moderator approval. - self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) - self._anne.verified_on = now() - # Runs until subscription confirmation. - token, token_owner, rmember = self._registrar.register(self._anne) - self.assertIsNotNone(token) - self.assertEqual(token_owner, TokenOwner.subscriber) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # Now confirm the subscription, and wait for the moderator to approve - # the subscription. She is still not subscribed. - new_token, token_owner, rmember = self._registrar.confirm(token) - # The status is not true because the user has not yet been subscribed - # to the mailing list. - self.assertIsNotNone(new_token) - self.assertEqual(token_owner, TokenOwner.moderator) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # The new token is different than the old token. - self.assertNotEqual(token, new_token) - # Trying to confirm with the old token does not work. - self.assertRaises(LookupError, self._registrar.confirm, token) - # Confirm once more, this time with the new token, as the moderator - # approving the subscription. Now she's a member. - done_token, token_owner, rmember = self._registrar.confirm(new_token) - # The token is None, signifying that the member has been subscribed. - self.assertIsNone(done_token) - self.assertEqual(token_owner, TokenOwner.no_one) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertEqual(rmember, member) - self.assertEqual(member.address, self._anne) - - def test_discard_waiting_for_confirmation(self): - # While waiting for a user to confirm their subscription, we discard - # the workflow. - self._mlist.subscription_policy = SubscriptionPolicy.confirm - self._anne.verified_on = now() - # Runs until subscription confirmation. - token, token_owner, rmember = self._registrar.register(self._anne) - self.assertIsNotNone(token) - self.assertEqual(token_owner, TokenOwner.subscriber) - self.assertIsNone(rmember) - member = self._mlist.regular_members.get_member('anne@example.com') - self.assertIsNone(member) - # Now discard the subscription request. - self._registrar.discard(token) - # Trying to confirm the token now results in an exception. - self.assertRaises(LookupError, self._registrar.confirm, token) - - def test_admin_notify_mchanges(self): - # When a user gets subscribed via the subscription policy workflow, - # the list administrators get an email notification. - self._mlist.subscription_policy = SubscriptionPolicy.open - self._mlist.admin_notify_mchanges = True - self._mlist.send_welcome_message = False - token, token_owner, member = self._registrar.register( - self._anne, pre_verified=True) - # Anne is now a member. - self.assertEqual(member.address.email, 'anne@example.com') - # And there's a notification email waiting for Bart. - items = get_queue_messages('virgin', expected_count=1) - message = items[0].msg - self.assertEqual(message['To'], 'ant-owner@example.com') - self.assertEqual(message['Subject'], 'Ant subscription notification') - self.assertEqual(message.get_payload(), """\ -anne@example.com has been successfully subscribed to Ant. -""") - - def test_no_admin_notify_mchanges(self): - # Even when a user gets subscribed via the subscription policy - # workflow, the list administrators won't get an email notification if - # they don't want one. - self._mlist.subscription_policy = SubscriptionPolicy.open - self._mlist.admin_notify_mchanges = False - self._mlist.send_welcome_message = False - # Bart is an administrator of the mailing list. - bart = getUtility(IUserManager).create_address( - 'bart@example.com', 'Bart Person') - self._mlist.subscribe(bart, MemberRole.owner) - token, token_owner, member = self._registrar.register( - self._anne, pre_verified=True) - # Anne is now a member. - self.assertEqual(member.address.email, 'anne@example.com') - # There's no notification email waiting for Bart. - get_queue_messages('virgin', expected_count=0) diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py index 9f02593a9..6a3a1aa77 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -287,7 +287,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # The moderator must approve the subscription. self._mlist.subscription_policy = SubscriptionPolicy.moderate anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = SubscriptionWorkflow(self._mlist, anne) workflow.run_thru('moderation_checks') with patch.object(workflow, '_step_get_moderator_approval') as step: next(workflow) @@ -299,7 +299,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # confirmations or approvals. self._mlist.subscription_policy = SubscriptionPolicy.open anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = SubscriptionWorkflow(self._mlist, anne) # Consume the entire state machine. list(workflow) # Anne is now a member of the mailing list. diff --git a/src/mailman/app/tests/test_unsubscriptions.py b/src/mailman/app/tests/test_unsubscriptions.py new file mode 100644 index 000000000..9c7b27b87 --- /dev/null +++ b/src/mailman/app/tests/test_unsubscriptions.py @@ -0,0 +1,412 @@ +# Copyright (C) 2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Test for un-subscription service.""" + +import unittest + + +from contextlib import suppress +from mailman.app.lifecycle import create_list +from mailman.app.unsubscriptions import UnSubscriptionWorkflow +from mailman.interfaces.bans import IBanManager +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.interfaces.member import MembershipIsBannedError +from mailman.interfaces.pending import IPendings +from mailman.interfaces.subscriptions import TokenOwner +from mailman.interfaces.usermanager import IUserManager +from mailman.testing.helpers import ( + LogFileMark, get_queue_messages, set_preferred) +from mailman.testing.layers import ConfigLayer +from mailman.utilities.datetime import now +from unittest.mock import patch +from zope.component import getUtility + + +class TestUnSubscriptionWorkflow(unittest.TestCase): + + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self._mlist = create_list('test@example.com') + self._mlist.admin_immed_notify = False + self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.send_welcome_message = False + self._anne = 'anne@example.com' + self._user_manager = getUtility(IUserManager) + self.anne = self._user_manager.create_user(self._anne) + self.anne.addresses[0].verified_on = now() + self.anne.preferred_address = self.anne.addresses[0] + self._mlist.subscribe(self.anne) + + def test_start_state(self): + # Test the workflow starts with no tokens or members. + workflow = UnSubscriptionWorkflow(self._mlist) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + self.assertIsNone(workflow.token) + self.assertIsNone(workflow.member) + + def test_pended_data(self): + # Test there is a Pendable object associated with a held un-subscription + # request and it has some valid data associated with it. + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + with suppress(StopIteration): + workflow.run_thru('send_confirmation') + self.assertIsNotNone(workflow.token) + pendable = getUtility(IPendings).confirm(workflow.token, expunge=False) + self.assertEqual(pendable['list_id'], 'test.example.com') + self.assertEqual(pendable['email'], 'anne@example.com') + self.assertEqual(pendable['display_name'], '') + self.assertEqual(pendable['when'], '2005-08-01T07:49:23') + self.assertEqual(pendable['token_owner'], 'subscriber') + + def test_user_or_address_required(self): + # The `subscriber` attribute must be a user or address that is provided + # to the workflow. + workflow = UnSubscriptionWorkflow(self._mlist) + self.assertRaises(AssertionError, list, workflow) + + def test_user_is_subscribed_to_unsubscribe(self): + # A user must be subscribed to a list when trying to unsubscribe. + addr = self._user_manager.create_address('aperson@example.org') + addr.verfied_on = now() + workflow = UnSubscriptionWorkflow(self._mlist, addr) + self.assertRaises(AssertionError, + workflow.run_thru, 'subscription_checks') + + def test_confirmation_checks_open_list(self): + # An un-subscription from an open list does not need to be confirmed or + # moderated. + self._mlist.unsubscription_policy = SubscriptionPolicy.open + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_do_unsubscription') as step: + next(workflow) + step.assert_called_once_with() + + def test_confirmation_checks_no_user_confirmation_needed(self): + # An un-subscription from a list which does not need user confirmation + # skips to the moderation checks. + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_confirmed=True) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_moderation_checks') as step: + next(workflow) + step.assert_called_once_with() + + def test_confirmation_checks_confirm_pre_confirmed(self): + # The unsubscription policy requires user-confirmation, but their + # un-subscription is pre-confirmed. Since moderation is not reuqired, + # the user will be immediately un-subscribed. + self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_confirmed=True) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_do_unsubscription') as step: + next(workflow) + step.assert_called_once_with() + + def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self): + # The un-subscription policy requires user confirmation, but their + # un-subscription is pre-confirmed. Since moderation is required, that + # check will be performed. + self._mlist.unsubscription_policy = ( + SubscriptionPolicy.confirm_then_moderate) + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_confirmed=True) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_do_unsubscription') as step: + next(workflow) + step.assert_called_once_with() + + def test_send_confirmation_checks_confirm_list(self): + # The un-subscription policy requires user confirmation and the + # un-subscription is not pre-confirmed. + self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_send_confirmation') as step: + next(workflow) + step.assert_called_once_with() + + def test_moderation_checks_moderated_list(self): + # The un-subscription policy requires moderation. + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_moderation_checks') as step: + next(workflow) + step.assert_called_once_with() + + def test_moderation_checks_approval_required(self): + # The moderator must approve the subscription request. + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + workflow.run_thru('moderation_checks') + with patch.object(workflow, '_step_get_moderator_approval') as step: + next(workflow) + step.assert_called_once_with() + + def test_do_unsusbcription(self): + # An open un-subscription policy means the user gets un-subscribed to + # the mailing list without any further confirmations or approvals. + self._mlist.unsubscription_policy = SubscriptionPolicy.open + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + list(workflow) + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + + def test_do_unsubscription_pre_approved(self): + # A moderation-requiring subscription policy plus a pre-approved address + # means the user gets un-subscribed from the mailing list without any + # further confirmation or approvals. + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_approved=True) + list(workflow) + # Anne is now unsubscribed form the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + + def test_do_unsubscription_pre_approved_pre_onfirmed(self): + # A moderation-requiring un-subscription policy plus a pre-appvoed + # address means the user gets un-subscribed to the mailing list without + # any further confirmations or approvals. + self._mlist.unsubscription_policy = ( + SubscriptionPolicy.confirm_then_moderate) + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_approved=True, + pre_confirmed=True) + list(workflow) + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + + def test_do_unsubscription_cleanups(self): + # Once the user is un-subscribed, the token and its associated pending + # database record will be removed from the database. + self._mlist.unsubscription_policy = SubscriptionPolicy.open + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_approved=True, + pre_confirmed=True) + # Cache the token. + token = workflow.token + # Run the workflow. + list(workflow) + # Anne is now un-subscribed from the list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + # Workflow is done, so it has no token. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + # The pendable associated with the token as been evicted. + self.assertIsNone(getUtility(IPendings).confirm(token, expunge=False)) + # There is no workflow associated with the token. This shows up as an + # exception when trying to restore the workflow. + new_workflow = UnSubscriptionWorkflow(self._mlist) + new_workflow.token = token + self.assertRaises(LookupError, new_workflow.restore) + + def test_moderator_approves(self): + # The workflow runs until moderator approval is required, at which + # point the workflow is saved. Once the moderator approves, the + # workflow resumes and the user is un-subscribed. + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_confirmed=True) + # Run the entire workflow. + list(workflow) + # The user is currently subscribed to the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertIsNotNone(workflow.member) + # The token is owned by the moderator. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.moderator) + # Create a new workflow with the previous workflow's save token, and + # restore its state. This models an approved un-sunscription request + # and should result in the user getting subscribed. + approved_workflow = UnSubscriptionWorkflow(self._mlist) + approved_workflow.token = workflow.token + approved_workflow.restore() + list(approved_workflow) + # Now the user is un-subscribed from the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + self.assertEqual(approved_workflow.member, member) + # No further token is needed. + self.assertIsNone(approved_workflow.token) + self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one) + + def test_get_moderator_approval_log_on_hold(self): + # When the un-subscription is held for moderator approval, a message is + # logged. + mark = LogFileMark('mailman.subscribe') + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_confirmed=True) + # Run the entire workflow. + list(workflow) + self.assertIn( + 'test@example.com: held unsubscription request from anne@example.com', + mark.readline() + ) + + def test_get_moderator_approval_notifies_moderators(self): + # When the un-subscription is held for moderator approval, and the list + # is so configured, a notification is sent to the list moderators. + self._mlist.admin_immed_notify = True + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_confirmed=True) + # Consume the entire state machine. + list(workflow) + items = get_queue_messages('virgin', expected_count=1) + message = items[0].msg + self.assertEqual(message['From'], 'test-owner@example.com') + self.assertEqual(message['To'], 'test-owner@example.com') + self.assertEqual( + message['Subject'], + 'New unsubscription request to Test from anne@example.com') + self.assertEqual(message.get_payload(), """\ +Your authorization is required for a mailing list unsubscription +request approval: + + For: anne@example.com + List: test@example.com""") + + def test_get_moderator_approval_no_notifications(self): + # When the un-subscription request is held for moderator approval, and + # the list is so configured, a notification is sent to the list + # moderators. + self._mlist.admin_immed_notify = False + self._mlist.unsubscription_policy = SubscriptionPolicy.moderate + workflow = UnSubscriptionWorkflow(self._mlist, self.anne, + pre_confirmed=True) + # Consume the entire state machine. + list(workflow) + get_queue_messages('virgin', expected_count=0) + + def test_send_confirmation(self): + # A confirmation message gets sent when the un-subscription must be + # confirmed. + self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + # Run the workflow to model the confirmation step. + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + list(workflow) + items = get_queue_messages('virgin', expected_count=1) + message = items[0].msg + token = workflow.token + self.assertEqual( + message['Subject'], 'confirm {}'.format(workflow.token)) + self.assertEqual( + message['From'], 'test-confirm+{}@example.com'.format(token)) + + def test_do_confirmation_unsubscribes_user(self): + # Un-subscriptions to the mailing list must be confirmed. Once that's + # done, the user's address is unsubscribed + self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + list(workflow) + # Anne is a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertIsNone(workflow.member) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) + # Confirm. + confirm_workflow = UnSubscriptionWorkflow(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + list(confirm_workflow) + # Anne is now un-subscribed. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + # No further token is needed. + self.assertIsNone(confirm_workflow.token) + self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one) + + def test_prevent_confirmation_replay_attacks(self): + # Ensure that if the workflow requires two confirmations, e.g. first + # the user confirming their subscription, and then the moderator + # approving it, that different tokens are used in these two cases. + self._mlist.unsubscription_policy = ( + SubscriptionPolicy.confirm_then_moderate) + workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + # Run the state machine up to the first confirmation, and cache the + # confirmation token. + list(workflow) + token = workflow.token + # Anne is still a member of the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertIsNotNone(workflow.member) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) + # The old token will not work for moderator approval. + moderator_workflow = UnSubscriptionWorkflow(self._mlist) + moderator_workflow.token = token + moderator_workflow.restore() + list(moderator_workflow) + # The token is owned by the moderator. + self.assertIsNotNone(moderator_workflow.token) + self.assertEqual(moderator_workflow.token_owner, TokenOwner.moderator) + # While we wait for the moderator to approve the subscription, note + # that there's a new token for the next steps. + self.assertNotEqual(token, moderator_workflow.token) + # The old token won't work. + final_workflow = UnSubscriptionWorkflow(self._mlist) + final_workflow.token = token + self.assertRaises(LookupError, final_workflow.restore) + # Running this workflow will fail. + self.assertRaises(AssertionError, list, final_workflow) + # Anne is still not un-subscribed. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertIsNotNone(final_workflow.member) + # However, if we use the new token, her subscription request will be + # approved by the moderator. + final_workflow.token = moderator_workflow.token + final_workflow.restore() + list(final_workflow) + # And now Anne is un-subscribed. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + # No further token is needed. + self.assertIsNone(final_workflow.token) + self.assertEqual(final_workflow.token_owner, TokenOwner.no_one) + + def test_confirmation_needed_and_pre_confirmed(self): + # The subscription policy is 'confirm' but the subscription is + # pre-confirmed so the moderation checks can be skipped. + self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + workflow = UnSubscriptionWorkflow( + self._mlist, self.anne, pre_confirmed=True, pre_approved=True) + list(workflow) + # Anne was un-subscribed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + self.assertIsNone(workflow.member) diff --git a/src/mailman/app/tests/test_workflowmanager.py b/src/mailman/app/tests/test_workflowmanager.py new file mode 100644 index 000000000..a6c5f92ca --- /dev/null +++ b/src/mailman/app/tests/test_workflowmanager.py @@ -0,0 +1,271 @@ +# Copyright (C) 2012-2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Test email address registration.""" + +import unittest +import pdb + +from mailman.app.lifecycle import create_list +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.interfaces.member import MemberRole +from mailman.interfaces.pending import IPendings +from mailman.interfaces.workflowmanager import IWorkflowManager +from mailman.interfaces.subscriptions import TokenOwner +from mailman.interfaces.usermanager import IUserManager +from mailman.testing.helpers import get_queue_messages +from mailman.testing.layers import ConfigLayer +from mailman.utilities.datetime import now +from zope.component import getUtility, getAdapter + + +class TestRegistrar(unittest.TestCase): + """Test registration.""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('ant@example.com') + self._registrar = getAdapter( + self._mlist, IWorkflowManager, name='subscribe') + self._pendings = getUtility(IPendings) + self._anne = getUtility(IUserManager).create_address( + 'anne@example.com') + + def test_initial_conditions(self): + # Registering a subscription request provides a unique token associated + # with a pendable, and the owner of the token. + self.assertEqual(self._pendings.count, 0) + token, token_owner, member = self._registrar.register(self._anne) + self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(member) + self.assertEqual(self._pendings.count, 1) + record = self._pendings.confirm(token, expunge=False) + self.assertEqual(record['list_id'], self._mlist.list_id) + self.assertEqual(record['email'], 'anne@example.com') + + def test_subscribe(self): + # Registering a subscription request where no confirmation or + # moderation steps are needed, leaves us with no token or owner, since + # there's nothing more to do. + self._mlist.subscription_policy = SubscriptionPolicy.open + self._anne.verified_on = now() + token, token_owner, rmember = self._registrar.register(self._anne) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) + self.assertEqual(member.address, self._anne) + # There's nothing to confirm. + record = self._pendings.confirm(token, expunge=False) + self.assertIsNone(record) + + def test_no_such_token(self): + # Given a token which is not in the database, a LookupError is raised. + self._registrar.register(self._anne) + self.assertRaises(LookupError, self._registrar.confirm, 'not-a-token') + + def test_confirm_because_verify(self): + # We have a subscription request which requires the user to confirm + # (because she does not have a verified address), but not the moderator + # to approve. Running the workflow gives us a token. Confirming the + # token subscribes the user. + self._mlist.subscription_policy = SubscriptionPolicy.open + token, token_owner, rmember = self._registrar.register(self._anne) + self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # Now confirm the subscription. + token, token_owner, rmember = self._registrar.confirm(token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) + self.assertEqual(member.address, self._anne) + + def test_confirm_because_confirm(self): + # We have a subscription request which requires the user to confirm + # (because of list policy), but not the moderator to approve. Running + # the workflow gives us a token. Confirming the token subscribes the + # user. + self._mlist.subscription_policy = SubscriptionPolicy.confirm + self._anne.verified_on = now() + token, token_owner, rmember = self._registrar.register(self._anne) + self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # Now confirm the subscription. + token, token_owner, rmember = self._registrar.confirm(token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) + self.assertEqual(member.address, self._anne) + + def test_confirm_because_moderation(self): + # We have a subscription request which requires the moderator to + # approve. Running the workflow gives us a token. Confirming the + # token subscribes the user. + self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._anne.verified_on = now() + token, token_owner, rmember = self._registrar.register(self._anne) + self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.moderator) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # Now confirm the subscription. + token, token_owner, rmember = self._registrar.confirm(token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) + self.assertEqual(member.address, self._anne) + + def test_confirm_because_confirm_then_moderation(self): + # We have a subscription request which requires the user to confirm + # (because she does not have a verified address) and the moderator to + # approve. Running the workflow gives us a token. Confirming the + # token runs the workflow a little farther, but still gives us a + # token. Confirming again subscribes the user. + self._mlist.subscription_policy = ( + SubscriptionPolicy.confirm_then_moderate) + self._anne.verified_on = now() + # Runs until subscription confirmation. + token, token_owner, rmember = self._registrar.register(self._anne) + self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # Now confirm the subscription, and wait for the moderator to approve + # the subscription. She is still not subscribed. + new_token, token_owner, rmember = self._registrar.confirm(token) + # The new token, used for the moderator to approve the message, is not + # the same as the old token. + self.assertNotEqual(new_token, token) + self.assertIsNotNone(new_token) + self.assertEqual(token_owner, TokenOwner.moderator) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # Confirm once more, this time as the moderator approving the + # subscription. Now she's a member. + token, token_owner, rmember = self._registrar.confirm(new_token) + self.assertIsNone(token) + self.assertEqual(token_owner, TokenOwner.no_one) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) + self.assertEqual(member.address, self._anne) + + def test_confirm_then_moderate_with_different_tokens(self): + # Ensure that the confirmation token the user sees when they have to + # confirm their subscription is different than the token the moderator + # sees when they approve the subscription. This prevents the user + # from using a replay attack to subvert moderator approval. + self._mlist.subscription_policy = ( + SubscriptionPolicy.confirm_then_moderate) + self._anne.verified_on = now() + # Runs until subscription confirmation. + token, token_owner, rmember = self._registrar.register(self._anne) + self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # Now confirm the subscription, and wait for the moderator to approve + # the subscription. She is still not subscribed. + new_token, token_owner, rmember = self._registrar.confirm(token) + # The status is not true because the user has not yet been subscribed + # to the mailing list. + self.assertIsNotNone(new_token) + self.assertEqual(token_owner, TokenOwner.moderator) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # The new token is different than the old token. + self.assertNotEqual(token, new_token) + # Trying to confirm with the old token does not work. + self.assertRaises(LookupError, self._registrar.confirm, token) + # Confirm once more, this time with the new token, as the moderator + # approving the subscription. Now she's a member. + done_token, token_owner, rmember = self._registrar.confirm(new_token) + # The token is None, signifying that the member has been subscribed. + self.assertIsNone(done_token) + self.assertEqual(token_owner, TokenOwner.no_one) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertEqual(rmember, member) + self.assertEqual(member.address, self._anne) + + def test_discard_waiting_for_confirmation(self): + # While waiting for a user to confirm their subscription, we discard + # the workflow. + self._mlist.subscription_policy = SubscriptionPolicy.confirm + self._anne.verified_on = now() + # Runs until subscription confirmation. + token, token_owner, rmember = self._registrar.register(self._anne) + self.assertIsNotNone(token) + self.assertEqual(token_owner, TokenOwner.subscriber) + self.assertIsNone(rmember) + member = self._mlist.regular_members.get_member('anne@example.com') + self.assertIsNone(member) + # Now discard the subscription request. + self._registrar.discard(token) + # Trying to confirm the token now results in an exception. + self.assertRaises(LookupError, self._registrar.confirm, token) + + def test_admin_notify_mchanges(self): + # When a user gets subscribed via the subscription policy workflow, + # the list administrators get an email notification. + self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.admin_notify_mchanges = True + self._mlist.send_welcome_message = False + token, token_owner, member = self._registrar.register( + self._anne, pre_verified=True) + # Anne is now a member. + self.assertEqual(member.address.email, 'anne@example.com') + # And there's a notification email waiting for Bart. + items = get_queue_messages('virgin', expected_count=1) + message = items[0].msg + self.assertEqual(message['To'], 'ant-owner@example.com') + self.assertEqual(message['Subject'], 'Ant subscription notification') + self.assertEqual(message.get_payload(), """\ +anne@example.com has been successfully subscribed to Ant. +""") + + def test_no_admin_notify_mchanges(self): + # Even when a user gets subscribed via the subscription policy + # workflow, the list administrators won't get an email notification if + # they don't want one. + self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.admin_notify_mchanges = False + self._mlist.send_welcome_message = False + # Bart is an administrator of the mailing list. + bart = getUtility(IUserManager).create_address( + 'bart@example.com', 'Bart Person') + self._mlist.subscribe(bart, MemberRole.owner) + token, token_owner, member = self._registrar.register( + self._anne, pre_verified=True) + # Anne is now a member. + self.assertEqual(member.address.email, 'anne@example.com') + # There's no notification email waiting for Bart. + get_queue_messages('virgin', expected_count=0) diff --git a/src/mailman/app/unsubscriptions.py b/src/mailman/app/unsubscriptions.py new file mode 100644 index 000000000..1a88b121f --- /dev/null +++ b/src/mailman/app/unsubscriptions.py @@ -0,0 +1,255 @@ +# Copyright (C) 2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Handle un-subscriptions.""" + +import uuid +import logging + +from datetime import timedelta +from email.utils import formataddr +from mailman import public +from mailman.app.membership import delete_member +from mailman.app.subscriptions import WhichSubscriber +from mailman.app.workflow import Workflow +from mailman.core.i18n import _ +from mailman.email.message import UserNotification +from mailman.interfaces.address import IAddress +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.interfaces.workflowmanager import ConfirmationNeededEvent +from mailman.interfaces.user import IUser +from mailman.interfaces.pending import IPendings, IPendable +from mailman.interfaces.subscriptions import TokenOwner +from mailman.interfaces.usermanager import IUserManager +from mailman.interfaces.workflow import IWorkflowStateManager +from mailman.utilities.datetime import now +from mailman.utilities.i18n import make +from zope.component import getUtility +from zope.event import notify +from zope.interface import implementer + + +log = logging.getLogger('mailman.subscribe') + + +@implementer(IPendable) +class Pendable(dict): + PEND_TYPE = 'unsubscription' + + +class UnSubscriptionWorkflow(Workflow): + """Workflow of a un-subscription request + """ + + INITIAL_STATE = 'subscription_checks' + SAVE_ATTRIBUTES = ( + 'pre_approved', + 'pre_confirmed', + 'address_key', + 'user_key', + 'subscriber_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_approved=False, pre_confirmed = False): + super().__init__() + self.mlist = mlist + self.address = None + self.user = None + self.which = None + self._set_token(TokenOwner.no_one) + # `subscriber` should be an implementer of IAddress. + if IAddress.providedBy(subscriber): + self.address = subscriber + self.user = self.address.user + self.which = WhichSubscriber.address + self.member = self.mlist.regular_members.get_member( + self.address.email) + elif IUser.providedBy(subscriber): + self.address = subscriber.preferred_address + self.user = subscriber + self.which = WhichSubscriber.address + self.member = self.mlist.regular_members.get_member( + self.address.email) + self.subscriber = subscriber + self.pre_confirmed = pre_confirmed + self.pre_approved = pre_approved + + @property + def user_key(self): + # For save. + return self.user.user_id.hex + + @user_key.setter + def user_key(self, hex_key): + # For restore. + uid = uuid.UUID(hex_key) + self.user = getUtility(IUserManager).get_user_by_id(uid) + assert self.user is not None + + @property + def address_key(self): + # For save. + return self.address.email + + @address_key.setter + def address_key(self, email): + # For restore. + self.address = getUtility(IUserManager).get_address(email) + assert self.address is not None + + @property + def subscriber_key(self): + return self.which.value + + @subscriber_key.setter + def subscriber_key(self, key): + self.which = WhichSubscriber(key) + + @property + def token_owner_key(self): + return self.token_owner.value + + @token_owner_key.setter + def token_owner_key(self, value): + self.token_owner = TokenOwner(value) + + def _set_token(self, token_owner): + assert isinstance(token_owner, TokenOwner) + pendings = getUtility(IPendings) + # Clear out the previous pending token if there is one. + if self.token is not None: + pendings.confirm(self.token) + # Create a new token to prevent replay attacks. It seems like this + # would produce the same token, but it won't because the pending adds a + # bit of randomization. + self.token_owner = token_owner + if token_owner is TokenOwner.no_one: + self.token = None + return + pendable = Pendable( + list_id=self.mlist.list_id, + email=self.address.email, + display_name=self.address.display_name, + when=now().replace(microsecond=0).isoformat(), + token_owner=token_owner.name, + ) + self.token = pendings.add(pendable, timedelta(days=3650)) + + def _step_subscription_checks(self): + assert self.mlist.is_subscribed(self.subscriber) + self.push('confirmation_checks') + + def _step_confirmation_checks(self): + # If list's unsubscription policy is open, the user can unsubscribe + # right now. + if self.mlist.unsubscription_policy is SubscriptionPolicy.open: + self.push('do_unsubscription') + return + # If we don't need the user's confirmation, then skip to the moderation + # checks + if self.mlist.unsubscription_policy is SubscriptionPolicy.moderate: + self.push('moderation_checks') + return + + if self.pre_confirmed: + next_step = ('moderation_checks' + if self.mlist.subscription_policy is + SubscriptionPolicy.confirm_then_moderate # noqa + else 'do_subscription') + self.push(next_step) + return + # The user must confirm their un-subsbcription. + self.push('send_confirmation') + + def _step_send_confirmation(self): + self._set_token(TokenOwner.subscriber) + self.push('do_confirm_verify') + self.save() + notify(ConfirmationNeededEvent( + self.mlist, self.token, self.address.email)) + raise StopIteration + + def _step_moderation_checks(self): + # Does the moderator need to approve the unsubscription request. + assert self.mlist.unsubscription_policy in ( + SubscriptionPolicy.moderate, + SubscriptionPolicy.confirm_then_moderate, + ), self.mlist.unsubscription_policy + if self.pre_approved: + self.push('do_unsubscription') + else: + self.push('get_moderator_approval') + + def _step_get_moderator_approval(self): + self._set_token(TokenOwner.moderator) + self.push('unsubscribe_from_restored') + self.save() + log.info('{}: held unsubscription request from {}'.format( + self.mlist.fqdn_listname, self.address.email)) + if self.mlist.admin_immed_notify: + subject = _( + 'New unsubscription request to $self.mlist.display_name ' + 'from $self.address.email') + username = formataddr( + (self.subscriber.display_name, self.address.email)) + text = make('unsubauth.txt', + mailing_list=self.mlist, + username=username, + listname=self.mlist.fqdn_listname, + ) + # This message should appear to come from the -owner so as + # to avoid any useless bounce processing. + msg = UserNotification( + self.mlist.owner_address, self.mlist.owner_address, + subject, text, self.mlist.preferred_language) + msg.send(self.mlist, tomoderators=True) + # The workflow must stop running here + raise StopIteration + + def _step_do_confirm_verify(self): + if self.which is WhichSubscriber.address: + self.subscriber = self.address + else: + assert self.which is WhichSubscriber.user + self.subscriber = self.user + # Reset the token so it can't be used in a replay attack. + self._set_token(TokenOwner.no_one) + next_step = ('moderation_checks' + if self.mlist.unsubscription_policy in ( + SubscriptionPolicy.moderate, + SubscriptionPolicy.confirm_then_moderate, + ) + else 'do_unsubscription') + self.push('do_unsubscription') + + def _step_do_unsubscription(self): + delete_member(self.mlist, self.address.email) + self.member = None + # This workflow is done so throw away any associated state. + getUtility(IWorkflowStateManager).restore(self.name, self.token) + + def _step_unsubscribe_from_restored(self): + # Prevent replay attacks. + self._set_token(TokenOwner.no_one) + if self.which is WhichSubscriber.address: + self.subscriber = self.address + else: + assert self.which is WhichSubsriber.user + self.subscriber = self.user + self.push('do_unsubscription') diff --git a/src/mailman/app/workflowmanager.py b/src/mailman/app/workflowmanager.py new file mode 100644 index 000000000..deb15ea76 --- /dev/null +++ b/src/mailman/app/workflowmanager.py @@ -0,0 +1,147 @@ +# Copyright (C) 2007-2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Implementation of the IWorkflowManager interface.""" + +import logging + +from mailman import public +from mailman.app.subscriptions import SubscriptionWorkflow +from mailman.app.unsubscriptions import UnSubscriptionWorkflow +from mailman.database.transaction import flush +from mailman.email.message import UserNotification +from mailman.interfaces.pending import IPendable, IPendings +from mailman.interfaces.workflowmanager import ( + ConfirmationNeededEvent, IWorkflowManager) +from mailman.interfaces.templates import ITemplateLoader +from mailman.interfaces.workflow import IWorkflowStateManager +from mailman.utilities.string import expand +from zope.component import getUtility +from zope.interface import implementer + + +log = logging.getLogger('mailman.error') + + +@implementer(IPendable) +class PendableRegistration(dict): + PEND_TYPE = 'registration' + + +class BaseWorkflowManager: + """Base class to handle registration and un-registration workflow. """ + + # Workflow type is the type of the workflow and could be either 'register' + # or 'unregister' depending on if it is for subscription workflow or + # unsubscription workflow. + + WORKFLOW_TYPE = None + + def __init__(self, mlist): + self._mlist = mlist + + def confirm(self, token): + workflow = self.workflowClass(self._mlist) + workflow.token = token + workflow.restore() + # In order to just run the whole workflow, all we need to do + # is iterate over the workflow object. On calling the __next__ + # over the workflow iterator it automatically executes the steps + # that needs to be done. + list(workflow) + return workflow.token, workflow.token_owner, workflow.member + + @property + def workflowClass(self): + if self.WORKFLOW_TYPE == 'subscribe': + return SubscriptionWorkflow + elif self.WORKFLOW_TYPE == 'unsubscribe': + return UnSubscriptionWorkflow + else: + raise ValueError('Invalid workflow type {}'.format( + self.WORKFLOW_TYPE)) + + def discard(self, token): + with flush(): + getUtility(IPendings).confirm(token) + getUtility(IWorkflowStateManager).discard( + self.workflowClass.__name__, token) + + +@public +@implementer(IWorkflowManager) +class SubscriptionWorkflowManager(BaseWorkflowManager): + """Handle registrations and confirmations for subscriptions.""" + + WORKFLOW_TYPE = 'subscribe' + + def register(self, subscriber=None, *, + pre_verified=False, pre_confirmed=False, pre_approved=False): + """See `IWorkflowManager`.""" + workflow = SubscriptionWorkflow( + self._mlist, subscriber, + pre_verified=pre_verified, + pre_confirmed=pre_confirmed, + pre_approved=pre_approved) + list(workflow) + return workflow.token, workflow.token_owner, workflow.member + + +@public +@implementer(IWorkflowManager) +class UnsubscriptionWorkflowManager(BaseWorkflowManager): + """Handle un-subscriptions and confirmations for un-subscriptions.""" + + WORKFLOW_TYPE = 'unsubscribe' + + def unregister(self, subscriber=None, *, + pre_confirmed=False, pre_approved=False): + workflow = UnSubscriptionWorkflow( + self._mlist, subscriber, + pre_confirmed=pre_confirmed, + pre_approved=pre_approved) + list(workflow) + + +@public +def handle_ConfirmationNeededEvent(event): + if not isinstance(event, ConfirmationNeededEvent): + return + # There are three ways for a user to confirm their subscription. They + # can reply to the original message and let the VERP'd return address + # encode the token, they can reply to the robot and keep the token in + # the Subject header, or they can click on the URL in the body of the + # message and confirm through the web. + subject = 'confirm {}'.format(event.token) + confirm_address = event.mlist.confirm_address(event.token) + email_address = event.email + # Send a verification email to the address. + template = getUtility(ITemplateLoader).get( + 'list:user:action:confirm', event.mlist) + text = expand(template, event.mlist, dict( + token=event.token, + subject=subject, + confirm_email=confirm_address, + user_email=email_address, + # For backward compatibility. + confirm_address=confirm_address, + email_address=email_address, + domain_name=event.mlist.domain.mail_host, + contact_address=event.mlist.owner_address, + )) + msg = UserNotification(email_address, confirm_address, subject, text) + msg.send(event.mlist, add_precedence=False) diff --git a/src/mailman/commands/eml_confirm.py b/src/mailman/commands/eml_confirm.py index 6a3e389bd..8522f8fa0 100644 --- a/src/mailman/commands/eml_confirm.py +++ b/src/mailman/commands/eml_confirm.py @@ -20,8 +20,9 @@ from mailman import public from mailman.core.i18n import _ from mailman.interfaces.command import ContinueProcessing, IEmailCommand -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.subscriptions import TokenOwner +from zope.component import getAdapter from zope.interface import implementer @@ -50,7 +51,8 @@ class Confirm: tokens.add(token) results.confirms = tokens try: - new_token, token_owner, member = IRegistrar(mlist).confirm(token) + new_token, token_owner, member = getAdapter( + mlist, IWorkflowManager, name='subscribe').confirm(token) if new_token is None: assert token_owner is TokenOwner.no_one, token_owner assert member is not None, member diff --git a/src/mailman/commands/eml_membership.py b/src/mailman/commands/eml_membership.py index 7d3704e14..af391fd5b 100644 --- a/src/mailman/commands/eml_membership.py +++ b/src/mailman/commands/eml_membership.py @@ -22,16 +22,16 @@ from mailman import public from mailman.core.i18n import _ from mailman.interfaces.command import ContinueProcessing, IEmailCommand from mailman.interfaces.member import DeliveryMode, MemberRole -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.subscriptions import ISubscriptionService from mailman.interfaces.usermanager import IUserManager -from zope.component import getUtility +from zope.component import getUtility, getAdapter from zope.interface import implementer def match_subscriber(email, display_name): # Return something matching the email which should be used as the - # subscriber by the IRegistrar interface. + # subscriber by the IWorkflowManager interface. manager = getUtility(IUserManager) # Is there a user with a preferred address matching the email? user = manager.get_user(email) @@ -101,7 +101,8 @@ used. print(_('$person is already a member'), file=results) return ContinueProcessing.yes subscriber = match_subscriber(email, display_name) - IRegistrar(mlist).register(subscriber) + getAdapter(mlist, + IWorkflowManager, name='subscribe').register(subscriber) print(_('Confirmation email sent to $person'), file=results) return ContinueProcessing.yes @@ -186,9 +187,12 @@ You may be asked to confirm your request.""") '$self.name: $email is not a member of $mlist.fqdn_listname'), file=results) return ContinueProcessing.no - member.unsubscribe() - person = formataddr((user.display_name, email)) # noqa: F841 - print(_('$person left $mlist.fqdn_listname'), file=results) + getAdapter(mlist, + IWorkflowManager, name='unsubscribe').register(user_address) + # member.unsubscribe() + person = formataddr((user.display_name, email)) # noqa + print(_('Confirmation email sent to $person to leave' + ' $mlist.fqdn_listname'), file=results) return ContinueProcessing.yes diff --git a/src/mailman/commands/tests/test_confirm.py b/src/mailman/commands/tests/test_confirm.py index 7cce4c3c7..d7414fbfa 100644 --- a/src/mailman/commands/tests/test_confirm.py +++ b/src/mailman/commands/tests/test_confirm.py @@ -25,12 +25,12 @@ from mailman.config import config from mailman.email.message import Message from mailman.interfaces.command import ContinueProcessing from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.usermanager import IUserManager from mailman.runners.command import CommandRunner, Results from mailman.testing.helpers import get_queue_messages, make_testable_runner from mailman.testing.layers import ConfigLayer -from zope.component import getUtility +from zope.component import getUtility, getAdapter class TestConfirm(unittest.TestCase): @@ -42,8 +42,8 @@ class TestConfirm(unittest.TestCase): self._mlist = create_list('test@example.com') anne = getUtility(IUserManager).create_address( 'anne@example.com', 'Anne Person') - self._token, token_owner, member = IRegistrar(self._mlist).register( - anne) + self._token, token_owner, member = getAdapter( + self._mlist, IWorkflowManager, name='subscribe').register(anne) self._command = Confirm() # Clear the virgin queue. get_queue_messages('virgin') @@ -88,8 +88,8 @@ class TestEmailResponses(unittest.TestCase): 'bart@example.com', 'Bart Person') # Clear any previously queued confirmation messages. get_queue_messages('virgin') - self._token, token_owner, member = IRegistrar(self._mlist).register( - bart) + self._token, token_owner, member = getAdapter( + self._mlist, IWorkflowManager, name='subscribe').register(bart) # There should now be one email message in the virgin queue, i.e. the # confirmation message sent to Bart. items = get_queue_messages('virgin', expected_count=1) diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml index f31e79525..153a8b9bd 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -48,8 +48,16 @@ + + . - -"""Interface describing a user registration service. - -This is a higher level interface to user registration, address confirmation, -etc. than the IUserManager. The latter does no validation, syntax checking, -or confirmation, while this interface does. -""" - -from mailman import public -from zope.interface import Interface - - -@public -class ConfirmationNeededEvent: - """Triggered when an address needs confirmation. - - Addresses must be verified before they can receive messages or post - to mailing list. The confirmation message is sent to the user when - this event is triggered. - """ - def __init__(self, mlist, token, email): - self.mlist = mlist - self.token = token - self.email = email - - -@public -class IRegistrar(Interface): - """Interface for subscribing addresses and users. - - This is a higher level interface to user registration, email address - confirmation, etc. than the IUserManager. The latter does no validation, - syntax checking, or confirmation, while this interface does. - - To use this, adapt an ``IMailingList`` to this interface. - """ - - def register(subscriber=None, *, - pre_verified=False, pre_confirmed=False, pre_approved=False): - """Subscribe an address or user according to subscription policies. - - The mailing list's subscription policy is used to subscribe - `subscriber` to the given mailing list. The subscriber can be - an ``IUser``, in which case the user must have a preferred - address, and that preferred address will be subscribed. The - subscriber can also be an ``IAddress``, in which case the - address will be subscribed. - - The workflow may pause (i.e. be serialized, saved, and - suspended) when some out-of-band confirmation step is required. - For example, if the user must confirm, or the moderator must - approve the subscription. Use the ``confirm(token)`` method to - resume the workflow. - - :param subscriber: The user or address to subscribe. - :type email: ``IUser`` or ``IAddress`` - :return: A 3-tuple is returned where the first element is the token - hash, the second element is a ``TokenOwner`, and the third element - is the subscribed member. If the subscriber got subscribed - immediately, the token will be None and the member will be - an ``IMember``. If the subscription got held, the token - will be a hash and the member will be None. - :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None) - :raises MembershipIsBannedError: when the address being subscribed - appears in the global or list-centric bans. - """ - - def confirm(token): - """Continue any paused workflow. - - Confirmation may occur after the user confirms their - subscription request, or their email address must be verified, - or the moderator must approve the subscription request. - - :param token: A token matching a workflow. - :type token: string - :return: A 3-tuple is returned where the first element is the token - hash, the second element is a ``TokenOwner`, and the third element - is the subscribed member. If the subscriber got subscribed - immediately, the token will be None and the member will be - an ``IMember``. If the subscription is still being held, the token - will be a hash and the member will be None. - :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None) - :raises LookupError: when no workflow is associated with the token. - """ - - def discard(token): - """Discard the workflow matched to the given `token`. - - :param token: A token matching a pending event with a type of - 'registration'. - :raises LookupError: when no workflow is associated with the token. - """ - - def evict(): - """Evict all saved workflows which have expired.""" diff --git a/src/mailman/interfaces/workflowmanager.py b/src/mailman/interfaces/workflowmanager.py new file mode 100644 index 000000000..906246e6d --- /dev/null +++ b/src/mailman/interfaces/workflowmanager.py @@ -0,0 +1,113 @@ +# Copyright (C) 2007-2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Interface describing a user registration service. + +This is a higher level interface to user registration, address confirmation, +etc. than the IUserManager. The latter does no validation, syntax checking, +or confirmation, while this interface does. +""" + +from mailman import public +from zope.interface import Interface + + +@public +class ConfirmationNeededEvent: + """Triggered when an address needs confirmation. + + Addresses must be verified before they can receive messages or post + to mailing list. The confirmation message is sent to the user when + this event is triggered. + """ + def __init__(self, mlist, token, email): + self.mlist = mlist + self.token = token + self.email = email + + +@public +class IWorkflowManager(Interface): + """Interface for handling subscription and un-subscription of addresses and + users. + + This is a higher level interface to user registration and un-registration, + email address confirmation, etc. than the IUserManager. The latter does no + validation, syntax checking, or confirmation, while this interface does. + + To use this, adapt an ``IMailingList`` to this interface. + """ + + def register(subscriber=None, *, + pre_verified=False, pre_confirmed=False, pre_approved=False): + """Subscribe an address or user according to subscription policies. + + The mailing list's subscription policy is used to subscribe + `subscriber` to the given mailing list. The subscriber can be + an ``IUser``, in which case the user must have a preferred + address, and that preferred address will be subscribed. The + subscriber can also be an ``IAddress``, in which case the + address will be subscribed. + + The workflow may pause (i.e. be serialized, saved, and + suspended) when some out-of-band confirmation step is required. + For example, if the user must confirm, or the moderator must + approve the subscription. Use the ``confirm(token)`` method to + resume the workflow. + + :param subscriber: The user or address to subscribe. + :type email: ``IUser`` or ``IAddress`` + :return: A 3-tuple is returned where the first element is the token + hash, the second element is a ``TokenOwner`, and the third element + is the subscribed member. If the subscriber got subscribed + immediately, the token will be None and the member will be + an ``IMember``. If the subscription got held, the token + will be a hash and the member will be None. + :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None) + :raises MembershipIsBannedError: when the address being subscribed + appears in the global or list-centric bans. + """ + + def confirm(token): + """Continue any paused workflow. + + Confirmation may occur after the user confirms their + subscription request, or their email address must be verified, + or the moderator must approve the subscription request. + + :param token: A token matching a workflow. + :type token: string + :return: A 3-tuple is returned where the first element is the token + hash, the second element is a ``TokenOwner`, and the third element + is the subscribed member. If the subscriber got subscribed + immediately, the token will be None and the member will be + an ``IMember``. If the subscription is still being held, the token + will be a hash and the member will be None. + :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None) + :raises LookupError: when no workflow is associated with the token. + """ + + def discard(token): + """Discard the workflow matched to the given `token`. + + :param token: A token matching a pending event with a type of + 'registration'. + :raises LookupError: when no workflow is associated with the token. + """ + + def evict(): + """Evict all saved workflows which have expired.""" diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py index 933384797..396b8ed3d 100644 --- a/src/mailman/model/mailinglist.py +++ b/src/mailman/model/mailinglist.py @@ -178,6 +178,7 @@ class MailingList(Model): topics = Column(PickleType) topics_bodylines_limit = Column(Integer) topics_enabled = Column(Boolean) + unsubscription_policy = Column(Enum(SubscriptionPolicy)) # ORM relationships. header_matches = relationship( 'HeaderMatch', backref='mailing_list', @@ -441,16 +442,14 @@ class MailingList(Model): raise ValueError('Undefined MemberRole: {}'.format(role)) @dbconnection - def subscribe(self, store, subscriber, role=MemberRole.member): - """See `IMailingList`.""" + def is_subscribed(self, store, subscriber, role=MemberRole.member): + """Check if a user/address is subscribed to this list.""" + member = None if IAddress.providedBy(subscriber): member = store.query(Member).filter( Member.role == role, Member.list_id == self._list_id, Member._address == subscriber).first() - if member: - raise AlreadySubscribedError( - self.fqdn_listname, subscriber.email, role) elif IUser.providedBy(subscriber): if subscriber.preferred_address is None: raise MissingPreferredAddressError(subscriber) @@ -458,13 +457,23 @@ class MailingList(Model): Member.role == role, Member.list_id == self._list_id, Member._user == subscriber).first() - if member: - raise AlreadySubscribedError( - self.fqdn_listname, - subscriber.preferred_address.email, - role) + + if member: + return True else: - raise ValueError('subscriber must be an address or user') + return False + + @dbconnection + def subscribe(self, store, subscriber, role=MemberRole.member): + """See `IMailingList`.""" + if IAddress.providedBy(subscriber): + email = subscriber.email + elif IUser.providedBy(subscriber): + email = subscriber.preferred_address.email + + if self.is_subscribed(subscriber, role): + raise AlreadySubscribedError(self.fqdn_listname, email, role) + member = Member(role=role, list_id=self._list_id, subscriber=subscriber) diff --git a/src/mailman/model/tests/test_mailinglist.py b/src/mailman/model/tests/test_mailinglist.py index c9aecc93b..3e3c5814c 100644 --- a/src/mailman/model/tests/test_mailinglist.py +++ b/src/mailman/model/tests/test_mailinglist.py @@ -17,6 +17,7 @@ """Test MailingLists and related model objects..""" +import pdb import unittest from mailman.app.lifecycle import create_list @@ -92,6 +93,18 @@ class TestMailingList(unittest.TestCase): self.assertIn('Anne Person ', items[0].msg.get_payload()) + def test_is_subscribed(self): + manager = getUtility(IUserManager) + user = manager.create_user('anne@example.com', 'Anne Person') + set_preferred(user) + self.assertEqual(False, self._mlist.is_subscribed(user)) + self._mlist.subscribe(user) + self.assertEqual(True, self._mlist.is_subscribed(user)) + address = manager.create_address('anne2@example.com', 'Anne Person') + address.verfied_on = now() + self.assertEqual(False, self._mlist.is_subscribed(address)) + self._mlist.subscribe(address) + self.assertEqual(True, self._mlist.is_subscribed(address)) class TestListArchiver(unittest.TestCase): layer = ConfigLayer diff --git a/src/mailman/model/tests/test_workflow.py b/src/mailman/model/tests/test_workflow.py index 4c8c6776f..afcba613b 100644 --- a/src/mailman/model/tests/test_workflow.py +++ b/src/mailman/model/tests/test_workflow.py @@ -124,7 +124,7 @@ class TestWorkflow(unittest.TestCase): self.assertEqual(self._manager.count, 1) def test_discard(self): - # Discard some workflow state. This is use by IRegistrar.discard(). + # Discard some workflow state. This is use by IWorkflowManager.discard(). self._manager.save('ant', 'token', 'one') self._manager.save('bee', 'token', 'two') self._manager.save('ant', 'nekot', 'three') diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index 11ad17c14..ac3fd96b4 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -25,7 +25,7 @@ from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError, MembershipIsBannedError, MissingPreferredAddressError) -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.subscriptions import ( ISubscriptionService, RequestRecord, SubscriptionPendingError, TokenOwner) from mailman.interfaces.user import IUser, UnverifiedAddressError @@ -37,7 +37,7 @@ from mailman.rest.preferences import Preferences, ReadOnlyPreferences from mailman.rest.validator import ( Validator, enum_validator, subscriber_validator) from uuid import UUID -from zope.component import getUtility +from zope.component import getUtility, getAdapter class _MemberBase(CollectionMixin): @@ -253,7 +253,7 @@ class AllMembers(_MemberBase): # Now we can run the registration process until either the # subscriber is subscribed, or the workflow is paused for # verification, confirmation, or approval. - registrar = IRegistrar(mlist) + registrar = getAdapter(mlist, IWorkflowManager, name='subscribe') try: token, token_owner, member = registrar.register( subscriber, diff --git a/src/mailman/rest/sub_moderation.py b/src/mailman/rest/sub_moderation.py index f5ef072ff..a6ef2ad35 100644 --- a/src/mailman/rest/sub_moderation.py +++ b/src/mailman/rest/sub_moderation.py @@ -23,12 +23,13 @@ from mailman.core.i18n import _ from mailman.interfaces.action import Action from mailman.interfaces.member import AlreadySubscribedError from mailman.interfaces.pending import IPendings -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.rest.helpers import ( CollectionMixin, bad_request, child, conflict, etag, no_content, not_found, okay) from mailman.rest.validator import Validator, enum_validator -from zope.component import getUtility +from mailman.utilities.i18n import _ +from zope.component import getUtility, getAdapter class _ModerationBase: @@ -54,7 +55,8 @@ class IndividualRequest(_ModerationBase): def __init__(self, mlist, token): super().__init__() self._mlist = mlist - self._registrar = IRegistrar(self._mlist) + self._registrar = getAdapter( + self._mlist, IWorkflowManager, name='subscribe') self._token = token def on_get(self, request, response): diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py index 1ea70e90b..9dec17fc8 100644 --- a/src/mailman/rest/tests/test_membership.py +++ b/src/mailman/rest/tests/test_membership.py @@ -25,7 +25,7 @@ from mailman.database.transaction import transaction from mailman.interfaces.bans import IBanManager from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.member import DeliveryMode, MemberRole -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.subscriptions import TokenOwner from mailman.interfaces.usermanager import IUserManager from mailman.runners.incoming import IncomingRunner @@ -35,7 +35,7 @@ from mailman.testing.helpers import ( from mailman.testing.layers import ConfigLayer, RESTLayer from mailman.utilities.datetime import now from urllib.error import HTTPError -from zope.component import getUtility +from zope.component import getUtility, getAdapter class TestMembership(unittest.TestCase): @@ -215,7 +215,7 @@ class TestMembership(unittest.TestCase): def test_duplicate_pending_subscription(self): # Issue #199 - a member's subscription is already pending and they try # to subscribe again. - registrar = IRegistrar(self._mlist) + registrar = getAdapter(self._mlist, IWorkflowManager, name='subscribe') with transaction(): self._mlist.subscription_policy = SubscriptionPolicy.moderate anne = self._usermanager.create_address('anne@example.com') @@ -238,7 +238,7 @@ class TestMembership(unittest.TestCase): # Issue #199 - a member's subscription is already pending and they try # to subscribe again. Unlike above, this pend is waiting for the user # to confirm their subscription. - registrar = IRegistrar(self._mlist) + registrar = getAdapter(self._mlist, IWorkflowManager, name='subscribe') with transaction(): self._mlist.subscription_policy = ( SubscriptionPolicy.confirm_then_moderate) diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py index 9f9da6b18..1b9febdce 100644 --- a/src/mailman/rest/tests/test_moderation.py +++ b/src/mailman/rest/tests/test_moderation.py @@ -24,7 +24,7 @@ from mailman.app.moderator import hold_message from mailman.database.transaction import transaction from mailman.interfaces.bans import IBanManager from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.requests import IListRequests, RequestType from mailman.interfaces.usermanager import IUserManager from mailman.testing.helpers import ( @@ -32,7 +32,7 @@ from mailman.testing.helpers import ( specialized_message_from_string as mfs) from mailman.testing.layers import RESTLayer from urllib.error import HTTPError -from zope.component import getUtility +from zope.component import getUtility, getAdapter class TestPostModeration(unittest.TestCase): @@ -150,7 +150,8 @@ class TestSubscriptionModeration(unittest.TestCase): def setUp(self): with transaction(): self._mlist = create_list('ant@example.com') - self._registrar = IRegistrar(self._mlist) + self._registrar = getAdapter( + self._mlist, IWorkflowManager, name='subscribe') manager = getUtility(IUserManager) self._anne = manager.create_address( 'anne@example.com', 'Anne Person') diff --git a/src/mailman/runners/tests/test_confirm.py b/src/mailman/runners/tests/test_confirm.py index 7dc2403fc..d49ba217b 100644 --- a/src/mailman/runners/tests/test_confirm.py +++ b/src/mailman/runners/tests/test_confirm.py @@ -24,14 +24,14 @@ from email.iterators import body_line_iterator from mailman.app.lifecycle import create_list from mailman.config import config from mailman.database.transaction import transaction -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.usermanager import IUserManager from mailman.runners.command import CommandRunner from mailman.testing.helpers import ( get_queue_messages, make_testable_runner, specialized_message_from_string as mfs) from mailman.testing.layers import ConfigLayer -from zope.component import getUtility +from zope.component import getUtility, getAdapter class TestConfirm(unittest.TestCase): @@ -47,7 +47,8 @@ class TestConfirm(unittest.TestCase): self._mlist = create_list('test@example.com') self._mlist.send_welcome_message = False anne = getUtility(IUserManager).create_address('anne@example.org') - registrar = IRegistrar(self._mlist) + registrar = getAdapter( + self._mlist, IWorkflowManager, name='subscribe') self._token, token_owner, member = registrar.register(anne) def test_confirm_with_re_prefix(self): diff --git a/src/mailman/runners/tests/test_join.py b/src/mailman/runners/tests/test_join.py index 0f24f3847..7409c8e89 100644 --- a/src/mailman/runners/tests/test_join.py +++ b/src/mailman/runners/tests/test_join.py @@ -23,7 +23,7 @@ from email.iterators import body_line_iterator from mailman.app.lifecycle import create_list from mailman.config import config from mailman.interfaces.member import DeliveryMode -from mailman.interfaces.registrar import IRegistrar +from mailman.interfaces.workflowmanager import IWorkflowManager from mailman.interfaces.subscriptions import ISubscriptionService, TokenOwner from mailman.interfaces.usermanager import IUserManager from mailman.runners.command import CommandRunner @@ -31,7 +31,7 @@ from mailman.testing.helpers import ( get_queue_messages, make_testable_runner, specialized_message_from_string as mfs) from mailman.testing.layers import ConfigLayer -from zope.component import getUtility +from zope.component import getUtility, getAdapter class TestJoin(unittest.TestCase): @@ -145,7 +145,8 @@ class TestJoinWithDigests(unittest.TestCase): subject_words = str(items[1].msg['subject']).split() self.assertEqual(subject_words[0], 'confirm') token = subject_words[1] - token, token_owner, rmember = IRegistrar(self._mlist).confirm(token) + token, token_owner, rmember = getAdapter( + self._mlist, IWorkflowManager, name='subscribe').confirm(token) self.assertIsNone(token) self.assertEqual(token_owner, TokenOwner.no_one) # Now, make sure that Anne is a member of the list and is receiving diff --git a/src/mailman/runners/tests/test_leave.py b/src/mailman/runners/tests/test_leave.py new file mode 100644 index 000000000..23257124a --- /dev/null +++ b/src/mailman/runners/tests/test_leave.py @@ -0,0 +1,81 @@ +# Copyright (C) 2016 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Test mailing list un-subscriptions.""" + +import pdb +import unittest + +from mailman.app.lifecycle import create_list +from mailman.config import config +from mailman.database.transaction import transaction +from mailman.interfaces.usermanager import IUserManager +from mailman.runners.command import CommandRunner +from mailman.testing.helpers import ( + get_queue_messages, make_testable_runner, + specialized_message_from_string as mfs) +from mailman.testing.layers import ConfigLayer +from mailman.testing.helpers import set_preferred +from mailman.utilities.datetime import now +from zope.component import getUtility, getAdapter + +class TestLeave(unittest.TestCase): + """Test mailing list un-subscriptions""" + + layer = ConfigLayer + + def setUp(self): + self._mlist = create_list('test@example.com') + self._mlist.send_welcome_message = False + self._commandq = config.switchboards['command'] + self._runner = make_testable_runner(CommandRunner, 'command') + + def test_leave(self): + with transaction(): + anne = getUtility(IUserManager).create_user('anne@example.org') + set_preferred(anne) + self._mlist.subscribe(list(anne.addresses)[0]) + msg = mfs("""\ +From: anne@example.org +To: test-leave@example.com + +leave +""") + self._commandq.enqueue(msg, dict(listid='test.example.com', + subaddress='leave')) + self._runner.run() + items = get_queue_messages('virgin', sort_on='subject', + expected_count=1) + print(items[0].msg) + print(anne.addresses) + pdb.set_trace() + self.assertTrue(str(items[0].msg['subject']).startswith('confirm')) + confirmation_lines = [] + in_results = False + for line in body_line_iterator(items[0].msg): + line = line.strip() + if in_results: + if line.startswith('- Done'): + break + if len(line) > 0: + confirmation_lines.append(line) + if line.strip() == '- Results:': + in_results = True + # There should be exactly one confirmation line. + self.assertEqual(len(confirmation_lines), 1) + # And the confirmation line should name Anne's email address. + self.assertIn('anne@example.org', confirmation_lines[0]) diff --git a/src/mailman/styles/base.py b/src/mailman/styles/base.py index 6620e85f2..7226d762a 100644 --- a/src/mailman/styles/base.py +++ b/src/mailman/styles/base.py @@ -67,6 +67,7 @@ class BasicOperation: mlist.default_member_action = Action.defer mlist.default_nonmember_action = Action.hold mlist.subscription_policy = SubscriptionPolicy.confirm + mlist.unsubscription_policy = SubscriptionPolicy.confirm # Notify the administrator of pending requests and membership changes. mlist.admin_immed_notify = True mlist.admin_notify_mchanges = False -- cgit v1.3.1