Remove TODO comment; turned into a gitlab issue

This commit is contained in:
s3lph 2022-04-16 05:37:38 +02:00
parent 032e211af1
commit a0fb9a503b
8 changed files with 406 additions and 48 deletions

View file

@ -57,31 +57,37 @@ class SchleuderApi:
# List Management # List Management
def get_lists(self) -> List['SchleuderList']: def get_lists(self) -> List[SchleuderList]:
lists = self.__request('lists.json') lists = self.__request('lists.json')
return [SchleuderList.from_api(**s) for s in lists] return [SchleuderList.from_api(**s) for s in lists]
# Subscriber Management # Subscriber Management
def get_subscribers(self, schleuder: 'SchleuderList') -> List['SchleuderSubscriber']: def get_subscribers(self, schleuder: SchleuderList) -> List[SchleuderSubscriber]:
response = self.__request('subscriptions.json', schleuder.id) response = self.__request('subscriptions.json', schleuder.id)
subs: List[SchleuderSubscriber] = [] subs: List[SchleuderSubscriber] = []
for r in response: for r in response:
key = self.get_key(r['fingerprint'], schleuder) key: Optional[SchleuderKey] = None
if r['fingerprint']:
key = self.get_key(r['fingerprint'], schleuder)
sub = SchleuderSubscriber.from_api(key, **r) sub = SchleuderSubscriber.from_api(key, **r)
subs.append(sub) subs.append(sub)
return subs return subs
def get_subscriber(self, email: str, schleuder: 'SchleuderList') -> 'SchleuderSubscriber': def get_subscriber(self, email: str, schleuder: SchleuderList) -> SchleuderSubscriber:
response = self.__request('subscriptions.json', list_id=schleuder.id) response = self.__request('subscriptions.json', list_id=schleuder.id)
for r in response: for r in response:
if r['email'] != email: if r['email'] != email:
continue continue
key = self.get_key(r['fingerprint'], schleuder) key: Optional[SchleuderKey] = None
if r['fingerprint']:
key = self.get_key(r['fingerprint'], schleuder)
return SchleuderSubscriber.from_api(key, **r) return SchleuderSubscriber.from_api(key, **r)
raise KeyError(f'{email} is not subscribed to {schleuder.name}') raise KeyError(f'{email} is not subscribed to {schleuder.name}')
def subscribe(self, sub: 'SchleuderSubscriber', schleuder: 'SchleuderList'): def subscribe(self, sub: SchleuderSubscriber, schleuder: SchleuderList):
if sub.key is None:
raise ValueError('Cannot subscribe without key')
if self._dry_run: if self._dry_run:
return return
data = json.dumps({ data = json.dumps({
@ -90,13 +96,15 @@ class SchleuderApi:
}) })
self.__request('subscriptions.json', list_id=schleuder.id, data=data, method='POST') self.__request('subscriptions.json', list_id=schleuder.id, data=data, method='POST')
def unsubscribe(self, sub: 'SchleuderSubscriber', schleuder: 'SchleuderList'): def unsubscribe(self, sub: SchleuderSubscriber, schleuder: SchleuderList):
listsub = self.get_subscriber(sub.email, schleuder) listsub = self.get_subscriber(sub.email, schleuder)
if self._dry_run: if self._dry_run:
return return
self.__request('subscriptions/{}.json', fmt=[listsub.id], method='DELETE') self.__request('subscriptions/{}.json', fmt=[listsub.id], method='DELETE')
def update_fingerprint(self, sub: 'SchleuderSubscriber', schleuder: 'SchleuderList'): def update_fingerprint(self, sub: SchleuderSubscriber, schleuder: SchleuderList):
if sub.key is None:
raise ValueError('Cannot update fingerprint without key')
listsub = self.get_subscriber(sub.email, schleuder) listsub = self.get_subscriber(sub.email, schleuder)
if self._dry_run: if self._dry_run:
return return
@ -105,13 +113,11 @@ class SchleuderApi:
# Key Management # Key Management
def get_key(self, fpr: str, schleuder: 'SchleuderList'): def get_key(self, fpr: str, schleuder: SchleuderList) -> SchleuderKey:
if self._dry_run:
return
key = self.__request('keys/{}.json', list_id=schleuder.id, fmt=[fpr]) key = self.__request('keys/{}.json', list_id=schleuder.id, fmt=[fpr])
return SchleuderKey.from_api(schleuder.id, **key) return SchleuderKey.from_api(schleuder.id, **key)
def post_key(self, key: 'SchleuderKey', schleuder: 'SchleuderList'): def post_key(self, key: SchleuderKey, schleuder: SchleuderList):
if self._dry_run: if self._dry_run:
return return
data = json.dumps({ data = json.dumps({
@ -119,7 +125,7 @@ class SchleuderApi:
}) })
self.__request('keys.json', list_id=schleuder.id, data=data, method='POST') self.__request('keys.json', list_id=schleuder.id, data=data, method='POST')
def delete_key(self, key: 'SchleuderKey', schleuder: 'SchleuderList'): def delete_key(self, key: SchleuderKey, schleuder: SchleuderList):
if self._dry_run: if self._dry_run:
return return
self.__request('keys/{}.json', list_id=schleuder.id, fmt=[key.fingerprint], method='DELETE') self.__request('keys/{}.json', list_id=schleuder.id, fmt=[key.fingerprint], method='DELETE')

