diff options
| author | Barry Warsaw | 2015-12-30 14:34:18 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2015-12-30 14:38:10 -0500 |
| commit | dac929973fbfc7ac4c807afafc22b992510b4e6d (patch) | |
| tree | 7deb45c8500cf099215fce8e61055b7e1242fb57 /src | |
| parent | 8e69b848270da6ba4852f8c6dfdeeed8124ab024 (diff) | |
| download | mailman-dac929973fbfc7ac4c807afafc22b992510b4e6d.tar.gz mailman-dac929973fbfc7ac4c807afafc22b992510b4e6d.tar.zst mailman-dac929973fbfc7ac4c807afafc22b992510b4e6d.zip | |
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 4 | ||||
| -rw-r--r-- | src/mailman/rest/addresses.py | 11 | ||||
| -rw-r--r-- | src/mailman/rest/docs/addresses.rst | 4 | ||||
| -rw-r--r-- | src/mailman/rest/domains.py | 4 | ||||
| -rw-r--r-- | src/mailman/rest/helpers.py | 2 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 8 | ||||
| -rw-r--r-- | src/mailman/rest/members.py | 54 | ||||
| -rw-r--r-- | src/mailman/rest/preferences.py | 2 | ||||
| -rw-r--r-- | src/mailman/rest/root.py | 30 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_addresses.py | 105 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_membership.py | 151 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_users.py | 78 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_validator.py | 35 | ||||
| -rw-r--r-- | src/mailman/rest/users.py | 54 | ||||
| -rw-r--r-- | src/mailman/rest/validator.py | 27 | ||||
| -rw-r--r-- | src/mailman/testing/documentation.py | 8 |
16 files changed, 497 insertions, 80 deletions
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 848106a86..e74a01d44 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -54,6 +54,8 @@ Bugs store. Given by Aurélien Bompard, tweaked by Barry Warsaw. (Closes: #167) * Fix a bug in ``SubscriptionService.find_members()`` when searching for a subscribed address that is not linked to a user. Given by Aurélien Bompard. + * Fix a REST server crash when trying to subscribe a user without a preferred + address. (Closes #185) Configuration ------------- @@ -101,7 +103,7 @@ REST * REST API version 3.1 introduced. Mostly backward compatible with version 3.0 except that UUIDs are represented as hex strings instead of 128-bit integers, since the latter are not compatible with all versions of - JavaScript. + JavaScript. (Closes #121) * When creating a user via REST using an address that already exists, but isn't linked, the address is linked to the new user. Given by Aurélien Bompard. diff --git a/src/mailman/rest/addresses.py b/src/mailman/rest/addresses.py index 2bc13e1cc..bc9324971 100644 --- a/src/mailman/rest/addresses.py +++ b/src/mailman/rest/addresses.py @@ -51,7 +51,7 @@ class _AddressBase(CollectionMixin): email=address.email, original_email=address.original_email, registered_on=address.registered_on, - self_link=self.path_to('addresses/{0}'.format(address.email)), + self_link=self.path_to('addresses/{}'.format(address.email)), ) # Add optional attributes. These can be None or the empty string. if address.display_name: @@ -59,8 +59,9 @@ class _AddressBase(CollectionMixin): if address.verified_on: representation['verified_on'] = address.verified_on if address.user: - representation['user'] = self.path_to( - 'users/{0}'.format(address.user.user_id.int)) + uid = getattr(address.user.user_id, + 'int' if self.api_version == '3.0' else 'hex') + representation['user'] = self.path_to('users/{}'.format(uid)) return representation def _get_collection(self, request): @@ -139,7 +140,7 @@ class AnAddress(_AddressBase): return NotFound(), [] child = Preferences( self._address.preferences, - 'addresses/{0}'.format(self._address.email)) + 'addresses/{}'.format(self._address.email)) return child, [] @child() @@ -177,8 +178,8 @@ class UserAddresses(_AddressBase): """The addresses of a user.""" def __init__(self, user): - self._user = user super().__init__() + self._user = user def _get_collection(self, request): """See `CollectionMixin`.""" diff --git a/src/mailman/rest/docs/addresses.rst b/src/mailman/rest/docs/addresses.rst index 6fbe84972..c850503c4 100644 --- a/src/mailman/rest/docs/addresses.rst +++ b/src/mailman/rest/docs/addresses.rst @@ -175,10 +175,10 @@ A link to the user resource is now available as a sub-resource. user: http://localhost:9001/3.0/users/1 To prevent automatic user creation from taking place, add the `auto_create` -parameter to the POST request and set it to a false-equivalent value like 0: +parameter to the POST request and set it to False. >>> dump_json('http://localhost:9001/3.0/addresses/anne@example.com/user', - ... {'display_name': 'Anne User', 'auto_create': 0}) + ... {'display_name': 'Anne User', 'auto_create': False}) Traceback (most recent call last): ... urllib.error.HTTPError: HTTP Error 403: ... diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py index 751dfb407..a1e0d811e 100644 --- a/src/mailman/rest/domains.py +++ b/src/mailman/rest/domains.py @@ -44,7 +44,7 @@ class _DomainBase(CollectionMixin): base_url=domain.base_url, description=domain.description, mail_host=domain.mail_host, - self_link=self.path_to('domains/{0}'.format(domain.mail_host)), + self_link=self.path_to('domains/{}'.format(domain.mail_host)), url_host=domain.url_host, ) @@ -125,7 +125,7 @@ class AllDomains(_DomainBase): except ValueError as error: bad_request(response, str(error)) else: - location = self.path_to('domains/{0}'.format(domain.mail_host)) + location = self.path_to('domains/{}'.format(domain.mail_host)) created(response, location) def on_get(self, request, response): diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py index e20324cfd..e519e4d7f 100644 --- a/src/mailman/rest/helpers.py +++ b/src/mailman/rest/helpers.py @@ -111,7 +111,7 @@ def etag(resource): # library requires a bytes. Use the safest possible encoding. hashfood = pformat(resource).encode('raw-unicode-escape') etag = hashlib.sha1(hashfood).hexdigest() - resource['http_etag'] = '"{0}"'.format(etag) + resource['http_etag'] = '"{}"'.format(etag) return json.dumps(resource, cls=ExtendedEncoder, sort_keys=as_boolean(config.devmode.enabled)) diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index f6c9716f3..2210d41b1 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -111,7 +111,7 @@ class _ListBase(CollectionMixin): mail_host=mlist.mail_host, member_count=mlist.members.member_count, volume=mlist.volume, - self_link=self.path_to('lists/{0}'.format(mlist.list_id)), + self_link=self.path_to('lists/{}'.format(mlist.list_id)), ) def _get_collection(self, request): @@ -157,7 +157,7 @@ class AList(_ListBase): if len(members) == 0: return NotFound(), [] assert len(members) == 1, 'Too many matches' - return AMember(members[0].member_id) + return AMember(request.context['api_version'], members[0].member_id) @child(roster_matcher) def roster(self, request, segments, role): @@ -234,7 +234,7 @@ class MembersOfList(MemberCollection): """The members of a mailing list.""" def __init__(self, mailing_list, role): - super(MembersOfList, self).__init__() + super().__init__() self._mlist = mailing_list self._role = role @@ -268,7 +268,7 @@ class ArchiverGetterSetter(GetterSetter): """Resource for updating archiver statuses.""" def __init__(self, mlist): - super(ArchiverGetterSetter, self).__init__() + super().__init__() self._archiver_set = IListArchiverSet(mlist) def put(self, mlist, attribute, value): diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index 88fa352a3..4e9668bb1 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -31,7 +31,7 @@ from mailman.interfaces.address import IAddress, InvalidEmailAddressError from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError, - MembershipIsBannedError, NotAMemberError) + MembershipIsBannedError, MissingPreferredAddressError, NotAMemberError) from mailman.interfaces.registrar import IRegistrar from mailman.interfaces.subscriptions import ( ISubscriptionService, RequestRecord, TokenOwner) @@ -52,16 +52,21 @@ from zope.component import getUtility class _MemberBase(CollectionMixin): """Shared base class for member representations.""" + def _get_uuid(self, member): + return getattr(member.member_id, + 'int' if self.api_version == '3.0' else 'hex') + def _resource_as_dict(self, member): """See `CollectionMixin`.""" enum, dot, role = str(member.role).partition('.') # The member will always have a member id and an address id. It will # only have a user id if the address is linked to a user. # E.g. nonmembers we've only seen via postings to lists they are not - # subscribed to will not have a user id. The user_id and the - # member_id are UUIDs. We need to use the integer equivalent in the - # URL. - member_id = member.member_id.int + # subscribed to will not have a user id. The user_id and the + # member_id are UUIDs. In API 3.0 we use the integer equivalent of + # the UID in the URL, but in API 3.1 we use the hex equivalent. See + # issue #121 for details. + member_id = self._get_uuid(member) response = dict( address=self.path_to('addresses/{}'.format(member.address.email)), delivery_mode=member.delivery_mode, @@ -75,8 +80,9 @@ class _MemberBase(CollectionMixin): # Add the user link if there is one. user = member.user if user is not None: - response['user'] = self.path_to( - 'users/{}'.format(user.user_id.int)) + user_id = getattr(user.user_id, + 'int' if self.api_version == '3.0' else 'hex') + response['user'] = self.path_to('users/{}'.format(user_id)) return response def _get_collection(self, request): @@ -106,13 +112,18 @@ class MemberCollection(_MemberBase): class AMember(_MemberBase): """A member.""" - def __init__(self, member_id_string): - # REST gives us the member id as the string of an int; we have to - # convert it to a UUID. + def __init__(self, api_version, member_id_string): + # The member_id_string is the string representation of the member's + # UUID. In API 3.0, the argument is the string representation of the + # int representation of the UUID. In API 3.1 it's the hex. + self.api_version = api_version try: - member_id = UUID(int=int(member_id_string)) + if api_version == '3.0': + member_id = UUID(int=int(member_id_string)) + else: + member_id = UUID(hex=member_id_string) except ValueError: - # The string argument could not be converted to an integer. + # The string argument could not be converted to a UUID. self._member = None else: service = getUtility(ISubscriptionService) @@ -132,9 +143,9 @@ class AMember(_MemberBase): return NotFound(), [] if self._member is None: return NotFound(), [] + member_id = self._get_uuid(self._member) child = Preferences( - self._member.preferences, - 'members/{0}'.format(self._member.member_id.int)) + self._member.preferences, 'members/{}'.format(member_id)) return child, [] @child() @@ -146,7 +157,7 @@ class AMember(_MemberBase): return NotFound(), [] child = ReadOnlyPreferences( self._member, - 'members/{0}/all'.format(self._member.member_id.int)) + 'members/{}/all'.format(self._get_uuid(self._member))) return child, [] def on_delete(self, request, response): @@ -213,7 +224,7 @@ class AllMembers(_MemberBase): try: validator = Validator( list_id=str, - subscriber=subscriber_validator, + subscriber=subscriber_validator(self.api_version), display_name=str, delivery_mode=enum_validator(DeliveryMode), role=enum_validator(MemberRole), @@ -276,14 +287,17 @@ class AllMembers(_MemberBase): except AlreadySubscribedError: conflict(response, b'Member already subscribed') return + except MissingPreferredAddressError: + bad_request(response, b'User has no preferred address') + return if token is None: assert token_owner is TokenOwner.no_one, token_owner # The subscription completed. Let's get the resulting member # and return the location to the new member. Member ids are # UUIDs and need to be converted to URLs because JSON doesn't # directly support UUIDs. - member_id = member.member_id.int - location = self.path_to('members/{0}'.format(member_id)) + member_id = self._get_uuid(member) + location = self.path_to('members/{}'.format(member_id)) created(response, location) return # The member could not be directly subscribed because there are @@ -332,8 +346,8 @@ class AllMembers(_MemberBase): # and return the location to the new member. Member ids are # UUIDs and need to be converted to URLs because JSON doesn't # directly support UUIDs. - member_id = member.member_id.int - location = self.path_to('members/{0}'.format(member_id)) + member_id = self._get_uuid(member) + location = self.path_to('members/{}'.format(member_id)) created(response, location) def on_get(self, request, response): diff --git a/src/mailman/rest/preferences.py b/src/mailman/rest/preferences.py index 8e13fd443..ecef4a774 100644 --- a/src/mailman/rest/preferences.py +++ b/src/mailman/rest/preferences.py @@ -65,7 +65,7 @@ class ReadOnlyPreferences: resource['preferred_language'] = preferred_language.code # Add the self link. resource['self_link'] = path_to( - '{0}/preferences'.format(self._base_url), + '{}/preferences'.format(self._base_url), self.api_version) okay(response, etag(resource)) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index 0682619b1..56c3716a0 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -178,10 +178,14 @@ class TopLevel: /<api>/addresses/<email> """ if len(segments) == 0: - return AllAddresses() + resource = AllAddresses() + resource.api_version = request.context['api_version'] + return resource else: email = segments.pop(0) - return AnAddress(email), segments + resource = AnAddress(email) + resource.api_version = request.context['api_version'] + return resource, segments @child() def domains(self, request, segments): @@ -214,24 +218,32 @@ class TopLevel: @child() def members(self, request, segments): """/<api>/members""" + api_version = request.context['api_version'] if len(segments) == 0: - return AllMembers() + resource = AllMembers() + resource.api_version = api_version + return resource # Either the next segment is the string "find" or a member id. They # cannot collide. segment = segments.pop(0) if segment == 'find': - return FindMembers(), segments + resource = FindMembers() + resource.api_version = api_version else: - return AMember(segment), segments + resource = AMember(api_version, segment) + return resource, segments @child() def users(self, request, segments): """/<api>/users""" + api_version = request.context['api_version'] if len(segments) == 0: - return AllUsers() + resource = AllUsers() + resource.api_version = api_version + return resource else: user_id = segments.pop(0) - return AUser(user_id), segments + return AUser(api_version, user_id), segments @child() def owners(self, request, segments): @@ -239,7 +251,9 @@ class TopLevel: if len(segments) != 0: return BadRequest(), [] else: - return ServerOwners(), segments + resource = ServerOwners() + resource.api_version = request.context['api_version'] + return resource, segments @child() def templates(self, request, segments): diff --git a/src/mailman/rest/tests/test_addresses.py b/src/mailman/rest/tests/test_addresses.py index 152ce4aa5..8f8dc95e6 100644 --- a/src/mailman/rest/tests/test_addresses.py +++ b/src/mailman/rest/tests/test_addresses.py @@ -18,6 +18,7 @@ """REST address tests.""" __all__ = [ + 'TestAPI31Addresses', 'TestAddresses', ] @@ -432,3 +433,107 @@ class TestAddresses(unittest.TestCase): 'http://localhost:9001/3.0/addresses/anne@example.com', method='DELETE') self.assertEqual(cm.exception.code, 404) + + + +class TestAPI31Addresses(unittest.TestCase): + """UUIDs are represented as hex instead of int in API 3.1 + + See issue #121 for details. This is a deliberately backward + incompatible change. + """ + + layer = RESTLayer + + def test_address_user_id_is_hex(self): + user_manager = getUtility(IUserManager) + with transaction(): + user_manager.create_user('anne@example.com', 'Anne') + response, headers = call_api( + 'http://localhost:9001/3.1/addresses/anne@example.com') + self.assertEqual( + response['user'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + + def test_addresses_user_ids_are_hex(self): + user_manager = getUtility(IUserManager) + with transaction(): + user_manager.create_user('anne@example.com', 'Anne') + user_manager.create_user('bart@example.com', 'Bart') + response, headers = call_api('http://localhost:9001/3.1/addresses') + entries = response['entries'] + self.assertEqual( + entries[0]['user'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + self.assertEqual( + entries[1]['user'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000002') + + def test_user_subresource_post(self): + # If the address is not yet linked to a user, POSTing a user id to the + # 'user' subresource links the address to the given user. In + # API 3.0, the user id must be the hex representation. + user_manager = getUtility(IUserManager) + with transaction(): + anne = user_manager.create_user('anne.person@example.org', 'Anne') + anne_addr = user_manager.create_address('anne@example.com') + response, headers = call_api( + 'http://localhost:9001/3.1/addresses/anne@example.com/user', { + 'user_id': anne.user_id.hex, + }) + self.assertEqual(headers['status'], '200') + self.assertEqual(anne_addr.user, anne) + self.assertEqual(sorted([a.email for a in anne.addresses]), + ['anne.person@example.org', 'anne@example.com']) + + def test_user_subresource_cannot_post_int(self): + # If the address is not yet linked to a user, POSTing a user id to the + # 'user' subresource links the address to the given user. In + # API 3.1, the user id must be the hex representation. + user_manager = getUtility(IUserManager) + with transaction(): + anne = user_manager.create_user('anne.person@example.org', 'Anne') + user_manager.create_address('anne@example.com') + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.1/addresses/anne@example.com/user', { + 'user_id': anne.user_id.int, + }) + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'badly formed hexadecimal UUID string') + + def test_user_subresource_put(self): + # By PUTing to the 'user' resource, you can change the user that an + # address is linked to. + user_manager = getUtility(IUserManager) + with transaction(): + anne = user_manager.create_user('anne@example.com', 'Anne') + bart = user_manager.create_user(display_name='Bart') + response, headers = call_api( + 'http://localhost:9001/3.1/addresses/anne@example.com/user', { + 'user_id': bart.user_id.hex, + }, method='PUT') + self.assertEqual(headers['status'], '200') + self.assertEqual(anne.addresses, []) + self.assertEqual([address.email for address in bart.addresses], + ['anne@example.com']) + self.assertEqual(bart, + user_manager.get_address('anne@example.com').user) + + def test_user_subresource_cannot_put_int(self): + # If the address is not yet linked to a user, POSTing a user id to the + # 'user' subresource links the address to the given user. In + # API 3.1, the user id must be the hex representation. + user_manager = getUtility(IUserManager) + with transaction(): + anne = user_manager.create_user('anne.person@example.org', 'Anne') + user_manager.create_address('anne@example.com') + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.1/addresses/anne@example.com/user', { + 'user_id': anne.user_id.int, + }, method='PUT') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'badly formed hexadecimal UUID string') diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py index a3f7646c2..1c27c538f 100644 --- a/src/mailman/rest/tests/test_membership.py +++ b/src/mailman/rest/tests/test_membership.py @@ -18,6 +18,7 @@ """REST membership tests.""" __all__ = [ + 'TestAPI31Members', 'TestMembership', 'TestNonmembership', ] @@ -28,10 +29,11 @@ import unittest from mailman.app.lifecycle import create_list from mailman.config import config from mailman.database.transaction import transaction +from mailman.interfaces.member import DeliveryMode from mailman.interfaces.usermanager import IUserManager from mailman.testing.helpers import ( TestableMaster, call_api, get_lmtp_client, make_testable_runner, - wait_for_webservice) + subscribe, wait_for_webservice) from mailman.runners.incoming import IncomingRunner from mailman.testing.layers import ConfigLayer, RESTLayer from mailman.utilities.datetime import now @@ -107,6 +109,21 @@ class TestMembership(unittest.TestCase): self.assertEqual(cm.exception.code, 409) self.assertEqual(cm.exception.reason, b'Member already subscribed') + def test_subscribe_user_without_preferred_address(self): + with transaction(): + getUtility(IUserManager).create_user('anne@example.com') + # Subscribe the user to the mailing list by hex UUID. + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.1/members', { + 'list_id': 'test.example.com', + 'subscriber': '00000000000000000000000000000001', + 'pre_verified': True, + 'pre_confirmed': True, + 'pre_approved': True, + }) + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, b'User has no preferred address') + def test_add_member_with_mixed_case_email(self): # LP: #1425359 - Mailman is case-perserving, case-insensitive. This # test subscribes the lower case address and ensures the original mixed @@ -371,3 +388,135 @@ Some text. # previously been linked to a user record. self.assertEqual(nonmember['user'], 'http://localhost:9001/3.0/users/1') + + + +class TestAPI31Members(unittest.TestCase): + layer = RESTLayer + + def setUp(self): + with transaction(): + self._mlist = create_list('ant@example.com') + + def test_member_ids_are_hex(self): + with transaction(): + subscribe(self._mlist, 'Anne') + subscribe(self._mlist, 'Bart') + response, headers = call_api('http://localhost:9001/3.1/members') + entries = response['entries'] + self.assertEqual(len(entries), 2) + self.assertEqual( + entries[0]['self_link'], + 'http://localhost:9001/3.1/members/00000000000000000000000000000001') + self.assertEqual( + entries[0]['member_id'], + '00000000000000000000000000000001') + self.assertEqual( + entries[0]['user'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + self.assertEqual( + entries[1]['self_link'], + 'http://localhost:9001/3.1/members/00000000000000000000000000000002') + self.assertEqual( + entries[1]['member_id'], + '00000000000000000000000000000002') + self.assertEqual( + entries[1]['user'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000002') + + def test_get_member_id_by_hex(self): + with transaction(): + subscribe(self._mlist, 'Anne') + response, headers = call_api( + 'http://localhost:9001/3.1/members/00000000000000000000000000000001') + self.assertEqual( + response['member_id'], + '00000000000000000000000000000001') + self.assertEqual( + response['self_link'], + 'http://localhost:9001/3.1/members/00000000000000000000000000000001') + self.assertEqual( + response['user'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + self.assertEqual( + response['address'], + 'http://localhost:9001/3.1/addresses/aperson@example.com') + + def test_cannot_get_member_id_by_int(self): + with transaction(): + subscribe(self._mlist, 'Anne') + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.1/members/1') + self.assertEqual(cm.exception.code, 404) + + def test_preferences(self): + with transaction(): + member = subscribe(self._mlist, 'Anne') + member.preferences.delivery_mode = DeliveryMode.summary_digests + response, headers = call_api( + 'http://localhost:9001/3.1/members' + '/00000000000000000000000000000001/preferences') + self.assertEqual(response['delivery_mode'], 'summary_digests') + + def test_all_preferences(self): + with transaction(): + member = subscribe(self._mlist, 'Anne') + member.preferences.delivery_mode = DeliveryMode.summary_digests + response, headers = call_api( + 'http://localhost:9001/3.1/members' + '/00000000000000000000000000000001/all/preferences') + self.assertEqual(response['delivery_mode'], 'summary_digests') + + def test_create_new_membership_by_hex(self): + with transaction(): + user = getUtility(IUserManager).create_user('anne@example.com') + _set_preferred(user) + # Subscribe the user to the mailing list by hex UUID. + response, headers = call_api( + 'http://localhost:9001/3.1/members', { + 'list_id': 'ant.example.com', + 'subscriber': '00000000000000000000000000000001', + 'pre_verified': True, + 'pre_confirmed': True, + 'pre_approved': True, + }) + self.assertEqual(headers.status, 201) + self.assertEqual( + headers['location'], + 'http://localhost:9001/3.1/members/00000000000000000000000000000001' + ) + + def test_create_new_owner_by_hex(self): + with transaction(): + user = getUtility(IUserManager).create_user('anne@example.com') + _set_preferred(user) + # Subscribe the user to the mailing list by hex UUID. + response, headers = call_api( + 'http://localhost:9001/3.1/members', { + 'list_id': 'ant.example.com', + 'subscriber': '00000000000000000000000000000001', + 'role': 'owner', + }) + self.assertEqual(headers.status, 201) + self.assertEqual( + headers['location'], + 'http://localhost:9001/3.1/members/00000000000000000000000000000001' + ) + + def test_cannot_create_new_membership_by_int(self): + with transaction(): + user = getUtility(IUserManager).create_user('anne@example.com') + _set_preferred(user) + # We can't use the int representation of the UUID with API 3.1. + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.1/members', { + 'list_id': 'ant.example.com', + '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 + # because that's reserved for URL lookups. + self.assertEqual(cm.exception.code, 400) diff --git a/src/mailman/rest/tests/test_users.py b/src/mailman/rest/tests/test_users.py index 6caaad401..2e79fd249 100644 --- a/src/mailman/rest/tests/test_users.py +++ b/src/mailman/rest/tests/test_users.py @@ -18,6 +18,7 @@ """REST user tests.""" __all__ = [ + 'TestAPI31Users', 'TestLP1074374', 'TestLP1419519', 'TestLogin', @@ -31,6 +32,7 @@ import unittest from mailman.app.lifecycle import create_list from mailman.config import config from mailman.database.transaction import transaction +from mailman.interfaces.member import DeliveryMode from mailman.interfaces.usermanager import IUserManager from mailman.testing.helpers import call_api, configuration from mailman.testing.layers import RESTLayer @@ -292,6 +294,16 @@ class TestUsers(unittest.TestCase): id=anne.preferences.id) self.assertEqual(preferences.count(), 0) + def test_preferences_self_link(self): + with transaction(): + user = getUtility(IUserManager).create_user('anne@example.com') + user.preferences.delivery_mode = DeliveryMode.summary_digests + content, response = call_api( + 'http://localhost:9001/3.0/users/1/preferences') + self.assertEqual( + content['self_link'], + 'http://localhost:9001/3.0/users/1/preferences') + class TestLogin(unittest.TestCase): @@ -502,3 +514,69 @@ class TestLP1419519(unittest.TestCase): config.db.abort() emails = sorted(address.email for address in self.manager.addresses) self.assertEqual(len(emails), 0) + + + +class TestAPI31Users(unittest.TestCase): + """UUIDs are represented as hex in API 3.1.""" + + layer = RESTLayer + + def test_get_user(self): + with transaction(): + getUtility(IUserManager).create_user('anne@example.com') + content, response = call_api( + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + self.assertEqual( + content['user_id'], '00000000000000000000000000000001') + self.assertEqual( + content['self_link'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + + def test_cannot_get_user_by_int(self): + with transaction(): + getUtility(IUserManager).create_user('anne@example.com') + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.1/users/1') + self.assertEqual(cm.exception.code, 404) + + def test_get_all_users(self): + user_manager = getUtility(IUserManager) + with transaction(): + user_manager.create_user('anne@example.com') + user_manager.create_user('bart@example.com') + content, response = call_api('http://localhost:9001/3.1/users') + entries = content['entries'] + self.assertEqual(len(entries), 2) + self.assertEqual( + entries[0]['user_id'], '00000000000000000000000000000001') + self.assertEqual( + entries[0]['self_link'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + self.assertEqual( + entries[1]['user_id'], '00000000000000000000000000000002') + self.assertEqual( + entries[1]['self_link'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000002') + + def test_create_user(self): + content, response = call_api( + 'http://localhost:9001/3.1/users', { + 'email': 'anne@example.com', + }) + self.assertEqual(response.status, 201) + self.assertEqual( + response['location'], + 'http://localhost:9001/3.1/users/00000000000000000000000000000001') + + def test_preferences_self_link(self): + with transaction(): + user = getUtility(IUserManager).create_user('anne@example.com') + user.preferences.delivery_mode = DeliveryMode.summary_digests + content, response = call_api( + 'http://localhost:9001/3.1/users' + '/00000000000000000000000000000001/preferences') + self.assertEqual( + content['self_link'], + 'http://localhost:9001/3.1/users' + '/00000000000000000000000000000001/preferences') diff --git a/src/mailman/rest/tests/test_validator.py b/src/mailman/rest/tests/test_validator.py index 2d515f828..030fd7aa9 100644 --- a/src/mailman/rest/tests/test_validator.py +++ b/src/mailman/rest/tests/test_validator.py @@ -50,15 +50,38 @@ class TestValidators(unittest.TestCase): self.assertRaises(ValueError, list_of_strings_validator, 7) self.assertRaises(ValueError, list_of_strings_validator, ['ant', 7]) - def test_subscriber_validator_uuid(self): + def test_subscriber_validator_int_uuid(self): # Convert from an existing user id to a UUID. anne = getUtility(IUserManager).make_user('anne@example.com') - uuid = subscriber_validator(str(anne.user_id.int)) + uuid = subscriber_validator('3.0')(str(anne.user_id.int)) self.assertEqual(anne.user_id, uuid) - def test_subscriber_validator_bad_uuid(self): - self.assertRaises(ValueError, subscriber_validator, 'not-a-thing') + def test_subscriber_validator_hex_uuid(self): + # Convert from an existing user id to a UUID. + anne = getUtility(IUserManager).make_user('anne@example.com') + uuid = subscriber_validator('3.1')(anne.user_id.hex) + self.assertEqual(anne.user_id, uuid) + + def test_subscriber_validator_no_int_uuid(self): + # API 3.1 does not accept ints as subscriber id's. + anne = getUtility(IUserManager).make_user('anne@example.com') + self.assertRaises(ValueError, + subscriber_validator('3.1'), str(anne.user_id.int)) + + def test_subscriber_validator_bad_int_uuid(self): + # In API 3.0, UUIDs are ints. + self.assertRaises(ValueError, + subscriber_validator('3.0'), 'not-a-thing') + + def test_subscriber_validator_bad_int_hex(self): + # In API 3.1, UUIDs are hexes. + self.assertRaises(ValueError, + subscriber_validator('3.1'), 'not-a-thing') + + def test_subscriber_validator_email_address_API30(self): + self.assertEqual(subscriber_validator('3.0')('anne@example.com'), + 'anne@example.com') - def test_subscriber_validator_email_address(self): - self.assertEqual(subscriber_validator('anne@example.com'), + def test_subscriber_validator_email_address_API31(self): + self.assertEqual(subscriber_validator('3.1')('anne@example.com'), 'anne@example.com') diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py index 2242c3ab7..700e4179d 100644 --- a/src/mailman/rest/users.py +++ b/src/mailman/rest/users.py @@ -117,8 +117,9 @@ def create_user(arguments, request, response): password = generate(int(config.passwords.password_length)) user.password = config.password_context.encrypt(password) user.is_server_owner = is_server_owner - location = path_to('users/{}'.format(user.user_id.int), - request.context['api_version']) + api_version = request.context['api_version'] + user_id = getattr(user.user_id, 'int' if api_version == '3.0' else 'hex') + location = path_to('users/{}'.format(user_id), api_version) created(response, location) return user @@ -127,13 +128,17 @@ def create_user(arguments, request, response): class _UserBase(CollectionMixin): """Shared base class for user representations.""" + def _get_uuid(self, user): + return getattr(user.user_id, + 'int' if self.api_version == '3.0' else 'hex') + def _resource_as_dict(self, user): """See `CollectionMixin`.""" # The canonical URL for a user is their unique user id, although we # can always look up a user based on any registered and validated # email address associated with their account. The user id is a UUID, # but we serialize its integer equivalent. - user_id = user.user_id.int + user_id = self._get_uuid(user) resource = dict( created_on=user.created_on, is_server_owner=user.is_server_owner, @@ -177,24 +182,29 @@ class AllUsers(_UserBase): class AUser(_UserBase): """A user.""" - def __init__(self, user_identifier): + def __init__(self, api_version, user_identifier): """Get a user by various type of identifiers. :param user_identifier: The identifier used to retrieve the user. The - identifier may either be an integer user-id, or an email address - controlled by the user. The type of identifier is auto-detected + identifier may either be an email address controlled by the user + or the UUID of the user. The type of identifier is auto-detected by looking for an `@` symbol, in which case it's taken as an email - address, otherwise it's assumed to be an integer. + address, otherwise it's assumed to be a UUID. However, UUIDs in + API 3.0 are integers, while in 3.1 are hex. :type user_identifier: string """ + self.api_version = api_version user_manager = getUtility(IUserManager) if '@' in user_identifier: self._user = user_manager.get_user(user_identifier) else: - # The identifier is the string representation of an integer that - # must be converted to a UUID. + # The identifier is the string representation of a UUID, either an + # int in API 3.0 or a hex in API 3.1. try: - user_id = UUID(int=int(user_identifier)) + if api_version == '3.0': + user_id = UUID(int=int(user_identifier)) + else: + user_id = UUID(hex=user_identifier) except ValueError: self._user = None else: @@ -227,14 +237,14 @@ class AUser(_UserBase): @child() def preferences(self, request, segments): - """/addresses/<email>/preferences""" + """/users/<id>/preferences""" if len(segments) != 0: return BadRequest(), [] if self._user is None: return NotFound(), [] child = Preferences( self._user.preferences, - 'users/{0}'.format(self._user.user_id.int)) + 'users/{}'.format(self._get_uuid(self._user))) return child, [] def on_patch(self, request, response): @@ -309,13 +319,14 @@ class AddressUser(_UserBase): if self._user: conflict(response) return + api_version = request.context['api_version'] # When creating a linked user by POSTing, the user either must already # exist, or it can be automatically created, if the auto_create flag # is given and true (if missing, it defaults to true). However, in # this case we do not accept 'email' as a POST field. fields = CREATION_FIELDS.copy() del fields['email'] - fields['user_id'] = int + fields['user_id'] = (int if api_version == '3.0' else str) fields['auto_create'] = as_boolean fields['_optional'] = fields['_optional'] + ( 'user_id', 'auto_create', 'is_server_owner') @@ -328,7 +339,12 @@ class AddressUser(_UserBase): user_manager = getUtility(IUserManager) if 'user_id' in arguments: raw_uid = arguments['user_id'] - user_id = UUID(int=raw_uid) + kws = {('int' if api_version == '3.0' else 'hex'): raw_uid} + try: + user_id = UUID(**kws) + except ValueError as error: + bad_request(response, str(error)) + return user = user_manager.get_user_by_id(user_id) if user is None: not_found(response, b'No user with ID {}'.format(raw_uid)) @@ -348,11 +364,12 @@ class AddressUser(_UserBase): def on_put(self, request, response): """Set or replace the addresses's user.""" + api_version = request.context['api_version'] if self._user: self._user.unlink(self._address) # Process post data and check for an existing user. fields = CREATION_FIELDS.copy() - fields['user_id'] = int + fields['user_id'] = (int if api_version == '3.0' else str) fields['_optional'] = fields['_optional'] + ( 'user_id', 'email', 'is_server_owner') try: @@ -364,7 +381,12 @@ class AddressUser(_UserBase): user_manager = getUtility(IUserManager) if 'user_id' in arguments: raw_uid = arguments['user_id'] - user_id = UUID(int=raw_uid) + kws = {('int' if api_version == '3.0' else 'hex'): raw_uid} + try: + user_id = UUID(**kws) + except ValueError as error: + bad_request(response, str(error)) + return user = user_manager.get_user_by_id(user_id) if user is None: not_found(response, b'No user with ID {}'.format(raw_uid)) diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py index 1d5ad4ef9..bcd015b9b 100644 --- a/src/mailman/rest/validator.py +++ b/src/mailman/rest/validator.py @@ -55,15 +55,24 @@ class enum_validator: raise ValueError(exception.args[0]) -def subscriber_validator(subscriber): - """Convert an email-or-int to an email-or-UUID.""" - try: - return UUID(int=int(subscriber)) - except ValueError: - # It must be an email address. - if getUtility(IEmailValidator).is_valid(subscriber): - return subscriber - raise ValueError +def subscriber_validator(api_version): + """Convert an email-or-(int|hex) to an email-or-UUID.""" + def _inner(subscriber): + # In API 3.0, the uuid is represented by an int, so if we can int + # convert the value, we know it's a UUID-as-int. In API 3.1 though, + # uuids are represented by the hex version, which of course cannot + # include an @ sign. + try: + if api_version == '3.0': + return UUID(int=int(subscriber)) + else: + return UUID(hex=subscriber) + except ValueError: + # It must be an email address. + if getUtility(IEmailValidator).is_valid(subscriber): + return subscriber + raise ValueError + return _inner def language_validator(code): diff --git a/src/mailman/testing/documentation.py b/src/mailman/testing/documentation.py index 46336fc94..d98877a70 100644 --- a/src/mailman/testing/documentation.py +++ b/src/mailman/testing/documentation.py @@ -129,13 +129,13 @@ def dump_json(url, data=None, method=None, username=None, password=None): # entry is a dictionary. print('entry %d:' % i) for entry_key in sorted(entry): - print(' {0}: {1}'.format(entry_key, entry[entry_key])) + print(' {}: {}'.format(entry_key, entry[entry_key])) elif isinstance(value, list): printable_value = COMMASPACE.join( - "'{0}'".format(s) for s in sorted(value)) - print('{0}: [{1}]'.format(key, printable_value)) + "'{}'".format(s) for s in sorted(value)) + print('{}: [{}]'.format(key, printable_value)) else: - print('{0}: {1}'.format(key, value)) + print('{}: {}'.format(key, value)) |
