From 00f52639673dc4db32ac9302e2a70483492d621e Mon Sep 17 00:00:00 2001 From: J08nY Date: Tue, 4 Jul 2017 21:18:23 +0200 Subject: Instantiate components only in add_components, not at lower levels. - The scan_module and find_components functions are also useful when they don't instantiate the components they load, and instantiation is left to add_components. --- src/mailman/utilities/modules.py | 27 ++++++++++++++------------- src/mailman/utilities/tests/test_modules.py | 4 ++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/mailman/utilities/modules.py b/src/mailman/utilities/modules.py index 155a34ed2..eb8f7184a 100644 --- a/src/mailman/utilities/modules.py +++ b/src/mailman/utilities/modules.py @@ -71,19 +71,18 @@ def call_name(dotted_name, *args, **kws): def scan_module(module, interface): - """Return all the object in a module that conform to an interface. + """Return all the items in a module that conform to an interface. Scan every item named in the module's `__all__`. If that item conforms to the given interface, *and* the item is not declared as an - `@abstract_component`, then instantiate the item and return the resulting - instance. + `@abstract_component`, then return the item. :param module: A module object. :type module: module :param interface: The interface that returned objects must conform to. :type interface: `Interface` - :return: The sequence of instantiated matching components. - :rtype: instantiated objects implementing `interface` + :return: The sequence of matching components. + :rtype: items implementing `interface` """ missing = object() for name in module.__all__: @@ -99,7 +98,7 @@ def scan_module(module, interface): # where the marker has been placed. The value of # __abstract_component__ doesn't matter, only its presence. and '__abstract_component__' not in component.__dict__): - yield component() + yield component @public @@ -107,15 +106,15 @@ def find_components(package, interface): """Find components which conform to a given interface. Search all the modules in a given package, returning an iterator over all - objects found that conform to the given interface, unless that object is + items found that conform to the given interface, unless that object is decorated with `@abstract_component`. :param package: The package path to search. :type package: string :param interface: The interface that returned objects must conform to. :type interface: `Interface` - :return: The sequence of instantiated matching components. - :rtype: instantiated objects implementing `interface` + :return: The sequence of matching components. + :rtype: items implementing `interface` """ for filename in resource_listdir(package, ''): basename, extension = os.path.splitext(filename) @@ -135,9 +134,10 @@ def add_components(package, interface, mapping): Similarly to `find_components()` this inspects all modules in a given package looking for objects that conform to a given interface. All such - found objects (unless decorated with `@abstract_component`) are added to - the given mapping, keyed by the object's `.name` attribute, which is - required. It is a fatal error if that key already exists in the mapping. + found objects (unless decorated with `@abstract_component`) are + instantiated and added to the given mapping, keyed by the object's `.name` + attribute, which is required. It is a fatal error if that key already + exists in the mapping. :param package: The package path to search. :type package: string @@ -150,7 +150,8 @@ def add_components(package, interface, mapping): containment tests (e.g. `in` and `not in`) and `__setitem__()`. :raises RuntimeError: when a duplicate key is found. """ - for component in find_components(package, interface): + for component_class in find_components(package, interface): + component = component_class() if component.name in mapping: raise RuntimeError( 'Duplicate key "{}" found in {}; previously {}'.format( diff --git a/src/mailman/utilities/tests/test_modules.py b/src/mailman/utilities/tests/test_modules.py index 82d44cff4..08ec3848a 100644 --- a/src/mailman/utilities/tests/test_modules.py +++ b/src/mailman/utilities/tests/test_modules.py @@ -99,8 +99,8 @@ class BadStyle: in find_components('mypackage', IStyle)] self.assertEqual(names, ['good-style']) - def test_find_components_no_instantiate(self): - # find_components() now instantiates the class unless it's been + def test_find_components_abstract_component(self): + # find_components() finds the class unless it's been # decorated with the @abstract_component decorator. with ExitStack() as resources: # Creating a temporary directory and adding it to sys.path. -- cgit v1.2.3-70-g09d2 From 8b09113eb40c39ada3dc902cb4e869c8f012c97d Mon Sep 17 00:00:00 2001 From: J08nY Date: Thu, 29 Jun 2017 23:51:47 +0200 Subject: Create mailman.workflows package. Move base Workflow there. - Also introduce IWorkflow, ISubscriptionWorkflow, IUnsubscriptionWorkflow. --- src/mailman/app/subscriptions.py | 4 +- src/mailman/app/tests/test_workflow.py | 12 +-- src/mailman/app/workflow.py | 151 ------------------------------- src/mailman/config/configure.zcml | 4 +- src/mailman/interfaces/workflow.py | 67 -------------- src/mailman/interfaces/workflows.py | 128 ++++++++++++++++++++++++++ src/mailman/model/tests/test_workflow.py | 2 +- src/mailman/model/workflow.py | 71 --------------- src/mailman/model/workflows.py | 71 +++++++++++++++ src/mailman/workflows/__init__.py | 0 src/mailman/workflows/base.py | 143 +++++++++++++++++++++++++++++ 11 files changed, 353 insertions(+), 300 deletions(-) delete mode 100644 src/mailman/app/workflow.py delete mode 100644 src/mailman/interfaces/workflow.py create mode 100644 src/mailman/interfaces/workflows.py delete mode 100644 src/mailman/model/workflow.py create mode 100644 src/mailman/model/workflows.py create mode 100644 src/mailman/workflows/__init__.py create mode 100644 src/mailman/workflows/base.py diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index 0d8176ffb..dace9ccb6 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -24,7 +24,6 @@ from datetime import timedelta from email.utils import formataddr from enum import Enum from mailman.app.membership import delete_member -from mailman.app.workflow import Workflow from mailman.core.i18n import _ from mailman.database.transaction import flush from mailman.email.message import UserNotification @@ -43,9 +42,10 @@ from mailman.interfaces.subscriptions import ( from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager -from mailman.interfaces.workflow import IWorkflowStateManager +from mailman.interfaces.workflows import IWorkflowStateManager from mailman.utilities.datetime import now from mailman.utilities.string import expand, wrap +from mailman.workflows.base import Workflow from public import public from zope.component import getUtility from zope.event import notify diff --git a/src/mailman/app/tests/test_workflow.py b/src/mailman/app/tests/test_workflow.py index e82001540..f242a73ec 100644 --- a/src/mailman/app/tests/test_workflow.py +++ b/src/mailman/app/tests/test_workflow.py @@ -20,15 +20,15 @@ import json import unittest -from mailman.app.workflow import Workflow -from mailman.interfaces.workflow import IWorkflowStateManager +from mailman.interfaces.workflows import IWorkflowStateManager from mailman.testing.layers import ConfigLayer +from mailman.workflows.base import Workflow from zope.component import getUtility class MyWorkflow(Workflow): - INITIAL_STATE = 'first' - SAVE_ATTRIBUTES = ('ant', 'bee', 'cat') + initial_state = 'first' + save_attributes = ('ant', 'bee', 'cat') def __init__(self): super().__init__() @@ -51,7 +51,7 @@ class MyWorkflow(Workflow): class DependentWorkflow(MyWorkflow): - SAVE_ATTRIBUTES = ('ant', 'bee', 'cat', 'elf') + save_attributes = ('ant', 'bee', 'cat', 'elf') def __init__(self): super().__init__() @@ -136,7 +136,7 @@ class TestWorkflow(unittest.TestCase): def test_save_and_restore_dependant_attributes(self): # Attributes must be restored in the order they are declared in - # SAVE_ATTRIBUTES. + # save_attributes. workflow = iter(DependentWorkflow()) workflow.elf = 6 workflow.save() diff --git a/src/mailman/app/workflow.py b/src/mailman/app/workflow.py deleted file mode 100644 index 92b78c184..000000000 --- a/src/mailman/app/workflow.py +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright (C) 2015-2017 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 . - -"""Generic workflow.""" - -import sys -import json -import logging - -from collections import deque -from mailman.interfaces.workflow import IWorkflowStateManager -from public import public -from zope.component import getUtility - - -COMMASPACE = ', ' -log = logging.getLogger('mailman.error') - - -@public -class Workflow: - """Generic workflow.""" - - SAVE_ATTRIBUTES = () - INITIAL_STATE = None - - def __init__(self): - self.token = None - self._next = deque() - self.push(self.INITIAL_STATE) - self.debug = False - self._count = 0 - - @property - def name(self): - return self.__class__.__name__ - - def __iter__(self): - return self - - def push(self, step): - self._next.append(step) - - def _pop(self): - name = self._next.popleft() - step = getattr(self, '_step_{}'.format(name)) - self._count += 1 - if self.debug: # pragma: nocover - print('[{:02d}] -> {}'.format(self._count, name), file=sys.stderr) - return name, step - - def __next__(self): - try: - name, step = self._pop() - return step() - except IndexError: - raise StopIteration - except: - log.exception('deque: {}'.format(COMMASPACE.join(self._next))) - raise - - def run_thru(self, stop_after): - """Run the state machine through and including the given step. - - :param stop_after: Name of method, sans prefix to run the - state machine through. In other words, the state machine runs - until the named method completes. - """ - results = [] - while True: - try: - name, step = self._pop() - except (StopIteration, IndexError): - # We're done. - break - results.append(step()) - if name == stop_after: - break - return results - - def run_until(self, stop_before): - """Trun the state machine until (not including) the given step. - - :param stop_before: Name of method, sans prefix that the - state machine is run until the method is reached. Unlike - `run_thru()` the named method is not run. - """ - results = [] - while True: - try: - name, step = self._pop() - except (StopIteration, IndexError): - # We're done. - break - if name == stop_before: - # Stop executing, but not before we push the last state back - # onto the deque. Otherwise, resuming the state machine would - # skip this step. - self._next.appendleft(name) - break - results.append(step()) - return results - - def save(self): - assert self.token, 'Workflow token must be set' - state_manager = getUtility(IWorkflowStateManager) - data = {attr: getattr(self, attr) for attr in self.SAVE_ATTRIBUTES} - # Note: only the next step is saved, not the whole stack. This is not - # an issue in practice, since there's never more than a single step in - # the queue anyway. If we want to support more than a single step in - # the queue *and* want to support state saving/restoring, change this - # method and the restore() method. - if len(self._next) == 0: - step = None - elif len(self._next) == 1: - step = self._next[0] - else: - raise AssertionError( - "Can't save a workflow state with more than one step " - "in the queue") - state_manager.save(self.token, step, json.dumps(data)) - - def restore(self): - state_manager = getUtility(IWorkflowStateManager) - state = state_manager.restore(self.token) - if state is None: - # The token doesn't exist in the database. - raise LookupError(self.token) - self._next.clear() - if state.step: - self._next.append(state.step) - data = json.loads(state.data) - for attr in self.SAVE_ATTRIBUTES: - try: - setattr(self, attr, data[attr]) - except KeyError: - pass diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml index 2fd0c8788..da4eb36b6 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -135,8 +135,8 @@ /> diff --git a/src/mailman/interfaces/workflow.py b/src/mailman/interfaces/workflow.py deleted file mode 100644 index 5b3582b58..000000000 --- a/src/mailman/interfaces/workflow.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (C) 2015-2017 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 . - -"""Interfaces describing the state of a workflow.""" - -from public import public -from zope.interface import Attribute, Interface - - -@public -class IWorkflowState(Interface): - """The state of a workflow.""" - - token = Attribute('A unique key identifying the workflow instance.') - - step = Attribute("This workflow's next step.") - - data = Attribute('Additional data (may be JSON-encoded).') - - -@public -class IWorkflowStateManager(Interface): - """The workflow states manager.""" - - def save(token, step, data=None): - """Save the state of a workflow. - - :param token: A unique token identifying this workflow instance. - :type token: str - :param step: The next step for this workflow. - :type step: str - :param data: Additional data (workflow-specific). - :type data: str - """ - - def restore(token): - """Get the saved state for a workflow or None if nothing was saved. - - :param token: A unique token identifying this workflow instance. - :type token: str - :return: The saved state associated with this name/token pair, or None - if the pair isn't in the database. - :rtype: ``IWorkflowState`` - """ - - def discard(token): - """Throw away the saved state for a workflow. - - :param token: A unique token identifying this workflow instance. - :type token: str - """ - - count = Attribute('The number of saved workflows in the database.') diff --git a/src/mailman/interfaces/workflows.py b/src/mailman/interfaces/workflows.py new file mode 100644 index 000000000..b110a481e --- /dev/null +++ b/src/mailman/interfaces/workflows.py @@ -0,0 +1,128 @@ +# Copyright (C) 2015-2017 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 . + +"""Interfaces describing the a workflow, it's state and it's state manager.""" + +from public import public +from zope.interface import Attribute, Interface + + +@public +class IWorkflowState(Interface): + """The state of a workflow.""" + + token = Attribute('A unique key identifying the workflow instance.') + + step = Attribute("This workflow's next step.") + + data = Attribute('Additional data (may be JSON-encoded).') + + +@public +class IWorkflowStateManager(Interface): + """The workflow states manager.""" + + def save(token, step, data=None): + """Save the state of a workflow. + + :param token: A unique token identifying this workflow instance. + :type token: str + :param step: The next step for this workflow. + :type step: str + :param data: Additional data (workflow-specific). + :type data: str + """ + + def restore(token): + """Get the saved state for a workflow or None if nothing was saved. + + :param token: A unique token identifying this workflow instance. + :type token: str + :return: The saved state associated with this name/token pair, or None + if the pair isn't in the database. + :rtype: ``IWorkflowState`` + """ + + def discard(token): + """Throw away the saved state for a workflow. + + :param token: A unique token identifying this workflow instance. + :type token: str + """ + + count = Attribute('The number of saved workflows in the database.') + + +@public +class IWorkflow(Interface): + """A workflow.""" + + name = Attribute('The name of the workflow, must be unique.') + description = Attribute('A brief description of the workflow.') + initial_state = Attribute('The state in which the workflow starts.') + save_attributes = Attribute('The sequence of attributes of the workflow, ' + 'which are saved.') + + def __iter__(): + """Return an iterator over the steps.""" + + def __next__(): + """Run the next step from the queue. + + :return: The result of the step run. + """ + + def push(step): + """Push a step to this workflows queue.""" + + def run_thru(stop_after): + """Run the state machine through and including the given step. + + :param stop_after: Name of method, sans prefix to run the + state machine through. In other words, the state machine runs + until the named method completes. + """ + + def run_until(stop_before): + """Run the state machine until (not including) the given step. + + :param stop_before: Name of method, sans prefix that the + state machine is run until the method is reached. Unlike + `run_thru()` the named method is not run. + """ + + def save(): + """Save the workflow in it's current state. + + Needs to have the `token` attribute set. + """ + + def restore(): + """Restore the workflow from the database. + + Needs to have the `token` attribute set. + """ + + +@public +class ISubscriptionWorkflow(IWorkflow): + """A workflow used for subscription.""" + + +@public +class IUnsubscriptionWorkflow(IWorkflow): + """A workflow used for unsubscription.""" diff --git a/src/mailman/model/tests/test_workflow.py b/src/mailman/model/tests/test_workflow.py index 9cc5446b7..41c00efc0 100644 --- a/src/mailman/model/tests/test_workflow.py +++ b/src/mailman/model/tests/test_workflow.py @@ -19,7 +19,7 @@ import unittest -from mailman.interfaces.workflow import IWorkflowStateManager +from mailman.interfaces.workflows import IWorkflowStateManager from mailman.testing.layers import ConfigLayer from zope.component import getUtility diff --git a/src/mailman/model/workflow.py b/src/mailman/model/workflow.py deleted file mode 100644 index 3ca3412a6..000000000 --- a/src/mailman/model/workflow.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright (C) 2015-2017 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 . - -"""Model for workflow states.""" - -from mailman.database.model import Model -from mailman.database.transaction import dbconnection -from mailman.database.types import SAUnicode -from mailman.interfaces.workflow import IWorkflowState, IWorkflowStateManager -from public import public -from sqlalchemy import Column -from zope.interface import implementer - - -@public -@implementer(IWorkflowState) -class WorkflowState(Model): - """Workflow states.""" - - __tablename__ = 'workflowstate' - - token = Column(SAUnicode, primary_key=True) - step = Column(SAUnicode) - data = Column(SAUnicode) - - -@public -@implementer(IWorkflowStateManager) -class WorkflowStateManager: - """See `IWorkflowStateManager`.""" - - @dbconnection - def save(self, store, token, step=None, data=None): - """See `IWorkflowStateManager`.""" - state = WorkflowState(token=token, step=step, data=data) - store.add(state) - - @dbconnection - def restore(self, store, token): - """See `IWorkflowStateManager`.""" - state = store.query(WorkflowState).get(token) - if state is not None: - store.delete(state) - return state - - @dbconnection - def discard(self, store, token): - """See `IWorkflowStateManager`.""" - state = store.query(WorkflowState).get(token) - if state is not None: - store.delete(state) - - @property - @dbconnection - def count(self, store): - """See `IWorkflowStateManager`.""" - return store.query(WorkflowState).count() diff --git a/src/mailman/model/workflows.py b/src/mailman/model/workflows.py new file mode 100644 index 000000000..587d3375e --- /dev/null +++ b/src/mailman/model/workflows.py @@ -0,0 +1,71 @@ +# Copyright (C) 2015-2017 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 . + +"""Model for workflow states.""" + +from mailman.database.model import Model +from mailman.database.transaction import dbconnection +from mailman.database.types import SAUnicode +from mailman.interfaces.workflows import IWorkflowState, IWorkflowStateManager +from public import public +from sqlalchemy import Column +from zope.interface import implementer + + +@public +@implementer(IWorkflowState) +class WorkflowState(Model): + """Workflow states.""" + + __tablename__ = 'workflowstate' + + token = Column(SAUnicode, primary_key=True) + step = Column(SAUnicode) + data = Column(SAUnicode) + + +@public +@implementer(IWorkflowStateManager) +class WorkflowStateManager: + """See `IWorkflowStateManager`.""" + + @dbconnection + def save(self, store, token, step=None, data=None): + """See `IWorkflowStateManager`.""" + state = WorkflowState(token=token, step=step, data=data) + store.add(state) + + @dbconnection + def restore(self, store, token): + """See `IWorkflowStateManager`.""" + state = store.query(WorkflowState).get(token) + if state is not None: + store.delete(state) + return state + + @dbconnection + def discard(self, store, token): + """See `IWorkflowStateManager`.""" + state = store.query(WorkflowState).get(token) + if state is not None: + store.delete(state) + + @property + @dbconnection + def count(self, store): + """See `IWorkflowStateManager`.""" + return store.query(WorkflowState).count() diff --git a/src/mailman/workflows/__init__.py b/src/mailman/workflows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/workflows/base.py b/src/mailman/workflows/base.py new file mode 100644 index 000000000..5988dfa43 --- /dev/null +++ b/src/mailman/workflows/base.py @@ -0,0 +1,143 @@ +# Copyright (C) 2015-2017 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 . + +"""Generic workflow.""" + +import sys +import json +import logging + +from collections import deque + +from mailman.interfaces.workflows import IWorkflowStateManager +from public import public +from zope.component import getUtility + + +COMMASPACE = ', ' +log = logging.getLogger('mailman.error') + + +@public +class Workflow: + """Generic workflow.""" + + initial_state = None + save_attributes = () + + def __init__(self): + self.token = None + self._next = deque() + self.push(self.initial_state) + self.debug = False + self._count = 0 + + def __iter__(self): + """See `IWorkflow`.""" + return self + + def __next__(self): + """See `IWorkflow`.""" + try: + name, step = self._pop() + return step() + except IndexError: + raise StopIteration + except: + log.exception('deque: {}'.format(COMMASPACE.join(self._next))) + raise + + def push(self, step): + """See `IWorkflow`.""" + self._next.append(step) + + def _pop(self): + name = self._next.popleft() + step = getattr(self, '_step_{}'.format(name)) + self._count += 1 + if self.debug: # pragma: nocover + print('[{:02d}] -> {}'.format(self._count, name), file=sys.stderr) + return name, step + + def run_thru(self, stop_after): + """See `IWorkflow`.""" + results = [] + while True: + try: + name, step = self._pop() + except (StopIteration, IndexError): + # We're done. + break + results.append(step()) + if name == stop_after: + break + return results + + def run_until(self, stop_before): + """See `IWorkflow`.""" + results = [] + while True: + try: + name, step = self._pop() + except (StopIteration, IndexError): + # We're done. + break + if name == stop_before: + # Stop executing, but not before we push the last state back + # onto the deque. Otherwise, resuming the state machine would + # skip this step. + self._next.appendleft(name) + break + results.append(step()) + return results + + def save(self): + """See `IWorkflow`.""" + assert self.token, 'Workflow token must be set' + state_manager = getUtility(IWorkflowStateManager) + data = {attr: getattr(self, attr) for attr in self.save_attributes} + # Note: only the next step is saved, not the whole stack. This is not + # an issue in practice, since there's never more than a single step in + # the queue anyway. If we want to support more than a single step in + # the queue *and* want to support state saving/restoring, change this + # method and the restore() method. + if len(self._next) == 0: + step = None + elif len(self._next) == 1: + step = self._next[0] + else: + raise AssertionError( + "Can't save a workflow state with more than one step " + "in the queue") + state_manager.save(self.token, step, json.dumps(data)) + + def restore(self): + """See `IWorkflow`.""" + state_manager = getUtility(IWorkflowStateManager) + state = state_manager.restore(self.token) + if state is None: + # The token doesn't exist in the database. + raise LookupError(self.token) + self._next.clear() + if state.step: + self._next.append(state.step) + data = json.loads(state.data) + for attr in self.save_attributes: + try: + setattr(self, attr, data[attr]) + except KeyError: + pass -- cgit v1.2.3-70-g09d2 From c0a6d7dedfe7bb59c1837d924ed5c6c2b2796846 Mon Sep 17 00:00:00 2001 From: J08nY Date: Thu, 6 Jul 2017 19:30:22 +0200 Subject: Move workflows from app.subscriptions to workflows.builtin. --- src/mailman/app/subscriptions.py | 480 +-------------------------------------- src/mailman/workflows/builtin.py | 409 +++++++++++++++++++++++++++++++++ src/mailman/workflows/common.py | 123 ++++++++++ 3 files changed, 539 insertions(+), 473 deletions(-) create mode 100644 src/mailman/workflows/builtin.py create mode 100644 src/mailman/workflows/common.py diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index dace9ccb6..90811722d 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -17,492 +17,26 @@ """Handle subscriptions.""" -import uuid -import logging - -from datetime import timedelta -from email.utils import formataddr -from enum import Enum -from mailman.app.membership import delete_member -from mailman.core.i18n import _ from mailman.database.transaction import flush from mailman.email.message import UserNotification -from mailman.interfaces.address import IAddress -from mailman.interfaces.bans import IBanManager from mailman.interfaces.listmanager import ListDeletingEvent -from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.member import ( - AlreadySubscribedError, MemberRole, MembershipIsBannedError, - NotAMemberError) -from mailman.interfaces.pending import IPendable, IPendings +from mailman.interfaces.pending import IPendings from mailman.interfaces.subscriptions import ( ISubscriptionManager, ISubscriptionService, - SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner, + SubscriptionConfirmationNeededEvent, UnsubscriptionConfirmationNeededEvent) from mailman.interfaces.template import ITemplateLoader -from mailman.interfaces.user import IUser -from mailman.interfaces.usermanager import IUserManager from mailman.interfaces.workflows import IWorkflowStateManager -from mailman.utilities.datetime import now -from mailman.utilities.string import expand, wrap -from mailman.workflows.base import Workflow +from mailman.utilities.string import expand +from mailman.workflows.builtin import (PendableSubscription, + PendableUnsubscription, + SubscriptionWorkflow, + UnSubscriptionWorkflow) from public import public from zope.component import getUtility -from zope.event import notify from zope.interface import implementer -log = logging.getLogger('mailman.subscribe') - - -class WhichSubscriber(Enum): - address = 1 - user = 2 - - -@implementer(IPendable) -class PendableSubscription(dict): - PEND_TYPE = 'subscription' - - -@implementer(IPendable) -class PendableUnsubscription(dict): - PEND_TYPE = 'unsubscription' - - -class _SubscriptionWorkflowCommon(Workflow): - """Common support between subscription and unsubscription.""" - - PENDABLE_CLASS = None - - def __init__(self, mlist, subscriber): - super().__init__() - self.mlist = mlist - self.address = None - self.user = None - self.which = None - self.member = None - self._set_token(TokenOwner.no_one) - # The subscriber must be either an IUser or IAddress. - if IAddress.providedBy(subscriber): - self.address = subscriber - self.user = self.address.user - self.which = WhichSubscriber.address - elif IUser.providedBy(subscriber): - self.address = subscriber.preferred_address - self.user = subscriber - self.which = WhichSubscriber.user - self.subscriber = subscriber - - @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) - if self.user is None: - self.user = self.address.user - - @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 = self.PENDABLE_CLASS( - 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)) - - -@public -class SubscriptionWorkflow(_SubscriptionWorkflowCommon): - """Workflow of a subscription request.""" - - PENDABLE_CLASS = PendableSubscription - INITIAL_STATE = 'sanity_checks' - SAVE_ATTRIBUTES = ( - 'pre_approved', - 'pre_confirmed', - 'pre_verified', - 'address_key', - 'subscriber_key', - 'user_key', - 'token_owner_key', - ) - - def __init__(self, mlist, subscriber=None, *, - pre_verified=False, pre_confirmed=False, pre_approved=False): - super().__init__(mlist, subscriber) - self.pre_verified = pre_verified - self.pre_confirmed = pre_confirmed - self.pre_approved = pre_approved - - def _step_sanity_checks(self): - # Ensure that we have both an address and a user, even if the address - # is not verified. We can't set the preferred address until it is - # verified. - if self.user is None: - # The address has no linked user so create one, link it, and set - # the user's preferred address. - assert self.address is not None, 'No address or user' - self.user = getUtility(IUserManager).make_user(self.address.email) - if self.address is None: - assert self.user.preferred_address is None, ( - "Preferred address exists, but wasn't used in constructor") - addresses = list(self.user.addresses) - if len(addresses) == 0: - raise AssertionError('User has no addresses: {}'.format( - self.user)) - # This is rather arbitrary, but we have no choice. - self.address = addresses[0] - assert self.user is not None and self.address is not None, ( - 'Insane sanity check results') - # Is this subscriber already a member? - if (self.which is WhichSubscriber.user and - self.user.preferred_address is not None): - subscriber = self.user - else: - subscriber = self.address - if self.mlist.is_subscribed(subscriber): - # 2017-04-22 BAW: This branch actually *does* get covered, as I've - # verified by a full coverage run, but diffcov for some reason - # claims that the test added in the branch that added this code - # does not cover the change. That seems like a bug in diffcov. - raise AlreadySubscribedError( # pragma: nocover - self.mlist.fqdn_listname, - self.address.email, - MemberRole.member) - # Is this email address banned? - if IBanManager(self.mlist).is_banned(self.address.email): - raise MembershipIsBannedError(self.mlist, self.address.email) - # Check if there is already a subscription request for this email. - pendings = getUtility(IPendings).find( - mlist=self.mlist, - pend_type='subscription') - for token, pendable in pendings: - if pendable['email'] == self.address.email: - raise SubscriptionPendingError(self.mlist, self.address.email) - # Start out with the subscriber being the token owner. - self.push('verification_checks') - - def _step_verification_checks(self): - # Is the address already verified, or is the pre-verified flag set? - if self.address.verified_on is None: - if self.pre_verified: - self.address.verified_on = now() - else: - # The address being subscribed is not yet verified, so we need - # to send a validation email that will also confirm that the - # user wants to be subscribed to this mailing list. - self.push('send_confirmation') - return - self.push('confirmation_checks') - - def _step_confirmation_checks(self): - # If the list's subscription policy is open, then the user can be - # subscribed right here and now. - if self.mlist.subscription_policy is SubscriptionPolicy.open: - self.push('do_subscription') - return - # If we do not need the user's confirmation, then skip to the - # moderation checks. - if self.mlist.subscription_policy is SubscriptionPolicy.moderate: - self.push('moderation_checks') - return - # If the subscription has been pre-confirmed, then we can skip the - # confirmation check can be skipped. If moderator approval is - # required we need to check that, otherwise we can go straight to - # subscription. - if self.pre_confirmed: - next_step = ( - 'moderation_checks' - if self.mlist.subscription_policy is - SubscriptionPolicy.confirm_then_moderate # noqa: E131 - else 'do_subscription') - self.push(next_step) - return - # The user must confirm their subscription. - self.push('send_confirmation') - - def _step_moderation_checks(self): - # Does the moderator need to approve the subscription request? - assert self.mlist.subscription_policy in ( - SubscriptionPolicy.moderate, - SubscriptionPolicy.confirm_then_moderate, - ), self.mlist.subscription_policy - if self.pre_approved: - self.push('do_subscription') - else: - self.push('get_moderator_approval') - - def _step_get_moderator_approval(self): - # Here's the next step in the workflow, assuming the moderator - # approves of the subscription. If they don't, the workflow and - # subscription request will just be thrown away. - self._set_token(TokenOwner.moderator) - self.push('subscribe_from_restored') - self.save() - log.info('{}: held subscription request from {}'.format( - self.mlist.fqdn_listname, self.address.email)) - # Possibly send a notification to the list moderators. - if self.mlist.admin_immed_notify: - subject = _( - 'New subscription request to $self.mlist.display_name ' - 'from $self.address.email') - username = formataddr( - (self.subscriber.display_name, self.address.email)) - template = getUtility(ITemplateLoader).get( - 'list:admin:action:subscribe', self.mlist) - text = wrap(expand(template, self.mlist, dict( - member=username, - ))) - # 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) - # The workflow must stop running here. - raise StopIteration - - def _step_subscribe_from_restored(self): - # Prevent replay attacks. - self._set_token(TokenOwner.no_one) - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # subscribe the user. - if self.which is WhichSubscriber.address: - self.subscriber = self.address - else: - assert self.which is WhichSubscriber.user - self.subscriber = self.user - self.push('do_subscription') - - def _step_do_subscription(self): - # We can immediately subscribe the user to the mailing list. - self.member = self.mlist.subscribe(self.subscriber) - assert self.token is None and self.token_owner is TokenOwner.no_one, ( - 'Unexpected active token at end of subscription workflow') - - def _step_send_confirmation(self): - self._set_token(TokenOwner.subscriber) - self.push('do_confirm_verify') - self.save() - # Triggering this event causes the confirmation message to be sent. - notify(SubscriptionConfirmationNeededEvent( - self.mlist, self.token, self.address.email)) - # Now we wait for the confirmation. - raise StopIteration - - def _step_do_confirm_verify(self): - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # continue with the confirmation/verification step. - 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) - # The user has confirmed their subscription request, and also verified - # their email address if necessary. This latter needs to be set on the - # IAddress, but there's nothing more to do about the confirmation step. - # We just continue along with the workflow. - if self.address.verified_on is None: - self.address.verified_on = now() - # The next step depends on the mailing list's subscription policy. - next_step = ('moderation_checks' - if self.mlist.subscription_policy in ( - SubscriptionPolicy.moderate, - SubscriptionPolicy.confirm_then_moderate, - ) - else 'do_subscription') - self.push(next_step) - - -@public -class UnSubscriptionWorkflow(_SubscriptionWorkflowCommon): - """Workflow of a unsubscription request.""" - - PENDABLE_CLASS = PendableUnsubscription - 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__(mlist, subscriber) - if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber): - self.member = self.mlist.regular_members.get_member( - self.address.email) - self.pre_confirmed = pre_confirmed - self.pre_approved = pre_approved - - 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 the request is pre-confirmed, then the user can unsubscribe right - # now. - if self.pre_confirmed: - self.push('do_unsubscription') - 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(UnsubscriptionConfirmationNeededEvent( - 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)) - template = getUtility(ITemplateLoader).get( - 'list:admin:action:unsubscribe', self.mlist) - text = wrap(expand(template, self.mlist, dict( - member=username, - ))) - # 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) - # The workflow must stop running here - raise StopIteration - - def _step_do_confirm_verify(self): - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # continue with the confirmation/verification step. - 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) - # Restore the member object. - self.member = self.mlist.regular_members.get_member(self.address.email) - # It's possible the member was already unsubscribed while we were - # waiting for the confirmation. - if self.member is None: - return - # The user has confirmed their unsubscription request - next_step = ('moderation_checks' - if self.mlist.unsubscription_policy in ( - SubscriptionPolicy.moderate, - SubscriptionPolicy.confirm_then_moderate, - ) - else 'do_unsubscription') - self.push(next_step) - - def _step_do_unsubscription(self): - try: - delete_member(self.mlist, self.address.email) - except NotAMemberError: - # The member has already been unsubscribed. - pass - self.member = None - assert self.token is None and self.token_owner is TokenOwner.no_one, ( - 'Unexpected active token at end of subscription workflow') - - 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 WhichSubscriber.user - self.subscriber = self.user - self.push('do_unsubscription') - - @public @implementer(ISubscriptionManager) class SubscriptionManager: diff --git a/src/mailman/workflows/builtin.py b/src/mailman/workflows/builtin.py new file mode 100644 index 000000000..e16a888fc --- /dev/null +++ b/src/mailman/workflows/builtin.py @@ -0,0 +1,409 @@ +# Copyright (C) 2015-2017 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 . + +"""The built in subscription and unsubscription workflows.""" + +import logging + +from email.utils import formataddr +from mailman.app.membership import delete_member +from mailman.core.i18n import _ +from mailman.email.message import UserNotification +from mailman.interfaces.address import IAddress +from mailman.interfaces.bans import IBanManager +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.interfaces.member import (AlreadySubscribedError, MemberRole, + MembershipIsBannedError, + NotAMemberError) +from mailman.interfaces.pending import IPendable, IPendings +from mailman.interfaces.subscriptions import ( + SubscriptionConfirmationNeededEvent, SubscriptionPendingError, + TokenOwner, UnsubscriptionConfirmationNeededEvent) +from mailman.interfaces.template import ITemplateLoader +from mailman.interfaces.user import IUser +from mailman.interfaces.usermanager import IUserManager +from mailman.utilities.datetime import now +from mailman.utilities.string import expand, wrap +from mailman.workflows.common import (SubscriptionWorkflowCommon, + WhichSubscriber) +from public import public +from zope.component import getUtility +from zope.event import notify +from zope.interface import implementer + + +log = logging.getLogger('mailman.subscribe') + + +@implementer(IPendable) +class PendableSubscription(dict): + PEND_TYPE = 'subscription' + + +@implementer(IPendable) +class PendableUnsubscription(dict): + PEND_TYPE = 'unsubscription' + + +@public +class SubscriptionWorkflow(SubscriptionWorkflowCommon): + """Workflow of a subscription request.""" + + name = 'subscription-default' + description = '' + + initial_state = 'sanity_checks' + save_attributes = ( + 'pre_approved', + 'pre_confirmed', + 'pre_verified', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_verified=False, pre_confirmed=False, pre_approved=False): + super().__init__(mlist, subscriber) + self.pre_verified = pre_verified + self.pre_confirmed = pre_confirmed + self.pre_approved = pre_approved + + def _step_sanity_checks(self): + # Ensure that we have both an address and a user, even if the address + # is not verified. We can't set the preferred address until it is + # verified. + if self.user is None: + # The address has no linked user so create one, link it, and set + # the user's preferred address. + assert self.address is not None, 'No address or user' + self.user = getUtility(IUserManager).make_user(self.address.email) + if self.address is None: + assert self.user.preferred_address is None, ( + "Preferred address exists, but wasn't used in constructor") + addresses = list(self.user.addresses) + if len(addresses) == 0: + raise AssertionError('User has no addresses: {}'.format( + self.user)) + # This is rather arbitrary, but we have no choice. + self.address = addresses[0] + assert self.user is not None and self.address is not None, ( + 'Insane sanity check results') + # Is this subscriber already a member? + if (self.which is WhichSubscriber.user and + self.user.preferred_address is not None): + subscriber = self.user + else: + subscriber = self.address + if self.mlist.is_subscribed(subscriber): + # 2017-04-22 BAW: This branch actually *does* get covered, as I've + # verified by a full coverage run, but diffcov for some reason + # claims that the test added in the branch that added this code + # does not cover the change. That seems like a bug in diffcov. + raise AlreadySubscribedError( # pragma: no cover + self.mlist.fqdn_listname, + self.address.email, + MemberRole.member) + # Is this email address banned? + if IBanManager(self.mlist).is_banned(self.address.email): + raise MembershipIsBannedError(self.mlist, self.address.email) + # Check if there is already a subscription request for this email. + pendings = getUtility(IPendings).find( + mlist=self.mlist, + pend_type='subscription') + for token, pendable in pendings: + if pendable['email'] == self.address.email: + raise SubscriptionPendingError(self.mlist, self.address.email) + # Start out with the subscriber being the token owner. + self.push('verification_checks') + + def _step_verification_checks(self): + # Is the address already verified, or is the pre-verified flag set? + if self.address.verified_on is None: + if self.pre_verified: + self.address.verified_on = now() + else: + # The address being subscribed is not yet verified, so we need + # to send a validation email that will also confirm that the + # user wants to be subscribed to this mailing list. + self.push('send_confirmation') + return + self.push('confirmation_checks') + + def _step_confirmation_checks(self): + # If the list's subscription policy is open, then the user can be + # subscribed right here and now. + if self.mlist.subscription_policy is SubscriptionPolicy.open: + self.push('do_subscription') + return + # If we do not need the user's confirmation, then skip to the + # moderation checks. + if self.mlist.subscription_policy is SubscriptionPolicy.moderate: + self.push('moderation_checks') + return + # If the subscription has been pre-confirmed, then we can skip the + # confirmation check can be skipped. If moderator approval is + # required we need to check that, otherwise we can go straight to + # subscription. + if self.pre_confirmed: + next_step = ( + 'moderation_checks' + if self.mlist.subscription_policy is + SubscriptionPolicy.confirm_then_moderate # noqa: E131 + else 'do_subscription') + self.push(next_step) + return + # The user must confirm their subscription. + self.push('send_confirmation') + + def _step_moderation_checks(self): + # Does the moderator need to approve the subscription request? + assert self.mlist.subscription_policy in ( + SubscriptionPolicy.moderate, + SubscriptionPolicy.confirm_then_moderate, + ), self.mlist.subscription_policy + if self.pre_approved: + self.push('do_subscription') + else: + self.push('get_moderator_approval') + + def _step_get_moderator_approval(self): + # Here's the next step in the workflow, assuming the moderator + # approves of the subscription. If they don't, the workflow and + # subscription request will just be thrown away. + self._set_token(TokenOwner.moderator) + self.push('subscribe_from_restored') + self.save() + log.info('{}: held subscription request from {}'.format( + self.mlist.fqdn_listname, self.address.email)) + # Possibly send a notification to the list moderators. + if self.mlist.admin_immed_notify: + subject = _( + 'New subscription request to $self.mlist.display_name ' + 'from $self.address.email') + username = formataddr( + (self.subscriber.display_name, self.address.email)) + template = getUtility(ITemplateLoader).get( + 'list:admin:action:subscribe', self.mlist) + text = wrap(expand(template, self.mlist, dict( + member=username, + ))) + # 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) + # The workflow must stop running here. + raise StopIteration + + def _step_subscribe_from_restored(self): + # Prevent replay attacks. + self._set_token(TokenOwner.no_one) + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # subscribe the user. + if self.which is WhichSubscriber.address: + self.subscriber = self.address + else: + assert self.which is WhichSubscriber.user + self.subscriber = self.user + self.push('do_subscription') + + def _step_do_subscription(self): + # We can immediately subscribe the user to the mailing list. + self.member = self.mlist.subscribe(self.subscriber) + assert self.token is None and self.token_owner is TokenOwner.no_one, ( + 'Unexpected active token at end of subscription workflow') + + def _step_send_confirmation(self): + self._set_token(TokenOwner.subscriber) + self.push('do_confirm_verify') + self.save() + # Triggering this event causes the confirmation message to be sent. + notify(SubscriptionConfirmationNeededEvent( + self.mlist, self.token, self.address.email)) + # Now we wait for the confirmation. + raise StopIteration + + def _step_do_confirm_verify(self): + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # continue with the confirmation/verification step. + 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) + # The user has confirmed their subscription request, and also verified + # their email address if necessary. This latter needs to be set on the + # IAddress, but there's nothing more to do about the confirmation step. + # We just continue along with the workflow. + if self.address.verified_on is None: + self.address.verified_on = now() + # The next step depends on the mailing list's subscription policy. + next_step = ('moderation_checks' + if self.mlist.subscription_policy in ( + SubscriptionPolicy.moderate, + SubscriptionPolicy.confirm_then_moderate, + ) + else 'do_subscription') + self.push(next_step) + + +@public +class UnSubscriptionWorkflow(SubscriptionWorkflowCommon): + """Workflow of a unsubscription request.""" + + name = 'unsubscription-default' + description = '' + + 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__(mlist, subscriber) + if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber): + self.member = self.mlist.regular_members.get_member( + self.address.email) + self.pre_confirmed = pre_confirmed + self.pre_approved = pre_approved + + 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 the request is pre-confirmed, then the user can unsubscribe right + # now. + if self.pre_confirmed: + self.push('do_unsubscription') + 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(UnsubscriptionConfirmationNeededEvent( + 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)) + template = getUtility(ITemplateLoader).get( + 'list:admin:action:unsubscribe', self.mlist) + text = wrap(expand(template, self.mlist, dict( + member=username, + ))) + # 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) + # The workflow must stop running here + raise StopIteration + + def _step_do_confirm_verify(self): + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # continue with the confirmation/verification step. + 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) + # Restore the member object. + self.member = self.mlist.regular_members.get_member(self.address.email) + # It's possible the member was already unsubscribed while we were + # waiting for the confirmation. + if self.member is None: + return + # The user has confirmed their unsubscription request + next_step = ('moderation_checks' + if self.mlist.unsubscription_policy in ( + SubscriptionPolicy.moderate, + SubscriptionPolicy.confirm_then_moderate, + ) + else 'do_unsubscription') + self.push(next_step) + + def _step_do_unsubscription(self): + try: + delete_member(self.mlist, self.address.email) + except NotAMemberError: + # The member has already been unsubscribed. + pass + self.member = None + assert self.token is None and self.token_owner is TokenOwner.no_one, ( + 'Unexpected active token at end of subscription workflow') + + 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 WhichSubscriber.user + self.subscriber = self.user + self.push('do_unsubscription') diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py new file mode 100644 index 000000000..940152272 --- /dev/null +++ b/src/mailman/workflows/common.py @@ -0,0 +1,123 @@ +# Copyright (C) 2015-2017 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 . + +"""Common support between subscription and unsubscription.""" + +import uuid + +from datetime import timedelta +from enum import Enum +from mailman.interfaces.address import IAddress +from mailman.interfaces.pending import IPendings +from mailman.interfaces.subscriptions import TokenOwner +from mailman.interfaces.user import IUser +from mailman.interfaces.usermanager import IUserManager +from mailman.utilities.datetime import now +from mailman.workflows.base import Workflow +from zope.component import getUtility + + +class WhichSubscriber(Enum): + address = 1 + user = 2 + + +class SubscriptionWorkflowCommon(Workflow): + """Common support between subscription and unsubscription.""" + + PENDABLE_CLASS = None + + def __init__(self, mlist, subscriber): + super().__init__() + self.mlist = mlist + self.address = None + self.user = None + self.which = None + self.member = None + self._set_token(TokenOwner.no_one) + # The subscriber must be either an IUser or IAddress. + if IAddress.providedBy(subscriber): + self.address = subscriber + self.user = self.address.user + self.which = WhichSubscriber.address + elif IUser.providedBy(subscriber): + self.address = subscriber.preferred_address + self.user = subscriber + self.which = WhichSubscriber.user + self.subscriber = subscriber + + @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) + if self.user is None: + self.user = self.address.user + + @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 = self.PENDABLE_CLASS( + 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)) -- cgit v1.2.3-70-g09d2 From 31854e7fadc147ec0dd0c79347f632091cf41461 Mon Sep 17 00:00:00 2001 From: J08nY Date: Fri, 30 Jun 2017 00:50:57 +0200 Subject: Make workflows implement their interfaces. --- src/mailman/app/subscriptions.py | 6 +++--- src/mailman/workflows/base.py | 6 +++++- src/mailman/workflows/builtin.py | 16 +++++----------- src/mailman/workflows/common.py | 28 ++++++++++++++++++++++++---- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index 90811722d..c89e1e79f 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -28,10 +28,10 @@ from mailman.interfaces.subscriptions import ( from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.workflows import IWorkflowStateManager from mailman.utilities.string import expand -from mailman.workflows.builtin import (PendableSubscription, - PendableUnsubscription, - SubscriptionWorkflow, +from mailman.workflows.builtin import (SubscriptionWorkflow, UnSubscriptionWorkflow) +from mailman.workflows.common import (PendableSubscription, + PendableUnsubscription) from public import public from zope.component import getUtility from zope.interface import implementer diff --git a/src/mailman/workflows/base.py b/src/mailman/workflows/base.py index 5988dfa43..e374d58b4 100644 --- a/src/mailman/workflows/base.py +++ b/src/mailman/workflows/base.py @@ -23,9 +23,11 @@ import logging from collections import deque -from mailman.interfaces.workflows import IWorkflowStateManager +from mailman.interfaces.workflows import IWorkflow, IWorkflowStateManager +from mailman.utilities.modules import abstract_component from public import public from zope.component import getUtility +from zope.interface import implementer COMMASPACE = ', ' @@ -33,6 +35,8 @@ log = logging.getLogger('mailman.error') @public +@abstract_component +@implementer(IWorkflow) class Workflow: """Generic workflow.""" diff --git a/src/mailman/workflows/builtin.py b/src/mailman/workflows/builtin.py index e16a888fc..33e428d0b 100644 --- a/src/mailman/workflows/builtin.py +++ b/src/mailman/workflows/builtin.py @@ -29,13 +29,15 @@ from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.member import (AlreadySubscribedError, MemberRole, MembershipIsBannedError, NotAMemberError) -from mailman.interfaces.pending import IPendable, IPendings +from mailman.interfaces.pending import IPendings from mailman.interfaces.subscriptions import ( SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner, UnsubscriptionConfirmationNeededEvent) from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager +from mailman.interfaces.workflows import (ISubscriptionWorkflow, + IUnsubscriptionWorkflow) from mailman.utilities.datetime import now from mailman.utilities.string import expand, wrap from mailman.workflows.common import (SubscriptionWorkflowCommon, @@ -49,17 +51,8 @@ from zope.interface import implementer log = logging.getLogger('mailman.subscribe') -@implementer(IPendable) -class PendableSubscription(dict): - PEND_TYPE = 'subscription' - - -@implementer(IPendable) -class PendableUnsubscription(dict): - PEND_TYPE = 'unsubscription' - - @public +@implementer(ISubscriptionWorkflow) class SubscriptionWorkflow(SubscriptionWorkflowCommon): """Workflow of a subscription request.""" @@ -269,6 +262,7 @@ class SubscriptionWorkflow(SubscriptionWorkflowCommon): @public +@implementer(IUnsubscriptionWorkflow) class UnSubscriptionWorkflow(SubscriptionWorkflowCommon): """Workflow of a unsubscription request.""" diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index 940152272..c57ce374d 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -22,13 +22,17 @@ import uuid from datetime import timedelta from enum import Enum from mailman.interfaces.address import IAddress -from mailman.interfaces.pending import IPendings +from mailman.interfaces.pending import IPendable, IPendings from mailman.interfaces.subscriptions import TokenOwner from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager +from mailman.interfaces.workflows import (ISubscriptionWorkflow, + IUnsubscriptionWorkflow, IWorkflow) from mailman.utilities.datetime import now from mailman.workflows.base import Workflow from zope.component import getUtility +from zope.interface import implementer +from zope.interface.exceptions import DoesNotImplement class WhichSubscriber(Enum): @@ -36,11 +40,19 @@ class WhichSubscriber(Enum): user = 2 +@implementer(IPendable) +class PendableSubscription(dict): + PEND_TYPE = 'subscription' + + +@implementer(IPendable) +class PendableUnsubscription(dict): + PEND_TYPE = 'unsubscription' + + class SubscriptionWorkflowCommon(Workflow): """Common support between subscription and unsubscription.""" - PENDABLE_CLASS = None - def __init__(self, mlist, subscriber): super().__init__() self.mlist = mlist @@ -113,7 +125,15 @@ class SubscriptionWorkflowCommon(Workflow): if token_owner is TokenOwner.no_one: self.token = None return - pendable = self.PENDABLE_CLASS( + + if ISubscriptionWorkflow.implementedBy(self.__class__): + pendable_class = PendableSubscription + elif IUnsubscriptionWorkflow.implementedBy(self.__class__): + pendable_class = PendableUnsubscription + else: + raise DoesNotImplement(IWorkflow) + + pendable = pendable_class( list_id=self.mlist.list_id, email=self.address.email, display_name=self.address.display_name, -- cgit v1.2.3-70-g09d2 From e8a0de7723c6e1d42ec2bf6fd4a4d8f22f237b78 Mon Sep 17 00:00:00 2001 From: J08nY Date: Tue, 4 Jul 2017 02:43:03 +0200 Subject: Split subscription workflow into mixins. --- src/mailman/workflows/common.py | 192 +++++++++++++++++++++++++++- src/mailman/workflows/subscription.py | 231 ++++++++++++++++++++++++++++++++++ 2 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/mailman/workflows/subscription.py diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index c57ce374d..79d57d0ae 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -18,23 +18,37 @@ """Common support between subscription and unsubscription.""" import uuid +import logging from datetime import timedelta +from email.utils import formataddr from enum import Enum +from mailman.core.i18n import _ +from mailman.email.message import UserNotification from mailman.interfaces.address import IAddress +from mailman.interfaces.bans import IBanManager +from mailman.interfaces.member import (AlreadySubscribedError, MemberRole, + MembershipIsBannedError) from mailman.interfaces.pending import IPendable, IPendings -from mailman.interfaces.subscriptions import TokenOwner +from mailman.interfaces.subscriptions import ( + SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner) +from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager from mailman.interfaces.workflows import (ISubscriptionWorkflow, IUnsubscriptionWorkflow, IWorkflow) from mailman.utilities.datetime import now +from mailman.utilities.string import expand, wrap from mailman.workflows.base import Workflow from zope.component import getUtility +from zope.event import notify from zope.interface import implementer from zope.interface.exceptions import DoesNotImplement +log = logging.getLogger('mailman.subscribe') + + class WhichSubscriber(Enum): address = 1 user = 2 @@ -141,3 +155,179 @@ class SubscriptionWorkflowCommon(Workflow): token_owner=token_owner.name, ) self.token = pendings.add(pendable, timedelta(days=3650)) + + +class SubscriptionBase(SubscriptionWorkflowCommon): + + def _step_sanity_checks(self): + # Ensure that we have both an address and a user, even if the address + # is not verified. We can't set the preferred address until it is + # verified. + if self.user is None: + # The address has no linked user so create one, link it, and set + # the user's preferred address. + assert self.address is not None, 'No address or user' + self.user = getUtility(IUserManager).make_user(self.address.email) + if self.address is None: + assert self.user.preferred_address is None, ( + "Preferred address exists, but wasn't used in constructor") + addresses = list(self.user.addresses) + if len(addresses) == 0: + raise AssertionError('User has no addresses: {}'.format( + self.user)) + # This is rather arbitrary, but we have no choice. + self.address = addresses[0] + assert self.user is not None and self.address is not None, ( + 'Insane sanity check results') + # Is this subscriber already a member? + if (self.which is WhichSubscriber.user and + self.user.preferred_address is not None): + subscriber = self.user + else: + subscriber = self.address + if self.mlist.is_subscribed(subscriber): + # 2017-04-22 BAW: This branch actually *does* get covered, as I've + # verified by a full coverage run, but diffcov for some reason + # claims that the test added in the branch that added this code + # does not cover the change. That seems like a bug in diffcov. + raise AlreadySubscribedError( # pragma: no cover + self.mlist.fqdn_listname, + self.address.email, + MemberRole.member) + # Is this email address banned? + if IBanManager(self.mlist).is_banned(self.address.email): + raise MembershipIsBannedError(self.mlist, self.address.email) + # Check if there is already a subscription request for this email. + pendings = getUtility(IPendings).find( + mlist=self.mlist, + pend_type='subscription') + for token, pendable in pendings: + if pendable['email'] == self.address.email: + raise SubscriptionPendingError(self.mlist, self.address.email) + # Start out with the subscriber being the token owner. + + def _step_do_subscription(self): + # We can immediately subscribe the user to the mailing list. + self.member = self.mlist.subscribe(self.subscriber) + assert self.token is None and self.token_owner is TokenOwner.no_one, ( + 'Unexpected active token at end of subscription workflow') + + +class RequestMixin: + + def _step_send_confirmation(self): + self._set_token(TokenOwner.subscriber) + self.push('do_confirm_verify') + self.save() + # Triggering this event causes the confirmation message to be sent. + notify(SubscriptionConfirmationNeededEvent( + self.mlist, self.token, self.address.email)) + # Now we wait for the confirmation. + raise StopIteration + + def _step_do_confirm_verify(self): + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # continue with the confirmation/verification step. + 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) + # The user has confirmed their subscription request, and also verified + # their email address if necessary. This latter needs to be set on the + # IAddress, but there's nothing more to do about the confirmation step. + # We just continue along with the workflow. + if self.address.verified_on is None: + self.address.verified_on = now() + self.verified = True + self.confirmed = True + + +class VerificationMixin(RequestMixin): + + def __init__(self, pre_verified=False): + self.verified = pre_verified + + def _step_verification_checks(self): + # Is the address already verified, or is the pre-verified flag set? + if self.address.verified_on is None: + if self.verified: + self.address.verified_on = now() + else: + # The address being subscribed is not yet verified, so we need + # to send a validation email that will also confirm that the + # user wants to be subscribed to this mailing list. + self.push('send_confirmation') + return + + +class ConfirmationMixin(RequestMixin): + + def __init__(self, pre_confirmed=False): + self.confirmed = pre_confirmed + + def _step_confirmation_checks(self): + # If the subscription has been pre-confirmed, then we can skip the + # confirmation check can be skipped. + if self.confirmed: + return + # The user must confirm their subscription. + self.push('send_confirmation') + + +class ModerationMixin: + + def __init__(self, pre_approved=False): + self.approved = pre_approved + + def _step_moderation_checks(self): + # Does the moderator need to approve the subscription request? + if self.approved: + return + # A moderator must confirm the subscription. + self.push('get_moderator_approval') + + def _step_get_moderator_approval(self): + # Here's the next step in the workflow, assuming the moderator + # approves of the subscription. If they don't, the workflow and + # subscription request will just be thrown away. + self._set_token(TokenOwner.moderator) + self.push('subscribe_from_restored') + self.save() + log.info('{}: held subscription request from {}'.format( + self.mlist.fqdn_listname, self.address.email)) + # Possibly send a notification to the list moderators. + if self.mlist.admin_immed_notify: + subject = _( + 'New subscription request to $self.mlist.display_name ' + 'from $self.address.email') + username = formataddr( + (self.subscriber.display_name, self.address.email)) + template = getUtility(ITemplateLoader).get( + 'list:admin:action:subscribe', self.mlist) + text = wrap(expand(template, self.mlist, dict( + member=username, + ))) + # 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) + # The workflow must stop running here. + raise StopIteration + + def _step_subscribe_from_restored(self): + # Prevent replay attacks. + self._set_token(TokenOwner.no_one) + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # subscribe the user. + if self.which is WhichSubscriber.address: + self.subscriber = self.address + else: + assert self.which is WhichSubscriber.user + self.subscriber = self.user diff --git a/src/mailman/workflows/subscription.py b/src/mailman/workflows/subscription.py new file mode 100644 index 000000000..f780c96a0 --- /dev/null +++ b/src/mailman/workflows/subscription.py @@ -0,0 +1,231 @@ +# Copyright (C) 2015-2017 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 . + +"""""" + +from mailman.core.i18n import _ +from mailman.interfaces.workflows import ISubscriptionWorkflow +from mailman.workflows.common import (ConfirmationMixin, ModerationMixin, + SubscriptionBase, VerificationMixin) +from public import public +from zope.interface import implementer + + +@public +@implementer(ISubscriptionWorkflow) +class OpenSubscriptionPolicy(SubscriptionBase, VerificationMixin): + """""" + + name = 'sub-policy-open' + description = _('An open subscription policy, only requires verification.') + initial_state = 'prepare' + save_attributes = ( + 'verified', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_verified=False): + """ + + :param mlist: + :param subscriber: The user or address to subscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_verified: A flag indicating whether the subscriber's email + address should be considered pre-verified. Normally a never + before seen email address must be verified by mail-back + confirmation. Setting this flag to True automatically verifies + such addresses without the mail-back. (A confirmation message may + still be sent under other conditions.) + :type pre_verified: bool + """ + SubscriptionBase.__init__(self, mlist, subscriber) + VerificationMixin.__init__(self, pre_verified=pre_verified) + + def _step_prepare(self): + self.push('do_subscription') + self.push('verification_checks') + self.push('sanity_checks') + + +@public +@implementer(ISubscriptionWorkflow) +class ConfirmSubscriptionPolicy(SubscriptionBase, ConfirmationMixin, + VerificationMixin): + """""" + + name = 'sub-policy-confirm' + description = _('An subscription policy that requires confirmation.') + initial_state = 'prepare' + save_attributes = ( + 'verified', + 'confirmed', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_verified=False, pre_confirmed=False): + """ + + :param mlist: + :param subscriber: The user or address to subscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_verified: A flag indicating whether the subscriber's email + address should be considered pre-verified. Normally a never + before seen email address must be verified by mail-back + confirmation. Setting this flag to True automatically verifies + such addresses without the mail-back. (A confirmation message may + still be sent under other conditions.) + :type pre_verified: bool + :param pre_confirmed: A flag indicating whether, when required by the + subscription policy, a subscription request should be considered + pre-confirmed. Normally in such cases, a mail-back confirmation + message is sent to the subscriber, which must be positively + acknowledged by some manner. Setting this flag to True + automatically confirms the subscription request. (A confirmation + message may still be sent under other conditions.) + :type pre_confirmed: bool + """ + SubscriptionBase.__init__(self, mlist, subscriber) + VerificationMixin.__init__(self, pre_verified=pre_verified) + ConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + + def _step_prepare(self): + self.push('do_subscription') + self.push('confirmation_checks') + self.push('verification_checks') + self.push('sanity_checks') + + +@public +@implementer(ISubscriptionWorkflow) +class ModerationSubscriptionPolicy(SubscriptionBase, ModerationMixin, + VerificationMixin): + """""" + + name = 'sub-policy-moderate' + description = _('A subscription policy that requires moderation.') + initial_state = 'prepare' + save_attributes = ( + 'approved', + 'verified', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_verified=False, pre_approved=False): + """ + + :param mlist: + :param subscriber: The user or address to subscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_verified: A flag indicating whether the subscriber's email + address should be considered pre-verified. Normally a never + before seen email address must be verified by mail-back + confirmation. Setting this flag to True automatically verifies + such addresses without the mail-back. (A confirmation message may + still be sent under other conditions.) + :type pre_verified: bool + :param pre_approved: A flag indicating whether, when required by the + subscription policy, a subscription request should be considered + pre-approved. Normally in such cases, the list administrator is + notified that an approval is necessary, which must be positively + acknowledged in some manner. Setting this flag to True + automatically approves the subscription request. + :type pre_approved: bool + """ + SubscriptionBase.__init__(self, mlist, subscriber) + VerificationMixin.__init__(self, pre_verified=pre_verified) + ModerationMixin.__init__(self, pre_approved=pre_approved) + + def _step_prepare(self): + self.push('do_subscription') + self.push('moderation_checks') + self.push('verification_checks') + self.push('sanity_checks') + + +@public +@implementer(ISubscriptionWorkflow) +class ConfirmModerationSubscriptionPolicy(SubscriptionBase, ConfirmationMixin, + ModerationMixin, VerificationMixin): + """""" + + name = 'sub-policy-confirm-moderate' + description = _( + 'A subscription policy that requires moderation after confirmation.') + initial_state = 'prepare' + save_attributes = ( + 'approved', + 'confirmed', + 'verified', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_verified=False, pre_confirmed=False, pre_approved=False): + """ + + :param mlist: + :param subscriber: The user or address to subscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_verified: A flag indicating whether the subscriber's email + address should be considered pre-verified. Normally a never + before seen email address must be verified by mail-back + confirmation. Setting this flag to True automatically verifies + such addresses without the mail-back. (A confirmation message may + still be sent under other conditions.) + :type pre_verified: bool + :param pre_confirmed: A flag indicating whether, when required by the + subscription policy, a subscription request should be considered + pre-confirmed. Normally in such cases, a mail-back confirmation + message is sent to the subscriber, which must be positively + acknowledged by some manner. Setting this flag to True + automatically confirms the subscription request. (A confirmation + message may still be sent under other conditions.) + :type pre_confirmed: bool + :param pre_approved: A flag indicating whether, when required by the + subscription policy, a subscription request should be considered + pre-approved. Normally in such cases, the list administrator is + notified that an approval is necessary, which must be positively + acknowledged in some manner. Setting this flag to True + automatically approves the subscription request. + :type pre_approved: bool + """ + SubscriptionBase.__init__(self, mlist, subscriber) + VerificationMixin.__init__(self, pre_verified=pre_verified) + ConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + ModerationMixin.__init__(self, pre_approved=pre_approved) + + def _step_prepare(self): + self.push('do_subscription') + self.push('moderation_checks') + self.push('confirmation_checks') + self.push('verification_checks') + self.push('sanity_checks') -- cgit v1.2.3-70-g09d2 From 505f47025ca3fab68a489238837c1a84e4b2a1b6 Mon Sep 17 00:00:00 2001 From: J08nY Date: Tue, 4 Jul 2017 17:54:47 +0200 Subject: Save the complete workflow stack, not only last step. --- src/mailman/app/tests/test_workflow.py | 2 +- .../versions/7c5b39d1ecc4_workflow_steps.py | 90 ++++++++++++++++++++ src/mailman/database/tests/test_migrations.py | 99 ++++++++++++++++++++++ src/mailman/interfaces/workflows.py | 8 +- src/mailman/model/tests/test_workflow.py | 22 ++--- src/mailman/model/workflows.py | 6 +- src/mailman/workflows/base.py | 22 ++--- 7 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 src/mailman/database/alembic/versions/7c5b39d1ecc4_workflow_steps.py diff --git a/src/mailman/app/tests/test_workflow.py b/src/mailman/app/tests/test_workflow.py index f242a73ec..3e7856b29 100644 --- a/src/mailman/app/tests/test_workflow.py +++ b/src/mailman/app/tests/test_workflow.py @@ -153,7 +153,7 @@ class TestWorkflow(unittest.TestCase): # Save the state of an old version of the workflow that would not have # the cat attribute. state_manager.save( - self._workflow.token, 'first', + self._workflow.token, '["first"]', json.dumps({'ant': 1, 'bee': 2})) # Restore in the current version that needs the cat attribute. new_workflow = MyWorkflow() diff --git a/src/mailman/database/alembic/versions/7c5b39d1ecc4_workflow_steps.py b/src/mailman/database/alembic/versions/7c5b39d1ecc4_workflow_steps.py new file mode 100644 index 000000000..c2daffd63 --- /dev/null +++ b/src/mailman/database/alembic/versions/7c5b39d1ecc4_workflow_steps.py @@ -0,0 +1,90 @@ +# Copyright (C) 2015-2017 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 . + +"""Save whole workflow stack in workflowstate. + +Revision ID: 7c5b39d1ecc4 +Revises: 4bd95c99b2e7 +Create Date: 2017-07-04 16:45:36.470746 + +""" + +import json +import sqlalchemy as sa + +from alembic import op +from mailman.database.types import SAUnicode + +# revision identifiers, used by Alembic. +revision = '7c5b39d1ecc4' +down_revision = '4bd95c99b2e7' + +old_state_table = sa.sql.table( + 'workflowstate', + sa.sql.column('token', SAUnicode), + sa.sql.column('step', SAUnicode), + sa.sql.column('data', SAUnicode) + ) +new_state_table = sa.sql.table( + 'workflowstate', + sa.sql.column('token', SAUnicode), + sa.sql.column('steps', SAUnicode), + sa.sql.column('data', SAUnicode) + ) + + +def upgrade(): + # Rename the column + with op.batch_alter_table('workflowstate') as batch_op: + batch_op.alter_column('step', new_column_name='steps', + existing_type=SAUnicode) + + connection = op.get_bind() + for token, step, data in connection.execute( + new_state_table.select()).fetchall(): + # Wrap the single step in a JSON array. + if step is not None: + new_steps = json.dumps([step]) + else: + new_steps = '[]' + connection.execute( + new_state_table.update().where( + new_state_table.c.token == token).values( + steps=new_steps) + ) + + +def downgrade(): + # Rename back the column + connection = op.get_bind() + with op.batch_alter_table('workflowstate') as batch_op: + batch_op.alter_column('steps', new_column_name='step', + existing_type=SAUnicode) + + for token, steps, data in connection.execute( + old_state_table.select()).fetchall(): + # Extract and save at least the last state. + step_stack = json.loads(steps) + if len(step_stack) == 0: + new_step = None + else: + new_step = step_stack.pop() + connection.execute( + old_state_table.update().where( + old_state_table.c.token == token).values( + step=new_step) + ) diff --git a/src/mailman/database/tests/test_migrations.py b/src/mailman/database/tests/test_migrations.py index ce1bc101b..5b686d13b 100644 --- a/src/mailman/database/tests/test_migrations.py +++ b/src/mailman/database/tests/test_migrations.py @@ -18,6 +18,7 @@ """Test database schema migrations with Alembic""" import os +import json import unittest import sqlalchemy as sa import alembic.command @@ -495,3 +496,101 @@ class TestMigrations(unittest.TestCase): self.assertEqual( len(list(config.db.store.execute(mlist_table.select()))), 0) + + def test_7c5b39d1ecc4_workflow_steps_upgrade(self): + old_state_table = sa.sql.table( + 'workflowstate', + sa.sql.column('token', SAUnicode), + sa.sql.column('step', SAUnicode), + sa.sql.column('data', SAUnicode) + ) + new_state_table = sa.sql.table( + 'workflowstate', + sa.sql.column('token', SAUnicode), + sa.sql.column('steps', SAUnicode), + sa.sql.column('data', SAUnicode) + ) + with transaction(): + # Start at the previous revision. + alembic.command.downgrade(alembic_cfg, '4bd95c99b2e7') + config.db.store.execute(old_state_table.insert().values([ + dict(token='12345', + step='some_step', + data='whatever data'), + dict(token='6789', + step=None, + data='other data') + ])) + + # Now upgrade. + alembic.command.upgrade(alembic_cfg, '7c5b39d1ecc4') + + token, steps, data = config.db.store.execute( + new_state_table.select().where( + new_state_table.c.token == '12345' + )).fetchone() + self.assertEqual(token, '12345') + self.assertEqual(steps, json.dumps(['some_step'])) + self.assertEqual(data, 'whatever data') + + token, steps, data = config.db.store.execute( + new_state_table.select().where( + new_state_table.c.token == '6789' + )).fetchone() + self.assertEqual(token, '6789') + self.assertEqual(steps, json.dumps([])) + self.assertEqual(data, 'other data') + + def test_7c5b39d1ecc4_workflow_steps_downgrade(self): + old_state_table = sa.sql.table( + 'workflowstate', + sa.sql.column('token', SAUnicode), + sa.sql.column('step', SAUnicode), + sa.sql.column('data', SAUnicode) + ) + new_state_table = sa.sql.table( + 'workflowstate', + sa.sql.column('token', SAUnicode), + sa.sql.column('steps', SAUnicode), + sa.sql.column('data', SAUnicode) + ) + with transaction(): + # Start at the revision. + alembic.command.downgrade(alembic_cfg, '7c5b39d1ecc4') + config.db.store.execute(new_state_table.insert().values([ + dict(token='12345', + steps=json.dumps(['next_step', 'some_step']), + data='whatever data'), + dict(token='6789', + steps=json.dumps(['only_step']), + data='other data'), + dict(token='abcde', + steps=json.dumps([]), + data='another data') + ])) + # Now downgrade. + alembic.command.downgrade(alembic_cfg, '4bd95c99b2e7') + + token, step, data = config.db.store.execute( + old_state_table.select().where( + old_state_table.c.token == '12345' + )).fetchone() + self.assertEqual(token, '12345') + self.assertEqual(step, 'some_step') + self.assertEqual(data, 'whatever data') + + token, step, data = config.db.store.execute( + old_state_table.select().where( + old_state_table.c.token == '6789' + )).fetchone() + self.assertEqual(token, '6789') + self.assertEqual(step, 'only_step') + self.assertEqual(data, 'other data') + + token, step, data = config.db.store.execute( + old_state_table.select().where( + old_state_table.c.token == 'abcde' + )).fetchone() + self.assertEqual(token, 'abcde') + self.assertEqual(step, None) + self.assertEqual(data, 'another data') diff --git a/src/mailman/interfaces/workflows.py b/src/mailman/interfaces/workflows.py index b110a481e..0fad52cca 100644 --- a/src/mailman/interfaces/workflows.py +++ b/src/mailman/interfaces/workflows.py @@ -27,7 +27,7 @@ class IWorkflowState(Interface): token = Attribute('A unique key identifying the workflow instance.') - step = Attribute("This workflow's next step.") + steps = Attribute("This workflow's next steps.") data = Attribute('Additional data (may be JSON-encoded).') @@ -36,13 +36,13 @@ class IWorkflowState(Interface): class IWorkflowStateManager(Interface): """The workflow states manager.""" - def save(token, step, data=None): + def save(token, steps, data=None): """Save the state of a workflow. :param token: A unique token identifying this workflow instance. :type token: str - :param step: The next step for this workflow. - :type step: str + :param steps: The next steps for this workflow. + :type steps: str :param data: Additional data (workflow-specific). :type data: str """ diff --git a/src/mailman/model/tests/test_workflow.py b/src/mailman/model/tests/test_workflow.py index 41c00efc0..900efbb25 100644 --- a/src/mailman/model/tests/test_workflow.py +++ b/src/mailman/model/tests/test_workflow.py @@ -33,12 +33,12 @@ class TestWorkflow(unittest.TestCase): def test_save_restore_workflow(self): # Save and restore a workflow. token = 'bee' - step = 'cat' + steps = 'cat' data = 'dog' - self._manager.save(token, step, data) + self._manager.save(token, steps, data) state = self._manager.restore(token) self.assertEqual(state.token, token) - self.assertEqual(state.step, step) + self.assertEqual(state.steps, steps) self.assertEqual(state.data, data) def test_save_restore_workflow_without_step(self): @@ -48,17 +48,17 @@ class TestWorkflow(unittest.TestCase): self._manager.save(token, data=data) state = self._manager.restore(token) self.assertEqual(state.token, token) - self.assertIsNone(state.step) + self.assertIsNone(state.steps) self.assertEqual(state.data, data) def test_save_restore_workflow_without_data(self): # Save and restore a workflow that contains no data. token = 'bee' - step = 'cat' - self._manager.save(token, step) + steps = 'cat' + self._manager.save(token, steps) state = self._manager.restore(token) self.assertEqual(state.token, token) - self.assertEqual(state.step, step) + self.assertEqual(state.steps, steps) self.assertIsNone(state.data) def test_save_restore_workflow_without_step_or_data(self): @@ -67,7 +67,7 @@ class TestWorkflow(unittest.TestCase): self._manager.save(token) state = self._manager.restore(token) self.assertEqual(state.token, token) - self.assertIsNone(state.step) + self.assertIsNone(state.steps) self.assertIsNone(state.data) def test_restore_workflow_with_no_matching_token(self): @@ -106,13 +106,13 @@ class TestWorkflow(unittest.TestCase): self._manager.discard('token2') self.assertEqual(self._manager.count, 3) state = self._manager.restore('token1') - self.assertEqual(state.step, 'one') + self.assertEqual(state.steps, 'one') state = self._manager.restore('token2') self.assertIsNone(state) state = self._manager.restore('token3') - self.assertEqual(state.step, 'three') + self.assertEqual(state.steps, 'three') state = self._manager.restore('token4') - self.assertEqual(state.step, 'four') + self.assertEqual(state.steps, 'four') def test_discard_missing_workflow(self): self._manager.discard('bogus-token') diff --git a/src/mailman/model/workflows.py b/src/mailman/model/workflows.py index 587d3375e..be793eb43 100644 --- a/src/mailman/model/workflows.py +++ b/src/mailman/model/workflows.py @@ -34,7 +34,7 @@ class WorkflowState(Model): __tablename__ = 'workflowstate' token = Column(SAUnicode, primary_key=True) - step = Column(SAUnicode) + steps = Column(SAUnicode) data = Column(SAUnicode) @@ -44,9 +44,9 @@ class WorkflowStateManager: """See `IWorkflowStateManager`.""" @dbconnection - def save(self, store, token, step=None, data=None): + def save(self, store, token, steps=None, data=None): """See `IWorkflowStateManager`.""" - state = WorkflowState(token=token, step=step, data=data) + state = WorkflowState(token=token, steps=steps, data=data) store.add(state) @dbconnection diff --git a/src/mailman/workflows/base.py b/src/mailman/workflows/base.py index e374d58b4..8153bf77d 100644 --- a/src/mailman/workflows/base.py +++ b/src/mailman/workflows/base.py @@ -70,7 +70,7 @@ class Workflow: self._next.append(step) def _pop(self): - name = self._next.popleft() + name = self._next.pop() step = getattr(self, '_step_{}'.format(name)) self._count += 1 if self.debug: # pragma: nocover @@ -114,20 +114,12 @@ class Workflow: assert self.token, 'Workflow token must be set' state_manager = getUtility(IWorkflowStateManager) data = {attr: getattr(self, attr) for attr in self.save_attributes} - # Note: only the next step is saved, not the whole stack. This is not - # an issue in practice, since there's never more than a single step in - # the queue anyway. If we want to support more than a single step in - # the queue *and* want to support state saving/restoring, change this - # method and the restore() method. + # Save the workflow stack. if len(self._next) == 0: - step = None - elif len(self._next) == 1: - step = self._next[0] + steps = '[]' else: - raise AssertionError( - "Can't save a workflow state with more than one step " - "in the queue") - state_manager.save(self.token, step, json.dumps(data)) + steps = json.dumps(list(self._next)) + state_manager.save(self.token, steps, json.dumps(data)) def restore(self): """See `IWorkflow`.""" @@ -137,8 +129,8 @@ class Workflow: # The token doesn't exist in the database. raise LookupError(self.token) self._next.clear() - if state.step: - self._next.append(state.step) + if state.steps: + self._next.extend(json.loads(state.steps)) data = json.loads(state.data) for attr in self.save_attributes: try: -- cgit v1.2.3-70-g09d2 From bf8550eaa2b65dd6c546eaf153dfefc8d2f4fe5f Mon Sep 17 00:00:00 2001 From: J08nY Date: Tue, 4 Jul 2017 19:45:11 +0200 Subject: Split unsubscription workflow into mixins. --- src/mailman/workflows/common.py | 121 +++++++++++++++++++- src/mailman/workflows/unsubscription.py | 190 ++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 src/mailman/workflows/unsubscription.py diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index 79d57d0ae..d6c372b1e 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -23,15 +23,19 @@ import logging from datetime import timedelta from email.utils import formataddr from enum import Enum + +from mailman.app.membership import delete_member from mailman.core.i18n import _ from mailman.email.message import UserNotification from mailman.interfaces.address import IAddress from mailman.interfaces.bans import IBanManager from mailman.interfaces.member import (AlreadySubscribedError, MemberRole, - MembershipIsBannedError) + MembershipIsBannedError, + NotAMemberError) from mailman.interfaces.pending import IPendable, IPendings from mailman.interfaces.subscriptions import ( - SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner) + SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner, + UnsubscriptionConfirmationNeededEvent) from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager @@ -331,3 +335,116 @@ class ModerationMixin: else: assert self.which is WhichSubscriber.user self.subscriber = self.user + + +class UnsubscriptionBase(SubscriptionWorkflowCommon): + + def __init__(self, mlist, subscriber): + super().__init__(mlist, subscriber) + if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber): + self.member = self.mlist.regular_members.get_member( + self.address.email) + + def _step_subscription_checks(self): + assert self.mlist.is_subscribed(self.subscriber) + + def _step_do_unsubscription(self): + try: + delete_member(self.mlist, self.address.email) + except NotAMemberError: + # The member has already been unsubscribed. + pass + self.member = None + assert self.token is None and self.token_owner is TokenOwner.no_one, ( + 'Unexpected active token at end of subscription workflow') + + +class UnRequestMixin: + + def _step_send_confirmation(self): + self._set_token(TokenOwner.subscriber) + self.push('do_confirm_verify') + self.save() + notify(UnsubscriptionConfirmationNeededEvent( + self.mlist, self.token, self.address.email)) + raise StopIteration + + def _step_do_confirm_verify(self): + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # continue with the confirmation/verification step. + 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) + # Restore the member object. + self.member = self.mlist.regular_members.get_member(self.address.email) + # It's possible the member was already unsubscribed while we were + # waiting for the confirmation. + if self.member is None: + return + # The user has confirmed their unsubscription request + self.confirmed = True + + +class UnConfirmationMixin(UnRequestMixin): + + def __init__(self, pre_confirmed=False): + self.confirmed = pre_confirmed + + def _step_confirmation_checks(self): + # If the unsubscription has been pre-confirmed, then we can skip the + # confirmation check can be skipped. + if self.confirmed: + return + # The user must confirm their unsubscription. + self.push('send_confirmation') + + +class UnModerationMixin(UnRequestMixin): + + def __init__(self, pre_approved=False): + self.approved = pre_approved + + def _step_moderation_checks(self): + # Does the moderator need to approve the unsubscription request? + if not self.approved: + 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)) + template = getUtility(ITemplateLoader).get( + 'list:admin:action:unsubscribe', self.mlist) + text = wrap(expand(template, self.mlist, dict( + member=username, + ))) + # 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) + # The workflow must stop running here + raise StopIteration + + 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 WhichSubscriber.user + self.subscriber = self.user diff --git a/src/mailman/workflows/unsubscription.py b/src/mailman/workflows/unsubscription.py new file mode 100644 index 000000000..3c4ad39de --- /dev/null +++ b/src/mailman/workflows/unsubscription.py @@ -0,0 +1,190 @@ +# Copyright (C) 2015-2017 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 . + +"""""" + +from mailman.core.i18n import _ +from mailman.interfaces.workflows import IUnsubscriptionWorkflow +from mailman.workflows.common import (UnConfirmationMixin, UnModerationMixin, + UnsubscriptionBase) +from public import public +from zope.interface import implementer + + +@public +@implementer(IUnsubscriptionWorkflow) +class OpenUnsubscriptionPolicy(UnsubscriptionBase): + """""" + + name = 'unsub-policy-open' + description = _( + 'An open unsubscription policy, only requires verification.') + initial_state = 'prepare' + save_attributes = ( + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('subscription_checks') + + +@public +@implementer(IUnsubscriptionWorkflow) +class ConfirmUnsubscriptionPolicy(UnsubscriptionBase, UnConfirmationMixin): + """""" + + name = 'unsub-policy-confirm' + description = _('An unsubscription policy that requires confirmation.') + initial_state = 'prepare' + save_attributes = ( + 'confirmed', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_confirmed=False): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_confirmed: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-confirmed. Normally in such cases, a mail-back + confirmation message is sent to the subscriber, which must be + positively acknowledged by some manner. Setting this flag to True + automatically confirms the unsubscription request. (A confirmation + message may still be sent under other conditions.) + :type pre_confirmed: bool + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + UnConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('confirmation_checks') + self.push('subscription_checks') + + +@public +@implementer(IUnsubscriptionWorkflow) +class ModerationUnsubscriptionPolicy(UnsubscriptionBase, UnModerationMixin): + """""" + + name = 'unsub-policy-moderate' + description = _('An unsubscription policy that requires moderation.') + initial_state = 'prepare' + save_attributes = ( + 'approved', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_approved=False): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_approved: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-approved. Normally in such cases, the list + administrator is notified that an approval is necessary, which + must be positively acknowledged in some manner. Setting this flag + to True automatically approves the unsubscription request. + :type pre_approved: bool + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + UnModerationMixin.__init__(self, pre_approved=pre_approved) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('moderation_checks') + self.push('subscription_checks') + + +@public +@implementer(IUnsubscriptionWorkflow) +class ConfirmModerationUnsubscriptionPolicy(UnsubscriptionBase, + UnConfirmationMixin, + UnModerationMixin): + """""" + + name = 'unsub-policy-confirm-moderate' + description = _( + 'An unsubscription policy, requires moderation after confirmation.') + initial_state = 'prepare' + save_attributes = ( + 'approved', + 'confirmed', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_confirmed=False, pre_approved=False): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_confirmed: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-confirmed. Normally in such cases, a mail-back + confirmation message is sent to the subscriber, which must be + positively acknowledged by some manner. Setting this flag to True + automatically confirms the unsubscription request. (A confirmation + message may still be sent under other conditions.) + :type pre_confirmed: bool + :param pre_approved: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-approved. Normally in such cases, the list + administrator is notified that an approval is necessary, which + must be positively acknowledged in some manner. Setting this flag + to True automatically approves the unsubscription request. + :type pre_approved: bool + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + UnConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + UnModerationMixin.__init__(self, pre_approved=pre_approved) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('moderation_checks') + self.push('confirmation_checks') + self.push('subscription_checks') -- cgit v1.2.3-70-g09d2 From 26f31b20c3cad6217f38ce8ded63c73da66ff3f8 Mon Sep 17 00:00:00 2001 From: J08nY Date: Tue, 4 Jul 2017 22:06:07 +0200 Subject: Initialize the workflows. --- src/mailman/config/config.py | 1 + src/mailman/core/initialize.py | 2 ++ src/mailman/core/workflows.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 src/mailman/core/workflows.py diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index 61c9fe6ed..4a4eadff5 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -76,6 +76,7 @@ class Configuration: self.handlers = {} self.pipelines = {} self.commands = {} + self.workflows = {} self.password_context = None self.db = None diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py index dc67e9a68..dfa365448 100644 --- a/src/mailman/core/initialize.py +++ b/src/mailman/core/initialize.py @@ -156,6 +156,7 @@ def initialize_2(debug=False, propagate_logs=None, testing=False): config.db = getUtility(IDatabaseFactory, utility_name).create() # Initialize the rules and chains. Do the imports here so as to avoid # circular imports. + from mailman.core.workflows import initialize as initialize_workflows from mailman.app.commands import initialize as initialize_commands from mailman.core.chains import initialize as initialize_chains from mailman.core.pipelines import initialize as initialize_pipelines @@ -165,6 +166,7 @@ def initialize_2(debug=False, propagate_logs=None, testing=False): initialize_chains() initialize_pipelines() initialize_commands() + initialize_workflows() @public diff --git a/src/mailman/core/workflows.py b/src/mailman/core/workflows.py new file mode 100644 index 000000000..b16da7df9 --- /dev/null +++ b/src/mailman/core/workflows.py @@ -0,0 +1,34 @@ +# Copyright (C) 2007-2017 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 . + +"""""" + +from mailman.config import config +from mailman.interfaces.workflows import IWorkflow +from mailman.utilities.modules import find_components +from public import public + + +@public +def initialize(): + """Find and register all workflows in all plugins.""" + for workflow in find_components('mailman.workflows', IWorkflow): + assert workflow.name not in config.workflows, ( + 'Duplicate key "{}" found in {}: "{}"'.format( + workflow.name, config.workflows, + config.workflows[workflow.name])) + config.workflows[workflow.name] = workflow -- cgit v1.2.3-70-g09d2 From 6c621405c88671a58ef24cd84a9bd74ca324207e Mon Sep 17 00:00:00 2001 From: J08nY Date: Wed, 5 Jul 2017 01:07:37 +0200 Subject: Migrate the [un]subscription_policy attribute. - This is quite a huge commit, since it changes the type of the MailingList.subscription_policy and unsubscription_policy attributes to the new names of pluggable workflows, in all occurences. - Also adds a migration to migrate the attributes to the new types. - Adds tests for the migration. --- src/mailman/app/docs/moderator.rst | 10 +- src/mailman/app/subscriptions.py | 25 +- src/mailman/app/tests/test_moderation.py | 2 +- src/mailman/app/tests/test_subscriptions.py | 226 ++++++------ src/mailman/app/tests/test_unsubscriptions.py | 152 ++++---- src/mailman/app/tests/test_workflowmanager.py | 22 +- src/mailman/commands/docs/membership.rst | 6 +- src/mailman/commands/tests/test_eml_confirm.py | 4 +- src/mailman/commands/tests/test_eml_membership.py | 4 +- .../ccb9e28c44f4_mailinglist_sub_unsub_policies.py | 141 +++++++ src/mailman/database/tests/test_migrations.py | 54 +++ src/mailman/interfaces/subscriptions.py | 6 +- src/mailman/model/docs/subscriptions.rst | 13 +- src/mailman/model/mailinglist.py | 36 +- src/mailman/rest/docs/membership.rst | 2 - src/mailman/rest/docs/sub-moderation.rst | 5 +- src/mailman/rest/listconf.py | 26 +- src/mailman/rest/members.py | 61 ++-- src/mailman/rest/tests/test_listconf.py | 6 +- src/mailman/rest/tests/test_membership.py | 19 +- src/mailman/rest/tests/test_moderation.py | 7 +- src/mailman/rest/tests/test_users.py | 2 +- src/mailman/rest/validator.py | 49 ++- src/mailman/runners/docs/command.rst | 4 +- src/mailman/runners/tests/test_leave.py | 7 +- src/mailman/styles/base.py | 8 +- src/mailman/utilities/importer.py | 17 +- src/mailman/utilities/tests/test_import.py | 22 +- src/mailman/workflows/builtin.py | 403 --------------------- 29 files changed, 620 insertions(+), 719 deletions(-) create mode 100644 src/mailman/database/alembic/versions/ccb9e28c44f4_mailinglist_sub_unsub_policies.py delete mode 100644 src/mailman/workflows/builtin.py diff --git a/src/mailman/app/docs/moderator.rst b/src/mailman/app/docs/moderator.rst index ce25a4711..6fcaf0b08 100644 --- a/src/mailman/app/docs/moderator.rst +++ b/src/mailman/app/docs/moderator.rst @@ -224,7 +224,7 @@ Fred is a member of the mailing list... >>> from mailman.interfaces.subscriptions import ISubscriptionManager >>> registrar = ISubscriptionManager(mlist) >>> token, token_owner, member = registrar.register( - ... fred, pre_verified=True, pre_confirmed=True, pre_approved=True) + ... fred, pre_verified=True, pre_confirmed=True) >>> member on ant@example.com as MemberRole.member> @@ -301,16 +301,16 @@ Usually, the list administrators want to be notified when there are membership change requests they need to moderate. These notifications are sent when the list is configured to send them. - >>> from mailman.interfaces.mailinglist import SubscriptionPolicy + >>> from mailman.workflows.subscription import ModerationSubscriptionPolicy >>> mlist.admin_immed_notify = True - >>> mlist.subscription_policy = SubscriptionPolicy.moderate + >>> mlist.subscription_policy = ModerationSubscriptionPolicy Gwen tries to subscribe to the mailing list. >>> gwen = getUtility(IUserManager).create_address( ... 'gwen@example.com', 'Gwen Person') >>> token, token_owner, member = registrar.register( - ... gwen, pre_verified=True, pre_confirmed=True) + ... gwen, pre_verified=True) Her subscription must be approved by the list administrator, so she is not yet a member of the mailing list. @@ -414,7 +414,7 @@ message. >>> herb = getUtility(IUserManager).create_address( ... 'herb@example.com', 'Herb Person') >>> token, token_owner, member = registrar.register( - ... herb, pre_verified=True, pre_confirmed=True, pre_approved=True) + ... herb, pre_verified=True, pre_approved=True) >>> messages = get_queue_messages('virgin') >>> len(messages) 1 diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index c89e1e79f..e1c1d8993 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -28,8 +28,6 @@ from mailman.interfaces.subscriptions import ( from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.workflows import IWorkflowStateManager from mailman.utilities.string import expand -from mailman.workflows.builtin import (SubscriptionWorkflow, - UnSubscriptionWorkflow) from mailman.workflows.common import (PendableSubscription, PendableUnsubscription) from public import public @@ -43,23 +41,16 @@ class SubscriptionManager: def __init__(self, mlist): self._mlist = mlist - def register(self, subscriber=None, *, - pre_verified=False, pre_confirmed=False, pre_approved=False): + def register(self, subscriber=None, **kwargs): """See `ISubscriptionManager`.""" - workflow = SubscriptionWorkflow( - self._mlist, subscriber, - pre_verified=pre_verified, - pre_confirmed=pre_confirmed, - pre_approved=pre_approved) + workflow = self._mlist.subscription_policy(self._mlist, subscriber, + **kwargs) list(workflow) return workflow.token, workflow.token_owner, workflow.member - def unregister(self, subscriber=None, *, - pre_confirmed=False, pre_approved=False): - workflow = UnSubscriptionWorkflow( - self._mlist, subscriber, - pre_confirmed=pre_confirmed, - pre_approved=pre_approved) + def unregister(self, subscriber=None, **kwargs): + workflow = self._mlist.unsubscription_policy(self._mlist, subscriber, + **kwargs) list(workflow) return workflow.token, workflow.token_owner, workflow.member @@ -72,9 +63,9 @@ class SubscriptionManager: workflow_type = pendable.get('type') assert workflow_type in (PendableSubscription.PEND_TYPE, PendableUnsubscription.PEND_TYPE) - workflow = (SubscriptionWorkflow + workflow = (self._mlist.subscription_policy if workflow_type == PendableSubscription.PEND_TYPE - else UnSubscriptionWorkflow)(self._mlist) + else self._mlist.unsubscription_policy)(self._mlist) workflow.token = token workflow.restore() # In order to just run the whole workflow, all we need to do diff --git a/src/mailman/app/tests/test_moderation.py b/src/mailman/app/tests/test_moderation.py index 5448a1d79..7d7524a10 100644 --- a/src/mailman/app/tests/test_moderation.py +++ b/src/mailman/app/tests/test_moderation.py @@ -162,7 +162,7 @@ class TestUnsubscription(unittest.TestCase): user_manager = getUtility(IUserManager) anne = user_manager.create_address('anne@example.org', 'Anne Person') token, token_owner, member = self._manager.register( - anne, pre_verified=True, pre_confirmed=True, pre_approved=True) + anne, pre_verified=True, pre_confirmed=True) self.assertIsNone(token) self.assertEqual(member.address.email, 'anne@example.org') bart = user_manager.create_user('bart@example.com', 'Bart User') diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py index 0519ec385..23487ab1c 100644 --- a/src/mailman/app/tests/test_subscriptions.py +++ b/src/mailman/app/tests/test_subscriptions.py @@ -21,9 +21,7 @@ import unittest from contextlib import suppress from mailman.app.lifecycle import create_list -from mailman.app.subscriptions import SubscriptionWorkflow from mailman.interfaces.bans import IBanManager -from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.member import MemberRole, MembershipIsBannedError from mailman.interfaces.pending import IPendings from mailman.interfaces.subscriptions import TokenOwner @@ -32,6 +30,9 @@ from mailman.testing.helpers import ( LogFileMark, get_queue_messages, set_preferred) from mailman.testing.layers import ConfigLayer from mailman.utilities.datetime import now +from mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) from unittest.mock import patch from zope.component import getUtility @@ -55,7 +56,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_start_state(self): # The workflow starts with no tokens or member. - workflow = SubscriptionWorkflow(self._mlist) + workflow = ConfirmSubscriptionPolicy(self._mlist) self.assertIsNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.no_one) self.assertIsNone(workflow.member) @@ -64,7 +65,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # There is a Pendable associated with the held request, and it has # some data associated with it. anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) with suppress(StopIteration): workflow.run_thru('send_confirmation') self.assertIsNotNone(workflow.token) @@ -79,14 +80,14 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_user_or_address_required(self): # The `subscriber` attribute must be a user or address. - workflow = SubscriptionWorkflow(self._mlist) + workflow = ConfirmSubscriptionPolicy(self._mlist) self.assertRaises(AssertionError, list, workflow) def test_sanity_checks_address(self): # Ensure that the sanity check phase, when given an IAddress, ends up # with a linked user. anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) self.assertIsNotNone(workflow.address) self.assertIsNone(workflow.user) workflow.run_thru('sanity_checks') @@ -99,7 +100,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # preferred address, ends up with an address. anne = self._user_manager.make_user(self._anne) address = set_preferred(anne) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) # The constructor sets workflow.address because the user has a # preferred address. self.assertEqual(workflow.address, address) @@ -113,7 +114,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # preferred address, but with at least one linked address, gets an # address. anne = self._user_manager.make_user(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) self.assertIsNone(workflow.address) self.assertEqual(workflow.user, anne) workflow.run_thru('sanity_checks') @@ -127,7 +128,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): anne = self._user_manager.make_user(self._anne) anne.link(self._user_manager.create_address('anne@example.net')) anne.link(self._user_manager.create_address('anne@example.org')) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) self.assertIsNone(workflow.address) self.assertEqual(workflow.user, anne) workflow.run_thru('sanity_checks') @@ -139,21 +140,21 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_sanity_checks_user_without_addresses(self): # It is an error to try to subscribe a user with no linked addresses. user = self._user_manager.create_user() - workflow = SubscriptionWorkflow(self._mlist, user) + workflow = ConfirmSubscriptionPolicy(self._mlist, user) self.assertRaises(AssertionError, workflow.run_thru, 'sanity_checks') def test_sanity_checks_globally_banned_address(self): # An exception is raised if the address is globally banned. anne = self._user_manager.create_address(self._anne) IBanManager(None).ban(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) self.assertRaises(MembershipIsBannedError, list, workflow) def test_sanity_checks_banned_address(self): # An exception is raised if the address is banned by the mailing list. anne = self._user_manager.create_address(self._anne) IBanManager(self._mlist).ban(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) self.assertRaises(MembershipIsBannedError, list, workflow) def test_verification_checks_with_verified_address(self): @@ -161,7 +162,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # confirmation checks. anne = self._user_manager.create_address(self._anne) anne.verified_on = now() - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) workflow.run_thru('verification_checks') with patch.object(workflow, '_step_confirmation_checks') as step: next(workflow) @@ -171,7 +172,8 @@ class TestSubscriptionWorkflow(unittest.TestCase): # When the address is not yet verified, but the pre-verified flag is # passed to the workflow, we skip to the confirmation checks. anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne, + pre_verified=True) workflow.run_thru('verification_checks') with patch.object(workflow, '_step_confirmation_checks') as step: next(workflow) @@ -184,7 +186,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # A confirmation message must be sent to the user which will also # verify their address. anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) workflow.run_thru('verification_checks') with patch.object(workflow, '_step_send_confirmation') as step: next(workflow) @@ -195,10 +197,11 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_confirmation_checks_open_list(self): # A subscription to an open list does not need to be confirmed or # moderated. - self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.subscription_policy = OpenSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) - workflow.run_thru('confirmation_checks') + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + workflow.run_thru('verification_checks') with patch.object(workflow, '_step_do_subscription') as step: next(workflow) step.assert_called_once_with() @@ -206,10 +209,11 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_confirmation_checks_no_user_confirmation_needed(self): # A subscription to a list which does not need user confirmation skips # to the moderation checks. - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) - workflow.run_thru('confirmation_checks') + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + workflow.run_thru('verification_checks') with patch.object(workflow, '_step_moderation_checks') as step: next(workflow) step.assert_called_once_with() @@ -218,11 +222,11 @@ class TestSubscriptionWorkflow(unittest.TestCase): # The subscription policy requires user confirmation, but their # subscription is pre-confirmed. Since moderation is not required, # the user will be immediately subscribed. - self._mlist.subscription_policy = SubscriptionPolicy.confirm + self._mlist.subscription_policy = ConfirmSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_confirmed=True) workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_do_subscription') as step: next(workflow) @@ -233,11 +237,11 @@ class TestSubscriptionWorkflow(unittest.TestCase): # subscription is pre-confirmed. Since moderation is required, that # check will be performed. self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_confirmed=True) workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_moderation_checks') as step: next(workflow) @@ -247,11 +251,11 @@ class TestSubscriptionWorkflow(unittest.TestCase): # The subscription policy requires user confirmation and moderation, # but their subscription is pre-confirmed. self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_confirmed=True) workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_moderation_checks') as step: next(workflow) @@ -260,9 +264,10 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_confirmation_checks_confirmation_needed(self): # The subscription policy requires confirmation and the subscription # is not pre-confirmed. - self._mlist.subscription_policy = SubscriptionPolicy.confirm + self._mlist.subscription_policy = ConfirmSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_send_confirmation') as step: next(workflow) @@ -272,9 +277,10 @@ class TestSubscriptionWorkflow(unittest.TestCase): # The subscription policy requires confirmation and moderation, and the # subscription is not pre-confirmed. self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_send_confirmation') as step: next(workflow) @@ -282,11 +288,11 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_moderation_checks_pre_approved(self): # The subscription is pre-approved by the moderator. - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_approved=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_approved=True) workflow.run_thru('moderation_checks') with patch.object(workflow, '_step_do_subscription') as step: next(workflow) @@ -294,9 +300,10 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_moderation_checks_approval_required(self): # The moderator must approve the subscription. - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) workflow.run_thru('moderation_checks') with patch.object(workflow, '_step_get_moderator_approval') as step: next(workflow) @@ -306,9 +313,10 @@ class TestSubscriptionWorkflow(unittest.TestCase): # An open subscription policy plus a pre-verified address means the # user gets subscribed to the mailing list without any further # confirmations or approvals. - self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.subscription_policy = OpenSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) # Consume the entire state machine. list(workflow) # Anne is now a member of the mailing list. @@ -323,11 +331,11 @@ class TestSubscriptionWorkflow(unittest.TestCase): # An moderation-requiring subscription policy plus a pre-verified and # pre-approved address means the user gets subscribed to the mailing # list without any further confirmations or approvals. - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_approved=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_approved=True) # Consume the entire state machine. list(workflow) # Anne is now a member of the mailing list. @@ -343,12 +351,12 @@ class TestSubscriptionWorkflow(unittest.TestCase): # pre-approved address means the user gets subscribed to the mailing # list without any further confirmations or approvals. self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True, - pre_approved=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_confirmed=True, + pre_approved=True) # Consume the entire state machine. list(workflow) # Anne is now a member of the mailing list. @@ -362,12 +370,10 @@ class TestSubscriptionWorkflow(unittest.TestCase): def test_do_subscription_cleanups(self): # Once the user is subscribed, the token, and its associated pending # database record will be removed from the database. - self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.subscription_policy = OpenSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True, - pre_approved=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) # Consume the entire state machine. list(workflow) # Anne is now a member of the mailing list. @@ -382,11 +388,10 @@ class TestSubscriptionWorkflow(unittest.TestCase): # 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 subscribed. - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) # Consume the entire state machine. list(workflow) # The user is not currently subscribed to the mailing list. @@ -399,7 +404,7 @@ class TestSubscriptionWorkflow(unittest.TestCase): # Create a new workflow with the previous workflow's save token, and # restore its state. This models an approved subscription and should # result in the user getting subscribed. - approved_workflow = SubscriptionWorkflow(self._mlist) + approved_workflow = self._mlist.subscription_policy(self._mlist) approved_workflow.token = workflow.token approved_workflow.restore() list(approved_workflow) @@ -415,11 +420,10 @@ class TestSubscriptionWorkflow(unittest.TestCase): # When the subscription is held for moderator approval, a message is # logged. mark = LogFileMark('mailman.subscribe') - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) # Consume the entire state machine. list(workflow) self.assertIn( @@ -434,14 +438,13 @@ class TestSubscriptionWorkflow(unittest.TestCase): # When the 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.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) bart = self._user_manager.create_user('bart@example.com', 'Bart User') address = set_preferred(bart) self._mlist.subscribe(address, MemberRole.moderator) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) # Consume the entire state machine. list(workflow) # Find the moderator message. @@ -452,13 +455,13 @@ class TestSubscriptionWorkflow(unittest.TestCase): else: raise AssertionError('No moderator email found') self.assertEqual( - item.msgdata['recipients'], {'test-owner@example.com'}) + item.msgdata['recipients'], {'test-owner@example.com'}) 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 subscription request to Test from anne@example.com') + message['Subject'], + 'New subscription request to Test from anne@example.com') self.assertEqual(message.get_payload(), """\ Your authorization is required for a mailing list subscription request approval: @@ -474,11 +477,10 @@ approval: # When the 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 = False - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) # Consume the entire state machine. list(workflow) get_queue_messages('virgin', expected_count=0) @@ -491,14 +493,14 @@ approval: anne = self._user_manager.create_address(self._anne) self.assertIsNone(anne.verified_on) # Run the workflow to model the confirmation step. - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = self._mlist.subscription_policy(self._mlist, anne) list(workflow) items = get_queue_messages('virgin', expected_count=1) message = items[0].msg token = workflow.token self.assertEqual(message['Subject'], 'confirm {}'.format(token)) self.assertEqual( - message['From'], 'test-confirm+{}@example.com'.format(token)) + message['From'], 'test-confirm+{}@example.com'.format(token)) # The confirmation message is not `Precedence: bulk`. self.assertIsNone(message['precedence']) # The state machine stopped at the moderator approval so there will be @@ -511,15 +513,16 @@ approval: anne = self._user_manager.create_address(self._anne) self.assertIsNone(anne.verified_on) # Run the workflow to model the confirmation step. - workflow = SubscriptionWorkflow(self._mlist, anne, pre_confirmed=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_confirmed=True) 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)) + message['Subject'], 'confirm {}'.format(workflow.token)) self.assertEqual( - message['From'], 'test-confirm+{}@example.com'.format(token)) + message['From'], 'test-confirm+{}@example.com'.format(token)) # The state machine stopped at the moderator approval so there will be # one token still in the database. self._expected_pendings_count = 1 @@ -527,19 +530,20 @@ approval: def test_send_confirmation_pre_verified(self): # A confirmation message gets sent even when the address is verified # when the subscription must be confirmed. - self._mlist.subscription_policy = SubscriptionPolicy.confirm + self._mlist.subscription_policy = ConfirmSubscriptionPolicy anne = self._user_manager.create_address(self._anne) self.assertIsNone(anne.verified_on) # Run the workflow to model the confirmation step. - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) 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)) + message['Subject'], 'confirm {}'.format(workflow.token)) self.assertEqual( - message['From'], 'test-confirm+{}@example.com'.format(token)) + message['From'], 'test-confirm+{}@example.com'.format(token)) # The state machine stopped at the moderator approval so there will be # one token still in the database. self._expected_pendings_count = 1 @@ -551,11 +555,11 @@ approval: anne = self._user_manager.create_address(self._anne) self.assertIsNone(anne.verified_on) # Run the workflow to model the confirmation step. - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = self._mlist.subscription_policy(self._mlist, anne) list(workflow) # The address is still not verified. self.assertIsNone(anne.verified_on) - confirm_workflow = SubscriptionWorkflow(self._mlist) + confirm_workflow = self._mlist.subscription_policy(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() confirm_workflow.run_thru('do_confirm_verify') @@ -569,11 +573,11 @@ approval: set_preferred(anne) # Run the workflow to model the confirmation step. There is no # subscriber attribute yet. - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = self._mlist.subscription_policy(self._mlist, anne) list(workflow) self.assertEqual(workflow.subscriber, anne) # Do a confirmation workflow, which should now set the subscriber. - confirm_workflow = SubscriptionWorkflow(self._mlist) + confirm_workflow = self._mlist.subscription_policy(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() confirm_workflow.run_thru('do_confirm_verify') @@ -584,10 +588,10 @@ approval: # Subscriptions to the mailing list must be confirmed. Once that's # done, the user's address (which is not initially verified) gets # subscribed to the mailing list. - self._mlist.subscription_policy = SubscriptionPolicy.confirm + self._mlist.subscription_policy = ConfirmSubscriptionPolicy anne = self._user_manager.create_address(self._anne) self.assertIsNone(anne.verified_on) - workflow = SubscriptionWorkflow(self._mlist, anne) + workflow = self._mlist.subscription_policy(self._mlist, anne) list(workflow) # Anne is not yet a member. member = self._mlist.regular_members.get_member(self._anne) @@ -597,7 +601,7 @@ approval: self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.subscriber) # Confirm. - confirm_workflow = SubscriptionWorkflow(self._mlist) + confirm_workflow = self._mlist.subscription_policy(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() list(confirm_workflow) @@ -615,9 +619,10 @@ approval: # the user confirming their subscription, and then the moderator # approving it, that different tokens are used in these two cases. self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) # Run the state machine up to the first confirmation, and cache the # confirmation token. list(workflow) @@ -630,7 +635,7 @@ approval: self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.subscriber) # The old token will not work for moderator approval. - moderator_workflow = SubscriptionWorkflow(self._mlist) + moderator_workflow = self._mlist.subscription_policy(self._mlist) moderator_workflow.token = token moderator_workflow.restore() list(moderator_workflow) @@ -641,7 +646,7 @@ approval: # that there's a new token for the next steps. self.assertNotEqual(token, moderator_workflow.token) # The old token won't work. - final_workflow = SubscriptionWorkflow(self._mlist) + final_workflow = self._mlist.subscription_policy(self._mlist) final_workflow.token = token self.assertRaises(LookupError, final_workflow.restore) # Running this workflow will fail. @@ -666,11 +671,11 @@ approval: 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.subscription_policy = SubscriptionPolicy.confirm + self._mlist.subscription_policy = ConfirmSubscriptionPolicy anne = self._user_manager.create_address(self._anne) - workflow = SubscriptionWorkflow( - self._mlist, anne, - pre_verified=True, pre_confirmed=True, pre_approved=True) + workflow = self._mlist.subscription_policy( + self._mlist, anne, + pre_verified=True, pre_confirmed=True) list(workflow) # Anne was subscribed. self.assertIsNone(workflow.token) @@ -680,17 +685,18 @@ approval: def test_restore_user_absorbed(self): # The subscribing user is absorbed (and thus deleted) before the # moderator approves the subscription. - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_user(self._anne) bill = self._user_manager.create_user('bill@example.com') set_preferred(bill) # anne subscribes. - workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) list(workflow) # bill absorbs anne. bill.absorb(anne) # anne's subscription request is approved. - approved_workflow = SubscriptionWorkflow(self._mlist) + approved_workflow = self._mlist.subscription_policy(self._mlist) approved_workflow.token = workflow.token approved_workflow.restore() self.assertEqual(approved_workflow.user, bill) @@ -700,19 +706,19 @@ approval: def test_restore_address_absorbed(self): # The subscribing user is absorbed (and thus deleted) before the # moderator approves the subscription. - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._user_manager.create_user(self._anne) anne_address = anne.addresses[0] bill = self._user_manager.create_user('bill@example.com') # anne subscribes. - workflow = SubscriptionWorkflow( - self._mlist, anne_address, pre_verified=True) + workflow = self._mlist.subscription_policy( + self._mlist, anne_address, pre_verified=True) list(workflow) # bill absorbs anne. bill.absorb(anne) self.assertIn(anne_address, bill.addresses) # anne's subscription request is approved. - approved_workflow = SubscriptionWorkflow(self._mlist) + approved_workflow = self._mlist.subscription_policy(self._mlist) approved_workflow.token = workflow.token approved_workflow.restore() self.assertEqual(approved_workflow.user, bill) diff --git a/src/mailman/app/tests/test_unsubscriptions.py b/src/mailman/app/tests/test_unsubscriptions.py index 4ca657190..2e210e90b 100644 --- a/src/mailman/app/tests/test_unsubscriptions.py +++ b/src/mailman/app/tests/test_unsubscriptions.py @@ -21,14 +21,15 @@ import unittest from contextlib import suppress from mailman.app.lifecycle import create_list -from mailman.app.subscriptions import UnSubscriptionWorkflow -from mailman.interfaces.mailinglist import SubscriptionPolicy 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 from mailman.testing.layers import ConfigLayer from mailman.utilities.datetime import now +from mailman.workflows.unsubscription import ( + ConfirmModerationUnsubscriptionPolicy, ConfirmUnsubscriptionPolicy, + ModerationUnsubscriptionPolicy, OpenUnsubscriptionPolicy) from unittest.mock import patch from zope.component import getUtility @@ -40,7 +41,7 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): def setUp(self): self._mlist = create_list('test@example.com') self._mlist.admin_immed_notify = False - self._mlist.unsubscription_policy = SubscriptionPolicy.open + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy self._mlist.send_welcome_message = False self._anne = 'anne@example.com' self._user_manager = getUtility(IUserManager) @@ -58,7 +59,7 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): def test_start_state(self): # Test the workflow starts with no tokens or members. - workflow = UnSubscriptionWorkflow(self._mlist) + workflow = self._mlist.unsubscription_policy(self._mlist) self.assertEqual(workflow.token_owner, TokenOwner.no_one) self.assertIsNone(workflow.token) self.assertIsNone(workflow.member) @@ -67,8 +68,8 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # Test there is a Pendable object associated with a held # unsubscription request and it has some valid data associated with # it. - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm - workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) with suppress(StopIteration): workflow.run_thru('send_confirmation') self.assertIsNotNone(workflow.token) @@ -84,23 +85,23 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): 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) + workflow = OpenUnsubscriptionPolicy(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) + workflow = self._mlist.unsubscription_policy(self._mlist, addr) self.assertRaises(AssertionError, workflow.run_thru, 'subscription_checks') def test_confirmation_checks_open_list(self): # An unsubscription 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') + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + workflow.run_thru('subscription_checks') with patch.object(workflow, '_step_do_unsubscription') as step: next(workflow) step.assert_called_once_with() @@ -108,10 +109,9 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): def test_confirmation_checks_no_user_confirmation_needed(self): # An unsubscription 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') + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + workflow.run_thru('subscription_checks') with patch.object(workflow, '_step_moderation_checks') as step: next(workflow) step.assert_called_once_with() @@ -120,8 +120,8 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # The unsubscription policy requires user-confirmation, but their # unsubscription is pre-confirmed. Since moderation is not reuqired, # the user will be immediately unsubscribed. - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm - workflow = UnSubscriptionWorkflow( + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( self._mlist, self.anne, pre_confirmed=True) workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_do_unsubscription') as step: @@ -133,19 +133,19 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # unsubscription is pre-confirmed. Since moderation is required, that # check will be performed. self._mlist.unsubscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) - workflow = UnSubscriptionWorkflow( + ConfirmModerationUnsubscriptionPolicy) + workflow = self._mlist.unsubscription_policy( self._mlist, self.anne, pre_confirmed=True) workflow.run_thru('confirmation_checks') - with patch.object(workflow, '_step_do_unsubscription') as step: + with patch.object(workflow, '_step_moderation_checks') as step: next(workflow) step.assert_called_once_with() def test_send_confirmation_checks_confirm_list(self): # The unsubscription policy requires user confirmation and the # unsubscription is not pre-confirmed. - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm - workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) workflow.run_thru('confirmation_checks') with patch.object(workflow, '_step_send_confirmation') as step: next(workflow) @@ -153,17 +153,17 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): def test_moderation_checks_moderated_list(self): # The unsubscription policy requires moderation. - self._mlist.unsubscription_policy = SubscriptionPolicy.moderate - workflow = UnSubscriptionWorkflow(self._mlist, self.anne) - workflow.run_thru('confirmation_checks') + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + workflow.run_thru('subscription_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) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) workflow.run_thru('moderation_checks') with patch.object(workflow, '_step_get_moderator_approval') as step: next(workflow) @@ -172,8 +172,8 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): def test_do_unsusbcription(self): # An open unsubscription policy means the user gets unsubscribed to # the mailing list without any further confirmations or approvals. - self._mlist.unsubscription_policy = SubscriptionPolicy.open - workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) list(workflow) member = self._mlist.regular_members.get_member(self._anne) self.assertIsNone(member) @@ -182,9 +182,9 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # A moderation-requiring subscription policy plus a pre-approved # address means the user gets unsubscribed 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) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(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) @@ -198,10 +198,10 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # address means the user gets unsubscribed 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) + ConfirmModerationUnsubscriptionPolicy) + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne, + pre_approved=True, + pre_confirmed=True) list(workflow) member = self._mlist.regular_members.get_member(self._anne) self.assertIsNone(member) @@ -212,10 +212,8 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): def test_do_unsubscription_cleanups(self): # Once the user is unsubscribed, 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) + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) # Run the workflow. list(workflow) # Anne is now unsubscribed from the list. @@ -229,9 +227,9 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # 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 unsubscribed. - self._mlist.unsubscription_policy = SubscriptionPolicy.moderate - workflow = UnSubscriptionWorkflow( - self._mlist, self.anne, pre_confirmed=True) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) # Run the entire workflow. list(workflow) # The user is currently subscribed to the mailing list. @@ -244,7 +242,7 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # 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 = self._mlist.unsubscription_policy(self._mlist) approved_workflow.token = workflow.token approved_workflow.restore() list(approved_workflow) @@ -260,9 +258,9 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # When the unsubscription 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) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) # Run the entire workflow. list(workflow) self.assertIn( @@ -277,9 +275,9 @@ class TestUnSubscriptionWorkflow(unittest.TestCase): # When the unsubscription 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) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) # Consume the entire state machine. list(workflow) items = get_queue_messages('virgin', expected_count=1) @@ -305,9 +303,9 @@ request approval: # 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) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) # Consume the entire state machine. list(workflow) get_queue_messages('virgin', expected_count=0) @@ -318,9 +316,9 @@ request approval: def test_send_confirmation(self): # A confirmation message gets sent when the unsubscription must be # confirmed. - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy # Run the workflow to model the confirmation step. - workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) list(workflow) items = get_queue_messages('virgin', expected_count=1) message = items[0].msg @@ -336,8 +334,8 @@ request approval: def test_do_confirmation_unsubscribes_user(self): # Unsubscriptions 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) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) list(workflow) # Anne is a member. member = self._mlist.regular_members.get_member(self._anne) @@ -347,7 +345,7 @@ request approval: self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.subscriber) # Confirm. - confirm_workflow = UnSubscriptionWorkflow(self._mlist) + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() list(confirm_workflow) @@ -363,8 +361,8 @@ request approval: # done, the address is unsubscribed. address = self.anne.register('anne.person@example.com') self._mlist.subscribe(address) - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm - workflow = UnSubscriptionWorkflow(self._mlist, address) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, address) list(workflow) # Bart is a member. member = self._mlist.regular_members.get_member( @@ -375,7 +373,7 @@ request approval: self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.subscriber) # Confirm. - confirm_workflow = UnSubscriptionWorkflow(self._mlist) + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() list(confirm_workflow) @@ -390,8 +388,8 @@ request approval: def test_do_confirmation_nonmember(self): # Attempt to confirm the unsubscription of a member who has already # been unsubscribed. - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm - workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) list(workflow) # Anne is a member. member = self._mlist.regular_members.get_member(self._anne) @@ -403,7 +401,7 @@ request approval: # Unsubscribe Anne out of band. member.unsubscribe() # Confirm. - confirm_workflow = UnSubscriptionWorkflow(self._mlist) + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() list(confirm_workflow) @@ -414,8 +412,8 @@ request approval: def test_do_confirmation_nonmember_final_step(self): # Attempt to confirm the unsubscription of a member who has already # been unsubscribed. - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm - workflow = UnSubscriptionWorkflow(self._mlist, self.anne) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) list(workflow) # Anne is a member. member = self._mlist.regular_members.get_member(self._anne) @@ -425,7 +423,7 @@ request approval: self.assertIsNotNone(workflow.token) self.assertEqual(workflow.token_owner, TokenOwner.subscriber) # Confirm. - confirm_workflow = UnSubscriptionWorkflow(self._mlist) + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) confirm_workflow.token = workflow.token confirm_workflow.restore() confirm_workflow.run_until('do_unsubscription') @@ -443,8 +441,8 @@ request approval: # 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) + ConfirmModerationUnsubscriptionPolicy) + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) # Run the state machine up to the first confirmation, and cache the # confirmation token. list(workflow) @@ -457,7 +455,7 @@ request approval: 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 = self._mlist.unsubscription_policy(self._mlist) moderator_workflow.token = token moderator_workflow.restore() list(moderator_workflow) @@ -468,7 +466,7 @@ request approval: # 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 = self._mlist.unsubscription_policy(self._mlist) final_workflow.token = token self.assertRaises(LookupError, final_workflow.restore) # Running this workflow will fail. @@ -492,9 +490,9 @@ request approval: 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) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne, pre_confirmed=True) list(workflow) # Anne was unsubscribed. self.assertIsNone(workflow.token) @@ -504,11 +502,11 @@ request approval: def test_confirmation_needed_moderator_address(self): address = self.anne.register('anne.person@example.com') self._mlist.subscribe(address) - self._mlist.unsubscription_policy = SubscriptionPolicy.moderate - workflow = UnSubscriptionWorkflow(self._mlist, address) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, address) # Get moderator approval. list(workflow) - approved_workflow = UnSubscriptionWorkflow(self._mlist) + approved_workflow = self._mlist.unsubscription_policy(self._mlist) approved_workflow.token = workflow.token approved_workflow.restore() list(approved_workflow) diff --git a/src/mailman/app/tests/test_workflowmanager.py b/src/mailman/app/tests/test_workflowmanager.py index 38ed2e468..e21e428f6 100644 --- a/src/mailman/app/tests/test_workflowmanager.py +++ b/src/mailman/app/tests/test_workflowmanager.py @@ -20,7 +20,6 @@ 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.subscriptions import ISubscriptionManager, TokenOwner @@ -28,6 +27,9 @@ 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 mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) from zope.component import getUtility @@ -60,7 +62,7 @@ class TestRegistrar(unittest.TestCase): # 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._mlist.subscription_policy = OpenSubscriptionPolicy self._anne.verified_on = now() token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNone(token) @@ -82,7 +84,7 @@ class TestRegistrar(unittest.TestCase): # (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 + self._mlist.subscription_policy = OpenSubscriptionPolicy token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) self.assertEqual(token_owner, TokenOwner.subscriber) @@ -102,7 +104,7 @@ class TestRegistrar(unittest.TestCase): # (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._mlist.subscription_policy = ConfirmSubscriptionPolicy self._anne.verified_on = now() token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) @@ -122,7 +124,7 @@ class TestRegistrar(unittest.TestCase): # 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._mlist.subscription_policy = ModerationSubscriptionPolicy self._anne.verified_on = now() token, token_owner, rmember = self._registrar.register(self._anne) self.assertIsNotNone(token) @@ -145,7 +147,7 @@ class TestRegistrar(unittest.TestCase): # 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) + ConfirmModerationSubscriptionPolicy) self._anne.verified_on = now() # Runs until subscription confirmation. token, token_owner, rmember = self._registrar.register(self._anne) @@ -180,7 +182,7 @@ class TestRegistrar(unittest.TestCase): # 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) + ConfirmModerationSubscriptionPolicy) self._anne.verified_on = now() # Runs until subscription confirmation. token, token_owner, rmember = self._registrar.register(self._anne) @@ -216,7 +218,7 @@ class TestRegistrar(unittest.TestCase): 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._mlist.subscription_policy = ConfirmSubscriptionPolicy self._anne.verified_on = now() # Runs until subscription confirmation. token, token_owner, rmember = self._registrar.register(self._anne) @@ -233,7 +235,7 @@ class TestRegistrar(unittest.TestCase): 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.subscription_policy = OpenSubscriptionPolicy self._mlist.admin_notify_mchanges = True self._mlist.send_welcome_message = False token, token_owner, member = self._registrar.register( @@ -253,7 +255,7 @@ anne@example.com has been successfully subscribed to Ant. # 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.subscription_policy = OpenSubscriptionPolicy self._mlist.admin_notify_mchanges = False self._mlist.send_welcome_message = False # Bart is an administrator of the mailing list. diff --git a/src/mailman/commands/docs/membership.rst b/src/mailman/commands/docs/membership.rst index 18c473c5b..e1266d690 100644 --- a/src/mailman/commands/docs/membership.rst +++ b/src/mailman/commands/docs/membership.rst @@ -221,9 +221,9 @@ to leave it. Because the mailing list allows for *open* unsubscriptions (i.e. no confirmation is needed), when she sends a message to the ``-leave`` address for the list, she is immediately removed. - >>> from mailman.interfaces.mailinglist import SubscriptionPolicy - >>> mlist_2.unsubscription_policy = SubscriptionPolicy.open - >>> mlist.unsubscription_policy = SubscriptionPolicy.open + >>> from mailman.workflows.unsubscription import OpenUnsubscriptionPolicy + >>> mlist_2.unsubscription_policy = OpenUnsubscriptionPolicy + >>> mlist.unsubscription_policy = OpenUnsubscriptionPolicy >>> results = Results() >>> print(leave.process(mlist_2, msg, {}, (), results)) ContinueProcessing.yes diff --git a/src/mailman/commands/tests/test_eml_confirm.py b/src/mailman/commands/tests/test_eml_confirm.py index 1ef392b76..d17a084f7 100644 --- a/src/mailman/commands/tests/test_eml_confirm.py +++ b/src/mailman/commands/tests/test_eml_confirm.py @@ -24,7 +24,6 @@ from mailman.commands.eml_confirm import Confirm 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.subscriptions import ISubscriptionManager from mailman.interfaces.usermanager import IUserManager from mailman.runners.command import CommandRunner, Results @@ -32,6 +31,7 @@ from mailman.testing.helpers import ( get_queue_messages, make_testable_runner, specialized_message_from_string as mfs, subscribe) from mailman.testing.layers import ConfigLayer +from mailman.workflows.subscription import ConfirmModerationSubscriptionPolicy from zope.component import getUtility @@ -109,7 +109,7 @@ class TestEmailResponses(unittest.TestCase): def test_confirm_then_moderate_workflow(self): # Issue #114 describes a problem when confirming the moderation email. self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) bart = getUtility(IUserManager).create_address( 'bart@example.com', 'Bart Person') # Clear any previously queued confirmation messages. diff --git a/src/mailman/commands/tests/test_eml_membership.py b/src/mailman/commands/tests/test_eml_membership.py index 6cf4802c6..d90e3319e 100644 --- a/src/mailman/commands/tests/test_eml_membership.py +++ b/src/mailman/commands/tests/test_eml_membership.py @@ -22,11 +22,11 @@ import unittest from mailman.app.lifecycle import create_list from mailman.commands.eml_membership import Leave from mailman.email.message import Message -from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.usermanager import IUserManager from mailman.runners.command import Results from mailman.testing.helpers import set_preferred from mailman.testing.layers import ConfigLayer +from mailman.workflows.unsubscription import ConfirmUnsubscriptionPolicy from zope.component import getUtility @@ -38,7 +38,7 @@ class TestLeave(unittest.TestCase): self._command = Leave() def test_confirm_leave_not_a_member(self): - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy # Try to unsubscribe someone who is not a member. Anne is a real # user, with a validated address, but she is not a member of the # mailing list. diff --git a/src/mailman/database/alembic/versions/ccb9e28c44f4_mailinglist_sub_unsub_policies.py b/src/mailman/database/alembic/versions/ccb9e28c44f4_mailinglist_sub_unsub_policies.py new file mode 100644 index 000000000..3eab48de0 --- /dev/null +++ b/src/mailman/database/alembic/versions/ccb9e28c44f4_mailinglist_sub_unsub_policies.py @@ -0,0 +1,141 @@ +# Copyright (C) 2015-2017 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 . + +"""Change the subscription_policy and unsubscription_policy attribute type. + +Revision ID: ccb9e28c44f4 +Revises: 7c5b39d1ecc4 +Create Date: 2017-07-05 00:02:18.238155 + +Changes the [un]subscription_policy MailingList attributes to a string keyä +of the workflow name from an Enum. +""" + +import sqlalchemy as sa + +from alembic import op + +from mailman.database.types import SAUnicode +from mailman.interfaces.mailinglist import SubscriptionPolicy +from mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) +from mailman.workflows.unsubscription import ( + ConfirmModerationUnsubscriptionPolicy, ConfirmUnsubscriptionPolicy, + ModerationUnsubscriptionPolicy, OpenUnsubscriptionPolicy) + +# revision identifiers, used by Alembic. +revision = 'ccb9e28c44f4' +down_revision = '7c5b39d1ecc4' + + +mlist_table = sa.sql.table( + 'mailinglist', + sa.sql.column('id', sa.Integer), + sa.sql.column('old_subscription_policy', sa.Integer), + sa.sql.column('old_unsubscription_policy', sa.Integer), + sa.sql.column('subscription_policy', SAUnicode), + sa.sql.column('unsubscription_policy', SAUnicode) + ) + +sub_map = { + SubscriptionPolicy.open.value: OpenSubscriptionPolicy.name, + SubscriptionPolicy.confirm.value: ConfirmSubscriptionPolicy.name, + SubscriptionPolicy.moderate.value: ModerationSubscriptionPolicy.name, + SubscriptionPolicy.confirm_then_moderate.value: + ConfirmModerationSubscriptionPolicy.name + } + +sub_inv_map = {v: k for k, v in sub_map.items()} + +unsub_map = { + SubscriptionPolicy.open.value: OpenUnsubscriptionPolicy.name, + SubscriptionPolicy.confirm.value: ConfirmUnsubscriptionPolicy.name, + SubscriptionPolicy.moderate.value: ModerationUnsubscriptionPolicy.name, + SubscriptionPolicy.confirm_then_moderate.value: + ConfirmModerationUnsubscriptionPolicy.name + } + +unsub_inv_map = {v: k for k, v in unsub_map.items()} + + +def upgrade(): + with op.batch_alter_table('mailinglist') as batch_op: + batch_op.alter_column('subscription_policy', + new_column_name='old_subscription_policy', + existing_type=sa.Integer, existing_nullable=True) + batch_op.alter_column('unsubscription_policy', + new_column_name='old_unsubscription_policy', + existing_type=sa.Integer, existing_nullable=True) + + with op.batch_alter_table('mailinglist') as batch_op: + batch_op.add_column(sa.Column('subscription_policy', SAUnicode)) + batch_op.add_column(sa.Column('unsubscription_policy', SAUnicode)) + + connection = op.get_bind() + for (table_id, old_sub_policy, old_unsub_policy, sub_policy, + unsub_policy) in connection.execute(mlist_table.select()).fetchall(): + new_sub = sub_map.get(old_sub_policy, + ConfirmSubscriptionPolicy.name) + + new_unsub = unsub_map.get(old_unsub_policy, + ConfirmUnsubscriptionPolicy.name) + + connection.execute(mlist_table.update().where( + mlist_table.c.id == table_id).values( + subscription_policy=new_sub, + unsubscription_policy=new_unsub + )) + + with op.batch_alter_table('mailinglist') as batch_op: + batch_op.drop_column('old_subscription_policy') + batch_op.drop_column('old_unsubscription_policy') + + +def downgrade(): + with op.batch_alter_table('mailinglist') as batch_op: + batch_op.alter_column('subscription_policy', + new_column_name='old_subscription_policy', + existing_type=SAUnicode) + batch_op.alter_column('unsubscription_policy', + new_column_name='old_unsubscription_policy', + existing_type=SAUnicode) + + with op.batch_alter_table('mailinglist') as batch_op: + batch_op.add_column(sa.Column('subscription_policy', sa.Integer, + nullable=True)) + batch_op.add_column(sa.Column('unsubscription_policy', sa.Integer, + nullable=True)) + + connection = op.get_bind() + for (table_id, old_sub_policy, old_unsub_policy, sub_policy, + unsub_policy) in connection.execute(mlist_table.select()).fetchall(): + + new_sub = sub_inv_map.get(old_sub_policy, + SubscriptionPolicy.confirm.value) + new_unsub = unsub_inv_map.get(old_unsub_policy, + SubscriptionPolicy.confirm.value) + + connection.execute(mlist_table.update().where( + mlist_table.c.id == table_id).values( + subscription_policy=new_sub, + unsubscription_policy=new_unsub + )) + + with op.batch_alter_table('mailinglist') as batch_op: + batch_op.drop_column('old_subscription_policy') + batch_op.drop_column('old_unsubscription_policy') diff --git a/src/mailman/database/tests/test_migrations.py b/src/mailman/database/tests/test_migrations.py index 5b686d13b..104487350 100644 --- a/src/mailman/database/tests/test_migrations.py +++ b/src/mailman/database/tests/test_migrations.py @@ -32,10 +32,13 @@ from mailman.database.transaction import transaction from mailman.database.types import Enum, SAUnicode from mailman.interfaces.action import Action from mailman.interfaces.cache import ICacheManager +from mailman.interfaces.mailinglist import SubscriptionPolicy from mailman.interfaces.member import MemberRole from mailman.interfaces.template import ITemplateManager from mailman.interfaces.usermanager import IUserManager from mailman.testing.layers import ConfigLayer +from mailman.workflows.subscription import ModerationSubscriptionPolicy +from mailman.workflows.unsubscription import OpenUnsubscriptionPolicy from warnings import catch_warnings, simplefilter from zope.component import getUtility @@ -594,3 +597,54 @@ class TestMigrations(unittest.TestCase): self.assertEqual(token, 'abcde') self.assertEqual(step, None) self.assertEqual(data, 'another data') + + def test_ccb9e28c44f4_mailinglist_sub_unsub_policies_downgrade(self): + # Downgrade to the tested revision. + alembic.command.downgrade(alembic_cfg, 'ccb9e28c44f4') + # Create our example list. + with transaction(): + mlist = create_list('test@example.com') + mlist.subscription_policy = ModerationSubscriptionPolicy + mlist.unsubscription_policy = OpenUnsubscriptionPolicy + # Downgrade, should keep the policies, since they have a value in the + # SubscriptionPolicy enum. + alembic.command.downgrade(alembic_cfg, '7c5b39d1ecc4') + old_mlist_table = sa.sql.table( + 'mailinglist', + sa.sql.column('id', sa.Integer), + sa.sql.column('subscription_policy', sa.Integer), + sa.sql.column('unsubscription_policy', sa.Integer) + ) + table_id, sub_policy, unsub_policy = config.db.store.execute( + old_mlist_table.select()).fetchone() + self.assertEqual(sub_policy, SubscriptionPolicy.moderate.value) + self.assertEqual(unsub_policy, SubscriptionPolicy.open.value) + + def test_ccb9e28c44f4_mailinglist_sub_unsub_policies_upgrade(self): + old_mlist_table = sa.sql.table( + 'mailinglist', + sa.sql.column('id', sa.Integer), + sa.sql.column('subscription_policy', sa.Integer), + sa.sql.column('unsubscription_policy', sa.Integer) + ) + with transaction(): + # Downgrade to a revision below the one tested. + alembic.command.downgrade(alembic_cfg, '7c5b39d1ecc4') + + config.db.store.execute( + old_mlist_table.insert().values( + subscription_policy=SubscriptionPolicy.moderate.value, + unsubscription_policy=SubscriptionPolicy.open.value) + ) + # Upgrade and test that the new names and types are there. + alembic.command.upgrade(alembic_cfg, 'ccb9e28c44f4') + new_mlist_table = sa.sql.table( + 'mailinglist', + sa.sql.column('id', sa.Integer), + sa.sql.column('subscription_policy', SAUnicode), + sa.sql.column('unsubscription_policy', SAUnicode) + ) + table_id, sub_policy, unsub_policy = config.db.store.execute( + new_mlist_table.select()).fetchone() + self.assertEqual(sub_policy, ModerationSubscriptionPolicy.name) + self.assertEqual(unsub_policy, OpenUnsubscriptionPolicy.name) diff --git a/src/mailman/interfaces/subscriptions.py b/src/mailman/interfaces/subscriptions.py index 8de517c09..40c66cb1f 100644 --- a/src/mailman/interfaces/subscriptions.py +++ b/src/mailman/interfaces/subscriptions.py @@ -218,8 +218,7 @@ class ISubscriptionManager(Interface): To use this, adapt an ``IMailingList`` to this interface. """ - def register(subscriber=None, *, - pre_verified=False, pre_confirmed=False, pre_approved=False): + def register(subscriber=None, **kwargs): """Subscribe an address or user according to subscription policies. The mailing list's subscription policy is used to subscribe @@ -270,8 +269,7 @@ class ISubscriptionManager(Interface): appears in the global or list-centric bans. """ - def unregister(subscriber=None, *, - pre_confirmed=False, pre_approved=False): + def unregister(subscriber=None, **kwargs): """Unsubscribe an address or user according to subscription policies. The mailing list's unsubscription policy is used to unsubscribe diff --git a/src/mailman/model/docs/subscriptions.rst b/src/mailman/model/docs/subscriptions.rst index 1e1810a7a..e23236b3b 100644 --- a/src/mailman/model/docs/subscriptions.rst +++ b/src/mailman/model/docs/subscriptions.rst @@ -33,8 +33,8 @@ list's subscription policy. For example, an open subscription policy does not require confirmation or approval, but the email address must still be verified, and the process will pause until these steps are completed. - >>> from mailman.interfaces.mailinglist import SubscriptionPolicy - >>> mlist.subscription_policy = SubscriptionPolicy.open + >>> from mailman.workflows.subscription import OpenSubscriptionPolicy + >>> mlist.subscription_policy = OpenSubscriptionPolicy Anne attempts to join the mailing list. A unique token is created which represents this work flow. @@ -81,7 +81,8 @@ Bart verifies his address and makes it his preferred address. The mailing list's subscription policy does not require Bart to confirm his subscription, but the moderate does want to approve all subscriptions. - >>> mlist.subscription_policy = SubscriptionPolicy.moderate + >>> from mailman.workflows.subscription import ModerationSubscriptionPolicy + >>> mlist.subscription_policy = ModerationSubscriptionPolicy Now when Bart registers as a user for the mailing list, a token will still be generated, but this is only used by the moderator. At first, Bart is not @@ -118,7 +119,8 @@ interface, but with a different name. If the mailing list's unsubscription policy is open, unregistering the subscription takes effect immediately. - >>> mlist.unsubscription_policy = SubscriptionPolicy.open + >>> from mailman.workflows.unsubscription import OpenUnsubscriptionPolicy + >>> mlist.unsubscription_policy = OpenUnsubscriptionPolicy >>> token, token_owner, member = manager.unregister(anne) >>> print(mlist.members.get_member('anne@example.com')) None @@ -127,7 +129,8 @@ Usually though, the member must confirm their unsubscription request, to prevent an attacker from unsubscribing them from the list without their knowledge. - >>> mlist.unsubscription_policy = SubscriptionPolicy.confirm + >>> from mailman.workflows.unsubscription import ConfirmUnsubscriptionPolicy + >>> mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy >>> token, token_owner, member = manager.unregister(bart) Bart hasn't confirmed yet, so he's still a member of the list. diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py index 2e50575d5..0764be0bb 100644 --- a/src/mailman/model/mailinglist.py +++ b/src/mailman/model/mailinglist.py @@ -34,13 +34,15 @@ from mailman.interfaces.languages import ILanguageManager from mailman.interfaces.mailinglist import ( DMARCMitigateAction, IAcceptableAlias, IAcceptableAliasSet, IHeaderMatch, IHeaderMatchList, IListArchiver, IListArchiverSet, - IMailingList, Personalization, ReplyToMunging, SubscriptionPolicy) + IMailingList, Personalization, ReplyToMunging) from mailman.interfaces.member import ( AlreadySubscribedError, MemberRole, MissingPreferredAddressError, SubscriptionEvent) from mailman.interfaces.mime import FilterType from mailman.interfaces.nntp import NewsgroupModeration from mailman.interfaces.user import IUser +from mailman.interfaces.workflows import (ISubscriptionWorkflow, + IUnsubscriptionWorkflow) from mailman.model import roster from mailman.model.digests import OneLastDigest from mailman.model.member import Member @@ -179,11 +181,11 @@ class MailingList(Model): send_goodbye_message = Column(Boolean) send_welcome_message = Column(Boolean) subject_prefix = Column(SAUnicode) - subscription_policy = Column(Enum(SubscriptionPolicy)) + _subscription_policy = Column('subscription_policy', SAUnicode) topics = Column(PickleType) topics_bodylines_limit = Column(Integer) topics_enabled = Column(Boolean) - unsubscription_policy = Column(Enum(SubscriptionPolicy)) + _unsubscription_policy = Column('unsubscription_policy', SAUnicode) # ORM relationships. header_matches = relationship( 'HeaderMatch', backref='mailing_list', @@ -494,6 +496,34 @@ class MailingList(Model): notify(SubscriptionEvent(self, member)) return member + @property + def subscription_policy(self): + return config.workflows[self._subscription_policy] + + @subscription_policy.setter + def subscription_policy(self, value): + if isinstance(value, str): + cls = config.workflows.get(value, None) + else: + cls = value + if (cls is None or not ISubscriptionWorkflow.implementedBy(cls)): + raise ValueError('Invalid subscription policy: {}'.format(value)) + self._subscription_policy = cls.name + + @property + def unsubscription_policy(self): + return config.workflows[self._unsubscription_policy] + + @unsubscription_policy.setter + def unsubscription_policy(self, value): + if isinstance(value, str): + cls = config.workflows.get(value, None) + else: + cls = value + if (cls is None or not IUnsubscriptionWorkflow.implementedBy(cls)): + raise ValueError('Invalid unsubscription policy: {}'.format(value)) + self._unsubscription_policy = cls.name + @public @implementer(IAcceptableAlias) diff --git a/src/mailman/rest/docs/membership.rst b/src/mailman/rest/docs/membership.rst index b0b5e1254..6e24d14f9 100644 --- a/src/mailman/rest/docs/membership.rst +++ b/src/mailman/rest/docs/membership.rst @@ -642,7 +642,6 @@ won't have to approve her subscription request. ... 'display_name': 'Elly Person', ... 'pre_verified': True, ... 'pre_confirmed': True, - ... 'pre_approved': True, ... }) content-length: 0 content-type: application/json; charset=UTF-8 @@ -699,7 +698,6 @@ list with her preferred address. ... 'subscriber': user_id, ... 'pre_verified': True, ... 'pre_confirmed': True, - ... 'pre_approved': True, ... }) content-length: 0 content-type: application/json; charset=UTF-8 diff --git a/src/mailman/rest/docs/sub-moderation.rst b/src/mailman/rest/docs/sub-moderation.rst index 92c0c8849..3c6f30aef 100644 --- a/src/mailman/rest/docs/sub-moderation.rst +++ b/src/mailman/rest/docs/sub-moderation.rst @@ -13,8 +13,8 @@ A mailing list starts with no pending subscription or unsubscription requests. >>> ant = create_list('ant@example.com') >>> ant.admin_immed_notify = False - >>> from mailman.interfaces.mailinglist import SubscriptionPolicy - >>> ant.subscription_policy = SubscriptionPolicy.moderate + >>> from mailman.workflows.subscription import ModerationSubscriptionPolicy + >>> ant.subscription_policy = ModerationSubscriptionPolicy >>> transaction.commit() >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com/requests') http_etag: "..." @@ -31,7 +31,6 @@ is returned to track her subscription request. ... 'subscriber': 'anne@example.com', ... 'display_name': 'Anne Person', ... 'pre_verified': True, - ... 'pre_confirmed': True, ... }) http_etag: ... token: 0000000000000000000000000000000000000001 diff --git a/src/mailman/rest/listconf.py b/src/mailman/rest/listconf.py index b62d34529..70f04999c 100644 --- a/src/mailman/rest/listconf.py +++ b/src/mailman/rest/listconf.py @@ -24,14 +24,17 @@ from mailman.interfaces.archiver import ArchivePolicy from mailman.interfaces.autorespond import ResponseAction from mailman.interfaces.digests import DigestFrequency from mailman.interfaces.mailinglist import ( - DMARCMitigateAction, IAcceptableAliasSet, IMailingList, ReplyToMunging, - SubscriptionPolicy) + DMARCMitigateAction, IAcceptableAliasSet, IMailingList, ReplyToMunging) from mailman.interfaces.template import ITemplateManager +from mailman.interfaces.workflows import ISubscriptionWorkflow from mailman.rest.helpers import ( GetterSetter, bad_request, etag, no_content, not_found, okay) from mailman.rest.validator import ( PatchValidator, ReadOnlyPATCHRequestError, UnknownPATCHRequestError, - Validator, enum_validator, list_of_strings_validator) + Validator, enum_validator, list_of_strings_validator, policy_validator) +from mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) from public import public from zope.component import getUtility @@ -86,6 +89,20 @@ class URIAttributeMapper(GetterSetter): getUtility(ITemplateManager).set(template_name, obj.list_id, value) +class SubscriptionPolicyMapper(GetterSetter): + + def get(self, obj, attribute): + assert IMailingList.providedBy(obj), obj + old_sub_map = { + OpenSubscriptionPolicy: 'open', + ConfirmSubscriptionPolicy: 'confirm', + ModerationSubscriptionPolicy: 'moderate', + ConfirmModerationSubscriptionPolicy: 'confirm_then_moderate' + } + cls = getattr(obj, attribute) + return old_sub_map.get(cls, cls.name) + + # Additional validators for converting from web request strings to internal # data types. See below for details. @@ -179,7 +196,8 @@ ATTRIBUTES = dict( request_address=GetterSetter(None), send_welcome_message=GetterSetter(as_boolean), subject_prefix=GetterSetter(str), - subscription_policy=GetterSetter(enum_validator(SubscriptionPolicy)), + subscription_policy=SubscriptionPolicyMapper( + policy_validator(ISubscriptionWorkflow)), volume=GetterSetter(None), ) diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index 3ff712259..2d5b95115 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -17,7 +17,6 @@ """REST for members.""" -from lazr.config import as_boolean from mailman.app.membership import add_member, delete_member from mailman.interfaces.action import Action from mailman.interfaces.address import IAddress @@ -198,22 +197,43 @@ class AllMembers(_MemberBase): def on_post(self, request, response): """Create a new member.""" - try: - validator = Validator( - list_id=str, - subscriber=subscriber_validator(self.api), - display_name=str, - delivery_mode=enum_validator(DeliveryMode), - role=enum_validator(MemberRole), - pre_verified=as_boolean, - pre_confirmed=as_boolean, - pre_approved=as_boolean, - _optional=('delivery_mode', 'display_name', 'role', - 'pre_verified', 'pre_confirmed', 'pre_approved')) - arguments = validator(request) - except ValueError as error: - bad_request(response, str(error)) + # Validate the params manually as the subscription_policy workflow + # attributes are dynamic, so parse all known and optional attirbutes + # and leave the rest to policy_kwargs. + required_arguments = ['list_id', 'subscriber'] + known_arguments = dict(list_id=str, + subscriber=subscriber_validator(self.api), + display_name=str, + delivery_mode=enum_validator(DeliveryMode), + role=enum_validator(MemberRole)) + + arguments = {} + policy_kwargs = {} + + wrong = [] + for key, value in request.params.items(): + try: + if key in known_arguments: + converter = known_arguments[key] + arguments[key] = converter(value) + else: + policy_kwargs[key] = value + except ValueError: + wrong.append(key) + + if len(wrong) > 0: + params = ', '.join(wrong) + bad_request(response, + 'Cannot convert parameters: {}'.format(params)) + return + + missing = [key for key in required_arguments if key not in arguments] + if len(missing) > 0: + bad_request(response, 'Missing parameters: {}'.format(missing)) return + # XXX policy_kwargs are string, while subscription workflows expects + # whatever. + # Dig the mailing list out of the arguments. list_id = arguments.pop('list_id') mlist = getUtility(IListManager).get_by_list_id(list_id) @@ -247,20 +267,13 @@ class AllMembers(_MemberBase): # nonmembers go through the legacy API for now. role = arguments.pop('role', MemberRole.member) if role is MemberRole.member: - # Get the pre_ flags for the subscription workflow. - pre_verified = arguments.pop('pre_verified', False) - pre_confirmed = arguments.pop('pre_confirmed', False) - pre_approved = arguments.pop('pre_approved', False) # Now we can run the registration process until either the # subscriber is subscribed, or the workflow is paused for # verification, confirmation, or approval. registrar = ISubscriptionManager(mlist) try: token, token_owner, member = registrar.register( - subscriber, - pre_verified=pre_verified, - pre_confirmed=pre_confirmed, - pre_approved=pre_approved) + subscriber, **policy_kwargs) except AlreadySubscribedError: conflict(response, b'Member already subscribed') return diff --git a/src/mailman/rest/tests/test_listconf.py b/src/mailman/rest/tests/test_listconf.py index 782effd29..bb88feb61 100644 --- a/src/mailman/rest/tests/test_listconf.py +++ b/src/mailman/rest/tests/test_listconf.py @@ -22,11 +22,11 @@ import unittest from mailman.app.lifecycle import create_list from mailman.database.transaction import transaction from mailman.interfaces.digests import DigestFrequency -from mailman.interfaces.mailinglist import ( - IAcceptableAliasSet, SubscriptionPolicy) +from mailman.interfaces.mailinglist import IAcceptableAliasSet from mailman.interfaces.template import ITemplateManager from mailman.testing.helpers import call_api from mailman.testing.layers import RESTLayer +from mailman.workflows.subscription import ConfirmModerationSubscriptionPolicy from urllib.error import HTTPError from zope.component import getUtility @@ -197,7 +197,7 @@ class TestConfiguration(unittest.TestCase): self.assertEqual(response.status_code, 204) # And now we verify that it has the requested setting. self.assertEqual(self._mlist.subscription_policy, - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) def test_patch_attribute_double(self): with self.assertRaises(HTTPError) as cm: diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py index e5a2ce283..bb0134ac5 100644 --- a/src/mailman/rest/tests/test_membership.py +++ b/src/mailman/rest/tests/test_membership.py @@ -23,7 +23,6 @@ from mailman.app.lifecycle import create_list from mailman.config import config 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.subscriptions import ISubscriptionManager, TokenOwner from mailman.interfaces.usermanager import IUserManager @@ -33,6 +32,8 @@ from mailman.testing.helpers import ( set_preferred, subscribe, wait_for_webservice) from mailman.testing.layers import ConfigLayer, RESTLayer from mailman.utilities.datetime import now +from mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ModerationSubscriptionPolicy) from urllib.error import HTTPError from zope.component import getUtility @@ -93,7 +94,6 @@ class TestMembership(unittest.TestCase): 'subscriber': 'anne@example.com', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, 'Member already subscribed') @@ -108,7 +108,6 @@ class TestMembership(unittest.TestCase): 'subscriber': 'anne@example.com', 'pre_verified': False, 'pre_confirmed': False, - 'pre_approved': False, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, 'Member already subscribed') @@ -123,7 +122,6 @@ class TestMembership(unittest.TestCase): 'subscriber': '00000000000000000000000000000001', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) self.assertEqual(cm.exception.code, 400) self.assertEqual(cm.exception.reason, 'User has no preferred address') @@ -135,7 +133,6 @@ class TestMembership(unittest.TestCase): 'subscriber': '00000000000000000000000000000801', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) self.assertEqual(cm.exception.code, 400) self.assertEqual(cm.exception.reason, 'No such user') @@ -153,7 +150,6 @@ class TestMembership(unittest.TestCase): 'subscriber': 'ANNE@example.com', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, 'Member already subscribed') @@ -171,7 +167,6 @@ class TestMembership(unittest.TestCase): 'subscriber': 'anne@example.com', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, 'Member already subscribed') @@ -195,7 +190,6 @@ class TestMembership(unittest.TestCase): 'display_name': 'Hugh Person', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) self.assertEqual(json, None) self.assertEqual(response.status_code, 201) @@ -231,10 +225,10 @@ class TestMembership(unittest.TestCase): # to subscribe again. registrar = ISubscriptionManager(self._mlist) with transaction(): - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy anne = self._usermanager.create_address('anne@example.com') token, token_owner, member = registrar.register( - anne, pre_verified=True, pre_confirmed=True) + anne, pre_verified=True) self.assertEqual(token_owner, TokenOwner.moderator) self.assertIsNone(member) with self.assertRaises(HTTPError) as cm: @@ -242,7 +236,6 @@ class TestMembership(unittest.TestCase): 'list_id': 'test.example.com', 'subscriber': 'anne@example.com', 'pre_verified': True, - 'pre_confirmed': True, }) self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, @@ -255,7 +248,7 @@ class TestMembership(unittest.TestCase): registrar = ISubscriptionManager(self._mlist) with transaction(): self._mlist.subscription_policy = ( - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) anne = self._usermanager.create_address('anne@example.com') token, token_owner, member = registrar.register( anne, pre_verified=True) @@ -645,7 +638,6 @@ class TestAPI31Members(unittest.TestCase): 'subscriber': '00000000000000000000000000000001', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) self.assertEqual(response.status_code, 201) self.assertEqual( @@ -681,7 +673,6 @@ class TestAPI31Members(unittest.TestCase): 'subscriber': '1', 'pre_verified': True, 'pre_confirmed': True, - 'pre_approved': True, }) # This is a bad request because the `subscriber` value isn't something # that's known to the system, in API 3.1. It's not technically a 404 diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py index 0a9bb2608..5b55eb575 100644 --- a/src/mailman/rest/tests/test_moderation.py +++ b/src/mailman/rest/tests/test_moderation.py @@ -24,7 +24,6 @@ from mailman.app.lifecycle import create_list 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.requests import IListRequests, RequestType from mailman.interfaces.subscriptions import ISubscriptionManager from mailman.interfaces.usermanager import IUserManager @@ -32,6 +31,7 @@ from mailman.testing.helpers import ( call_api, get_queue_messages, set_preferred, specialized_message_from_string as mfs) from mailman.testing.layers import RESTLayer +from mailman.workflows.subscription import ModerationSubscriptionPolicy from pkg_resources import resource_filename from urllib.error import HTTPError from zope.component import getUtility @@ -296,10 +296,9 @@ class TestSubscriptionModeration(unittest.TestCase): # Anne tries to subscribe to a list that only requests moderator # approval. with transaction(): - self._mlist.subscription_policy = SubscriptionPolicy.moderate + self._mlist.subscription_policy = ModerationSubscriptionPolicy token, token_owner, member = self._registrar.register( - self._anne, - pre_verified=True, pre_confirmed=True) + self._anne, pre_verified=True) # There's now one request in the queue, and it's waiting on moderator # approval. json, response = call_api( diff --git a/src/mailman/rest/tests/test_users.py b/src/mailman/rest/tests/test_users.py index 97f4b76d2..4c97e5c1d 100644 --- a/src/mailman/rest/tests/test_users.py +++ b/src/mailman/rest/tests/test_users.py @@ -437,7 +437,7 @@ class TestLP1074374(unittest.TestCase): list_id='test.example.com', subscriber='anne@example.com', role='member', - pre_verified=True, pre_confirmed=True, pre_approved=True)) + pre_verified=True, pre_confirmed=True)) # This is not the Anne you're looking for. (IOW, the new Anne is a # different user). json, response = call_api( diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py index b325fdc84..d5f684df9 100644 --- a/src/mailman/rest/validator.py +++ b/src/mailman/rest/validator.py @@ -16,15 +16,35 @@ # GNU Mailman. If not, see . """REST web form validation.""" - +from mailman.config import config from mailman.interfaces.address import IEmailValidator from mailman.interfaces.errors import MailmanError from mailman.interfaces.languages import ILanguageManager +from mailman.interfaces.workflows import (ISubscriptionWorkflow, + IUnsubscriptionWorkflow) +from mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) +from mailman.workflows.unsubscription import ( + ConfirmModerationUnsubscriptionPolicy, ConfirmUnsubscriptionPolicy, + ModerationUnsubscriptionPolicy, OpenUnsubscriptionPolicy) from public import public from zope.component import getUtility COMMASPACE = ', ' +OLD_SUB_MAP = { + 'open': OpenSubscriptionPolicy, + 'confirm': ConfirmSubscriptionPolicy, + 'moderate': ModerationSubscriptionPolicy, + 'confirm_then_moderate': ConfirmModerationSubscriptionPolicy + } +OLD_UNSUB_MAP = { + 'open': OpenUnsubscriptionPolicy, + 'confirm': ConfirmUnsubscriptionPolicy, + 'moderate': ModerationUnsubscriptionPolicy, + 'confirm_then_moderate': ConfirmModerationUnsubscriptionPolicy + } @public @@ -99,6 +119,33 @@ def list_of_strings_validator(values): return values +@public +class policy_validator: + """""" + + def __init__(self, policy_interface): + self._policy_interface = policy_interface + if policy_interface is ISubscriptionWorkflow: + self._old_map = OLD_SUB_MAP + elif policy_interface is IUnsubscriptionWorkflow: + self._old_map = OLD_UNSUB_MAP + else: + raise ValueError('Expected a workflow interface.') + + def __call__(self, policy): + if self._policy_interface.implementedBy(policy): + return policy + if (policy in config.workflows and + self._policy_interface.implementedBy( + config.workflows[policy])): + return config.workflows[policy] + # For backwards compatibility. + policy = self._old_map.get(policy, None) + if policy is not None: + return policy + raise ValueError('Unknown policy: {}'.format(policy)) + + @public class Validator: """A validator of parameter input.""" diff --git a/src/mailman/runners/docs/command.rst b/src/mailman/runners/docs/command.rst index 0dbce2ee1..73bacf5b4 100644 --- a/src/mailman/runners/docs/command.rst +++ b/src/mailman/runners/docs/command.rst @@ -166,8 +166,8 @@ Similarly, to leave a mailing list, the user need only email the ``-leave`` or ... ... """) - >>> from mailman.interfaces.mailinglist import SubscriptionPolicy - >>> mlist.unsubscription_policy = SubscriptionPolicy.open + >>> from mailman.workflows.unsubscription import OpenUnsubscriptionPolicy + >>> mlist.unsubscription_policy = OpenUnsubscriptionPolicy >>> filebase = inject_message( ... mlist, msg, switchboard='command', subaddress='leave') >>> command.run() diff --git a/src/mailman/runners/tests/test_leave.py b/src/mailman/runners/tests/test_leave.py index 7069e6710..c20637c3f 100644 --- a/src/mailman/runners/tests/test_leave.py +++ b/src/mailman/runners/tests/test_leave.py @@ -23,13 +23,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.mailinglist import SubscriptionPolicy from mailman.interfaces.usermanager import IUserManager from mailman.runners.command import CommandRunner from mailman.testing.helpers import ( get_queue_messages, make_testable_runner, set_preferred, specialized_message_from_string as mfs) from mailman.testing.layers import ConfigLayer +from mailman.workflows.unsubscription import (ConfirmUnsubscriptionPolicy, + OpenUnsubscriptionPolicy) from zope.component import getUtility @@ -65,7 +66,7 @@ class TestLeave(unittest.TestCase): def test_leave(self): with transaction(): - self._mlist.unsubscription_policy = SubscriptionPolicy.confirm + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy anne = getUtility(IUserManager).create_user('anne@example.org') set_preferred(anne) self._mlist.subscribe(anne.preferred_address) @@ -100,7 +101,7 @@ leave # sent to the -leave address and it contains the 'leave' command, we # should only process one command per email. with transaction(): - self._mlist.unsubscription_policy = SubscriptionPolicy.open + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy anne = getUtility(IUserManager).create_user('anne@example.org') set_preferred(anne) self._mlist.subscribe(anne.preferred_address) diff --git a/src/mailman/styles/base.py b/src/mailman/styles/base.py index 9e012385d..740a56044 100644 --- a/src/mailman/styles/base.py +++ b/src/mailman/styles/base.py @@ -31,8 +31,10 @@ from mailman.interfaces.autorespond import ResponseAction from mailman.interfaces.bounce import UnrecognizedBounceDisposition from mailman.interfaces.digests import DigestFrequency from mailman.interfaces.mailinglist import ( - DMARCMitigateAction, Personalization, ReplyToMunging, SubscriptionPolicy) + DMARCMitigateAction, Personalization, ReplyToMunging) from mailman.interfaces.nntp import NewsgroupModeration +from mailman.workflows.subscription import ConfirmSubscriptionPolicy +from mailman.workflows.unsubscription import ConfirmUnsubscriptionPolicy from public import public @@ -66,8 +68,8 @@ class BasicOperation: mlist.personalize = Personalization.none mlist.default_member_action = Action.defer mlist.default_nonmember_action = Action.hold - mlist.subscription_policy = SubscriptionPolicy.confirm - mlist.unsubscription_policy = SubscriptionPolicy.confirm + mlist.subscription_policy = ConfirmSubscriptionPolicy + mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy # Notify the administrator of pending requests and membership changes. mlist.admin_immed_notify = True mlist.admin_notify_mchanges = False diff --git a/src/mailman/utilities/importer.py b/src/mailman/utilities/importer.py index 578e95dff..c5e73bcc5 100644 --- a/src/mailman/utilities/importer.py +++ b/src/mailman/utilities/importer.py @@ -35,14 +35,16 @@ from mailman.interfaces.digests import DigestFrequency from mailman.interfaces.errors import MailmanError from mailman.interfaces.languages import ILanguageManager from mailman.interfaces.mailinglist import ( - IAcceptableAliasSet, IHeaderMatchList, Personalization, ReplyToMunging, - SubscriptionPolicy) + IAcceptableAliasSet, IHeaderMatchList, Personalization, ReplyToMunging) from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole from mailman.interfaces.nntp import NewsgroupModeration from mailman.interfaces.template import ITemplateManager from mailman.interfaces.usermanager import IUserManager from mailman.utilities.filesystem import makedirs from mailman.utilities.i18n import search +from mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) from public import public from sqlalchemy import Boolean from zope.component import getUtility @@ -138,6 +140,15 @@ def action_to_chain(value): }[value] +def sub_policy_to_class(value): + return { + 0: OpenSubscriptionPolicy, + 1: ConfirmSubscriptionPolicy, + 2: ModerationSubscriptionPolicy, + 3: ConfirmModerationSubscriptionPolicy + }[value] + + def check_language_code(code): if code is None: return None @@ -180,7 +191,7 @@ TYPES = dict( personalize=Personalization, preferred_language=check_language_code, reply_goes_to_list=ReplyToMunging, - subscription_policy=SubscriptionPolicy, + subscription_policy=sub_policy_to_class, ) diff --git a/src/mailman/utilities/tests/test_import.py b/src/mailman/utilities/tests/test_import.py index b1b4764ff..6db0c4a4a 100644 --- a/src/mailman/utilities/tests/test_import.py +++ b/src/mailman/utilities/tests/test_import.py @@ -33,8 +33,7 @@ from mailman.interfaces.bans import IBanManager from mailman.interfaces.bounce import UnrecognizedBounceDisposition from mailman.interfaces.domain import IDomainManager from mailman.interfaces.languages import ILanguageManager -from mailman.interfaces.mailinglist import ( - IAcceptableAliasSet, SubscriptionPolicy) +from mailman.interfaces.mailinglist import IAcceptableAliasSet from mailman.interfaces.member import DeliveryMode, DeliveryStatus from mailman.interfaces.nntp import NewsgroupModeration from mailman.interfaces.template import ITemplateLoader, ITemplateManager @@ -44,6 +43,9 @@ from mailman.testing.layers import ConfigLayer from mailman.utilities.filesystem import makedirs from mailman.utilities.importer import ( Import21Error, check_language_code, import_config_pck) +from mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) from pickle import load from pkg_resources import resource_filename from unittest import mock @@ -295,32 +297,32 @@ class TestBasicImport(unittest.TestCase): self.assertEqual(self._mlist.encode_ascii_prefixes, True) def test_subscription_policy_open(self): - self._mlist.subscription_policy = SubscriptionPolicy.confirm + self._mlist.subscription_policy = ConfirmSubscriptionPolicy self._pckdict['subscribe_policy'] = 0 self._import() self.assertEqual(self._mlist.subscription_policy, - SubscriptionPolicy.open) + OpenSubscriptionPolicy) def test_subscription_policy_confirm(self): - self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.subscription_policy = OpenSubscriptionPolicy self._pckdict['subscribe_policy'] = 1 self._import() self.assertEqual(self._mlist.subscription_policy, - SubscriptionPolicy.confirm) + ConfirmSubscriptionPolicy) def test_subscription_policy_moderate(self): - self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.subscription_policy = OpenSubscriptionPolicy self._pckdict['subscribe_policy'] = 2 self._import() self.assertEqual(self._mlist.subscription_policy, - SubscriptionPolicy.moderate) + ModerationSubscriptionPolicy) def test_subscription_policy_confirm_then_moderate(self): - self._mlist.subscription_policy = SubscriptionPolicy.open + self._mlist.subscription_policy = OpenSubscriptionPolicy self._pckdict['subscribe_policy'] = 3 self._import() self.assertEqual(self._mlist.subscription_policy, - SubscriptionPolicy.confirm_then_moderate) + ConfirmModerationSubscriptionPolicy) def test_header_matches(self): # This test containes real cases of header_filter_rules. diff --git a/src/mailman/workflows/builtin.py b/src/mailman/workflows/builtin.py deleted file mode 100644 index 33e428d0b..000000000 --- a/src/mailman/workflows/builtin.py +++ /dev/null @@ -1,403 +0,0 @@ -# Copyright (C) 2015-2017 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 . - -"""The built in subscription and unsubscription workflows.""" - -import logging - -from email.utils import formataddr -from mailman.app.membership import delete_member -from mailman.core.i18n import _ -from mailman.email.message import UserNotification -from mailman.interfaces.address import IAddress -from mailman.interfaces.bans import IBanManager -from mailman.interfaces.mailinglist import SubscriptionPolicy -from mailman.interfaces.member import (AlreadySubscribedError, MemberRole, - MembershipIsBannedError, - NotAMemberError) -from mailman.interfaces.pending import IPendings -from mailman.interfaces.subscriptions import ( - SubscriptionConfirmationNeededEvent, SubscriptionPendingError, - TokenOwner, UnsubscriptionConfirmationNeededEvent) -from mailman.interfaces.template import ITemplateLoader -from mailman.interfaces.user import IUser -from mailman.interfaces.usermanager import IUserManager -from mailman.interfaces.workflows import (ISubscriptionWorkflow, - IUnsubscriptionWorkflow) -from mailman.utilities.datetime import now -from mailman.utilities.string import expand, wrap -from mailman.workflows.common import (SubscriptionWorkflowCommon, - WhichSubscriber) -from public import public -from zope.component import getUtility -from zope.event import notify -from zope.interface import implementer - - -log = logging.getLogger('mailman.subscribe') - - -@public -@implementer(ISubscriptionWorkflow) -class SubscriptionWorkflow(SubscriptionWorkflowCommon): - """Workflow of a subscription request.""" - - name = 'subscription-default' - description = '' - - initial_state = 'sanity_checks' - save_attributes = ( - 'pre_approved', - 'pre_confirmed', - 'pre_verified', - 'address_key', - 'subscriber_key', - 'user_key', - 'token_owner_key', - ) - - def __init__(self, mlist, subscriber=None, *, - pre_verified=False, pre_confirmed=False, pre_approved=False): - super().__init__(mlist, subscriber) - self.pre_verified = pre_verified - self.pre_confirmed = pre_confirmed - self.pre_approved = pre_approved - - def _step_sanity_checks(self): - # Ensure that we have both an address and a user, even if the address - # is not verified. We can't set the preferred address until it is - # verified. - if self.user is None: - # The address has no linked user so create one, link it, and set - # the user's preferred address. - assert self.address is not None, 'No address or user' - self.user = getUtility(IUserManager).make_user(self.address.email) - if self.address is None: - assert self.user.preferred_address is None, ( - "Preferred address exists, but wasn't used in constructor") - addresses = list(self.user.addresses) - if len(addresses) == 0: - raise AssertionError('User has no addresses: {}'.format( - self.user)) - # This is rather arbitrary, but we have no choice. - self.address = addresses[0] - assert self.user is not None and self.address is not None, ( - 'Insane sanity check results') - # Is this subscriber already a member? - if (self.which is WhichSubscriber.user and - self.user.preferred_address is not None): - subscriber = self.user - else: - subscriber = self.address - if self.mlist.is_subscribed(subscriber): - # 2017-04-22 BAW: This branch actually *does* get covered, as I've - # verified by a full coverage run, but diffcov for some reason - # claims that the test added in the branch that added this code - # does not cover the change. That seems like a bug in diffcov. - raise AlreadySubscribedError( # pragma: no cover - self.mlist.fqdn_listname, - self.address.email, - MemberRole.member) - # Is this email address banned? - if IBanManager(self.mlist).is_banned(self.address.email): - raise MembershipIsBannedError(self.mlist, self.address.email) - # Check if there is already a subscription request for this email. - pendings = getUtility(IPendings).find( - mlist=self.mlist, - pend_type='subscription') - for token, pendable in pendings: - if pendable['email'] == self.address.email: - raise SubscriptionPendingError(self.mlist, self.address.email) - # Start out with the subscriber being the token owner. - self.push('verification_checks') - - def _step_verification_checks(self): - # Is the address already verified, or is the pre-verified flag set? - if self.address.verified_on is None: - if self.pre_verified: - self.address.verified_on = now() - else: - # The address being subscribed is not yet verified, so we need - # to send a validation email that will also confirm that the - # user wants to be subscribed to this mailing list. - self.push('send_confirmation') - return - self.push('confirmation_checks') - - def _step_confirmation_checks(self): - # If the list's subscription policy is open, then the user can be - # subscribed right here and now. - if self.mlist.subscription_policy is SubscriptionPolicy.open: - self.push('do_subscription') - return - # If we do not need the user's confirmation, then skip to the - # moderation checks. - if self.mlist.subscription_policy is SubscriptionPolicy.moderate: - self.push('moderation_checks') - return - # If the subscription has been pre-confirmed, then we can skip the - # confirmation check can be skipped. If moderator approval is - # required we need to check that, otherwise we can go straight to - # subscription. - if self.pre_confirmed: - next_step = ( - 'moderation_checks' - if self.mlist.subscription_policy is - SubscriptionPolicy.confirm_then_moderate # noqa: E131 - else 'do_subscription') - self.push(next_step) - return - # The user must confirm their subscription. - self.push('send_confirmation') - - def _step_moderation_checks(self): - # Does the moderator need to approve the subscription request? - assert self.mlist.subscription_policy in ( - SubscriptionPolicy.moderate, - SubscriptionPolicy.confirm_then_moderate, - ), self.mlist.subscription_policy - if self.pre_approved: - self.push('do_subscription') - else: - self.push('get_moderator_approval') - - def _step_get_moderator_approval(self): - # Here's the next step in the workflow, assuming the moderator - # approves of the subscription. If they don't, the workflow and - # subscription request will just be thrown away. - self._set_token(TokenOwner.moderator) - self.push('subscribe_from_restored') - self.save() - log.info('{}: held subscription request from {}'.format( - self.mlist.fqdn_listname, self.address.email)) - # Possibly send a notification to the list moderators. - if self.mlist.admin_immed_notify: - subject = _( - 'New subscription request to $self.mlist.display_name ' - 'from $self.address.email') - username = formataddr( - (self.subscriber.display_name, self.address.email)) - template = getUtility(ITemplateLoader).get( - 'list:admin:action:subscribe', self.mlist) - text = wrap(expand(template, self.mlist, dict( - member=username, - ))) - # 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) - # The workflow must stop running here. - raise StopIteration - - def _step_subscribe_from_restored(self): - # Prevent replay attacks. - self._set_token(TokenOwner.no_one) - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # subscribe the user. - if self.which is WhichSubscriber.address: - self.subscriber = self.address - else: - assert self.which is WhichSubscriber.user - self.subscriber = self.user - self.push('do_subscription') - - def _step_do_subscription(self): - # We can immediately subscribe the user to the mailing list. - self.member = self.mlist.subscribe(self.subscriber) - assert self.token is None and self.token_owner is TokenOwner.no_one, ( - 'Unexpected active token at end of subscription workflow') - - def _step_send_confirmation(self): - self._set_token(TokenOwner.subscriber) - self.push('do_confirm_verify') - self.save() - # Triggering this event causes the confirmation message to be sent. - notify(SubscriptionConfirmationNeededEvent( - self.mlist, self.token, self.address.email)) - # Now we wait for the confirmation. - raise StopIteration - - def _step_do_confirm_verify(self): - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # continue with the confirmation/verification step. - 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) - # The user has confirmed their subscription request, and also verified - # their email address if necessary. This latter needs to be set on the - # IAddress, but there's nothing more to do about the confirmation step. - # We just continue along with the workflow. - if self.address.verified_on is None: - self.address.verified_on = now() - # The next step depends on the mailing list's subscription policy. - next_step = ('moderation_checks' - if self.mlist.subscription_policy in ( - SubscriptionPolicy.moderate, - SubscriptionPolicy.confirm_then_moderate, - ) - else 'do_subscription') - self.push(next_step) - - -@public -@implementer(IUnsubscriptionWorkflow) -class UnSubscriptionWorkflow(SubscriptionWorkflowCommon): - """Workflow of a unsubscription request.""" - - name = 'unsubscription-default' - description = '' - - 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__(mlist, subscriber) - if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber): - self.member = self.mlist.regular_members.get_member( - self.address.email) - self.pre_confirmed = pre_confirmed - self.pre_approved = pre_approved - - 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 the request is pre-confirmed, then the user can unsubscribe right - # now. - if self.pre_confirmed: - self.push('do_unsubscription') - 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(UnsubscriptionConfirmationNeededEvent( - 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)) - template = getUtility(ITemplateLoader).get( - 'list:admin:action:unsubscribe', self.mlist) - text = wrap(expand(template, self.mlist, dict( - member=username, - ))) - # 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) - # The workflow must stop running here - raise StopIteration - - def _step_do_confirm_verify(self): - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # continue with the confirmation/verification step. - 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) - # Restore the member object. - self.member = self.mlist.regular_members.get_member(self.address.email) - # It's possible the member was already unsubscribed while we were - # waiting for the confirmation. - if self.member is None: - return - # The user has confirmed their unsubscription request - next_step = ('moderation_checks' - if self.mlist.unsubscription_policy in ( - SubscriptionPolicy.moderate, - SubscriptionPolicy.confirm_then_moderate, - ) - else 'do_unsubscription') - self.push(next_step) - - def _step_do_unsubscription(self): - try: - delete_member(self.mlist, self.address.email) - except NotAMemberError: - # The member has already been unsubscribed. - pass - self.member = None - assert self.token is None and self.token_owner is TokenOwner.no_one, ( - 'Unexpected active token at end of subscription workflow') - - 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 WhichSubscriber.user - self.subscriber = self.user - self.push('do_unsubscription') -- cgit v1.2.3-70-g09d2 From 7e47b06fbd20ac349b653f4cfcd2157229be1176 Mon Sep 17 00:00:00 2001 From: J08nY Date: Thu, 6 Jul 2017 18:13:47 +0200 Subject: Refactor the duplicate workflow mixins. --- src/mailman/workflows/common.py | 228 +++++++++++--------------------- src/mailman/workflows/unsubscription.py | 18 +-- 2 files changed, 88 insertions(+), 158 deletions(-) diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index d6c372b1e..8593d0997 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -58,6 +58,11 @@ class WhichSubscriber(Enum): user = 2 +class WhichWorkflow(Enum): + subscription = 1 + unsubscription = 2 + + @implementer(IPendable) class PendableSubscription(dict): PEND_TYPE = 'subscription' @@ -130,6 +135,16 @@ class SubscriptionWorkflowCommon(Workflow): def token_owner_key(self, value): self.token_owner = TokenOwner(value) + def _restore_subscriber(self): + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # continue with the confirmation/verification step. + if self.which is WhichSubscriber.address: + self.subscriber = self.address + else: + assert self.which is WhichSubscriber.user + self.subscriber = self.user + def _set_token(self, token_owner): assert isinstance(token_owner, TokenOwner) pendings = getUtility(IPendings) @@ -163,6 +178,10 @@ class SubscriptionWorkflowCommon(Workflow): class SubscriptionBase(SubscriptionWorkflowCommon): + def __init__(self, mlist, subscriber): + super().__init__(mlist, subscriber) + self._workflow = WhichWorkflow.subscription + def _step_sanity_checks(self): # Ensure that we have both an address and a user, even if the address # is not verified. We can't set the preferred address until it is @@ -217,27 +236,49 @@ class SubscriptionBase(SubscriptionWorkflowCommon): 'Unexpected active token at end of subscription workflow') +class UnsubscriptionBase(SubscriptionWorkflowCommon): + + def __init__(self, mlist, subscriber): + super().__init__(mlist, subscriber) + if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber): + self.member = self.mlist.regular_members.get_member( + self.address.email) + self._workflow = WhichWorkflow.unsubscription + + def _step_subscription_checks(self): + assert self.mlist.is_subscribed(self.subscriber) + + def _step_do_unsubscription(self): + try: + delete_member(self.mlist, self.address.email) + except NotAMemberError: + # The member has already been unsubscribed. + pass + self.member = None + assert self.token is None and self.token_owner is TokenOwner.no_one, ( + 'Unexpected active token at end of subscription workflow') + + class RequestMixin: def _step_send_confirmation(self): self._set_token(TokenOwner.subscriber) self.push('do_confirm_verify') self.save() + if self._workflow is WhichWorkflow.subscription: + event_class = SubscriptionConfirmationNeededEvent + else: + event_class = UnsubscriptionConfirmationNeededEvent + # Triggering this event causes the confirmation message to be sent. - notify(SubscriptionConfirmationNeededEvent( - self.mlist, self.token, self.address.email)) + notify(event_class( + self.mlist, self.token, self.address.email)) # Now we wait for the confirmation. raise StopIteration def _step_do_confirm_verify(self): - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # continue with the confirmation/verification step. - if self.which is WhichSubscriber.address: - self.subscriber = self.address - else: - assert self.which is WhichSubscriber.user - self.subscriber = self.user + # Restore a little extra state that can't be stored in the database. + self._restore_subscriber() # Reset the token so it can't be used in a replay attack. self._set_token(TokenOwner.no_one) # The user has confirmed their subscription request, and also verified @@ -249,6 +290,10 @@ class RequestMixin: self.verified = True self.confirmed = True + if self._workflow is WhichWorkflow.unsubscription: + self.member = self.mlist.regular_members.get_member( + self.address.email) + class VerificationMixin(RequestMixin): @@ -265,7 +310,6 @@ class VerificationMixin(RequestMixin): # to send a validation email that will also confirm that the # user wants to be subscribed to this mailing list. self.push('send_confirmation') - return class ConfirmationMixin(RequestMixin): @@ -275,11 +319,10 @@ class ConfirmationMixin(RequestMixin): def _step_confirmation_checks(self): # If the subscription has been pre-confirmed, then we can skip the - # confirmation check can be skipped. - if self.confirmed: - return - # The user must confirm their subscription. - self.push('send_confirmation') + # confirmation check. + if not self.confirmed: + # The user must confirm their subscription. + self.push('send_confirmation') class ModerationMixin: @@ -288,146 +331,36 @@ class ModerationMixin: self.approved = pre_approved def _step_moderation_checks(self): - # Does the moderator need to approve the subscription request? - if self.approved: - return - # A moderator must confirm the subscription. - self.push('get_moderator_approval') + # Does the moderator need to approve the request? + if not self.approved: + self.push('get_moderator_approval') def _step_get_moderator_approval(self): # Here's the next step in the workflow, assuming the moderator - # approves of the subscription. If they don't, the workflow and - # subscription request will just be thrown away. + # approves of the request. If they don't, the workflow and + # request will just be thrown away. self._set_token(TokenOwner.moderator) - self.push('subscribe_from_restored') - self.save() - log.info('{}: held subscription request from {}'.format( - self.mlist.fqdn_listname, self.address.email)) - # Possibly send a notification to the list moderators. - if self.mlist.admin_immed_notify: - subject = _( - 'New subscription request to $self.mlist.display_name ' - 'from $self.address.email') - username = formataddr( - (self.subscriber.display_name, self.address.email)) - template = getUtility(ITemplateLoader).get( - 'list:admin:action:subscribe', self.mlist) - text = wrap(expand(template, self.mlist, dict( - member=username, - ))) - # 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) - # The workflow must stop running here. - raise StopIteration - - def _step_subscribe_from_restored(self): - # Prevent replay attacks. - self._set_token(TokenOwner.no_one) - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # subscribe the user. - if self.which is WhichSubscriber.address: - self.subscriber = self.address - else: - assert self.which is WhichSubscriber.user - self.subscriber = self.user - - -class UnsubscriptionBase(SubscriptionWorkflowCommon): - - def __init__(self, mlist, subscriber): - super().__init__(mlist, subscriber) - if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber): - self.member = self.mlist.regular_members.get_member( - self.address.email) - - def _step_subscription_checks(self): - assert self.mlist.is_subscribed(self.subscriber) - - def _step_do_unsubscription(self): - try: - delete_member(self.mlist, self.address.email) - except NotAMemberError: - # The member has already been unsubscribed. - pass - self.member = None - assert self.token is None and self.token_owner is TokenOwner.no_one, ( - 'Unexpected active token at end of subscription workflow') - - -class UnRequestMixin: - - def _step_send_confirmation(self): - self._set_token(TokenOwner.subscriber) - self.push('do_confirm_verify') + self.push('restore') self.save() - notify(UnsubscriptionConfirmationNeededEvent( - self.mlist, self.token, self.address.email)) - raise StopIteration - def _step_do_confirm_verify(self): - # Restore a little extra state that can't be stored in the database - # (because the order of setattr() on restore is indeterminate), then - # continue with the confirmation/verification step. - if self.which is WhichSubscriber.address: - self.subscriber = self.address + if self._workflow is WhichWorkflow.subscription: + workflow_name = 'subscription' + template_name = 'list:admin:action:subscribe' 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) - # Restore the member object. - self.member = self.mlist.regular_members.get_member(self.address.email) - # It's possible the member was already unsubscribed while we were - # waiting for the confirmation. - if self.member is None: - return - # The user has confirmed their unsubscription request - self.confirmed = True - - -class UnConfirmationMixin(UnRequestMixin): - - def __init__(self, pre_confirmed=False): - self.confirmed = pre_confirmed - - def _step_confirmation_checks(self): - # If the unsubscription has been pre-confirmed, then we can skip the - # confirmation check can be skipped. - if self.confirmed: - return - # The user must confirm their unsubscription. - self.push('send_confirmation') - + workflow_name = 'unsubscription' + template_name = 'list:admin:action:unsubscribe' -class UnModerationMixin(UnRequestMixin): - - def __init__(self, pre_approved=False): - self.approved = pre_approved - - def _step_moderation_checks(self): - # Does the moderator need to approve the unsubscription request? - if not self.approved: - 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)) + log.info('{}: held {} request from {}'.format( + self.mlist.fqdn_listname, workflow_name, self.address.email)) + # Possibly send a notification to the list moderators. if self.mlist.admin_immed_notify: subject = _( - 'New unsubscription request to $self.mlist.display_name ' + 'New $workflow_name request to $self.mlist.display_name ' 'from $self.address.email') username = formataddr( (self.subscriber.display_name, self.address.email)) template = getUtility(ITemplateLoader).get( - 'list:admin:action:unsubscribe', self.mlist) + template_name, self.mlist) text = wrap(expand(template, self.mlist, dict( member=username, ))) @@ -437,14 +370,11 @@ class UnModerationMixin(UnRequestMixin): self.mlist.owner_address, self.mlist.owner_address, subject, text, self.mlist.preferred_language) msg.send(self.mlist) - # The workflow must stop running here + # The workflow must stop running here. raise StopIteration - def _step_unsubscribe_from_restored(self): + def _step_restore(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 WhichSubscriber.user - self.subscriber = self.user + # Restore a little extra state that can't be stored in the database. + self._restore_subscriber() diff --git a/src/mailman/workflows/unsubscription.py b/src/mailman/workflows/unsubscription.py index 3c4ad39de..45dad92f5 100644 --- a/src/mailman/workflows/unsubscription.py +++ b/src/mailman/workflows/unsubscription.py @@ -19,7 +19,7 @@ from mailman.core.i18n import _ from mailman.interfaces.workflows import IUnsubscriptionWorkflow -from mailman.workflows.common import (UnConfirmationMixin, UnModerationMixin, +from mailman.workflows.common import (ConfirmationMixin, ModerationMixin, UnsubscriptionBase) from public import public from zope.interface import implementer @@ -57,7 +57,7 @@ class OpenUnsubscriptionPolicy(UnsubscriptionBase): @public @implementer(IUnsubscriptionWorkflow) -class ConfirmUnsubscriptionPolicy(UnsubscriptionBase, UnConfirmationMixin): +class ConfirmUnsubscriptionPolicy(UnsubscriptionBase, ConfirmationMixin): """""" name = 'unsub-policy-confirm' @@ -88,7 +88,7 @@ class ConfirmUnsubscriptionPolicy(UnsubscriptionBase, UnConfirmationMixin): :type pre_confirmed: bool """ UnsubscriptionBase.__init__(self, mlist, subscriber) - UnConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + ConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) def _step_prepare(self): self.push('do_unsubscription') @@ -98,7 +98,7 @@ class ConfirmUnsubscriptionPolicy(UnsubscriptionBase, UnConfirmationMixin): @public @implementer(IUnsubscriptionWorkflow) -class ModerationUnsubscriptionPolicy(UnsubscriptionBase, UnModerationMixin): +class ModerationUnsubscriptionPolicy(UnsubscriptionBase, ModerationMixin): """""" name = 'unsub-policy-moderate' @@ -128,7 +128,7 @@ class ModerationUnsubscriptionPolicy(UnsubscriptionBase, UnModerationMixin): :type pre_approved: bool """ UnsubscriptionBase.__init__(self, mlist, subscriber) - UnModerationMixin.__init__(self, pre_approved=pre_approved) + ModerationMixin.__init__(self, pre_approved=pre_approved) def _step_prepare(self): self.push('do_unsubscription') @@ -139,8 +139,8 @@ class ModerationUnsubscriptionPolicy(UnsubscriptionBase, UnModerationMixin): @public @implementer(IUnsubscriptionWorkflow) class ConfirmModerationUnsubscriptionPolicy(UnsubscriptionBase, - UnConfirmationMixin, - UnModerationMixin): + ConfirmationMixin, + ModerationMixin): """""" name = 'unsub-policy-confirm-moderate' @@ -180,8 +180,8 @@ class ConfirmModerationUnsubscriptionPolicy(UnsubscriptionBase, :type pre_approved: bool """ UnsubscriptionBase.__init__(self, mlist, subscriber) - UnConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) - UnModerationMixin.__init__(self, pre_approved=pre_approved) + ConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + ModerationMixin.__init__(self, pre_approved=pre_approved) def _step_prepare(self): self.push('do_unsubscription') -- cgit v1.2.3-70-g09d2 From eddbcf479421e234100ee7cd9b425b9c057b04ee Mon Sep 17 00:00:00 2001 From: J08nY Date: Thu, 6 Jul 2017 20:01:54 +0200 Subject: Remove [Un]SubscriptionConfirmationNeeded events, send msg in workflows. --- src/mailman/app/events.py | 2 -- src/mailman/app/subscriptions.py | 42 +-------------------------------- src/mailman/interfaces/subscriptions.py | 27 --------------------- src/mailman/workflows/common.py | 33 ++++++++++++++++++-------- 4 files changed, 24 insertions(+), 80 deletions(-) diff --git a/src/mailman/app/events.py b/src/mailman/app/events.py index bf7f2ece8..a3fca30a8 100644 --- a/src/mailman/app/events.py +++ b/src/mailman/app/events.py @@ -38,7 +38,5 @@ def initialize(): passwords.handle_ConfigurationUpdatedEvent, style_manager.handle_ConfigurationUpdatedEvent, subscriptions.handle_ListDeletingEvent, - subscriptions.handle_SubscriptionConfirmationNeededEvent, - subscriptions.handle_UnsubscriptionConfirmationNeededEvent, switchboard.handle_ConfigurationUpdatedEvent, ]) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index e1c1d8993..cc28ea859 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -18,16 +18,11 @@ """Handle subscriptions.""" from mailman.database.transaction import flush -from mailman.email.message import UserNotification from mailman.interfaces.listmanager import ListDeletingEvent from mailman.interfaces.pending import IPendings from mailman.interfaces.subscriptions import ( - ISubscriptionManager, ISubscriptionService, - SubscriptionConfirmationNeededEvent, - UnsubscriptionConfirmationNeededEvent) -from mailman.interfaces.template import ITemplateLoader + ISubscriptionManager, ISubscriptionService) from mailman.interfaces.workflows import IWorkflowStateManager -from mailman.utilities.string import expand from mailman.workflows.common import (PendableSubscription, PendableUnsubscription) from public import public @@ -81,41 +76,6 @@ class SubscriptionManager: getUtility(IWorkflowStateManager).discard(token) -def _handle_confirmation_needed_events(event, template_name): - 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(template_name, 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) - - -@public -def handle_SubscriptionConfirmationNeededEvent(event): - if not isinstance(event, SubscriptionConfirmationNeededEvent): - return - _handle_confirmation_needed_events(event, 'list:user:action:subscribe') - - -@public -def handle_UnsubscriptionConfirmationNeededEvent(event): - if not isinstance(event, UnsubscriptionConfirmationNeededEvent): - return - _handle_confirmation_needed_events(event, 'list:user:action:unsubscribe') - - @public def handle_ListDeletingEvent(event): """Delete a mailing list's members when the list is being deleted.""" diff --git a/src/mailman/interfaces/subscriptions.py b/src/mailman/interfaces/subscriptions.py index 40c66cb1f..4a82da096 100644 --- a/src/mailman/interfaces/subscriptions.py +++ b/src/mailman/interfaces/subscriptions.py @@ -77,33 +77,6 @@ class TokenOwner(Enum): moderator = 2 -@public -class SubscriptionConfirmationNeededEvent: - """Triggered when a subscription 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 UnsubscriptionConfirmationNeededEvent: - """Triggered when an unsubscription request needs confirmation. - - 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 ISubscriptionService(Interface): """General subscription services.""" diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index 8593d0997..41733f6e5 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -33,9 +33,8 @@ from mailman.interfaces.member import (AlreadySubscribedError, MemberRole, MembershipIsBannedError, NotAMemberError) from mailman.interfaces.pending import IPendable, IPendings -from mailman.interfaces.subscriptions import ( - SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner, - UnsubscriptionConfirmationNeededEvent) +from mailman.interfaces.subscriptions import (SubscriptionPendingError, + TokenOwner) from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager @@ -45,7 +44,6 @@ from mailman.utilities.datetime import now from mailman.utilities.string import expand, wrap from mailman.workflows.base import Workflow from zope.component import getUtility -from zope.event import notify from zope.interface import implementer from zope.interface.exceptions import DoesNotImplement @@ -266,13 +264,28 @@ class RequestMixin: self.push('do_confirm_verify') self.save() if self._workflow is WhichWorkflow.subscription: - event_class = SubscriptionConfirmationNeededEvent + template_name = 'list:user:action:subscribe' else: - event_class = UnsubscriptionConfirmationNeededEvent - - # Triggering this event causes the confirmation message to be sent. - notify(event_class( - self.mlist, self.token, self.address.email)) + template_name = 'list:user:action:unsubscribe' + + subject = 'confirm {}'.format(self.token) + confirm_address = self.mlist.confirm_address(self.token) + email_address = self.address.email + # Send a verification email to the address. + template = getUtility(ITemplateLoader).get(template_name, self.mlist) + text = expand(template, self.mlist, dict( + token=self.token, + subject=subject, + confirm_email=confirm_address, + user_email=email_address, + # For backward compatibility. + confirm_address=confirm_address, + email_address=email_address, + domain_name=self.mlist.domain.mail_host, + contact_address=self.mlist.owner_address, + )) + msg = UserNotification(email_address, confirm_address, subject, text) + msg.send(self.mlist, add_precedence=False) # Now we wait for the confirmation. raise StopIteration -- cgit v1.2.3-70-g09d2 From 39fba3777fc7d37414368f40bf3504dadaa1841a Mon Sep 17 00:00:00 2001 From: J08nY Date: Thu, 6 Jul 2017 20:16:36 +0200 Subject: Move workflow tests to mailman.workflows. --- src/mailman/app/tests/test_subscriptions.py | 726 --------------------- src/mailman/app/tests/test_unsubscriptions.py | 520 --------------- src/mailman/app/tests/test_workflow.py | 183 ------ src/mailman/workflows/tests/__init__.py | 0 src/mailman/workflows/tests/test_subscriptions.py | 726 +++++++++++++++++++++ .../workflows/tests/test_unsubscriptions.py | 520 +++++++++++++++ src/mailman/workflows/tests/test_workflow.py | 183 ++++++ 7 files changed, 1429 insertions(+), 1429 deletions(-) delete mode 100644 src/mailman/app/tests/test_subscriptions.py delete mode 100644 src/mailman/app/tests/test_unsubscriptions.py delete mode 100644 src/mailman/app/tests/test_workflow.py create mode 100644 src/mailman/workflows/tests/__init__.py create mode 100644 src/mailman/workflows/tests/test_subscriptions.py create mode 100644 src/mailman/workflows/tests/test_unsubscriptions.py create mode 100644 src/mailman/workflows/tests/test_workflow.py diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py deleted file mode 100644 index 23487ab1c..000000000 --- a/src/mailman/app/tests/test_subscriptions.py +++ /dev/null @@ -1,726 +0,0 @@ -# Copyright (C) 2011-2017 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 . - -"""Tests for the subscription service.""" - -import unittest - -from contextlib import suppress -from mailman.app.lifecycle import create_list -from mailman.interfaces.bans import IBanManager -from mailman.interfaces.member import MemberRole, 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 mailman.workflows.subscription import ( - ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, - ModerationSubscriptionPolicy, OpenSubscriptionPolicy) -from unittest.mock import patch -from zope.component import getUtility - - -class TestSubscriptionWorkflow(unittest.TestCase): - layer = ConfigLayer - maxDiff = None - - def setUp(self): - self._mlist = create_list('test@example.com') - self._mlist.admin_immed_notify = False - self._anne = 'anne@example.com' - self._user_manager = getUtility(IUserManager) - self._expected_pendings_count = 0 - - def tearDown(self): - # There usually should be no pending after all is said and done, but - # some tests don't complete the workflow. - self.assertEqual(getUtility(IPendings).count, - self._expected_pendings_count) - - def test_start_state(self): - # The workflow starts with no tokens or member. - workflow = ConfirmSubscriptionPolicy(self._mlist) - self.assertIsNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.no_one) - self.assertIsNone(workflow.member) - - def test_pended_data(self): - # There is a Pendable associated with the held request, and it has - # some data associated with it. - anne = self._user_manager.create_address(self._anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, 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') - # The token is still in the database. - self._expected_pendings_count = 1 - - def test_user_or_address_required(self): - # The `subscriber` attribute must be a user or address. - workflow = ConfirmSubscriptionPolicy(self._mlist) - self.assertRaises(AssertionError, list, workflow) - - def test_sanity_checks_address(self): - # Ensure that the sanity check phase, when given an IAddress, ends up - # with a linked user. - anne = self._user_manager.create_address(self._anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - self.assertIsNotNone(workflow.address) - self.assertIsNone(workflow.user) - workflow.run_thru('sanity_checks') - self.assertIsNotNone(workflow.address) - self.assertIsNotNone(workflow.user) - self.assertEqual(list(workflow.user.addresses)[0].email, self._anne) - - def test_sanity_checks_user_with_preferred_address(self): - # Ensure that the sanity check phase, when given an IUser with a - # preferred address, ends up with an address. - anne = self._user_manager.make_user(self._anne) - address = set_preferred(anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - # The constructor sets workflow.address because the user has a - # preferred address. - self.assertEqual(workflow.address, address) - self.assertEqual(workflow.user, anne) - workflow.run_thru('sanity_checks') - self.assertEqual(workflow.address, address) - self.assertEqual(workflow.user, anne) - - def test_sanity_checks_user_without_preferred_address(self): - # Ensure that the sanity check phase, when given a user without a - # preferred address, but with at least one linked address, gets an - # address. - anne = self._user_manager.make_user(self._anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - self.assertIsNone(workflow.address) - self.assertEqual(workflow.user, anne) - workflow.run_thru('sanity_checks') - self.assertIsNotNone(workflow.address) - self.assertEqual(workflow.user, anne) - - def test_sanity_checks_user_with_multiple_linked_addresses(self): - # Ensure that the santiy check phase, when given a user without a - # preferred address, but with multiple linked addresses, gets of of - # those addresses (exactly which one is undefined). - anne = self._user_manager.make_user(self._anne) - anne.link(self._user_manager.create_address('anne@example.net')) - anne.link(self._user_manager.create_address('anne@example.org')) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - self.assertIsNone(workflow.address) - self.assertEqual(workflow.user, anne) - workflow.run_thru('sanity_checks') - self.assertIn(workflow.address.email, ['anne@example.com', - 'anne@example.net', - 'anne@example.org']) - self.assertEqual(workflow.user, anne) - - def test_sanity_checks_user_without_addresses(self): - # It is an error to try to subscribe a user with no linked addresses. - user = self._user_manager.create_user() - workflow = ConfirmSubscriptionPolicy(self._mlist, user) - self.assertRaises(AssertionError, workflow.run_thru, 'sanity_checks') - - def test_sanity_checks_globally_banned_address(self): - # An exception is raised if the address is globally banned. - anne = self._user_manager.create_address(self._anne) - IBanManager(None).ban(self._anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - self.assertRaises(MembershipIsBannedError, list, workflow) - - def test_sanity_checks_banned_address(self): - # An exception is raised if the address is banned by the mailing list. - anne = self._user_manager.create_address(self._anne) - IBanManager(self._mlist).ban(self._anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - self.assertRaises(MembershipIsBannedError, list, workflow) - - def test_verification_checks_with_verified_address(self): - # When the address is already verified, we skip straight to the - # confirmation checks. - anne = self._user_manager.create_address(self._anne) - anne.verified_on = now() - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - workflow.run_thru('verification_checks') - with patch.object(workflow, '_step_confirmation_checks') as step: - next(workflow) - step.assert_called_once_with() - - def test_verification_checks_with_pre_verified_address(self): - # When the address is not yet verified, but the pre-verified flag is - # passed to the workflow, we skip to the confirmation checks. - anne = self._user_manager.create_address(self._anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne, - pre_verified=True) - workflow.run_thru('verification_checks') - with patch.object(workflow, '_step_confirmation_checks') as step: - next(workflow) - step.assert_called_once_with() - # And now the address is verified. - self.assertIsNotNone(anne.verified_on) - - def test_verification_checks_confirmation_needed(self): - # The address is neither verified, nor is the pre-verified flag set. - # A confirmation message must be sent to the user which will also - # verify their address. - anne = self._user_manager.create_address(self._anne) - workflow = ConfirmSubscriptionPolicy(self._mlist, anne) - workflow.run_thru('verification_checks') - with patch.object(workflow, '_step_send_confirmation') as step: - next(workflow) - step.assert_called_once_with() - # The address still hasn't been verified. - self.assertIsNone(anne.verified_on) - - def test_confirmation_checks_open_list(self): - # A subscription to an open list does not need to be confirmed or - # moderated. - self._mlist.subscription_policy = OpenSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - workflow.run_thru('verification_checks') - with patch.object(workflow, '_step_do_subscription') as step: - next(workflow) - step.assert_called_once_with() - - def test_confirmation_checks_no_user_confirmation_needed(self): - # A subscription to a list which does not need user confirmation skips - # to the moderation checks. - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - workflow.run_thru('verification_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 subscription policy requires user confirmation, but their - # subscription is pre-confirmed. Since moderation is not required, - # the user will be immediately subscribed. - self._mlist.subscription_policy = ConfirmSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True, - pre_confirmed=True) - workflow.run_thru('confirmation_checks') - with patch.object(workflow, '_step_do_subscription') as step: - next(workflow) - step.assert_called_once_with() - - def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self): - # The subscription policy requires user confirmation, but their - # subscription is pre-confirmed. Since moderation is required, that - # check will be performed. - self._mlist.subscription_policy = ( - ConfirmModerationSubscriptionPolicy) - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True, - 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_and_moderate_pre_confirmed(self): - # The subscription policy requires user confirmation and moderation, - # but their subscription is pre-confirmed. - self._mlist.subscription_policy = ( - ConfirmModerationSubscriptionPolicy) - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True, - 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_confirmation_needed(self): - # The subscription policy requires confirmation and the subscription - # is not pre-confirmed. - self._mlist.subscription_policy = ConfirmSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - workflow.run_thru('confirmation_checks') - with patch.object(workflow, '_step_send_confirmation') as step: - next(workflow) - step.assert_called_once_with() - - def test_confirmation_checks_moderate_confirmation_needed(self): - # The subscription policy requires confirmation and moderation, and the - # subscription is not pre-confirmed. - self._mlist.subscription_policy = ( - ConfirmModerationSubscriptionPolicy) - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - 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_pre_approved(self): - # The subscription is pre-approved by the moderator. - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True, - pre_approved=True) - workflow.run_thru('moderation_checks') - with patch.object(workflow, '_step_do_subscription') as step: - next(workflow) - step.assert_called_once_with() - - def test_moderation_checks_approval_required(self): - # The moderator must approve the subscription. - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - 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_subscription(self): - # An open subscription policy plus a pre-verified address means the - # user gets subscribed to the mailing list without any further - # confirmations or approvals. - self._mlist.subscription_policy = OpenSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - # Consume the entire state machine. - list(workflow) - # Anne is now a member of the mailing list. - member = self._mlist.regular_members.get_member(self._anne) - self.assertEqual(member.address, anne) - self.assertEqual(workflow.member, member) - # No further token is needed. - self.assertIsNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.no_one) - - def test_do_subscription_pre_approved(self): - # An moderation-requiring subscription policy plus a pre-verified and - # pre-approved address means the user gets subscribed to the mailing - # list without any further confirmations or approvals. - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True, - pre_approved=True) - # Consume the entire state machine. - list(workflow) - # Anne is now a member of the mailing list. - member = self._mlist.regular_members.get_member(self._anne) - self.assertEqual(member.address, anne) - self.assertEqual(workflow.member, member) - # No further token is needed. - self.assertIsNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.no_one) - - def test_do_subscription_pre_approved_pre_confirmed(self): - # An moderation-requiring subscription policy plus a pre-verified and - # pre-approved address means the user gets subscribed to the mailing - # list without any further confirmations or approvals. - self._mlist.subscription_policy = ( - ConfirmModerationSubscriptionPolicy) - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True, - pre_confirmed=True, - pre_approved=True) - # Consume the entire state machine. - list(workflow) - # Anne is now a member of the mailing list. - member = self._mlist.regular_members.get_member(self._anne) - self.assertEqual(member.address, anne) - self.assertEqual(workflow.member, member) - # No further token is needed. - self.assertIsNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.no_one) - - def test_do_subscription_cleanups(self): - # Once the user is subscribed, the token, and its associated pending - # database record will be removed from the database. - self._mlist.subscription_policy = OpenSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - # Consume the entire state machine. - list(workflow) - # Anne is now a member of the mailing list. - member = self._mlist.regular_members.get_member(self._anne) - self.assertEqual(member.address, anne) - self.assertEqual(workflow.member, member) - # The workflow is done, so it has no token. - self.assertIsNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.no_one) - - 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 subscribed. - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - # Consume the entire state machine. - list(workflow) - # The user is not currently subscribed to the mailing list. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNone(member) - self.assertIsNone(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 subscription and should - # result in the user getting subscribed. - approved_workflow = self._mlist.subscription_policy(self._mlist) - approved_workflow.token = workflow.token - approved_workflow.restore() - list(approved_workflow) - # Now the user is subscribed to the mailing list. - member = self._mlist.regular_members.get_member(self._anne) - self.assertEqual(member.address, anne) - 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 subscription is held for moderator approval, a message is - # logged. - mark = LogFileMark('mailman.subscribe') - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - # Consume the entire state machine. - list(workflow) - self.assertIn( - 'test@example.com: held subscription request from anne@example.com', - mark.readline() - ) - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_get_moderator_approval_notifies_moderators(self): - # When the 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.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - bart = self._user_manager.create_user('bart@example.com', 'Bart User') - address = set_preferred(bart) - self._mlist.subscribe(address, MemberRole.moderator) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - # Consume the entire state machine. - list(workflow) - # Find the moderator message. - items = get_queue_messages('virgin', expected_count=1) - for item in items: - if item.msg['to'] == 'test-owner@example.com': - break - else: - raise AssertionError('No moderator email found') - self.assertEqual( - item.msgdata['recipients'], {'test-owner@example.com'}) - 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 subscription request to Test from anne@example.com') - self.assertEqual(message.get_payload(), """\ -Your authorization is required for a mailing list subscription request -approval: - - For: anne@example.com - List: test@example.com -""") - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_get_moderator_approval_no_notifications(self): - # When the 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 = False - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - # Consume the entire state machine. - list(workflow) - get_queue_messages('virgin', expected_count=0) - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_send_confirmation(self): - # A confirmation message gets sent when the address is not verified. - anne = self._user_manager.create_address(self._anne) - self.assertIsNone(anne.verified_on) - # Run the workflow to model the confirmation step. - workflow = self._mlist.subscription_policy(self._mlist, anne) - list(workflow) - items = get_queue_messages('virgin', expected_count=1) - message = items[0].msg - token = workflow.token - self.assertEqual(message['Subject'], 'confirm {}'.format(token)) - self.assertEqual( - message['From'], 'test-confirm+{}@example.com'.format(token)) - # The confirmation message is not `Precedence: bulk`. - self.assertIsNone(message['precedence']) - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_send_confirmation_pre_confirmed(self): - # A confirmation message gets sent when the address is not verified - # but the subscription is pre-confirmed. - anne = self._user_manager.create_address(self._anne) - self.assertIsNone(anne.verified_on) - # Run the workflow to model the confirmation step. - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_confirmed=True) - 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)) - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_send_confirmation_pre_verified(self): - # A confirmation message gets sent even when the address is verified - # when the subscription must be confirmed. - self._mlist.subscription_policy = ConfirmSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - self.assertIsNone(anne.verified_on) - # Run the workflow to model the confirmation step. - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - 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)) - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_do_confirm_verify_address(self): - # The address is not yet verified, nor are we pre-verifying. A - # confirmation message will be sent. When the user confirms their - # subscription request, the address will end up being verified. - anne = self._user_manager.create_address(self._anne) - self.assertIsNone(anne.verified_on) - # Run the workflow to model the confirmation step. - workflow = self._mlist.subscription_policy(self._mlist, anne) - list(workflow) - # The address is still not verified. - self.assertIsNone(anne.verified_on) - confirm_workflow = self._mlist.subscription_policy(self._mlist) - confirm_workflow.token = workflow.token - confirm_workflow.restore() - confirm_workflow.run_thru('do_confirm_verify') - # The address is now verified. - self.assertIsNotNone(anne.verified_on) - - def test_do_confirm_verify_user(self): - # A confirmation step is necessary when a user subscribes with their - # preferred address, and we are not pre-confirming. - anne = self._user_manager.create_user(self._anne) - set_preferred(anne) - # Run the workflow to model the confirmation step. There is no - # subscriber attribute yet. - workflow = self._mlist.subscription_policy(self._mlist, anne) - list(workflow) - self.assertEqual(workflow.subscriber, anne) - # Do a confirmation workflow, which should now set the subscriber. - confirm_workflow = self._mlist.subscription_policy(self._mlist) - confirm_workflow.token = workflow.token - confirm_workflow.restore() - confirm_workflow.run_thru('do_confirm_verify') - # The address is now verified. - self.assertEqual(confirm_workflow.subscriber, anne) - - def test_do_confirmation_subscribes_user(self): - # Subscriptions to the mailing list must be confirmed. Once that's - # done, the user's address (which is not initially verified) gets - # subscribed to the mailing list. - self._mlist.subscription_policy = ConfirmSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - self.assertIsNone(anne.verified_on) - workflow = self._mlist.subscription_policy(self._mlist, anne) - list(workflow) - # Anne is not yet a member. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNone(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 = self._mlist.subscription_policy(self._mlist) - confirm_workflow.token = workflow.token - confirm_workflow.restore() - list(confirm_workflow) - self.assertIsNotNone(anne.verified_on) - # Anne is now a member. - member = self._mlist.regular_members.get_member(self._anne) - self.assertEqual(member.address, anne) - self.assertEqual(confirm_workflow.member, 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.subscription_policy = ( - ConfirmModerationSubscriptionPolicy) - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - # Run the state machine up to the first confirmation, and cache the - # confirmation token. - list(workflow) - token = workflow.token - # Anne is not yet a member of the mailing list. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNone(member) - self.assertIsNone(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 = self._mlist.subscription_policy(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 = self._mlist.subscription_policy(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 subscribed. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNone(member) - self.assertIsNone(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 a member. - member = self._mlist.regular_members.get_member(self._anne) - self.assertEqual(member.address.email, self._anne) - self.assertEqual(final_workflow.member, 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.subscription_policy = ConfirmSubscriptionPolicy - anne = self._user_manager.create_address(self._anne) - workflow = self._mlist.subscription_policy( - self._mlist, anne, - pre_verified=True, pre_confirmed=True) - list(workflow) - # Anne was subscribed. - self.assertIsNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.no_one) - self.assertEqual(workflow.member.address, anne) - - def test_restore_user_absorbed(self): - # The subscribing user is absorbed (and thus deleted) before the - # moderator approves the subscription. - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_user(self._anne) - bill = self._user_manager.create_user('bill@example.com') - set_preferred(bill) - # anne subscribes. - workflow = self._mlist.subscription_policy(self._mlist, anne, - pre_verified=True) - list(workflow) - # bill absorbs anne. - bill.absorb(anne) - # anne's subscription request is approved. - approved_workflow = self._mlist.subscription_policy(self._mlist) - approved_workflow.token = workflow.token - approved_workflow.restore() - self.assertEqual(approved_workflow.user, bill) - # Run the workflow through. - list(approved_workflow) - - def test_restore_address_absorbed(self): - # The subscribing user is absorbed (and thus deleted) before the - # moderator approves the subscription. - self._mlist.subscription_policy = ModerationSubscriptionPolicy - anne = self._user_manager.create_user(self._anne) - anne_address = anne.addresses[0] - bill = self._user_manager.create_user('bill@example.com') - # anne subscribes. - workflow = self._mlist.subscription_policy( - self._mlist, anne_address, pre_verified=True) - list(workflow) - # bill absorbs anne. - bill.absorb(anne) - self.assertIn(anne_address, bill.addresses) - # anne's subscription request is approved. - approved_workflow = self._mlist.subscription_policy(self._mlist) - approved_workflow.token = workflow.token - approved_workflow.restore() - self.assertEqual(approved_workflow.user, bill) - # Run the workflow through. - list(approved_workflow) diff --git a/src/mailman/app/tests/test_unsubscriptions.py b/src/mailman/app/tests/test_unsubscriptions.py deleted file mode 100644 index 2e210e90b..000000000 --- a/src/mailman/app/tests/test_unsubscriptions.py +++ /dev/null @@ -1,520 +0,0 @@ -# Copyright (C) 2016-2017 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 unsubscription service.""" - -import unittest - -from contextlib import suppress -from mailman.app.lifecycle import create_list -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 -from mailman.testing.layers import ConfigLayer -from mailman.utilities.datetime import now -from mailman.workflows.unsubscription import ( - ConfirmModerationUnsubscriptionPolicy, ConfirmUnsubscriptionPolicy, - ModerationUnsubscriptionPolicy, OpenUnsubscriptionPolicy) -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.unsubscription_policy = OpenUnsubscriptionPolicy - 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) - self._expected_pendings_count = 0 - - def tearDown(self): - # There usually should be no pending after all is said and done, but - # some tests don't complete the workflow. - self.assertEqual(getUtility(IPendings).count, - self._expected_pendings_count) - - def test_start_state(self): - # Test the workflow starts with no tokens or members. - workflow = self._mlist.unsubscription_policy(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 - # unsubscription request and it has some valid data associated with - # it. - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(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') - # The token is still in the database. - self._expected_pendings_count = 1 - - def test_user_or_address_required(self): - # The `subscriber` attribute must be a user or address that is provided - # to the workflow. - workflow = OpenUnsubscriptionPolicy(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 = self._mlist.unsubscription_policy(self._mlist, addr) - self.assertRaises(AssertionError, - workflow.run_thru, 'subscription_checks') - - def test_confirmation_checks_open_list(self): - # An unsubscription from an open list does not need to be confirmed or - # moderated. - self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) - workflow.run_thru('subscription_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 unsubscription from a list which does not need user confirmation - # skips to the moderation checks. - self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) - workflow.run_thru('subscription_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 - # unsubscription is pre-confirmed. Since moderation is not reuqired, - # the user will be immediately unsubscribed. - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy( - 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 unsubscription policy requires user confirmation, but their - # unsubscription is pre-confirmed. Since moderation is required, that - # check will be performed. - self._mlist.unsubscription_policy = ( - ConfirmModerationUnsubscriptionPolicy) - workflow = self._mlist.unsubscription_policy( - 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_send_confirmation_checks_confirm_list(self): - # The unsubscription policy requires user confirmation and the - # unsubscription is not pre-confirmed. - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(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 unsubscription policy requires moderation. - self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) - workflow.run_thru('subscription_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 = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(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 unsubscription policy means the user gets unsubscribed to - # the mailing list without any further confirmations or approvals. - self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(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 unsubscribed from the mailing list - # without any further confirmation or approvals. - self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(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_confirmed(self): - # A moderation-requiring unsubscription policy plus a pre-appvoed - # address means the user gets unsubscribed to the mailing list without - # any further confirmations or approvals. - self._mlist.unsubscription_policy = ( - ConfirmModerationUnsubscriptionPolicy) - workflow = self._mlist.unsubscription_policy(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 unsubscribed, the token and its associated pending - # database record will be removed from the database. - self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) - # Run the workflow. - list(workflow) - # Anne is now unsubscribed 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) - - 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 unsubscribed. - self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy( - self._mlist, self.anne) - # 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 = self._mlist.unsubscription_policy(self._mlist) - approved_workflow.token = workflow.token - approved_workflow.restore() - list(approved_workflow) - # Now the user is unsubscribed 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 unsubscription is held for moderator approval, a message is - # logged. - mark = LogFileMark('mailman.subscribe') - self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy( - self._mlist, self.anne) - # Run the entire workflow. - list(workflow) - self.assertIn( - 'test@example.com: held unsubscription request from anne@example.com', - mark.readline() - ) - # The state machine stopped at the moderator approval step so there - # will be one token still in the database. - self._expected_pendings_count = 1 - - def test_get_moderator_approval_notifies_moderators(self): - # When the unsubscription 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 = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy( - self._mlist, self.anne) - # 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 -""") - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_get_moderator_approval_no_notifications(self): - # When the unsubscription 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 = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy( - self._mlist, self.anne) - # Consume the entire state machine. - list(workflow) - get_queue_messages('virgin', expected_count=0) - # The state machine stopped at the moderator approval so there will be - # one token still in the database. - self._expected_pendings_count = 1 - - def test_send_confirmation(self): - # A confirmation message gets sent when the unsubscription must be - # confirmed. - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - # Run the workflow to model the confirmation step. - workflow = self._mlist.unsubscription_policy(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)) - # The state machine stopped at the member confirmation step so there - # will be one token still in the database. - self._expected_pendings_count = 1 - - def test_do_confirmation_unsubscribes_user(self): - # Unsubscriptions to the mailing list must be confirmed. Once that's - # done, the user's address is unsubscribed. - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) - list(workflow) - # Anne is a member. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNotNone(member) - self.assertEqual(member, workflow.member) - # The token is owned by the subscriber. - self.assertIsNotNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.subscriber) - # Confirm. - confirm_workflow = self._mlist.unsubscription_policy(self._mlist) - confirm_workflow.token = workflow.token - confirm_workflow.restore() - list(confirm_workflow) - # Anne is now unsubscribed. - 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_do_confirmation_unsubscribes_address(self): - # Unsubscriptions to the mailing list must be confirmed. Once that's - # done, the address is unsubscribed. - address = self.anne.register('anne.person@example.com') - self._mlist.subscribe(address) - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, address) - list(workflow) - # Bart is a member. - member = self._mlist.regular_members.get_member( - 'anne.person@example.com') - self.assertIsNotNone(member) - self.assertEqual(member, workflow.member) - # The token is owned by the subscriber. - self.assertIsNotNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.subscriber) - # Confirm. - confirm_workflow = self._mlist.unsubscription_policy(self._mlist) - confirm_workflow.token = workflow.token - confirm_workflow.restore() - list(confirm_workflow) - # Bart is now unsubscribed. - member = self._mlist.regular_members.get_member( - 'anne.person@example.com') - self.assertIsNone(member) - # No further token is needed. - self.assertIsNone(confirm_workflow.token) - self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one) - - def test_do_confirmation_nonmember(self): - # Attempt to confirm the unsubscription of a member who has already - # been unsubscribed. - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) - list(workflow) - # Anne is a member. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNotNone(member) - self.assertEqual(member, workflow.member) - # The token is owned by the subscriber. - self.assertIsNotNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.subscriber) - # Unsubscribe Anne out of band. - member.unsubscribe() - # Confirm. - confirm_workflow = self._mlist.unsubscription_policy(self._mlist) - confirm_workflow.token = workflow.token - confirm_workflow.restore() - list(confirm_workflow) - # No further token is needed. - self.assertIsNone(confirm_workflow.token) - self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one) - - def test_do_confirmation_nonmember_final_step(self): - # Attempt to confirm the unsubscription of a member who has already - # been unsubscribed. - self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) - list(workflow) - # Anne is a member. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNotNone(member) - self.assertEqual(member, workflow.member) - # The token is owned by the subscriber. - self.assertIsNotNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.subscriber) - # Confirm. - confirm_workflow = self._mlist.unsubscription_policy(self._mlist) - confirm_workflow.token = workflow.token - confirm_workflow.restore() - confirm_workflow.run_until('do_unsubscription') - self.assertEqual(member, confirm_workflow.member) - # Unsubscribe Anne out of band. - member.unsubscribe() - list(confirm_workflow) - self.assertIsNone(confirm_workflow.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 = ( - ConfirmModerationUnsubscriptionPolicy) - workflow = self._mlist.unsubscription_policy(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 = self._mlist.unsubscription_policy(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 = self._mlist.unsubscription_policy(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 unsubscribed. - member = self._mlist.regular_members.get_member(self._anne) - self.assertIsNotNone(member) - self.assertIsNone(final_workflow.member) - # However, if we use the new token, her unsubscription request will be - # approved by the moderator. - final_workflow.token = moderator_workflow.token - final_workflow.restore() - list(final_workflow) - # And now Anne is unsubscribed. - 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 = ConfirmUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy( - self._mlist, self.anne, pre_confirmed=True) - list(workflow) - # Anne was unsubscribed. - self.assertIsNone(workflow.token) - self.assertEqual(workflow.token_owner, TokenOwner.no_one) - self.assertIsNone(workflow.member) - - def test_confirmation_needed_moderator_address(self): - address = self.anne.register('anne.person@example.com') - self._mlist.subscribe(address) - self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy - workflow = self._mlist.unsubscription_policy(self._mlist, address) - # Get moderator approval. - list(workflow) - approved_workflow = self._mlist.unsubscription_policy(self._mlist) - approved_workflow.token = workflow.token - approved_workflow.restore() - list(approved_workflow) - self.assertEqual(approved_workflow.subscriber, address) - # Anne was unsubscribed. - self.assertIsNone(approved_workflow.token) - self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one) - self.assertIsNone(approved_workflow.member) - member = self._mlist.regular_members.get_member( - 'anne.person@example.com') - self.assertIsNone(member) diff --git a/src/mailman/app/tests/test_workflow.py b/src/mailman/app/tests/test_workflow.py deleted file mode 100644 index 3e7856b29..000000000 --- a/src/mailman/app/tests/test_workflow.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright (C) 2015-2017 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 . - -"""App-level workflow tests.""" - -import json -import unittest - -from mailman.interfaces.workflows import IWorkflowStateManager -from mailman.testing.layers import ConfigLayer -from mailman.workflows.base import Workflow -from zope.component import getUtility - - -class MyWorkflow(Workflow): - initial_state = 'first' - save_attributes = ('ant', 'bee', 'cat') - - def __init__(self): - super().__init__() - self.token = 'test-workflow' - self.ant = 1 - self.bee = 2 - self.cat = 3 - self.dog = 4 - - def _step_first(self): - self.push('second') - return 'one' - - def _step_second(self): - self.push('third') - return 'two' - - def _step_third(self): - return 'three' - - -class DependentWorkflow(MyWorkflow): - save_attributes = ('ant', 'bee', 'cat', 'elf') - - def __init__(self): - super().__init__() - self._elf = 5 - - @property - def elf(self): - return self._elf - - @elf.setter - def elf(self, value): - # This attribute depends on other attributes. - assert self.ant is not None - assert self.bee is not None - assert self.cat is not None - self._elf = value - - -class TestWorkflow(unittest.TestCase): - layer = ConfigLayer - - def setUp(self): - self._workflow = iter(MyWorkflow()) - - def test_basic_workflow(self): - # The work flows from one state to the next. - results = list(self._workflow) - self.assertEqual(results, ['one', 'two', 'three']) - - def test_partial_workflow(self): - # You don't have to flow through every step. - results = next(self._workflow) - self.assertEqual(results, 'one') - - def test_exhaust_workflow(self): - # Manually flow through a few steps, then consume the whole thing. - results = [next(self._workflow)] - results.extend(self._workflow) - self.assertEqual(results, ['one', 'two', 'three']) - - def test_save_and_restore_workflow(self): - # Without running any steps, save and restore the workflow. Then - # consume the restored workflow. - self._workflow.save() - new_workflow = MyWorkflow() - new_workflow.restore() - results = list(new_workflow) - self.assertEqual(results, ['one', 'two', 'three']) - - def test_save_and_restore_partial_workflow(self): - # After running a few steps, save and restore the workflow. Then - # consume the restored workflow. - next(self._workflow) - self._workflow.save() - new_workflow = MyWorkflow() - new_workflow.restore() - results = list(new_workflow) - self.assertEqual(results, ['two', 'three']) - - def test_save_and_restore_exhausted_workflow(self): - # After consuming the entire workflow, save and restore it. - list(self._workflow) - self._workflow.save() - new_workflow = MyWorkflow() - new_workflow.restore() - results = list(new_workflow) - self.assertEqual(len(results), 0) - - def test_save_and_restore_attributes(self): - # Saved attributes are restored. - self._workflow.ant = 9 - self._workflow.bee = 8 - self._workflow.cat = 7 - # Don't save .dog. - self._workflow.save() - new_workflow = MyWorkflow() - new_workflow.restore() - self.assertEqual(new_workflow.ant, 9) - self.assertEqual(new_workflow.bee, 8) - self.assertEqual(new_workflow.cat, 7) - self.assertEqual(new_workflow.dog, 4) - - def test_save_and_restore_dependant_attributes(self): - # Attributes must be restored in the order they are declared in - # save_attributes. - workflow = iter(DependentWorkflow()) - workflow.elf = 6 - workflow.save() - new_workflow = DependentWorkflow() - # The elf attribute must be restored last, set triggering values for - # attributes it depends on. - new_workflow.ant = new_workflow.bee = new_workflow.cat = None - new_workflow.restore() - self.assertEqual(new_workflow.elf, 6) - - def test_save_and_restore_obsolete_attributes(self): - # Obsolete saved attributes are ignored. - state_manager = getUtility(IWorkflowStateManager) - # Save the state of an old version of the workflow that would not have - # the cat attribute. - state_manager.save( - self._workflow.token, '["first"]', - json.dumps({'ant': 1, 'bee': 2})) - # Restore in the current version that needs the cat attribute. - new_workflow = MyWorkflow() - try: - new_workflow.restore() - except KeyError: - self.fail('Restore does not handle obsolete attributes') - # Restoring must not raise an exception, the default value is kept. - self.assertEqual(new_workflow.cat, 3) - - def test_run_thru(self): - # Run all steps through the given one. - results = self._workflow.run_thru('second') - self.assertEqual(results, ['one', 'two']) - - def test_run_thru_completes(self): - results = self._workflow.run_thru('all of them') - self.assertEqual(results, ['one', 'two', 'three']) - - def test_run_until(self): - # Run until (but not including) the given step. - results = self._workflow.run_until('second') - self.assertEqual(results, ['one']) - - def test_run_until_completes(self): - results = self._workflow.run_until('all of them') - self.assertEqual(results, ['one', 'two', 'three']) diff --git a/src/mailman/workflows/tests/__init__.py b/src/mailman/workflows/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/mailman/workflows/tests/test_subscriptions.py b/src/mailman/workflows/tests/test_subscriptions.py new file mode 100644 index 000000000..23487ab1c --- /dev/null +++ b/src/mailman/workflows/tests/test_subscriptions.py @@ -0,0 +1,726 @@ +# Copyright (C) 2011-2017 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 . + +"""Tests for the subscription service.""" + +import unittest + +from contextlib import suppress +from mailman.app.lifecycle import create_list +from mailman.interfaces.bans import IBanManager +from mailman.interfaces.member import MemberRole, 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 mailman.workflows.subscription import ( + ConfirmModerationSubscriptionPolicy, ConfirmSubscriptionPolicy, + ModerationSubscriptionPolicy, OpenSubscriptionPolicy) +from unittest.mock import patch +from zope.component import getUtility + + +class TestSubscriptionWorkflow(unittest.TestCase): + layer = ConfigLayer + maxDiff = None + + def setUp(self): + self._mlist = create_list('test@example.com') + self._mlist.admin_immed_notify = False + self._anne = 'anne@example.com' + self._user_manager = getUtility(IUserManager) + self._expected_pendings_count = 0 + + def tearDown(self): + # There usually should be no pending after all is said and done, but + # some tests don't complete the workflow. + self.assertEqual(getUtility(IPendings).count, + self._expected_pendings_count) + + def test_start_state(self): + # The workflow starts with no tokens or member. + workflow = ConfirmSubscriptionPolicy(self._mlist) + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + self.assertIsNone(workflow.member) + + def test_pended_data(self): + # There is a Pendable associated with the held request, and it has + # some data associated with it. + anne = self._user_manager.create_address(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, 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') + # The token is still in the database. + self._expected_pendings_count = 1 + + def test_user_or_address_required(self): + # The `subscriber` attribute must be a user or address. + workflow = ConfirmSubscriptionPolicy(self._mlist) + self.assertRaises(AssertionError, list, workflow) + + def test_sanity_checks_address(self): + # Ensure that the sanity check phase, when given an IAddress, ends up + # with a linked user. + anne = self._user_manager.create_address(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + self.assertIsNotNone(workflow.address) + self.assertIsNone(workflow.user) + workflow.run_thru('sanity_checks') + self.assertIsNotNone(workflow.address) + self.assertIsNotNone(workflow.user) + self.assertEqual(list(workflow.user.addresses)[0].email, self._anne) + + def test_sanity_checks_user_with_preferred_address(self): + # Ensure that the sanity check phase, when given an IUser with a + # preferred address, ends up with an address. + anne = self._user_manager.make_user(self._anne) + address = set_preferred(anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + # The constructor sets workflow.address because the user has a + # preferred address. + self.assertEqual(workflow.address, address) + self.assertEqual(workflow.user, anne) + workflow.run_thru('sanity_checks') + self.assertEqual(workflow.address, address) + self.assertEqual(workflow.user, anne) + + def test_sanity_checks_user_without_preferred_address(self): + # Ensure that the sanity check phase, when given a user without a + # preferred address, but with at least one linked address, gets an + # address. + anne = self._user_manager.make_user(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + self.assertIsNone(workflow.address) + self.assertEqual(workflow.user, anne) + workflow.run_thru('sanity_checks') + self.assertIsNotNone(workflow.address) + self.assertEqual(workflow.user, anne) + + def test_sanity_checks_user_with_multiple_linked_addresses(self): + # Ensure that the santiy check phase, when given a user without a + # preferred address, but with multiple linked addresses, gets of of + # those addresses (exactly which one is undefined). + anne = self._user_manager.make_user(self._anne) + anne.link(self._user_manager.create_address('anne@example.net')) + anne.link(self._user_manager.create_address('anne@example.org')) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + self.assertIsNone(workflow.address) + self.assertEqual(workflow.user, anne) + workflow.run_thru('sanity_checks') + self.assertIn(workflow.address.email, ['anne@example.com', + 'anne@example.net', + 'anne@example.org']) + self.assertEqual(workflow.user, anne) + + def test_sanity_checks_user_without_addresses(self): + # It is an error to try to subscribe a user with no linked addresses. + user = self._user_manager.create_user() + workflow = ConfirmSubscriptionPolicy(self._mlist, user) + self.assertRaises(AssertionError, workflow.run_thru, 'sanity_checks') + + def test_sanity_checks_globally_banned_address(self): + # An exception is raised if the address is globally banned. + anne = self._user_manager.create_address(self._anne) + IBanManager(None).ban(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + self.assertRaises(MembershipIsBannedError, list, workflow) + + def test_sanity_checks_banned_address(self): + # An exception is raised if the address is banned by the mailing list. + anne = self._user_manager.create_address(self._anne) + IBanManager(self._mlist).ban(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + self.assertRaises(MembershipIsBannedError, list, workflow) + + def test_verification_checks_with_verified_address(self): + # When the address is already verified, we skip straight to the + # confirmation checks. + anne = self._user_manager.create_address(self._anne) + anne.verified_on = now() + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + workflow.run_thru('verification_checks') + with patch.object(workflow, '_step_confirmation_checks') as step: + next(workflow) + step.assert_called_once_with() + + def test_verification_checks_with_pre_verified_address(self): + # When the address is not yet verified, but the pre-verified flag is + # passed to the workflow, we skip to the confirmation checks. + anne = self._user_manager.create_address(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne, + pre_verified=True) + workflow.run_thru('verification_checks') + with patch.object(workflow, '_step_confirmation_checks') as step: + next(workflow) + step.assert_called_once_with() + # And now the address is verified. + self.assertIsNotNone(anne.verified_on) + + def test_verification_checks_confirmation_needed(self): + # The address is neither verified, nor is the pre-verified flag set. + # A confirmation message must be sent to the user which will also + # verify their address. + anne = self._user_manager.create_address(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + workflow.run_thru('verification_checks') + with patch.object(workflow, '_step_send_confirmation') as step: + next(workflow) + step.assert_called_once_with() + # The address still hasn't been verified. + self.assertIsNone(anne.verified_on) + + def test_confirmation_checks_open_list(self): + # A subscription to an open list does not need to be confirmed or + # moderated. + self._mlist.subscription_policy = OpenSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + workflow.run_thru('verification_checks') + with patch.object(workflow, '_step_do_subscription') as step: + next(workflow) + step.assert_called_once_with() + + def test_confirmation_checks_no_user_confirmation_needed(self): + # A subscription to a list which does not need user confirmation skips + # to the moderation checks. + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + workflow.run_thru('verification_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 subscription policy requires user confirmation, but their + # subscription is pre-confirmed. Since moderation is not required, + # the user will be immediately subscribed. + self._mlist.subscription_policy = ConfirmSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_confirmed=True) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_do_subscription') as step: + next(workflow) + step.assert_called_once_with() + + def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self): + # The subscription policy requires user confirmation, but their + # subscription is pre-confirmed. Since moderation is required, that + # check will be performed. + self._mlist.subscription_policy = ( + ConfirmModerationSubscriptionPolicy) + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + 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_and_moderate_pre_confirmed(self): + # The subscription policy requires user confirmation and moderation, + # but their subscription is pre-confirmed. + self._mlist.subscription_policy = ( + ConfirmModerationSubscriptionPolicy) + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + 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_confirmation_needed(self): + # The subscription policy requires confirmation and the subscription + # is not pre-confirmed. + self._mlist.subscription_policy = ConfirmSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + workflow.run_thru('confirmation_checks') + with patch.object(workflow, '_step_send_confirmation') as step: + next(workflow) + step.assert_called_once_with() + + def test_confirmation_checks_moderate_confirmation_needed(self): + # The subscription policy requires confirmation and moderation, and the + # subscription is not pre-confirmed. + self._mlist.subscription_policy = ( + ConfirmModerationSubscriptionPolicy) + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + 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_pre_approved(self): + # The subscription is pre-approved by the moderator. + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_approved=True) + workflow.run_thru('moderation_checks') + with patch.object(workflow, '_step_do_subscription') as step: + next(workflow) + step.assert_called_once_with() + + def test_moderation_checks_approval_required(self): + # The moderator must approve the subscription. + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + 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_subscription(self): + # An open subscription policy plus a pre-verified address means the + # user gets subscribed to the mailing list without any further + # confirmations or approvals. + self._mlist.subscription_policy = OpenSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + # Consume the entire state machine. + list(workflow) + # Anne is now a member of the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + + def test_do_subscription_pre_approved(self): + # An moderation-requiring subscription policy plus a pre-verified and + # pre-approved address means the user gets subscribed to the mailing + # list without any further confirmations or approvals. + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_approved=True) + # Consume the entire state machine. + list(workflow) + # Anne is now a member of the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + + def test_do_subscription_pre_approved_pre_confirmed(self): + # An moderation-requiring subscription policy plus a pre-verified and + # pre-approved address means the user gets subscribed to the mailing + # list without any further confirmations or approvals. + self._mlist.subscription_policy = ( + ConfirmModerationSubscriptionPolicy) + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True, + pre_confirmed=True, + pre_approved=True) + # Consume the entire state machine. + list(workflow) + # Anne is now a member of the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) + # No further token is needed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + + def test_do_subscription_cleanups(self): + # Once the user is subscribed, the token, and its associated pending + # database record will be removed from the database. + self._mlist.subscription_policy = OpenSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + # Consume the entire state machine. + list(workflow) + # Anne is now a member of the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address, anne) + self.assertEqual(workflow.member, member) + # The workflow is done, so it has no token. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + + 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 subscribed. + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + # Consume the entire state machine. + list(workflow) + # The user is not currently subscribed to the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + self.assertIsNone(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 subscription and should + # result in the user getting subscribed. + approved_workflow = self._mlist.subscription_policy(self._mlist) + approved_workflow.token = workflow.token + approved_workflow.restore() + list(approved_workflow) + # Now the user is subscribed to the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address, anne) + 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 subscription is held for moderator approval, a message is + # logged. + mark = LogFileMark('mailman.subscribe') + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + # Consume the entire state machine. + list(workflow) + self.assertIn( + 'test@example.com: held subscription request from anne@example.com', + mark.readline() + ) + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_get_moderator_approval_notifies_moderators(self): + # When the 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.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + bart = self._user_manager.create_user('bart@example.com', 'Bart User') + address = set_preferred(bart) + self._mlist.subscribe(address, MemberRole.moderator) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + # Consume the entire state machine. + list(workflow) + # Find the moderator message. + items = get_queue_messages('virgin', expected_count=1) + for item in items: + if item.msg['to'] == 'test-owner@example.com': + break + else: + raise AssertionError('No moderator email found') + self.assertEqual( + item.msgdata['recipients'], {'test-owner@example.com'}) + 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 subscription request to Test from anne@example.com') + self.assertEqual(message.get_payload(), """\ +Your authorization is required for a mailing list subscription request +approval: + + For: anne@example.com + List: test@example.com +""") + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_get_moderator_approval_no_notifications(self): + # When the 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 = False + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + # Consume the entire state machine. + list(workflow) + get_queue_messages('virgin', expected_count=0) + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_send_confirmation(self): + # A confirmation message gets sent when the address is not verified. + anne = self._user_manager.create_address(self._anne) + self.assertIsNone(anne.verified_on) + # Run the workflow to model the confirmation step. + workflow = self._mlist.subscription_policy(self._mlist, anne) + list(workflow) + items = get_queue_messages('virgin', expected_count=1) + message = items[0].msg + token = workflow.token + self.assertEqual(message['Subject'], 'confirm {}'.format(token)) + self.assertEqual( + message['From'], 'test-confirm+{}@example.com'.format(token)) + # The confirmation message is not `Precedence: bulk`. + self.assertIsNone(message['precedence']) + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_send_confirmation_pre_confirmed(self): + # A confirmation message gets sent when the address is not verified + # but the subscription is pre-confirmed. + anne = self._user_manager.create_address(self._anne) + self.assertIsNone(anne.verified_on) + # Run the workflow to model the confirmation step. + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_confirmed=True) + 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)) + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_send_confirmation_pre_verified(self): + # A confirmation message gets sent even when the address is verified + # when the subscription must be confirmed. + self._mlist.subscription_policy = ConfirmSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + self.assertIsNone(anne.verified_on) + # Run the workflow to model the confirmation step. + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + 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)) + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_do_confirm_verify_address(self): + # The address is not yet verified, nor are we pre-verifying. A + # confirmation message will be sent. When the user confirms their + # subscription request, the address will end up being verified. + anne = self._user_manager.create_address(self._anne) + self.assertIsNone(anne.verified_on) + # Run the workflow to model the confirmation step. + workflow = self._mlist.subscription_policy(self._mlist, anne) + list(workflow) + # The address is still not verified. + self.assertIsNone(anne.verified_on) + confirm_workflow = self._mlist.subscription_policy(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + confirm_workflow.run_thru('do_confirm_verify') + # The address is now verified. + self.assertIsNotNone(anne.verified_on) + + def test_do_confirm_verify_user(self): + # A confirmation step is necessary when a user subscribes with their + # preferred address, and we are not pre-confirming. + anne = self._user_manager.create_user(self._anne) + set_preferred(anne) + # Run the workflow to model the confirmation step. There is no + # subscriber attribute yet. + workflow = self._mlist.subscription_policy(self._mlist, anne) + list(workflow) + self.assertEqual(workflow.subscriber, anne) + # Do a confirmation workflow, which should now set the subscriber. + confirm_workflow = self._mlist.subscription_policy(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + confirm_workflow.run_thru('do_confirm_verify') + # The address is now verified. + self.assertEqual(confirm_workflow.subscriber, anne) + + def test_do_confirmation_subscribes_user(self): + # Subscriptions to the mailing list must be confirmed. Once that's + # done, the user's address (which is not initially verified) gets + # subscribed to the mailing list. + self._mlist.subscription_policy = ConfirmSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + self.assertIsNone(anne.verified_on) + workflow = self._mlist.subscription_policy(self._mlist, anne) + list(workflow) + # Anne is not yet a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(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 = self._mlist.subscription_policy(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + list(confirm_workflow) + self.assertIsNotNone(anne.verified_on) + # Anne is now a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address, anne) + self.assertEqual(confirm_workflow.member, 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.subscription_policy = ( + ConfirmModerationSubscriptionPolicy) + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + # Run the state machine up to the first confirmation, and cache the + # confirmation token. + list(workflow) + token = workflow.token + # Anne is not yet a member of the mailing list. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + self.assertIsNone(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 = self._mlist.subscription_policy(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 = self._mlist.subscription_policy(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 subscribed. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNone(member) + self.assertIsNone(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 a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertEqual(member.address.email, self._anne) + self.assertEqual(final_workflow.member, 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.subscription_policy = ConfirmSubscriptionPolicy + anne = self._user_manager.create_address(self._anne) + workflow = self._mlist.subscription_policy( + self._mlist, anne, + pre_verified=True, pre_confirmed=True) + list(workflow) + # Anne was subscribed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + self.assertEqual(workflow.member.address, anne) + + def test_restore_user_absorbed(self): + # The subscribing user is absorbed (and thus deleted) before the + # moderator approves the subscription. + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_user(self._anne) + bill = self._user_manager.create_user('bill@example.com') + set_preferred(bill) + # anne subscribes. + workflow = self._mlist.subscription_policy(self._mlist, anne, + pre_verified=True) + list(workflow) + # bill absorbs anne. + bill.absorb(anne) + # anne's subscription request is approved. + approved_workflow = self._mlist.subscription_policy(self._mlist) + approved_workflow.token = workflow.token + approved_workflow.restore() + self.assertEqual(approved_workflow.user, bill) + # Run the workflow through. + list(approved_workflow) + + def test_restore_address_absorbed(self): + # The subscribing user is absorbed (and thus deleted) before the + # moderator approves the subscription. + self._mlist.subscription_policy = ModerationSubscriptionPolicy + anne = self._user_manager.create_user(self._anne) + anne_address = anne.addresses[0] + bill = self._user_manager.create_user('bill@example.com') + # anne subscribes. + workflow = self._mlist.subscription_policy( + self._mlist, anne_address, pre_verified=True) + list(workflow) + # bill absorbs anne. + bill.absorb(anne) + self.assertIn(anne_address, bill.addresses) + # anne's subscription request is approved. + approved_workflow = self._mlist.subscription_policy(self._mlist) + approved_workflow.token = workflow.token + approved_workflow.restore() + self.assertEqual(approved_workflow.user, bill) + # Run the workflow through. + list(approved_workflow) diff --git a/src/mailman/workflows/tests/test_unsubscriptions.py b/src/mailman/workflows/tests/test_unsubscriptions.py new file mode 100644 index 000000000..2e210e90b --- /dev/null +++ b/src/mailman/workflows/tests/test_unsubscriptions.py @@ -0,0 +1,520 @@ +# Copyright (C) 2016-2017 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 unsubscription service.""" + +import unittest + +from contextlib import suppress +from mailman.app.lifecycle import create_list +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 +from mailman.testing.layers import ConfigLayer +from mailman.utilities.datetime import now +from mailman.workflows.unsubscription import ( + ConfirmModerationUnsubscriptionPolicy, ConfirmUnsubscriptionPolicy, + ModerationUnsubscriptionPolicy, OpenUnsubscriptionPolicy) +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.unsubscription_policy = OpenUnsubscriptionPolicy + 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) + self._expected_pendings_count = 0 + + def tearDown(self): + # There usually should be no pending after all is said and done, but + # some tests don't complete the workflow. + self.assertEqual(getUtility(IPendings).count, + self._expected_pendings_count) + + def test_start_state(self): + # Test the workflow starts with no tokens or members. + workflow = self._mlist.unsubscription_policy(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 + # unsubscription request and it has some valid data associated with + # it. + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(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') + # The token is still in the database. + self._expected_pendings_count = 1 + + def test_user_or_address_required(self): + # The `subscriber` attribute must be a user or address that is provided + # to the workflow. + workflow = OpenUnsubscriptionPolicy(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 = self._mlist.unsubscription_policy(self._mlist, addr) + self.assertRaises(AssertionError, + workflow.run_thru, 'subscription_checks') + + def test_confirmation_checks_open_list(self): + # An unsubscription from an open list does not need to be confirmed or + # moderated. + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + workflow.run_thru('subscription_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 unsubscription from a list which does not need user confirmation + # skips to the moderation checks. + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + workflow.run_thru('subscription_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 + # unsubscription is pre-confirmed. Since moderation is not reuqired, + # the user will be immediately unsubscribed. + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + 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 unsubscription policy requires user confirmation, but their + # unsubscription is pre-confirmed. Since moderation is required, that + # check will be performed. + self._mlist.unsubscription_policy = ( + ConfirmModerationUnsubscriptionPolicy) + workflow = self._mlist.unsubscription_policy( + 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_send_confirmation_checks_confirm_list(self): + # The unsubscription policy requires user confirmation and the + # unsubscription is not pre-confirmed. + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(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 unsubscription policy requires moderation. + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + workflow.run_thru('subscription_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 = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(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 unsubscription policy means the user gets unsubscribed to + # the mailing list without any further confirmations or approvals. + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(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 unsubscribed from the mailing list + # without any further confirmation or approvals. + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(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_confirmed(self): + # A moderation-requiring unsubscription policy plus a pre-appvoed + # address means the user gets unsubscribed to the mailing list without + # any further confirmations or approvals. + self._mlist.unsubscription_policy = ( + ConfirmModerationUnsubscriptionPolicy) + workflow = self._mlist.unsubscription_policy(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 unsubscribed, the token and its associated pending + # database record will be removed from the database. + self._mlist.unsubscription_policy = OpenUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + # Run the workflow. + list(workflow) + # Anne is now unsubscribed 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) + + 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 unsubscribed. + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) + # 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 = self._mlist.unsubscription_policy(self._mlist) + approved_workflow.token = workflow.token + approved_workflow.restore() + list(approved_workflow) + # Now the user is unsubscribed 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 unsubscription is held for moderator approval, a message is + # logged. + mark = LogFileMark('mailman.subscribe') + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) + # Run the entire workflow. + list(workflow) + self.assertIn( + 'test@example.com: held unsubscription request from anne@example.com', + mark.readline() + ) + # The state machine stopped at the moderator approval step so there + # will be one token still in the database. + self._expected_pendings_count = 1 + + def test_get_moderator_approval_notifies_moderators(self): + # When the unsubscription 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 = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) + # 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 +""") + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_get_moderator_approval_no_notifications(self): + # When the unsubscription 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 = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne) + # Consume the entire state machine. + list(workflow) + get_queue_messages('virgin', expected_count=0) + # The state machine stopped at the moderator approval so there will be + # one token still in the database. + self._expected_pendings_count = 1 + + def test_send_confirmation(self): + # A confirmation message gets sent when the unsubscription must be + # confirmed. + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + # Run the workflow to model the confirmation step. + workflow = self._mlist.unsubscription_policy(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)) + # The state machine stopped at the member confirmation step so there + # will be one token still in the database. + self._expected_pendings_count = 1 + + def test_do_confirmation_unsubscribes_user(self): + # Unsubscriptions to the mailing list must be confirmed. Once that's + # done, the user's address is unsubscribed. + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + list(workflow) + # Anne is a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertEqual(member, workflow.member) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) + # Confirm. + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + list(confirm_workflow) + # Anne is now unsubscribed. + 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_do_confirmation_unsubscribes_address(self): + # Unsubscriptions to the mailing list must be confirmed. Once that's + # done, the address is unsubscribed. + address = self.anne.register('anne.person@example.com') + self._mlist.subscribe(address) + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, address) + list(workflow) + # Bart is a member. + member = self._mlist.regular_members.get_member( + 'anne.person@example.com') + self.assertIsNotNone(member) + self.assertEqual(member, workflow.member) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) + # Confirm. + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + list(confirm_workflow) + # Bart is now unsubscribed. + member = self._mlist.regular_members.get_member( + 'anne.person@example.com') + self.assertIsNone(member) + # No further token is needed. + self.assertIsNone(confirm_workflow.token) + self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one) + + def test_do_confirmation_nonmember(self): + # Attempt to confirm the unsubscription of a member who has already + # been unsubscribed. + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + list(workflow) + # Anne is a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertEqual(member, workflow.member) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) + # Unsubscribe Anne out of band. + member.unsubscribe() + # Confirm. + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + list(confirm_workflow) + # No further token is needed. + self.assertIsNone(confirm_workflow.token) + self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one) + + def test_do_confirmation_nonmember_final_step(self): + # Attempt to confirm the unsubscription of a member who has already + # been unsubscribed. + self._mlist.unsubscription_policy = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, self.anne) + list(workflow) + # Anne is a member. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertEqual(member, workflow.member) + # The token is owned by the subscriber. + self.assertIsNotNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.subscriber) + # Confirm. + confirm_workflow = self._mlist.unsubscription_policy(self._mlist) + confirm_workflow.token = workflow.token + confirm_workflow.restore() + confirm_workflow.run_until('do_unsubscription') + self.assertEqual(member, confirm_workflow.member) + # Unsubscribe Anne out of band. + member.unsubscribe() + list(confirm_workflow) + self.assertIsNone(confirm_workflow.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 = ( + ConfirmModerationUnsubscriptionPolicy) + workflow = self._mlist.unsubscription_policy(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 = self._mlist.unsubscription_policy(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 = self._mlist.unsubscription_policy(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 unsubscribed. + member = self._mlist.regular_members.get_member(self._anne) + self.assertIsNotNone(member) + self.assertIsNone(final_workflow.member) + # However, if we use the new token, her unsubscription request will be + # approved by the moderator. + final_workflow.token = moderator_workflow.token + final_workflow.restore() + list(final_workflow) + # And now Anne is unsubscribed. + 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 = ConfirmUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy( + self._mlist, self.anne, pre_confirmed=True) + list(workflow) + # Anne was unsubscribed. + self.assertIsNone(workflow.token) + self.assertEqual(workflow.token_owner, TokenOwner.no_one) + self.assertIsNone(workflow.member) + + def test_confirmation_needed_moderator_address(self): + address = self.anne.register('anne.person@example.com') + self._mlist.subscribe(address) + self._mlist.unsubscription_policy = ModerationUnsubscriptionPolicy + workflow = self._mlist.unsubscription_policy(self._mlist, address) + # Get moderator approval. + list(workflow) + approved_workflow = self._mlist.unsubscription_policy(self._mlist) + approved_workflow.token = workflow.token + approved_workflow.restore() + list(approved_workflow) + self.assertEqual(approved_workflow.subscriber, address) + # Anne was unsubscribed. + self.assertIsNone(approved_workflow.token) + self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one) + self.assertIsNone(approved_workflow.member) + member = self._mlist.regular_members.get_member( + 'anne.person@example.com') + self.assertIsNone(member) diff --git a/src/mailman/workflows/tests/test_workflow.py b/src/mailman/workflows/tests/test_workflow.py new file mode 100644 index 000000000..3e7856b29 --- /dev/null +++ b/src/mailman/workflows/tests/test_workflow.py @@ -0,0 +1,183 @@ +# Copyright (C) 2015-2017 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 . + +"""App-level workflow tests.""" + +import json +import unittest + +from mailman.interfaces.workflows import IWorkflowStateManager +from mailman.testing.layers import ConfigLayer +from mailman.workflows.base import Workflow +from zope.component import getUtility + + +class MyWorkflow(Workflow): + initial_state = 'first' + save_attributes = ('ant', 'bee', 'cat') + + def __init__(self): + super().__init__() + self.token = 'test-workflow' + self.ant = 1 + self.bee = 2 + self.cat = 3 + self.dog = 4 + + def _step_first(self): + self.push('second') + return 'one' + + def _step_second(self): + self.push('third') + return 'two' + + def _step_third(self): + return 'three' + + +class DependentWorkflow(MyWorkflow): + save_attributes = ('ant', 'bee', 'cat', 'elf') + + def __init__(self): + super().__init__() + self._elf = 5 + + @property + def elf(self): + return self._elf + + @elf.setter + def elf(self, value): + # This attribute depends on other attributes. + assert self.ant is not None + assert self.bee is not None + assert self.cat is not None + self._elf = value + + +class TestWorkflow(unittest.TestCase): + layer = ConfigLayer + + def setUp(self): + self._workflow = iter(MyWorkflow()) + + def test_basic_workflow(self): + # The work flows from one state to the next. + results = list(self._workflow) + self.assertEqual(results, ['one', 'two', 'three']) + + def test_partial_workflow(self): + # You don't have to flow through every step. + results = next(self._workflow) + self.assertEqual(results, 'one') + + def test_exhaust_workflow(self): + # Manually flow through a few steps, then consume the whole thing. + results = [next(self._workflow)] + results.extend(self._workflow) + self.assertEqual(results, ['one', 'two', 'three']) + + def test_save_and_restore_workflow(self): + # Without running any steps, save and restore the workflow. Then + # consume the restored workflow. + self._workflow.save() + new_workflow = MyWorkflow() + new_workflow.restore() + results = list(new_workflow) + self.assertEqual(results, ['one', 'two', 'three']) + + def test_save_and_restore_partial_workflow(self): + # After running a few steps, save and restore the workflow. Then + # consume the restored workflow. + next(self._workflow) + self._workflow.save() + new_workflow = MyWorkflow() + new_workflow.restore() + results = list(new_workflow) + self.assertEqual(results, ['two', 'three']) + + def test_save_and_restore_exhausted_workflow(self): + # After consuming the entire workflow, save and restore it. + list(self._workflow) + self._workflow.save() + new_workflow = MyWorkflow() + new_workflow.restore() + results = list(new_workflow) + self.assertEqual(len(results), 0) + + def test_save_and_restore_attributes(self): + # Saved attributes are restored. + self._workflow.ant = 9 + self._workflow.bee = 8 + self._workflow.cat = 7 + # Don't save .dog. + self._workflow.save() + new_workflow = MyWorkflow() + new_workflow.restore() + self.assertEqual(new_workflow.ant, 9) + self.assertEqual(new_workflow.bee, 8) + self.assertEqual(new_workflow.cat, 7) + self.assertEqual(new_workflow.dog, 4) + + def test_save_and_restore_dependant_attributes(self): + # Attributes must be restored in the order they are declared in + # save_attributes. + workflow = iter(DependentWorkflow()) + workflow.elf = 6 + workflow.save() + new_workflow = DependentWorkflow() + # The elf attribute must be restored last, set triggering values for + # attributes it depends on. + new_workflow.ant = new_workflow.bee = new_workflow.cat = None + new_workflow.restore() + self.assertEqual(new_workflow.elf, 6) + + def test_save_and_restore_obsolete_attributes(self): + # Obsolete saved attributes are ignored. + state_manager = getUtility(IWorkflowStateManager) + # Save the state of an old version of the workflow that would not have + # the cat attribute. + state_manager.save( + self._workflow.token, '["first"]', + json.dumps({'ant': 1, 'bee': 2})) + # Restore in the current version that needs the cat attribute. + new_workflow = MyWorkflow() + try: + new_workflow.restore() + except KeyError: + self.fail('Restore does not handle obsolete attributes') + # Restoring must not raise an exception, the default value is kept. + self.assertEqual(new_workflow.cat, 3) + + def test_run_thru(self): + # Run all steps through the given one. + results = self._workflow.run_thru('second') + self.assertEqual(results, ['one', 'two']) + + def test_run_thru_completes(self): + results = self._workflow.run_thru('all of them') + self.assertEqual(results, ['one', 'two', 'three']) + + def test_run_until(self): + # Run until (but not including) the given step. + results = self._workflow.run_until('second') + self.assertEqual(results, ['one']) + + def test_run_until_completes(self): + results = self._workflow.run_until('all of them') + self.assertEqual(results, ['one', 'two', 'three']) -- cgit v1.2.3-70-g09d2 From 139a4b484415843d4f0dcf723ed7b56fc52b2547 Mon Sep 17 00:00:00 2001 From: J08nY Date: Wed, 12 Jul 2017 18:57:01 +0200 Subject: Save workflows name in Pendable PEND_TYPE. - Saves workflow name in as Pendables type, so that it is correctly restored even if the MailingLists sub/unsub policy changes while it was pending. --- src/mailman/app/subscriptions.py | 15 +++++++------ src/mailman/rest/docs/sub-moderation.rst | 4 ++-- src/mailman/rest/sub_moderation.py | 14 +++++++++++-- src/mailman/workflows/common.py | 36 +++++++++++++++++++------------- 4 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index cc28ea859..a0c0c8059 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -17,6 +17,7 @@ """Handle subscriptions.""" +from mailman.config import config from mailman.database.transaction import flush from mailman.interfaces.listmanager import ListDeletingEvent from mailman.interfaces.pending import IPendings @@ -56,11 +57,13 @@ class SubscriptionManager: if pendable is None: raise LookupError workflow_type = pendable.get('type') - assert workflow_type in (PendableSubscription.PEND_TYPE, - PendableUnsubscription.PEND_TYPE) - workflow = (self._mlist.subscription_policy - if workflow_type == PendableSubscription.PEND_TYPE - else self._mlist.unsubscription_policy)(self._mlist) + if workflow_type in {PendableSubscription.PEND_TYPE, + PendableUnsubscription.PEND_TYPE}: + workflow = (self._mlist.subscription_policy + if workflow_type == PendableSubscription.PEND_TYPE + else self._mlist.unsubscription_policy)(self._mlist) + else: + workflow = config.workflows[workflow_type](self._mlist) workflow.token = token workflow.restore() # In order to just run the whole workflow, all we need to do @@ -84,6 +87,6 @@ def handle_ListDeletingEvent(event): return # Find all the members still associated with the mailing list. members = getUtility(ISubscriptionService).find_members( - list_id=event.mailing_list.list_id) + list_id=event.mailing_list.list_id) for member in members: member.unsubscribe() diff --git a/src/mailman/rest/docs/sub-moderation.rst b/src/mailman/rest/docs/sub-moderation.rst index 3c6f30aef..8bf95fcd2 100644 --- a/src/mailman/rest/docs/sub-moderation.rst +++ b/src/mailman/rest/docs/sub-moderation.rst @@ -47,7 +47,7 @@ The subscription request can be viewed in the REST API. list_id: ant.example.com token: 0000000000000000000000000000000000000001 token_owner: moderator - type: subscription + type: sub-policy-moderate when: 2005-08-01T07:49:23 http_etag: "..." start: 0 @@ -68,7 +68,7 @@ You can view an individual membership change request by providing the token list_id: ant.example.com token: 0000000000000000000000000000000000000001 token_owner: moderator - type: subscription + type: sub-policy-moderate when: 2005-08-01T07:49:23 diff --git a/src/mailman/rest/sub_moderation.py b/src/mailman/rest/sub_moderation.py index 47745b729..ad1c5751b 100644 --- a/src/mailman/rest/sub_moderation.py +++ b/src/mailman/rest/sub_moderation.py @@ -16,13 +16,16 @@ # GNU Mailman. If not, see . """REST API for held subscription requests.""" +from itertools import chain from mailman.app.moderator import send_rejection +from mailman.config import config 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.subscriptions import ISubscriptionManager +from mailman.interfaces.workflows import ISubscriptionWorkflow from mailman.rest.helpers import ( CollectionMixin, bad_request, child, conflict, etag, no_content, not_found, okay) @@ -122,8 +125,15 @@ class SubscriptionRequests(_ModerationBase, CollectionMixin): self._mlist = mlist def _get_collection(self, request): - pendings = getUtility(IPendings).find( - mlist=self._mlist, pend_type='subscription') + sub_workflows = [workflow_class + for workflow_class in config.workflows.values() + if ISubscriptionWorkflow.implementedBy(workflow_class) + ] + generators = [getUtility(IPendings).find(mlist=self._mlist, + pend_type=sub_workflow.name) + for + sub_workflow in sub_workflows] + pendings = chain.from_iterable(generators) return [token for token, pendable in pendings] def on_get(self, request, response): diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index 41733f6e5..1f63d3d1d 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -23,8 +23,10 @@ import logging from datetime import timedelta from email.utils import formataddr from enum import Enum +from itertools import chain from mailman.app.membership import delete_member +from mailman.config import config from mailman.core.i18n import _ from mailman.email.message import UserNotification from mailman.interfaces.address import IAddress @@ -38,14 +40,12 @@ from mailman.interfaces.subscriptions import (SubscriptionPendingError, from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager -from mailman.interfaces.workflows import (ISubscriptionWorkflow, - IUnsubscriptionWorkflow, IWorkflow) +from mailman.interfaces.workflows import ISubscriptionWorkflow from mailman.utilities.datetime import now from mailman.utilities.string import expand, wrap from mailman.workflows.base import Workflow from zope.component import getUtility from zope.interface import implementer -from zope.interface.exceptions import DoesNotImplement log = logging.getLogger('mailman.subscribe') @@ -157,14 +157,7 @@ class SubscriptionWorkflowCommon(Workflow): self.token = None return - if ISubscriptionWorkflow.implementedBy(self.__class__): - pendable_class = PendableSubscription - elif IUnsubscriptionWorkflow.implementedBy(self.__class__): - pendable_class = PendableUnsubscription - else: - raise DoesNotImplement(IWorkflow) - - pendable = pendable_class( + pendable = self.pendable_class()( list_id=self.mlist.list_id, email=self.address.email, display_name=self.address.display_name, @@ -173,6 +166,13 @@ class SubscriptionWorkflowCommon(Workflow): ) self.token = pendings.add(pendable, timedelta(days=3650)) + @classmethod + def pendable_class(cls): + @implementer(IPendable) + class Pendable(dict): + PEND_TYPE = cls.name + return Pendable + class SubscriptionBase(SubscriptionWorkflowCommon): @@ -219,9 +219,17 @@ class SubscriptionBase(SubscriptionWorkflowCommon): if IBanManager(self.mlist).is_banned(self.address.email): raise MembershipIsBannedError(self.mlist, self.address.email) # Check if there is already a subscription request for this email. - pendings = getUtility(IPendings).find( - mlist=self.mlist, - pend_type='subscription') + # Look at all known subscription workflows, because any pending + # subscription workflow is exclusive. + sub_workflows = [workflow_class + for workflow_class in config.workflows.values() + if ISubscriptionWorkflow.implementedBy(workflow_class) + ] + generators = [getUtility(IPendings).find(mlist=self.mlist, + pend_type=sub_workflow.name) + for + sub_workflow in sub_workflows] + pendings = chain.from_iterable(generators) for token, pendable in pendings: if pendable['email'] == self.address.email: raise SubscriptionPendingError(self.mlist, self.address.email) -- cgit v1.2.3-70-g09d2 From b902d7858d8302d248add89a5983c521c3581c4c Mon Sep 17 00:00:00 2001 From: J08nY Date: Tue, 25 Jul 2017 23:08:35 +0200 Subject: Add more tests for coverage. --- src/mailman/app/subscriptions.py | 2 +- src/mailman/model/tests/test_mailinglist.py | 24 +++++++++++++ src/mailman/rest/tests/test_validator.py | 44 ++++++++++++++++++++++- src/mailman/workflows/common.py | 2 +- src/mailman/workflows/tests/test_subscriptions.py | 14 +++++++- 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py index a0c0c8059..ba62e9f52 100644 --- a/src/mailman/app/subscriptions.py +++ b/src/mailman/app/subscriptions.py @@ -59,7 +59,7 @@ class SubscriptionManager: workflow_type = pendable.get('type') if workflow_type in {PendableSubscription.PEND_TYPE, PendableUnsubscription.PEND_TYPE}: - workflow = (self._mlist.subscription_policy + workflow = (self._mlist.subscription_policy # pragma: nocover if workflow_type == PendableSubscription.PEND_TYPE else self._mlist.unsubscription_policy)(self._mlist) else: diff --git a/src/mailman/model/tests/test_mailinglist.py b/src/mailman/model/tests/test_mailinglist.py index 72232cb53..a402c6dc4 100644 --- a/src/mailman/model/tests/test_mailinglist.py +++ b/src/mailman/model/tests/test_mailinglist.py @@ -28,6 +28,8 @@ from mailman.interfaces.mailinglist import ( from mailman.interfaces.member import ( AlreadySubscribedError, MemberRole, MissingPreferredAddressError) from mailman.interfaces.usermanager import IUserManager +from mailman.interfaces.workflows import ( + ISubscriptionWorkflow, IUnsubscriptionWorkflow) from mailman.testing.helpers import ( configuration, get_queue_messages, set_preferred) from mailman.testing.layers import ConfigLayer @@ -408,3 +410,25 @@ class TestHeaderMatch(unittest.TestCase): header_matches = IHeaderMatchList(self._mlist) with self.assertRaises(IndexError): del header_matches[0] + + def test_subscription_policy(self): + for workflow_class in config.workflows: + if ISubscriptionWorkflow.implementedBy(workflow_class): + self._mlist.subscription_policy = workflow_class.name + self.assertEqual(self._mlist.subscription_policy, + workflow_class) + + def test_subscription_policy_invalid(self): + with self.assertRaises(ValueError): + self._mlist.subscription_policy = 'not-a-subscription-policy' + + def test_unsubscription_policy(self): + for workflow_class in config.workflows: + if IUnsubscriptionWorkflow.implementedBy(workflow_class): + self._mlist.unsubscription_policy = workflow_class.name + self.assertEqual(self._mlist.unsubscription_policy, + workflow_class) + + def test_unsubscription_policy_invalid(self): + with self.assertRaises(ValueError): + self._mlist.unsubscription_policy = 'not-an-unsubscription-policy' diff --git a/src/mailman/rest/tests/test_validator.py b/src/mailman/rest/tests/test_validator.py index 0d197032e..95855d170 100644 --- a/src/mailman/rest/tests/test_validator.py +++ b/src/mailman/rest/tests/test_validator.py @@ -22,9 +22,14 @@ import unittest from mailman.core.api import API30, API31 from mailman.interfaces.action import Action from mailman.interfaces.usermanager import IUserManager +from mailman.interfaces.workflows import (ISubscriptionWorkflow, + IUnsubscriptionWorkflow) from mailman.rest.validator import ( - enum_validator, list_of_strings_validator, subscriber_validator) + enum_validator, list_of_strings_validator, policy_validator, + subscriber_validator) from mailman.testing.layers import RESTLayer +from mailman.workflows.subscription import ConfirmSubscriptionPolicy +from mailman.workflows.unsubscription import ConfirmUnsubscriptionPolicy from zope.component import getUtility @@ -91,3 +96,40 @@ class TestValidators(unittest.TestCase): def test_enum_validator_blank(self): self.assertEqual(enum_validator(Action, allow_blank=True)(''), None) + + def test_policy_validator_wrong_policy_class(self): + self.assertRaises(ValueError, policy_validator, None) + + def test_policy_validator_sub_name(self): + self.assertEqual(policy_validator(ISubscriptionWorkflow)( + ConfirmSubscriptionPolicy.name), ConfirmSubscriptionPolicy) + + def test_policy_validator_sub_class(self): + self.assertEqual(policy_validator(ISubscriptionWorkflow)( + ConfirmSubscriptionPolicy), ConfirmSubscriptionPolicy) + + def test_policy_validator_sub_backward_compat(self): + self.assertEqual(policy_validator(ISubscriptionWorkflow)('confirm'), + ConfirmSubscriptionPolicy) + + def test_policy_validator_sub_wrong_policy(self): + validator = policy_validator(ISubscriptionWorkflow) + with self.assertRaises(ValueError): + validator('not a subscription policy') + + def test_policy_validator_unsub_name(self): + self.assertEqual(policy_validator(IUnsubscriptionWorkflow)( + ConfirmUnsubscriptionPolicy.name), ConfirmUnsubscriptionPolicy) + + def test_policy_validator_unsub_class(self): + self.assertEqual(policy_validator(IUnsubscriptionWorkflow)( + ConfirmUnsubscriptionPolicy), ConfirmUnsubscriptionPolicy) + + def test_policy_validator_unsub_backward_compat(self): + self.assertEqual(policy_validator(IUnsubscriptionWorkflow)('confirm'), + ConfirmUnsubscriptionPolicy) + + def test_policy_validator_unsub_wrong_policy(self): + validator = policy_validator(IUnsubscriptionWorkflow) + with self.assertRaises(ValueError): + validator('not an unsubscription policy') diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index 1f63d3d1d..c250785c2 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -211,7 +211,7 @@ class SubscriptionBase(SubscriptionWorkflowCommon): # verified by a full coverage run, but diffcov for some reason # claims that the test added in the branch that added this code # does not cover the change. That seems like a bug in diffcov. - raise AlreadySubscribedError( # pragma: no cover + raise AlreadySubscribedError( # pragma: nocover self.mlist.fqdn_listname, self.address.email, MemberRole.member) diff --git a/src/mailman/workflows/tests/test_subscriptions.py b/src/mailman/workflows/tests/test_subscriptions.py index 23487ab1c..65569691b 100644 --- a/src/mailman/workflows/tests/test_subscriptions.py +++ b/src/mailman/workflows/tests/test_subscriptions.py @@ -24,7 +24,9 @@ from mailman.app.lifecycle import create_list from mailman.interfaces.bans import IBanManager from mailman.interfaces.member import MemberRole, MembershipIsBannedError from mailman.interfaces.pending import IPendings -from mailman.interfaces.subscriptions import TokenOwner +from mailman.interfaces.subscriptions import ( + SubscriptionPendingError, + TokenOwner) from mailman.interfaces.usermanager import IUserManager from mailman.testing.helpers import ( LogFileMark, get_queue_messages, set_preferred) @@ -157,6 +159,16 @@ class TestSubscriptionWorkflow(unittest.TestCase): workflow = ConfirmSubscriptionPolicy(self._mlist, anne) self.assertRaises(MembershipIsBannedError, list, workflow) + def test_sanity_checks_already_requested(self): + # An exception is raised if there is already a subscription request. + anne = self._user_manager.create_address(self._anne) + workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + list(workflow) + other_workflow = ConfirmSubscriptionPolicy(self._mlist, anne) + self.assertRaises(SubscriptionPendingError, list, other_workflow) + # The original workflow token is still in the database. + self._expected_pendings_count = 1 + def test_verification_checks_with_verified_address(self): # When the address is already verified, we skip straight to the # confirmation checks. -- cgit v1.2.3-70-g09d2