View file

@ -1,5 +1,5 @@
from typing import Dict, List from typing import Dict, List, Optional
import email.mime.application import email.mime.application
import email.mime.multipart import email.mime.multipart
@ -45,9 +45,10 @@ class ConflictMessage:
self._chosen.schleuder)) self._chosen.schleuder))
# Include all subscriptions, including the FULL key # Include all subscriptions, including the FULL key
for s in subs: for s in subs:
h.update(struct.pack('!ds', key = b'N/A'
s.schleuder, if s.key is not None:
s.key.blob.encode())) key = s.key.blob.encode()
h.update(struct.pack('!ds', s.schleuder, key))
return h.hexdigest() return h.hexdigest()
@property @property
@ -57,10 +58,12 @@ class ConflictMessage:
@property @property
def mime(self) -> email.mime.multipart.MIMEMultipart: def mime(self) -> email.mime.multipart.MIMEMultipart:
# Render the message body # Render the message body
_chosen = f'{self._chosen.key.fingerprint} {self._chosen.email}' fpr = 'N/A' if self._chosen.key is None else self._chosen.key.fingerprint
_chosen = f'{fpr} {self._chosen.email}'
_affected = '' _affected = ''
for affected in self._affected: for affected in self._affected:
_affected += f'{affected.key.fingerprint} {affected.schleuder}\n' fpr = 'N/A' if affected.key is None else affected.key.fingerprint
_affected += f'{fpr} {affected.schleuder}\n'
msg: str = self._template.format( msg: str = self._template.format(
subscriber=self._chosen.email, subscriber=self._chosen.email,
schleuder=self._schleuder, schleuder=self._schleuder,
@ -73,9 +76,10 @@ class ConflictMessage:
sessionkey = cipher.gen_key() sessionkey = cipher.gen_key()
try: try:
for affected in self._affected: for affected in self._affected:
key, _ = pgpy.PGPKey.from_blob(affected.key.blob) if affected.key is not None:
key._require_usage_flags = False key, _ = pgpy.PGPKey.from_blob(affected.key.blob)
pgp = key.encrypt(pgp, cipher=cipher, sessionkey=sessionkey) key._require_usage_flags = False
pgp = key.encrypt(pgp, cipher=cipher, sessionkey=sessionkey)
finally: finally:
del sessionkey del sessionkey
# Build the MIME message # Build the MIME message
@ -123,22 +127,26 @@ class KeyConflictResolution:
for s in subscriptions: for s in subscriptions:
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
return [self._resolve(target, mail_from, s) for s in subs.values()] resolved = [self._resolve(target, mail_from, s) for s in subs.values()]
return [r for r in resolved if r is not None]
def _resolve(self, def _resolve(self,
target: str, target: str,
mail_from: str, mail_from: str,
subscriptions: List[SchleuderSubscriber]) -> SchleuderSubscriber: subscriptions: List[SchleuderSubscriber]) -> Optional[SchleuderSubscriber]:
if len(subscriptions) == 1: notnull = [s for s in subscriptions if s.key is not None]
return subscriptions[0] if len(notnull) == 0:
if len({s.key.blob for s in subscriptions}) == 1: return None
if len({s.key.blob for s in subscriptions if s.key is not None}) == 1:
# No conflict if all keys are the same # No conflict if all keys are the same
return subscriptions[0] return notnull[0]
# Conflict Resolution: Choose the OLDEST subscriptions, but notify using ALL keys # Conflict Resolution: Choose the OLDEST subscription with a key, but notify using ALL keys
earliest: SchleuderSubscriber = min(subscriptions, key=lambda x: x.created_at) earliest: SchleuderSubscriber = min(notnull, key=lambda x: x.created_at)
self._logger.debug(f'Key Conflict for {earliest.email} in lists, chose {earliest.schleuder}:') assert earliest.key is not None # Make mypy happy; it can't know that earliest.key can't be None
self._logger.debug(f'Key Conflict for {earliest.email} in lists, chose {earliest.key.fingerprint}:')
for s in subscriptions: for s in subscriptions:
self._logger.debug(f' - {s.schleuder}: {s.key.fingerprint}') fpr = 'N/A' if s.key is None else s.key.fingerprint
self._logger.debug(f' - {s.schleuder}: {fpr}')
# At this point, the messages are only queued locally, they will be sent afterwards # At this point, the messages are only queued locally, they will be sent afterwards
msg = ConflictMessage( msg = ConflictMessage(
target, target,

View file

@ -36,7 +36,7 @@ class MultiList:
for s in self._api.get_subscribers(target_list) for s in self._api.get_subscribers(target_list)
if s.email not in self._unmanaged if s.email not in self._unmanaged
} }
current_keys: Set[SchleuderKey] = {s.key for s in current_subs} current_keys: Set[SchleuderKey] = {s.key for s in current_subs if s.key is not None}
all_subs: List[SchleuderSubscriber] = [] all_subs: List[SchleuderSubscriber] = []
# This loop may return multiple subscriptions for some users if they are subscribed on multiple sub-lists ... # This loop may return multiple subscriptions for some users if they are subscribed on multiple sub-lists ...
for source in sources: for source in sources:
@ -46,9 +46,9 @@ class MultiList:
continue continue
all_subs.append(s) all_subs.append(s)
# ... which is taken care of by the key conflict resolution routine # ... which is taken care of by the key conflict resolution routine
self._kcr.resolve(self._target, self._mail_from, all_subs) resolved = self._kcr.resolve(self._target, self._mail_from, all_subs)
intended_subs: Set[SchleuderSubscriber] = set(all_subs) intended_subs: Set[SchleuderSubscriber] = set(resolved)
intended_keys: Set[SchleuderKey] = {s.key for s in intended_subs} intended_keys: Set[SchleuderKey] = {s.key for s in intended_subs if s.key is not None}
# Determine the change set # Determine the change set
to_subscribe = intended_subs.difference(current_subs) to_subscribe = intended_subs.difference(current_subs)
to_unsubscribe = current_subs.difference(intended_subs) to_unsubscribe = current_subs.difference(intended_subs)
@ -58,19 +58,19 @@ class MultiList:
# Perform the actual list modifications in an order which avoids race conditions # Perform the actual list modifications in an order which avoids race conditions
for key in to_add: for key in to_add:
self._api.post_key(key, target_list) self._api.post_key(key, target_list)
self._logger.info(f'Added key: {key}') self._logger.info(f'Added key: {key}')
for sub in to_subscribe: for sub in to_subscribe:
self._api.subscribe(sub, target_list) self._api.subscribe(sub, target_list)
self._logger.info(f'Subscribed user: {sub}') self._logger.info(f'Subscribed user: {sub}')
for sub in to_update: for sub in to_update:
self._api.update_fingerprint(sub, target_list) self._api.update_fingerprint(sub, target_list)
self._logger.info(f'Updated key: {sub}') self._logger.info(f'Updated key: {sub}')
for sub in to_unsubscribe: for sub in to_unsubscribe:
self._api.unsubscribe(sub, target_list) self._api.unsubscribe(sub, target_list)
self._logger.info(f'Unsubscribed user: {sub}') self._logger.info(f'Unsubscribed user: {sub}')
for key in to_remove: for key in to_remove:
self._api.delete_key(key, target_list) self._api.delete_key(key, target_list)
self._logger.info(f'Removed key: {key}') self._logger.info(f'Removed key: {key}')
# Finally, send any queued key conflict messages. # Finally, send any queued key conflict messages.
self._kcr.send_messages() self._kcr.send_messages()
if len(to_add) + len(to_subscribe) + len(to_unsubscribe) + len(to_remove) == 0: if len(to_add) + len(to_subscribe) + len(to_unsubscribe) + len(to_remove) == 0:

View file

@ -79,6 +79,21 @@ _SUBSCRIBER_RESPONSE = '''
] ]
''' '''
_SUBSCRIBER_RESPONSE_NOKEY = '''
[
{
"id": 24,
"list_id": 42,
"email": "foo@example.org",
"fingerprint": "",
"admin": false,
"delivery_enabled": true,
"created_at": "2022-04-15T01:11:12.123Z",
"updated_at": "2022-04-15T01:11:12.123Z"
}
]
'''
_KEY_RESPONSE = ''' _KEY_RESPONSE = '''
{ {
"fingerprint": "2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6", "fingerprint": "2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6",
@ -96,7 +111,7 @@ _KEY_RESPONSE = '''
class TestSchleuderApi(unittest.TestCase): class TestSchleuderApi(unittest.TestCase):
def _mock_api(self, mock): def _mock_api(self, mock, nokey=False):
m = MagicMock() m = MagicMock()
m.getcode.return_value = 200 m.getcode.return_value = 200
@ -105,6 +120,8 @@ class TestSchleuderApi(unittest.TestCase):
if '/lists' in url: if '/lists' in url:
return _LIST_RESPONSE.encode() return _LIST_RESPONSE.encode()
if '/subscriptions' in url: if '/subscriptions' in url:
if nokey:
return _SUBSCRIBER_RESPONSE_NOKEY.encode()
return _SUBSCRIBER_RESPONSE.encode() return _SUBSCRIBER_RESPONSE.encode()
if '/keys' in url: if '/keys' in url:
return _KEY_RESPONSE.encode() return _KEY_RESPONSE.encode()
@ -147,6 +164,21 @@ class TestSchleuderApi(unittest.TestCase):
self.assertIn('-----BEGIN PGP PUBLIC KEY BLOCK-----', subs[0].key.blob) self.assertIn('-----BEGIN PGP PUBLIC KEY BLOCK-----', subs[0].key.blob)
self.assertEqual(42, subs[0].key.schleuder) self.assertEqual(42, subs[0].key.schleuder)
@patch('urllib.request.urlopen')
def test_get_subscribers_nokey(self, mock):
api = self._mock_api(mock, nokey=True)
subs = api.get_subscribers(SchleuderList(42, '', ''))
# Test request data
self.assertEqual('https://localhost:4443/subscriptions.json?list_id=42',
mock.call_args_list[0][0][0].get_full_url())
self.assertEqual(1, len(subs))
self.assertEqual(24, subs[0].id)
self.assertEqual('foo@example.org', subs[0].email)
self.assertEqual(42, subs[0].schleuder)
self.assertEqual(datetime(2022, 4, 15, 1, 11, 12, 123000, tzinfo=tzutc()),
subs[0].created_at)
self.assertIsNone(subs[0].key)
@patch('urllib.request.urlopen') @patch('urllib.request.urlopen')
def test_get_subscriber(self, mock): def test_get_subscriber(self, mock):
api = self._mock_api(mock) api = self._mock_api(mock)
@ -159,6 +191,14 @@ class TestSchleuderApi(unittest.TestCase):
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
api.get_subscriber('bar@example.org', SchleuderList(42, '', '')) api.get_subscriber('bar@example.org', SchleuderList(42, '', ''))
@patch('urllib.request.urlopen')
def test_get_subscriber_nokey(self, mock):
api = self._mock_api(mock, nokey=True)
sub = api.get_subscriber('foo@example.org', SchleuderList(42, '', ''))
self.assertEqual(24, sub.id)
self.assertEqual('foo@example.org', sub.email)
self.assertIsNone(sub.key)
@patch('urllib.request.urlopen') @patch('urllib.request.urlopen')
def test_subscribe(self, mock): def test_subscribe(self, mock):
api = self._mock_api(mock) api = self._mock_api(mock)
@ -171,6 +211,14 @@ class TestSchleuderApi(unittest.TestCase):
self.assertEqual('POST', mock.call_args_list[-1][0][0].method) self.assertEqual('POST', mock.call_args_list[-1][0][0].method)
# todo assert request payload # todo assert request payload
@patch('urllib.request.urlopen')
def test_subscribe_nokey(self, mock):
api = self._mock_api(mock)
now = datetime.utcnow()
sub = SchleuderSubscriber(23, 'foo@example.org', None, 42, now)
with self.assertRaises(ValueError):
api.subscribe(sub, SchleuderList(42, '', ''))
@patch('urllib.request.urlopen') @patch('urllib.request.urlopen')
def test_unsubscribe(self, mock): def test_unsubscribe(self, mock):
api = self._mock_api(mock) api = self._mock_api(mock)
@ -195,6 +243,14 @@ class TestSchleuderApi(unittest.TestCase):
self.assertEqual('PATCH', mock.call_args_list[-1][0][0].method) self.assertEqual('PATCH', mock.call_args_list[-1][0][0].method)
# todo assert request payload # todo assert request payload
@patch('urllib.request.urlopen')
def test_update_fingerprint_nokey(self, mock):
api = self._mock_api(mock)
now = datetime.utcnow()
sub = SchleuderSubscriber(23, 'foo@example.org', None, 42, now)
with self.assertRaises(ValueError):
api.update_fingerprint(sub, SchleuderList(42, '', ''))
@patch('urllib.request.urlopen') @patch('urllib.request.urlopen')
def test_get_key(self, mock): def test_get_key(self, mock):
api = self._mock_api(mock) api = self._mock_api(mock)
@ -234,13 +290,13 @@ class TestSchleuderApi(unittest.TestCase):
key = SchleuderKey('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', 'foo@example.org', 'verylongpgpkeyblock', 42) key = SchleuderKey('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', 'foo@example.org', 'verylongpgpkeyblock', 42)
sub = SchleuderSubscriber(23, 'foo@example.org', key, 42, now) sub = SchleuderSubscriber(23, 'foo@example.org', key, 42, now)
sch = SchleuderList(42, '', '') sch = SchleuderList(42, '', '')
# create, update, delete should be no-ops; 2 requests for retrieving the subscriptions in unsub & update # create, update, delete should be no-ops; 5 requests for retrieving the keys & subscriptions in unsub & update
api.subscribe(sub, sch) api.subscribe(sub, sch)
api.unsubscribe(sub, sch) api.unsubscribe(sub, sch)
api.update_fingerprint(sub, sch) api.update_fingerprint(sub, sch)
api.post_key(key, SchleuderList(42, '', '')) api.post_key(key, SchleuderList(42, '', ''))
api.delete_key(key, SchleuderList(42, '', '')) api.delete_key(key, SchleuderList(42, '', ''))
self.assertGreater(3, len(mock.call_args_list)) self.assertGreater(5, len(mock.call_args_list))
# only reads should execute # only reads should execute
api.get_lists() api.get_lists()
api.get_subscribers(sch) api.get_subscribers(sch)

View file

@ -182,6 +182,40 @@ class TestKeyConflictResolution(unittest.TestCase):
self.assertEqual('test@schleuder.example.org', payload['schleuder']) self.assertEqual('test@schleuder.example.org', payload['schleuder'])
self.assertEqual('135AFA0FB3FF584828911208B7913308392972A4 foo@example.org', payload['chosen']) self.assertEqual('135AFA0FB3FF584828911208B7913308392972A4 foo@example.org', payload['chosen'])
def test_conflict_one_nullkey(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 has no key, it it will be discarded
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
sub2 = SchleuderSubscriber(7, 'foo@example.org', None, sch2.id, date2)
kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE)
resolved = kcr.resolve(
target='test@schleuder.example.org',
mail_from='test-owner@schleuder.example.org',
subscriptions=[sub1, sub2])
self.assertEqual(1, len(resolved))
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', resolved[0].key.fingerprint)
# Null key should not trigger a confict
self.assertEqual(0, len(kcr._messages))
def test_conflict_only_nullkeys(self):
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
sub1 = SchleuderSubscriber(3, 'foo@example.org', None, sch1.id, date1)
kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE)
resolved = kcr.resolve(
target='test@schleuder.example.org',
mail_from='test-owner@schleuder.example.org',
subscriptions=[sub1])
self.assertEqual(0, len(resolved))
def test_send_messages_nostate(self): def test_send_messages_nostate(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)
@ -326,3 +360,34 @@ class TestKeyConflictResolution(unittest.TestCase):
mock_smtp.send_message.assert_not_called() mock_smtp.send_message.assert_not_called()
contents.seek(0) contents.seek(0)
self.assertEqual(_CONFLICT_STATE_STALE, contents.read()) self.assertEqual(_CONFLICT_STATE_STALE, contents.read())
def test_send_messages_nullkey(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 its key will be preferred
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
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())
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
# This subscription does not have a key, so it will not be considered
sch3 = SchleuderList(7, 'test-east@schleuder.example.org', '36C545D9C88AD0E7EFEF18DE06ACC53D5820EBC0')
date3 = datetime(2022, 4, 11, 5, 23, 42, 0, tzinfo=tzutc())
sub3 = SchleuderSubscriber(7, 'foo@example.org', None, sch3.id, date3)
mock_smtp = MagicMock()
mock_smtp.__enter__.return_value = mock_smtp
kcr = KeyConflictResolution(mock_smtp, 3600, '/tmp/state.json', _TEMPLATE)
resolved = kcr.resolve(
target='test@schleuder.example.org',
mail_from='test-owner@schleuder.example.org',
subscriptions=[sub1, sub2, sub3])
self.assertEqual(1, len(kcr._messages))
msg = kcr._messages[0].mime
pgp = pgpy.PGPMessage.from_blob(msg.get_payload()[1].get_payload(decode=True))
# Verify that the message is encrypted with both keys
self.assertEqual(2, len(pgp.encrypters))
dec1 = _PRIVKEY_1.decrypt(pgp)
dec2 = _PRIVKEY_2.decrypt(pgp)
self.assertEqual(dec1.message, dec2.message)

View file

@ -0,0 +1,210 @@
from typing import Dict, List
import unittest
from datetime import datetime
from unittest.mock import MagicMock
from dateutil.tz import tzutc
from multischleuder.processor import MultiList
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
def _resolve(target: str,
mail_from: str,
subscriptions: List[SchleuderSubscriber]) -> List[SchleuderSubscriber]:
# Mock conflict resolution that does not send or prepare messages
subs: Dict[str, List[SchleuderSubscriber]] = {}
for s in subscriptions:
subs.setdefault(s.email, []).append(s)
return [min(s, key=lambda x: x.created_at) for s in subs.values()]
def _list_lists():
return [
SchleuderList(1, 'test@schleuder.example.org', '0CD581127A178F1931909B14B7CC98DA082D2A64'),
SchleuderList(2, 'test-global@schleuder.example.org', 'F60B441065D485224E0F53EBBEAD4E67512CB645'),
SchleuderList(3, 'test-north@schleuder.example.org', '552BB62D10424FF169498B3D183C27F29DAAFDFB'),
SchleuderList(4, 'test-south@schleuder.example.org', '552BB62D10424FF169498B3D183C27F29DAAFDFB'),
SchleuderList(5, 'test-east@schleuder.example.org', '552BB62D10424FF169498B3D183C27F29DAAFDFB'),
SchleuderList(6, 'test-west@schleuder.example.org', '552BB62D10424FF169498B3D183C27F29DAAFDFB'),
SchleuderList(7, 'test2-global@schleuder.example.org', 'A821095FB503836134D54138D3306C629C0DDFB5')
]
def _get_key(fpr: str, schleuder: SchleuderList):
key1 = SchleuderKey('966842467B3254143F994D5E5C408C012D216471',
'admin@example.org', 'BEGIN PGP 2D216471', schleuder.id)
key2 = SchleuderKey('6449FFB6EE68187962FA013B5CA2F4F51791BAF6',
'ada.lovelace@example.org', 'BEGIN PGP 1791BAF6', schleuder.id)
key3 = SchleuderKey('414D3960D34730F63C74D5190EBC5A16716DEC79',
'alex.example@example.org', 'BEGIN PGP 716DEC79', schleuder.id)
key4 = SchleuderKey('8258FAF8B161B3DD8F784874F73E2DDF045AE2D6',
'aspammer@example.org', 'BEGIN PGP 045AE2D6', schleuder.id)
key5 = SchleuderKey('3699B7549B2A78F27AC93420FB6AD038C95558E5',
'anna.example@example.org', 'BEGIN PGP C95558E5', schleuder.id)
key6 = SchleuderKey('698118749D1490C9822FA1D9600E77BC529681F9',
'anna.example@example.org', 'BEGIN PGP 529681F9', schleuder.id)
key7 = SchleuderKey('BE11EE506C39B496A18A1FF56B3D3C0C9233D3E3',
'anotherspammer@example.org', 'BEGIN PGP 9233D3E3', schleuder.id)
key8 = SchleuderKey('73E6C1170DA158D0139EF1F2349F17BB8C89A2B9',
'andy.example@example.org', 'BEGIN PGP 8C89A2B9', schleuder.id)
key9 = SchleuderKey('C8C77172C31A796C563920C9358EBEA0F7662478',
'aaron.example@example.org', 'BEGIN PGP F7662478', schleuder.id)
return {
'966842467B3254143F994D5E5C408C012D216471': key1,
'6449FFB6EE68187962FA013B5CA2F4F51791BAF6': key2,
'414D3960D34730F63C74D5190EBC5A16716DEC79': key3,
'8258FAF8B161B3DD8F784874F73E2DDF045AE2D6': key4,
'3699B7549B2A78F27AC93420FB6AD038C95558E5': key5,
'698118749D1490C9822FA1D9600E77BC529681F9': key6,
'BE11EE506C39B496A18A1FF56B3D3C0C9233D3E3': key7,
'73E6C1170DA158D0139EF1F2349F17BB8C89A2B9': key8,
'C8C77172C31A796C563920C9358EBEA0F7662478': key9,
}[fpr]
def _get_subs(schleuder: SchleuderList):
key1 = SchleuderKey('966842467B3254143F994D5E5C408C012D216471',
'admin@example.org', 'BEGIN PGP 2D216471', schleuder.id)
key2 = SchleuderKey('6449FFB6EE68187962FA013B5CA2F4F51791BAF6',
'ada.lovelace@example.org', 'BEGIN PGP 1791BAF6', schleuder.id)
key3 = SchleuderKey('414D3960D34730F63C74D5190EBC5A16716DEC79',
'alex.example@example.org', 'BEGIN PGP 716DEC79', schleuder.id)
key4 = SchleuderKey('8258FAF8B161B3DD8F784874F73E2DDF045AE2D6',
'aspammer@example.org', 'BEGIN PGP 045AE2D6', schleuder.id)
key5 = SchleuderKey('3699B7549B2A78F27AC93420FB6AD038C95558E5',
'anna.example@example.org', 'BEGIN PGP C95558E5', schleuder.id)
key6 = SchleuderKey('698118749D1490C9822FA1D9600E77BC529681F9',
'anna.example@example.org', 'BEGIN PGP 529681F9', schleuder.id)
key7 = SchleuderKey('BE11EE506C39B496A18A1FF56B3D3C0C9233D3E3',
'anotherspammer@example.org', 'BEGIN PGP 9233D3E3', schleuder.id)
key8 = SchleuderKey('73E6C1170DA158D0139EF1F2349F17BB8C89A2B9',
'andy.example@example.org', 'BEGIN PGP 8C89A2B9', schleuder.id)
key9 = SchleuderKey('C8C77172C31A796C563920C9358EBEA0F7662478',
'aaron.example@example.org', 'BEGIN PGP F7662478', schleuder.id)
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
if schleuder.id == 2:
return [
# admin should be ignored (unmanaged)
SchleuderSubscriber(0, 'admin@example.org', key1, schleuder.id, date1),
# ada.lovelace should be unsubscribed
SchleuderSubscriber(1, 'ada.lovelace@example.org', key2, schleuder.id, date1),
# alex.example already subscribed - no change
SchleuderSubscriber(2, 'alex.example@example.org', key3, schleuder.id, date1),
# aspammer should be unsubscribed (banned)
SchleuderSubscriber(3, 'aspammer@example.org', key4, schleuder.id, date1),
# anna.example's key should be updated, but the subscrption should stay unchanged
SchleuderSubscriber(4, 'anna.example@example.org', key5, schleuder.id, date1),
]
elif schleuder.id == 3:
return [
SchleuderSubscriber(5, 'alex.example@example.org', key3, schleuder.id, date1),
SchleuderSubscriber(6, 'aspammer@example.org', key4, schleuder.id, date1),
]
elif schleuder.id == 4:
return [
SchleuderSubscriber(7, 'anna.example@example.org', key6, schleuder.id, date1),
SchleuderSubscriber(8, 'anotherspammer@example.org', key7, schleuder.id, date1),
]
elif schleuder.id == 5:
return [
SchleuderSubscriber(9, 'andy.example@example.org', key8, schleuder.id, date1),
]
elif schleuder.id == 6:
return [
SchleuderSubscriber(10, 'aaron.example@example.org', key9, schleuder.id, date2),
]
else:
return []
def _get_sub(email: str, schleuder: SchleuderList):
subs = _get_subs(schleuder)
return [s for s in subs if s.email == email][0]
class TestMultiList(unittest.TestCase):
def _kcr_mock(self):
kcr = MagicMock()
kcr.resolve = _resolve
return kcr
def _api_mock(self):
api = MagicMock()
api.get_lists = _list_lists
api.get_subscribers = _get_subs
api.get_subscriber = _get_sub
api.get_key = _get_key
return api
def test_create(self):
sources = [
'test-north@schleuder.example.org',
'test-east@schleuder.example.org',
'test-south@schleuder.example.org',
'test-west@schleuder.example.org'
]
api = self._api_mock()
ml = MultiList(sources=sources,
target='test-global@schleuder.example.org',
unmanaged=['admin@example.org'],
banned=['aspammer@example.org', 'anotherspammer@example.org'],
mail_from='test-global-owner@schleuder.example.org',
api=api,
kcr=self._kcr_mock())
ml.process()
# Key uploads
self.assertEqual(3, len(api.post_key.call_args_list))
a, b, c = sorted([x[0] for x in api.post_key.call_args_list], key=lambda x: x[0].fingerprint)
self.assertEqual(2, a[1].id)
self.assertEqual('anna.example@example.org', a[0].email)
self.assertEqual('698118749D1490C9822FA1D9600E77BC529681F9', a[0].fingerprint)
self.assertEqual(2, b[1].id)
self.assertEqual('andy.example@example.org', b[0].email)
self.assertEqual('73E6C1170DA158D0139EF1F2349F17BB8C89A2B9', b[0].fingerprint)
self.assertEqual(2, c[1].id)
self.assertEqual('aaron.example@example.org', c[0].email)
self.assertEqual('C8C77172C31A796C563920C9358EBEA0F7662478', c[0].fingerprint)
# Subscriptions
self.assertEqual(2, len(api.subscribe.call_args_list))
a, b = sorted([x[0] for x in api.subscribe.call_args_list], key=lambda x: x[0].email)
self.assertEqual(2, a[1].id)
self.assertEqual('aaron.example@example.org', a[0].email)
self.assertEqual('C8C77172C31A796C563920C9358EBEA0F7662478', a[0].key.fingerprint)
self.assertEqual(2, b[1].id)
self.assertEqual('andy.example@example.org', b[0].email)
self.assertEqual('73E6C1170DA158D0139EF1F2349F17BB8C89A2B9', b[0].key.fingerprint)
# Key updates
self.assertEqual(1, len(api.update_fingerprint.call_args_list))
a = api.update_fingerprint.call_args_list[0][0]
self.assertEqual(2, a[1].id)
self.assertEqual('anna.example@example.org', a[0].email)
self.assertEqual('698118749D1490C9822FA1D9600E77BC529681F9', a[0].key.fingerprint)
# Unsubscribes
self.assertEqual(2, len(api.unsubscribe.call_args_list))
a, b = sorted([x[0] for x in api.unsubscribe.call_args_list], key=lambda x: x[0].email)
self.assertEqual(2, a[1].id)
self.assertEqual('ada.lovelace@example.org', a[0].email)
self.assertEqual(2, b[1].id)
self.assertEqual('aspammer@example.org', b[0].email)
# Key deletions
self.assertEqual(3, len(api.delete_key.call_args_list))
a, b, c = sorted([x[0] for x in api.delete_key.call_args_list], key=lambda x: x[0].fingerprint)
self.assertEqual(2, a[1].id)
self.assertEqual('anna.example@example.org', a[0].email)
self.assertEqual('3699B7549B2A78F27AC93420FB6AD038C95558E5', a[0].fingerprint)
self.assertEqual(2, b[1].id)
self.assertEqual('ada.lovelace@example.org', b[0].email)
self.assertEqual('6449FFB6EE68187962FA013B5CA2F4F51791BAF6', b[0].fingerprint)
self.assertEqual(2, c[1].id)
self.assertEqual('aspammer@example.org', c[0].email)
self.assertEqual('8258FAF8B161B3DD8F784874F73E2DDF045AE2D6', c[0].fingerprint)

View file

@ -35,3 +35,14 @@ class TestSchleuderTypes(unittest.TestCase):
self.assertEqual(datetime.datetime(2022, 4, 15, 1, 11, 12, 123000, tzinfo=tzutc()), self.assertEqual(datetime.datetime(2022, 4, 15, 1, 11, 12, 123000, tzinfo=tzutc()),
s.created_at) s.created_at)
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', s.key.fingerprint) self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', s.key.fingerprint)
self.assertIn('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6 (foo@example.org)', str(k))
def test_parse_subscriber_nokey(self):
s = SchleuderSubscriber.from_api(None, **json.loads(_SUBSCRIBER_RESPONSE)[0])
self.assertEqual(23, s.id)
self.assertEqual('foo@example.org', str(s))
self.assertEqual('foo@example.org', s.email)
self.assertEqual(42, s.schleuder)
self.assertEqual(datetime.datetime(2022, 4, 15, 1, 11, 12, 123000, tzinfo=tzutc()),
s.created_at)
self.assertIsNone(s.key)

