Add conflict resolution for multiple users using the same key. Fixes #1.
This commit is contained in:
parent
e33692f40a
commit
4b9dfa550a
8 changed files with 360 additions and 74 deletions
|
@ -16,16 +16,17 @@ from datetime import datetime
|
||||||
|
|
||||||
import pgpy # type: ignore
|
import pgpy # type: ignore
|
||||||
|
|
||||||
from multischleuder.reporting import ConflictMessage, Message
|
from multischleuder.reporting import KeyConflictMessage, Message, UserConflictMessage
|
||||||
from multischleuder.types import SchleuderKey, SchleuderSubscriber
|
from multischleuder.types import SchleuderKey, SchleuderSubscriber
|
||||||
|
|
||||||
|
|
||||||
class KeyConflictResolution:
|
class KeyConflictResolution:
|
||||||
|
|
||||||
def __init__(self, interval: int, statefile: str, template: str):
|
def __init__(self, interval: int, statefile: str, key_template: str, user_template: str):
|
||||||
self._interval: int = interval
|
self._interval: int = interval
|
||||||
self._state_file: str = statefile
|
self._state_file: str = statefile
|
||||||
self._template: str = template
|
self._key_template: str = key_template
|
||||||
|
self._user_template: str = user_template
|
||||||
self._dry_run: bool = False
|
self._dry_run: bool = False
|
||||||
self._logger: logging.Logger = logging.getLogger()
|
self._logger: logging.Logger = logging.getLogger()
|
||||||
|
|
||||||
|
@ -36,25 +37,41 @@ class KeyConflictResolution:
|
||||||
target: str,
|
target: str,
|
||||||
mail_from: str,
|
mail_from: str,
|
||||||
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[Optional[Message]]]:
|
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[Optional[Message]]]:
|
||||||
subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict()
|
conflicts: List[Optional[Message]] = []
|
||||||
|
|
||||||
|
# First check for keys that are being used by more than one subscriber
|
||||||
|
keys: Dict[str, List[SchleuderSubscriber]] = {}
|
||||||
for s in subscriptions:
|
for s in subscriptions:
|
||||||
|
if s.key is None:
|
||||||
|
continue
|
||||||
|
keys.setdefault(s.key.fingerprint, []).append(s)
|
||||||
|
key_resolved: List[SchleuderSubscriber] = []
|
||||||
|
for k in keys.values():
|
||||||
|
rk, m = self._resolve_users(target, mail_from, k)
|
||||||
|
if rk is not None:
|
||||||
|
key_resolved.append(rk)
|
||||||
|
if m is not None:
|
||||||
|
conflicts.extend(m)
|
||||||
|
|
||||||
|
subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict()
|
||||||
|
# Only consider subscribers who have not been removed due to a key used by multiple users
|
||||||
|
for s in key_resolved:
|
||||||
subs.setdefault(s.email, []).append(s)
|
subs.setdefault(s.email, []).append(s)
|
||||||
# Perform conflict resolution for each set of subscribers with the same email
|
# Perform conflict resolution for each set of subscribers with the same email
|
||||||
resolved: List[SchleuderSubscriber] = []
|
resolved: List[SchleuderSubscriber] = []
|
||||||
conflicts: List[Optional[Message]] = []
|
|
||||||
for c in subs.values():
|
for c in subs.values():
|
||||||
r, m = self._resolve(target, mail_from, c)
|
r, msg = self._resolve_keys(target, mail_from, c)
|
||||||
if r is not None:
|
if r is not None:
|
||||||
resolved.append(r)
|
resolved.append(r)
|
||||||
if m is not None:
|
if msg is not None:
|
||||||
conflicts.append(m)
|
conflicts.append(msg)
|
||||||
return resolved, conflicts
|
return resolved, conflicts
|
||||||
|
|
||||||
def _resolve(self,
|
def _resolve_keys(self,
|
||||||
target: str,
|
target: str,
|
||||||
mail_from: str,
|
mail_from: str,
|
||||||
subscriptions: List[SchleuderSubscriber]) \
|
subscriptions: List[SchleuderSubscriber]) \
|
||||||
-> Tuple[Optional[SchleuderSubscriber], Optional[ConflictMessage]]:
|
-> Tuple[Optional[SchleuderSubscriber], Optional[Message]]:
|
||||||
notnull = [s for s in subscriptions if s.key is not None]
|
notnull = [s for s in subscriptions if s.key is not None]
|
||||||
if len(notnull) == 0:
|
if len(notnull) == 0:
|
||||||
return None, None
|
return None, None
|
||||||
|
@ -69,21 +86,58 @@ class KeyConflictResolution:
|
||||||
fpr = 'no key' if s.key is None else s.key.fingerprint
|
fpr = 'no key' if s.key is None else s.key.fingerprint
|
||||||
self._logger.debug(f' - {s.schleuder}: {fpr}')
|
self._logger.debug(f' - {s.schleuder}: {fpr}')
|
||||||
# Generate a SHA1 digest that only changes when the subscription list changes
|
# Generate a SHA1 digest that only changes when the subscription list changes
|
||||||
digest = self._make_digest(earliest, subscriptions)
|
digest = self._make_key_digest(earliest, subscriptions)
|
||||||
msg: Optional[ConflictMessage] = None
|
msg: Optional[Message] = None
|
||||||
# Create a conflict message only if it hasn't been sent recently
|
# Create a conflict message only if it hasn't been sent recently
|
||||||
if self._should_send(digest):
|
if self._should_send(digest):
|
||||||
msg = ConflictMessage(
|
msg = KeyConflictMessage(
|
||||||
target,
|
target,
|
||||||
earliest,
|
earliest,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
digest,
|
digest,
|
||||||
mail_from,
|
mail_from,
|
||||||
self._template
|
self._key_template
|
||||||
)
|
)
|
||||||
# Return the result of conflict resolution
|
# Return the result of conflict resolution
|
||||||
return earliest, msg
|
return earliest, msg
|
||||||
|
|
||||||
|
def _resolve_users(self,
|
||||||
|
target: str,
|
||||||
|
mail_from: str,
|
||||||
|
subscriptions: List[SchleuderSubscriber]) \
|
||||||
|
-> Tuple[Optional[SchleuderSubscriber], Optional[List[Message]]]:
|
||||||
|
notnull = [s for s in subscriptions if s.key is not None]
|
||||||
|
if len(notnull) == 0:
|
||||||
|
return None, None
|
||||||
|
emails = {s.email for s in notnull}
|
||||||
|
if len(emails) == 1:
|
||||||
|
# No conflict if all emails are the same
|
||||||
|
return notnull[0], None
|
||||||
|
# Conflict Resolution: Choose the OLDEST subscription with a key, but notify using ALL recipients
|
||||||
|
earliest: SchleuderSubscriber = min(notnull, key=lambda x: x.created_at)
|
||||||
|
assert earliest.key is not None # Make mypy happy; it can't know that earliest.key can't be None
|
||||||
|
self._logger.debug(f'User Conflict for {earliest.key.fingerprint} in lists, chose {earliest.email}:')
|
||||||
|
for s in subscriptions:
|
||||||
|
self._logger.debug(f' - {s.schleuder}: {s.email}')
|
||||||
|
# Generate a SHA1 digest that only changes when the subscription list changes
|
||||||
|
digest = self._make_user_digest(earliest, subscriptions)
|
||||||
|
msgs: Optional[List[Message]] = None
|
||||||
|
# Create a conflict message only if it hasn't been sent recently
|
||||||
|
if self._should_send(digest):
|
||||||
|
msgs = []
|
||||||
|
for email in emails:
|
||||||
|
msgs.append(UserConflictMessage(
|
||||||
|
target,
|
||||||
|
email,
|
||||||
|
earliest,
|
||||||
|
subscriptions,
|
||||||
|
digest,
|
||||||
|
mail_from,
|
||||||
|
self._user_template
|
||||||
|
))
|
||||||
|
# Return the result of conflict resolution
|
||||||
|
return earliest, msgs
|
||||||
|
|
||||||
def _should_send(self, digest: str) -> bool:
|
def _should_send(self, digest: str) -> bool:
|
||||||
now = int(datetime.utcnow().timestamp())
|
now = int(datetime.utcnow().timestamp())
|
||||||
try:
|
try:
|
||||||
|
@ -114,7 +168,7 @@ class KeyConflictResolution:
|
||||||
self._logger.exception('Cannot open or write statefile. Not sending any messages!')
|
self._logger.exception('Cannot open or write statefile. Not sending any messages!')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _make_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str:
|
def _make_key_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str:
|
||||||
# Sort so the hash stays the same if the set of subscriptions is the same.
|
# Sort so the hash stays the same if the set of subscriptions is the same.
|
||||||
# There is no guarantee that the subs are in any specific order.
|
# There is no guarantee that the subs are in any specific order.
|
||||||
subs: List[SchleuderSubscriber] = sorted(candidates, key=lambda x: x.schleuder)
|
subs: List[SchleuderSubscriber] = sorted(candidates, key=lambda x: x.schleuder)
|
||||||
|
@ -130,3 +184,19 @@ class KeyConflictResolution:
|
||||||
key = s.key.blob.encode()
|
key = s.key.blob.encode()
|
||||||
h.update(struct.pack('!ds', s.schleuder, key))
|
h.update(struct.pack('!ds', s.schleuder, key))
|
||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
|
def _make_user_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str:
|
||||||
|
# Sort so the hash stays the same if the set of subscriptions is the same.
|
||||||
|
# There is no guarantee that the subs are in any specific order.
|
||||||
|
subs: List[SchleuderSubscriber] = sorted(candidates, key=lambda x: x.schleuder)
|
||||||
|
h = hashlib.new('sha1')
|
||||||
|
assert chosen.key is not None # Make mypy happy; it can't know that chosen.key can't be None
|
||||||
|
# Include the chosen email an source sub-list
|
||||||
|
h.update(struct.pack('!ssd',
|
||||||
|
chosen.key.fingerprint.encode(),
|
||||||
|
chosen.email.encode(),
|
||||||
|
chosen.schleuder))
|
||||||
|
# Include all subscriptions, including the FULL key
|
||||||
|
for s in subs:
|
||||||
|
h.update(struct.pack('!ds', s.schleuder, s.email.encode()))
|
||||||
|
return h.hexdigest()
|
||||||
|
|
|
@ -83,7 +83,7 @@ class Message(abc.ABC):
|
||||||
return mp0
|
return mp0
|
||||||
|
|
||||||
|
|
||||||
class ConflictMessage(Message):
|
class KeyConflictMessage(Message):
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
schleuder: str,
|
schleuder: str,
|
||||||
|
@ -119,6 +119,43 @@ class ConflictMessage(Message):
|
||||||
self.mime['X-MultiSchleuder-Digest'] = digest
|
self.mime['X-MultiSchleuder-Digest'] = digest
|
||||||
|
|
||||||
|
|
||||||
|
class UserConflictMessage(Message):
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
schleuder: str,
|
||||||
|
subscriber: str,
|
||||||
|
chosen: SchleuderSubscriber,
|
||||||
|
affected: List[SchleuderSubscriber],
|
||||||
|
digest: str,
|
||||||
|
mail_from: str,
|
||||||
|
template: str):
|
||||||
|
# Render the message body
|
||||||
|
assert chosen.key is not None
|
||||||
|
_chosen = chosen.email
|
||||||
|
_affected = ''
|
||||||
|
for a in affected:
|
||||||
|
_affected += f'{a.email} {a.schleuder}\n'
|
||||||
|
content = template.format(
|
||||||
|
subscriber=subscriber,
|
||||||
|
fingerprint=chosen.key.fingerprint,
|
||||||
|
schleuder=schleuder,
|
||||||
|
chosen=_chosen,
|
||||||
|
affected=_affected
|
||||||
|
)
|
||||||
|
self._chosen: SchleuderSubscriber = chosen
|
||||||
|
self._affected: List[SchleuderSubscriber] = affected
|
||||||
|
self._template: str = template
|
||||||
|
super().__init__(
|
||||||
|
schleuder=schleuder,
|
||||||
|
mail_from=mail_from,
|
||||||
|
mail_to=chosen.email,
|
||||||
|
content=content,
|
||||||
|
encrypt_to=[chosen.key.blob]
|
||||||
|
)
|
||||||
|
self.mime['Subject'] = f'MultiSchleuder {self._schleuder} - Subscriber Conflict'
|
||||||
|
self.mime['X-MultiSchleuder-Digest'] = digest
|
||||||
|
|
||||||
|
|
||||||
class AdminReport(Message):
|
class AdminReport(Message):
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
|
@ -194,7 +231,9 @@ class Reporter:
|
||||||
def add_message(self, message: Optional[Message]):
|
def add_message(self, message: Optional[Message]):
|
||||||
if message is None:
|
if message is None:
|
||||||
return
|
return
|
||||||
if not self._send_conflict_messages and isinstance(message, ConflictMessage):
|
if not self._send_conflict_messages and isinstance(message, KeyConflictMessage):
|
||||||
|
return
|
||||||
|
if not self._send_conflict_messages and isinstance(message, UserConflictMessage):
|
||||||
return
|
return
|
||||||
if not self._send_admin_reports and isinstance(message, AdminReport):
|
if not self._send_admin_reports and isinstance(message, AdminReport):
|
||||||
return
|
return
|
||||||
|
|
|
@ -13,7 +13,8 @@ api:
|
||||||
conflict:
|
conflict:
|
||||||
interval: 3600
|
interval: 3600
|
||||||
statefile: /tmp/state.json
|
statefile: /tmp/state.json
|
||||||
template: ''
|
key_template: ''
|
||||||
|
user_template: ''
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,13 +35,20 @@ smtp:
|
||||||
conflict:
|
conflict:
|
||||||
interval: 3600
|
interval: 3600
|
||||||
statefile: /tmp/state.json
|
statefile: /tmp/state.json
|
||||||
template: |
|
key_template: |
|
||||||
Dear {subscriber}
|
Dear {subscriber}
|
||||||
This is a test and should not be used in production:
|
This is a test and should not be used in production:
|
||||||
{affected}
|
{affected}
|
||||||
If you ever receive this text via email, notify your admin:
|
If you ever receive this text via email, notify your admin:
|
||||||
{chosen}
|
{chosen}
|
||||||
Regards, {schleuder}
|
Regards, {schleuder}
|
||||||
|
user_template: |
|
||||||
|
Dear {subscriber}
|
||||||
|
This is a test and should not be used in production:
|
||||||
|
{affected}
|
||||||
|
If you ever receive this text via email, notify your admin:
|
||||||
|
{chosen}
|
||||||
|
Kind regards, {schleuder}
|
||||||
|
|
||||||
lists:
|
lists:
|
||||||
|
|
||||||
|
@ -97,7 +105,8 @@ class TestConfig(unittest.TestCase):
|
||||||
self.assertEqual(False, list1._api._dry_run)
|
self.assertEqual(False, list1._api._dry_run)
|
||||||
self.assertEqual(3600, list2._kcr._interval)
|
self.assertEqual(3600, list2._kcr._interval)
|
||||||
self.assertEqual('/tmp/state.json', list1._kcr._state_file)
|
self.assertEqual('/tmp/state.json', list1._kcr._state_file)
|
||||||
self.assertIn('Regards, {schleuder}', list2._kcr._template)
|
self.assertIn('Regards, {schleuder}', list2._kcr._key_template)
|
||||||
|
self.assertIn('Kind regards, {schleuder}', list2._kcr._user_template)
|
||||||
|
|
||||||
self.assertEqual('test-global@schleuder.example.org', list1._target)
|
self.assertEqual('test-global@schleuder.example.org', list1._target)
|
||||||
self.assertEqual('test-global-owner@schleuder.example.org', list1._mail_from)
|
self.assertEqual('test-global-owner@schleuder.example.org', list1._mail_from)
|
||||||
|
|
|
@ -8,7 +8,8 @@ from unittest.mock import patch, mock_open, MagicMock
|
||||||
import pgpy # type: ignore
|
import pgpy # type: ignore
|
||||||
from dateutil.tz import tzutc
|
from dateutil.tz import tzutc
|
||||||
|
|
||||||
from multischleuder.conflict import ConflictMessage, KeyConflictResolution
|
from multischleuder.conflict import KeyConflictResolution
|
||||||
|
from multischleuder.reporting import UserConflictMessage
|
||||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,7 +53,13 @@ QBzu2Swgk6MkU45SLQD+LagpBVJxHcbvmK+n8MFvTSrusF8H78P4TrMLP4Onvw4=
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
|
||||||
_TEMPLATE = '''{{
|
_KEY_TEMPLATE = '''{{
|
||||||
|
"subscriber": "{subscriber}",
|
||||||
|
"schleuder": "{schleuder}",
|
||||||
|
"chosen": "{chosen}"
|
||||||
|
}}'''
|
||||||
|
_USER_TEMPLATE = '''{{
|
||||||
|
"fingerprint": "{fingerprint}",
|
||||||
"subscriber": "{subscriber}",
|
"subscriber": "{subscriber}",
|
||||||
"schleuder": "{schleuder}",
|
"schleuder": "{schleuder}",
|
||||||
"chosen": "{chosen}"
|
"chosen": "{chosen}"
|
||||||
|
@ -70,7 +77,7 @@ _CONFLICT_STATE_RECENT = f'''{{
|
||||||
|
|
||||||
class TestKeyConflictResolution(unittest.TestCase):
|
class TestKeyConflictResolution(unittest.TestCase):
|
||||||
|
|
||||||
def test_order_resistent_hash(self):
|
def test_order_resistent_key_hash(self):
|
||||||
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
||||||
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||||
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
|
@ -79,19 +86,39 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
|
|
||||||
digest1 = kcr._make_digest(sub2, [sub1, sub2])
|
digest1 = kcr._make_key_digest(sub2, [sub1, sub2])
|
||||||
digest2 = kcr._make_digest(sub2, [sub2, sub1])
|
digest2 = kcr._make_key_digest(sub2, [sub2, sub1])
|
||||||
digest3 = kcr._make_digest(sub1, [sub1, sub2])
|
digest3 = kcr._make_key_digest(sub1, [sub1, sub2])
|
||||||
digest4 = kcr._make_digest(sub1, [sub2, sub1])
|
digest4 = kcr._make_key_digest(sub1, [sub2, sub1])
|
||||||
|
|
||||||
|
self.assertEqual(digest1, digest2)
|
||||||
|
self.assertNotEqual(digest1, digest3)
|
||||||
|
self.assertEqual(digest3, digest4)
|
||||||
|
|
||||||
|
def test_order_resistent_user_hash(self):
|
||||||
|
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
||||||
|
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||||
|
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
|
sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1)
|
||||||
|
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
|
||||||
|
key2 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
|
||||||
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
|
sub2 = SchleuderSubscriber(7, 'bar@example.org', key2, sch2.id, date2)
|
||||||
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
|
|
||||||
|
digest1 = kcr._make_user_digest(sub2, [sub1, sub2])
|
||||||
|
digest2 = kcr._make_user_digest(sub2, [sub2, sub1])
|
||||||
|
digest3 = kcr._make_user_digest(sub1, [sub1, sub2])
|
||||||
|
digest4 = kcr._make_user_digest(sub1, [sub2, sub1])
|
||||||
|
|
||||||
self.assertEqual(digest1, digest2)
|
self.assertEqual(digest1, digest2)
|
||||||
self.assertNotEqual(digest1, digest3)
|
self.assertNotEqual(digest1, digest3)
|
||||||
self.assertEqual(digest3, digest4)
|
self.assertEqual(digest3, digest4)
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
@ -105,7 +132,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||||
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1)
|
sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1)
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
resolved, messages = kcr.resolve('', '', [sub1])
|
resolved, messages = kcr.resolve('', '', [sub1])
|
||||||
self.assertEqual(1, len(resolved))
|
self.assertEqual(1, len(resolved))
|
||||||
self.assertEqual(sub1, resolved[0])
|
self.assertEqual(sub1, resolved[0])
|
||||||
|
@ -123,7 +150,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
@ -150,7 +177,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
@ -187,7 +214,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', None, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', None, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
@ -207,7 +234,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub1 = SchleuderSubscriber(3, 'foo@example.org', None, sch1.id, date1)
|
sub1 = SchleuderSubscriber(3, 'foo@example.org', None, sch1.id, date1)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
@ -219,6 +246,114 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
self.assertEqual(0, len(resolved))
|
self.assertEqual(0, len(resolved))
|
||||||
self.assertEqual(0, len(messages))
|
self.assertEqual(0, len(messages))
|
||||||
|
|
||||||
|
def test_different_emails_conflict(self):
|
||||||
|
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
||||||
|
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||||
|
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
|
sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1)
|
||||||
|
|
||||||
|
# This subscription is older, so it will be preferred
|
||||||
|
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
|
||||||
|
key2 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
|
||||||
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
|
sub2 = SchleuderSubscriber(7, 'bar@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
mock_statefile().__enter__.return_value = contents
|
||||||
|
resolved, messages = kcr.resolve(
|
||||||
|
target='test@schleuder.example.org',
|
||||||
|
mail_from='test-owner@schleuder.example.org',
|
||||||
|
subscriptions=[sub1, sub2])
|
||||||
|
|
||||||
|
self.assertEqual(1, len(resolved))
|
||||||
|
self.assertEqual('bar@example.org', resolved[0].email)
|
||||||
|
# Different emails should trigger conflict messages
|
||||||
|
self.assertEqual(2, len(messages))
|
||||||
|
self.assertIsInstance(messages[0], UserConflictMessage)
|
||||||
|
self.assertIsInstance(messages[1], UserConflictMessage)
|
||||||
|
msg1 = messages[0].mime
|
||||||
|
pgp1 = pgpy.PGPMessage.from_blob(msg1.get_payload()[1].get_payload(decode=True))
|
||||||
|
msg2 = messages[1].mime
|
||||||
|
pgp2 = pgpy.PGPMessage.from_blob(msg2.get_payload()[1].get_payload(decode=True))
|
||||||
|
# Verify that the message is encrypted
|
||||||
|
dec1 = _PRIVKEY_1.decrypt(pgp1)
|
||||||
|
dec2 = _PRIVKEY_1.decrypt(pgp2)
|
||||||
|
self.assertNotEqual(dec1.message, dec2.message)
|
||||||
|
|
||||||
|
payload1 = json.loads(dec1.message)
|
||||||
|
payload2 = json.loads(dec2.message)
|
||||||
|
if payload1['subscriber'] != 'foo@example.org':
|
||||||
|
payload1, payload2 = payload2, payload1
|
||||||
|
self.assertEqual('foo@example.org', payload1['subscriber'])
|
||||||
|
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload1['fingerprint'])
|
||||||
|
self.assertEqual('test@schleuder.example.org', payload1['schleuder'])
|
||||||
|
self.assertEqual('bar@example.org', payload1['chosen'])
|
||||||
|
self.assertEqual('bar@example.org', payload2['subscriber'])
|
||||||
|
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload2['fingerprint'])
|
||||||
|
self.assertEqual('test@schleuder.example.org', payload2['schleuder'])
|
||||||
|
self.assertEqual('bar@example.org', payload2['chosen'])
|
||||||
|
|
||||||
|
def test_zigzag_conflict(self):
|
||||||
|
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
|
|
||||||
|
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
||||||
|
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||||
|
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_2.pubkey), sch1.id)
|
||||||
|
sub1 = SchleuderSubscriber(1, 'foo@example.org', key1, sch1.id, date2)
|
||||||
|
sub2 = SchleuderSubscriber(2, 'bar@example.org', key2, sch1.id, date2)
|
||||||
|
|
||||||
|
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
|
||||||
|
key3 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
|
||||||
|
sub3 = SchleuderSubscriber(7, 'bar@example.org', key3, sch2.id, date1)
|
||||||
|
|
||||||
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
mock_statefile().__enter__.return_value = contents
|
||||||
|
resolved, messages = kcr.resolve(
|
||||||
|
target='test@schleuder.example.org',
|
||||||
|
mail_from='test-owner@schleuder.example.org',
|
||||||
|
subscriptions=[sub1, sub2, sub3])
|
||||||
|
|
||||||
|
self.assertEqual(2, len(resolved))
|
||||||
|
foo, bar = resolved
|
||||||
|
if foo.email != 'foo@example.org':
|
||||||
|
foo, bar = bar, foo
|
||||||
|
self.assertEqual('foo@example.org', foo.email)
|
||||||
|
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', foo.key.fingerprint)
|
||||||
|
self.assertEqual('bar@example.org', bar.email)
|
||||||
|
self.assertEqual('135AFA0FB3FF584828911208B7913308392972A4', bar.key.fingerprint)
|
||||||
|
# Different emails should trigger conflict messages
|
||||||
|
self.assertEqual(2, len(messages))
|
||||||
|
self.assertIsInstance(messages[0], UserConflictMessage)
|
||||||
|
self.assertIsInstance(messages[1], UserConflictMessage)
|
||||||
|
msg1 = messages[0].mime
|
||||||
|
pgp1 = pgpy.PGPMessage.from_blob(msg1.get_payload()[1].get_payload(decode=True))
|
||||||
|
msg2 = messages[1].mime
|
||||||
|
pgp2 = pgpy.PGPMessage.from_blob(msg2.get_payload()[1].get_payload(decode=True))
|
||||||
|
# Verify that the message is encrypted
|
||||||
|
dec1 = _PRIVKEY_1.decrypt(pgp1)
|
||||||
|
dec2 = _PRIVKEY_1.decrypt(pgp2)
|
||||||
|
self.assertNotEqual(dec1.message, dec2.message)
|
||||||
|
|
||||||
|
payload1 = json.loads(dec1.message)
|
||||||
|
payload2 = json.loads(dec2.message)
|
||||||
|
if payload1['subscriber'] != 'foo@example.org':
|
||||||
|
payload1, payload2 = payload2, payload1
|
||||||
|
self.assertEqual('foo@example.org', payload1['subscriber'])
|
||||||
|
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload1['fingerprint'])
|
||||||
|
self.assertEqual('test@schleuder.example.org', payload1['schleuder'])
|
||||||
|
self.assertEqual('foo@example.org', payload1['chosen'])
|
||||||
|
self.assertEqual('bar@example.org', payload2['subscriber'])
|
||||||
|
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload2['fingerprint'])
|
||||||
|
self.assertEqual('test@schleuder.example.org', payload2['schleuder'])
|
||||||
|
self.assertEqual('foo@example.org', payload2['chosen'])
|
||||||
|
|
||||||
def test_send_messages_nofile(self):
|
def test_send_messages_nofile(self):
|
||||||
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
||||||
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||||
|
@ -230,7 +365,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/nonexistent/directory/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/nonexistent/directory/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
resolved, msgs = kcr.resolve(
|
resolved, msgs = kcr.resolve(
|
||||||
target='test@schleuder.example.org',
|
target='test@schleuder.example.org',
|
||||||
mail_from='test-owner@schleuder.example.org',
|
mail_from='test-owner@schleuder.example.org',
|
||||||
|
@ -248,7 +383,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO('[[intentionally/broken\\json]]')
|
contents = io.StringIO('[[intentionally/broken\\json]]')
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data='[[intentionally/broken\\json]]')) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data='[[intentionally/broken\\json]]')) as mock_statefile:
|
||||||
|
@ -270,7 +405,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO()
|
contents = io.StringIO()
|
||||||
with patch('builtins.open', mock_open(read_data='')) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data='')) as mock_statefile:
|
||||||
mock_statefile().__enter__.return_value = contents
|
mock_statefile().__enter__.return_value = contents
|
||||||
|
@ -299,7 +434,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
@ -318,12 +453,6 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
self.assertIn(msgs[0].mime['X-MultiSchleuder-Digest'], state)
|
self.assertIn(msgs[0].mime['X-MultiSchleuder-Digest'], state)
|
||||||
self.assertLess(now - state[msgs[0].mime['X-MultiSchleuder-Digest']], 60)
|
self.assertLess(now - state[msgs[0].mime['X-MultiSchleuder-Digest']], 60)
|
||||||
|
|
||||||
# Todo: move this over to test_multilist
|
|
||||||
# mock_smtp = MagicMock()
|
|
||||||
# mock_smtp.__enter__.return_value = mock_smtp
|
|
||||||
# mock_smtp.__enter__.assert_called_once()
|
|
||||||
# self.assertEqual(2, mock_smtp.send_message.call_count)
|
|
||||||
|
|
||||||
def test_send_messages_stalestate(self):
|
def test_send_messages_stalestate(self):
|
||||||
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
||||||
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||||
|
@ -335,7 +464,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_STALE)
|
contents = io.StringIO(_CONFLICT_STATE_STALE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_STALE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_STALE)) as mock_statefile:
|
||||||
|
@ -366,7 +495,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(86400, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(86400, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_RECENT)
|
contents = io.StringIO(_CONFLICT_STATE_RECENT)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_RECENT)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_RECENT)) as mock_statefile:
|
||||||
|
@ -398,7 +527,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
kcr.dry_run()
|
kcr.dry_run()
|
||||||
contents = io.StringIO(_CONFLICT_STATE_STALE)
|
contents = io.StringIO(_CONFLICT_STATE_STALE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
|
@ -431,7 +560,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
||||||
date3 = datetime(2022, 4, 11, 5, 23, 42, 0, tzinfo=tzutc())
|
date3 = datetime(2022, 4, 11, 5, 23, 42, 0, tzinfo=tzutc())
|
||||||
sub3 = SchleuderSubscriber(7, 'foo@example.org', None, sch3.id, date3)
|
sub3 = SchleuderSubscriber(7, 'foo@example.org', None, sch3.id, date3)
|
||||||
|
|
||||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||||
|
|
|
@ -8,13 +8,13 @@ from unittest.mock import MagicMock
|
||||||
from dateutil.tz import tzutc
|
from dateutil.tz import tzutc
|
||||||
|
|
||||||
from multischleuder.processor import MultiList
|
from multischleuder.processor import MultiList
|
||||||
from multischleuder.reporting import ConflictMessage
|
from multischleuder.reporting import Message
|
||||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||||
|
|
||||||
|
|
||||||
def _resolve(target: str,
|
def _resolve(target: str,
|
||||||
mail_from: str,
|
mail_from: str,
|
||||||
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[ConflictMessage]]:
|
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[Message]]:
|
||||||
# Mock conflict resolution that does not send or prepare messages
|
# Mock conflict resolution that does not send or prepare messages
|
||||||
subs: Dict[str, List[SchleuderSubscriber]] = {}
|
subs: Dict[str, List[SchleuderSubscriber]] = {}
|
||||||
for s in subscriptions:
|
for s in subscriptions:
|
||||||
|
|
|
@ -3,13 +3,16 @@ import unittest
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from multischleuder.reporting import ConflictMessage, AdminReport, Reporter
|
from multischleuder.reporting import KeyConflictMessage, AdminReport, Reporter, UserConflictMessage
|
||||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||||
|
from multischleuder.test.test_conflict import _PRIVKEY_1
|
||||||
|
|
||||||
|
|
||||||
def one_of_each_kind():
|
def one_of_each_kind():
|
||||||
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
||||||
msg1 = ConflictMessage(
|
key = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), 1)
|
||||||
|
sub2 = SchleuderSubscriber(2, 'bar@example.org', key, 1, datetime.utcnow())
|
||||||
|
msg1 = KeyConflictMessage(
|
||||||
schleuder='test@example.org',
|
schleuder='test@example.org',
|
||||||
chosen=sub,
|
chosen=sub,
|
||||||
affected=[sub],
|
affected=[sub],
|
||||||
|
@ -26,7 +29,15 @@ def one_of_each_kind():
|
||||||
updated={},
|
updated={},
|
||||||
added={},
|
added={},
|
||||||
removed={})
|
removed={})
|
||||||
return [msg1, msg2]
|
msg3 = UserConflictMessage(
|
||||||
|
schleuder='test@example.org',
|
||||||
|
subscriber='bar@example.org',
|
||||||
|
chosen=sub2,
|
||||||
|
affected=[sub2],
|
||||||
|
digest='digest',
|
||||||
|
mail_from='test-owner@example.org',
|
||||||
|
template='averylongmessage')
|
||||||
|
return [msg1, msg2, msg3]
|
||||||
|
|
||||||
|
|
||||||
class TestReporting(unittest.TestCase):
|
class TestReporting(unittest.TestCase):
|
||||||
|
@ -36,11 +47,13 @@ class TestReporting(unittest.TestCase):
|
||||||
r = Reporter(send_conflict_messages=True,
|
r = Reporter(send_conflict_messages=True,
|
||||||
send_admin_reports=True)
|
send_admin_reports=True)
|
||||||
r.add_messages(msgs)
|
r.add_messages(msgs)
|
||||||
self.assertEquals(2, len(Reporter.get_messages()))
|
self.assertEqual(3, len(Reporter.get_messages()))
|
||||||
self.assertIsInstance(Reporter.get_messages()[-2], ConflictMessage)
|
self.assertIsInstance(Reporter.get_messages()[0], KeyConflictMessage)
|
||||||
self.assertEquals('foo@example.org', Reporter.get_messages()[-2]._to)
|
self.assertEqual('foo@example.org', Reporter.get_messages()[0]._to)
|
||||||
self.assertIsInstance(Reporter.get_messages()[-1], AdminReport)
|
self.assertIsInstance(Reporter.get_messages()[1], AdminReport)
|
||||||
self.assertEquals('admin@example.org', Reporter.get_messages()[-1]._to)
|
self.assertEqual('admin@example.org', Reporter.get_messages()[1]._to)
|
||||||
|
self.assertIsInstance(Reporter.get_messages()[2], UserConflictMessage)
|
||||||
|
self.assertEqual('bar@example.org', Reporter.get_messages()[2]._to)
|
||||||
Reporter.clear_messages()
|
Reporter.clear_messages()
|
||||||
|
|
||||||
def test_reporter_config_conflict_only(self):
|
def test_reporter_config_conflict_only(self):
|
||||||
|
@ -48,9 +61,11 @@ class TestReporting(unittest.TestCase):
|
||||||
r = Reporter(send_conflict_messages=True,
|
r = Reporter(send_conflict_messages=True,
|
||||||
send_admin_reports=False)
|
send_admin_reports=False)
|
||||||
r.add_messages(msgs)
|
r.add_messages(msgs)
|
||||||
self.assertEquals(1, len(Reporter.get_messages()))
|
self.assertEqual(2, len(Reporter.get_messages()))
|
||||||
self.assertIsInstance(Reporter.get_messages()[-1], ConflictMessage)
|
self.assertIsInstance(Reporter.get_messages()[0], KeyConflictMessage)
|
||||||
self.assertEquals('foo@example.org', Reporter.get_messages()[-1]._to)
|
self.assertEqual('foo@example.org', Reporter.get_messages()[0]._to)
|
||||||
|
self.assertIsInstance(Reporter.get_messages()[1], UserConflictMessage)
|
||||||
|
self.assertEqual('bar@example.org', Reporter.get_messages()[1]._to)
|
||||||
Reporter.clear_messages()
|
Reporter.clear_messages()
|
||||||
|
|
||||||
def test_reporter_config_admin_only(self):
|
def test_reporter_config_admin_only(self):
|
||||||
|
@ -58,9 +73,9 @@ class TestReporting(unittest.TestCase):
|
||||||
r = Reporter(send_conflict_messages=False,
|
r = Reporter(send_conflict_messages=False,
|
||||||
send_admin_reports=True)
|
send_admin_reports=True)
|
||||||
r.add_messages(msgs)
|
r.add_messages(msgs)
|
||||||
self.assertEquals(1, len(Reporter.get_messages()))
|
self.assertEqual(1, len(Reporter.get_messages()))
|
||||||
self.assertIsInstance(Reporter.get_messages()[-1], AdminReport)
|
self.assertIsInstance(Reporter.get_messages()[-1], AdminReport)
|
||||||
self.assertEquals('admin@example.org', Reporter.get_messages()[-1]._to)
|
self.assertEqual('admin@example.org', Reporter.get_messages()[-1]._to)
|
||||||
Reporter.clear_messages()
|
Reporter.clear_messages()
|
||||||
|
|
||||||
def test_reporter_config_all_disabled(self):
|
def test_reporter_config_all_disabled(self):
|
||||||
|
@ -68,11 +83,11 @@ class TestReporting(unittest.TestCase):
|
||||||
r = Reporter(send_conflict_messages=False,
|
r = Reporter(send_conflict_messages=False,
|
||||||
send_admin_reports=False)
|
send_admin_reports=False)
|
||||||
r.add_messages(msgs)
|
r.add_messages(msgs)
|
||||||
self.assertEquals(0, len(Reporter.get_messages()))
|
self.assertEqual(0, len(Reporter.get_messages()))
|
||||||
|
|
||||||
def test_reporter_null_message(self):
|
def test_reporter_null_message(self):
|
||||||
r = Reporter(send_conflict_messages=True,
|
r = Reporter(send_conflict_messages=True,
|
||||||
send_admin_reports=True)
|
send_admin_reports=True)
|
||||||
r.add_messages([None])
|
r.add_messages([None])
|
||||||
self.assertEquals(0, len(Reporter.get_messages()))
|
self.assertEqual(0, len(Reporter.get_messages()))
|
||||||
Reporter.clear_messages()
|
Reporter.clear_messages()
|
||||||
|
|
|
@ -7,7 +7,7 @@ from email.mime.text import MIMEText
|
||||||
from aiosmtpd.controller import Controller
|
from aiosmtpd.controller import Controller
|
||||||
from aiosmtpd.smtp import AuthResult, SMTP
|
from aiosmtpd.smtp import AuthResult, SMTP
|
||||||
|
|
||||||
from multischleuder.reporting import ConflictMessage, AdminReport
|
from multischleuder.reporting import KeyConflictMessage, AdminReport
|
||||||
from multischleuder.smtp import SmtpClient, TlsMode
|
from multischleuder.smtp import SmtpClient, TlsMode
|
||||||
from multischleuder.types import SchleuderSubscriber
|
from multischleuder.types import SchleuderSubscriber
|
||||||
|
|
||||||
|
@ -170,7 +170,7 @@ class TestSmtpClient(unittest.TestCase):
|
||||||
username='example',
|
username='example',
|
||||||
password='supersecurepassword')
|
password='supersecurepassword')
|
||||||
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
||||||
msg1 = ConflictMessage(
|
msg1 = KeyConflictMessage(
|
||||||
schleuder='test@example.org',
|
schleuder='test@example.org',
|
||||||
chosen=sub,
|
chosen=sub,
|
||||||
affected=[sub],
|
affected=[sub],
|
||||||
|
@ -203,7 +203,7 @@ class TestSmtpClient(unittest.TestCase):
|
||||||
password='supersecurepassword')
|
password='supersecurepassword')
|
||||||
client.dry_run()
|
client.dry_run()
|
||||||
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
||||||
msg1 = ConflictMessage(
|
msg1 = KeyConflictMessage(
|
||||||
schleuder='test@example.org',
|
schleuder='test@example.org',
|
||||||
chosen=sub,
|
chosen=sub,
|
||||||
affected=[sub],
|
affected=[sub],
|
||||||
|
|
|
@ -40,7 +40,7 @@ smtp:
|
||||||
conflict:
|
conflict:
|
||||||
interval: 3600 # 1 hour - you don't want this in production
|
interval: 3600 # 1 hour - you don't want this in production
|
||||||
statefile: /tmp/conflict.json
|
statefile: /tmp/conflict.json
|
||||||
template: |
|
key_template: |
|
||||||
Hi {subscriber},
|
Hi {subscriber},
|
||||||
|
|
||||||
While compiling the subscriber list of {schleuder}, your
|
While compiling the subscriber list of {schleuder}, your
|
||||||
|
@ -65,8 +65,32 @@ conflict:
|
||||||
message, please refer to your local Schleuder admin, or reply to this
|
message, please refer to your local Schleuder admin, or reply to this
|
||||||
message.
|
message.
|
||||||
|
|
||||||
Note that this automated message is unsigned, since MultiSchleuder does
|
Regards
|
||||||
not have access to Schleuder private keys.
|
MultiSchleuder {schleuder}
|
||||||
|
user_template: |
|
||||||
|
Hi {subscriber},
|
||||||
|
|
||||||
|
While compiling the subscriber list of {schleuder}, your
|
||||||
|
key {fingerprint} was used by subscribers on multiple sub-lists with
|
||||||
|
different email adresses. There may be something fishy or malicious
|
||||||
|
going on, or this may simply have been a mistake by you or a list admin.
|
||||||
|
|
||||||
|
You have only been subscribed to {schleuder} using the address you
|
||||||
|
have been subscribed with for the *longest* time:
|
||||||
|
|
||||||
|
{chosen}
|
||||||
|
|
||||||
|
Please review the following adresses and talk to the admins of the
|
||||||
|
corresponding sub-lists to resolve this issue:
|
||||||
|
|
||||||
|
Adress Sub-List
|
||||||
|
------ --------
|
||||||
|
{affected}
|
||||||
|
|
||||||
|
For your convenience, this message has been sent to *all* of the above
|
||||||
|
adresses. If you have any questions, or do not understand this
|
||||||
|
message, please refer to your local Schleuder admin, or reply to this
|
||||||
|
message.
|
||||||
|
|
||||||
Regards
|
Regards
|
||||||
MultiSchleuder {schleuder}
|
MultiSchleuder {schleuder}
|
||||||
|
|
Loading…
Reference in a new issue