View file

@ -1,10 +1,12 @@
from typing import Optional
from dataclasses import dataclass, field, Field from dataclasses import dataclass, field, Field
from datetime import datetime from datetime import datetime
from dateutil.parser import isoparse from dateutil.parser import isoparse
@dataclass @dataclass(frozen=True)
class SchleuderList: class SchleuderList:
id: int = field(compare=True) id: int = field(compare=True)
name: str = field(compare=False) name: str = field(compare=False)
@ -18,16 +20,16 @@ class SchleuderList:
return SchleuderList(id, email, fingerprint) return SchleuderList(id, email, fingerprint)
@dataclass @dataclass(frozen=True)
class SchleuderSubscriber: class SchleuderSubscriber:
id: int = field(compare=False) id: int = field(compare=False)
email: str = field(compare=True) email: str = field(compare=True)
key: 'SchleuderKey' = field(compare=False) key: Optional['SchleuderKey'] = field(compare=False)
schleuder: int = field(compare=False) schleuder: int = field(compare=False)
created_at: datetime = field(compare=False) created_at: datetime = field(compare=False)
@staticmethod @staticmethod
def from_api(key: 'SchleuderKey', def from_api(key: Optional['SchleuderKey'],
id: int, id: int,
list_id: int, list_id: int,
email: str, email: str,
@ -40,7 +42,7 @@ class SchleuderSubscriber:
return f'{self.email}' return f'{self.email}'
@dataclass @dataclass(frozen=True)
class SchleuderKey: class SchleuderKey:
fingerprint: str = field(compare=True) fingerprint: str = field(compare=True)
email: str = field(compare=True) email: str = field(compare=True)