Barry Warsaw pushed to branch release-3.0 at mailman / Mailman
Commits: e3bc060b by Barry Warsaw at 2015-12-17T18:53:17Z Backport fix for issue #182. - - - - - 3 changed files: - src/mailman/rest/docs/listconf.rst - src/mailman/rest/listconf.py - src/mailman/rest/tests/test_listconf.py Changes: ===================================== src/mailman/rest/docs/listconf.rst ===================================== --- a/src/mailman/rest/docs/listconf.rst +++ b/src/mailman/rest/docs/listconf.rst @@ -186,10 +186,56 @@ These values are changed permanently. Sub-resources ============= -Many of the mailing list configuration variables are actually available as -sub-resources on the mailing list. This is because they are collections, -sequences, and other complex configuration types. Their values can be -retrieved and set through the sub-resource. +Mailing list configuration variables are actually available as sub-resources +on the mailing list. Their values can be retrieved and set through the +sub-resource. + + +Simple resources +---------------- + +You can view the current value of the sub-resource. + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com' + ... '/config/display_name') + display_name: My List + http_etag: ... + +The resource can be changed by PUTting to it. Note that the value still +requires a dictionary, and that dictionary must have a single key matching the +name of the resource. +:: + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com' + ... '/config/display_name', + ... dict(display_name='Your List'), + ... 'PUT') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com' + ... '/config/display_name') + display_name: Your List + http_etag: ... + +PATCH works the same way, with the same effect, so you can choose to use +either method. + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com' + ... '/config/display_name', + ... dict(display_name='Their List'), + ... 'PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/lists/ant.example.com' + ... '/config/display_name') + display_name: Their List + http_etag: ... Acceptable aliases ===================================== src/mailman/rest/listconf.py ===================================== --- a/src/mailman/rest/listconf.py +++ b/src/mailman/rest/listconf.py @@ -32,7 +32,7 @@ from mailman.interfaces.autorespond import ResponseAction from mailman.interfaces.mailinglist import ( IAcceptableAliasSet, ReplyToMunging, SubscriptionPolicy) from mailman.rest.helpers import ( - GetterSetter, bad_request, etag, no_content, okay) + GetterSetter, bad_request, etag, no_content, not_found, okay) from mailman.rest.validator import ( PatchValidator, Validator, enum_validator, list_of_strings_validator) @@ -44,7 +44,7 @@ class AcceptableAliases(GetterSetter): def get(self, mlist, attribute): """Return the mailing list's acceptable aliases.""" assert attribute == 'acceptable_aliases', ( - 'Unexpected attribute: {}'.format(attribute)) + 'Unexpected attribute: {}'.format(attribute)) # pragma: no cover aliases = IAcceptableAliasSet(mlist) return sorted(aliases.aliases) @@ -56,7 +56,7 @@ class AcceptableAliases(GetterSetter): ignored. """ assert attribute == 'acceptable_aliases', ( - 'Unexpected attribute: {}'.format(attribute)) + 'Unexpected attribute: {}'.format(attribute)) # pragma: no cover alias_set = IAcceptableAliasSet(mlist) alias_set.clear() for alias in value: @@ -161,15 +161,18 @@ class ListConfiguration: """Get a mailing list configuration.""" resource = {} if self._attribute is None: - # Return all readable attributes. + # This is a requst for all the mailing list's configuration + # variables. Return all readable attributes. for attribute in ATTRIBUTES: value = ATTRIBUTES[attribute].get(self._mlist, attribute) resource[attribute] = value elif self._attribute not in ATTRIBUTES: - bad_request( - response, b'Unknown attribute: {}'.format(self._attribute)) + # This is a request for a specific, nonexistent attribute. + not_found( + response, 'Unknown attribute: {}'.format(self._attribute)) return else: + # This is a request for a specific attribute. attribute = self._attribute value = ATTRIBUTES[attribute].get(self._mlist, attribute) resource[attribute] = value @@ -179,20 +182,31 @@ class ListConfiguration: """Set a mailing list configuration.""" attribute = self._attribute if attribute is None: + # This is a request to update all the list's writable + # configuration variables. All must be provided in the request. validator = Validator(**VALIDATORS) try: validator.update(self._mlist, request) except ValueError as error: + # Unlike the case where we're PUTting to a specific + # configuration sub-resource, if we're PUTting to the list's + # entire configuration, but the request has a bogus attribute, + # the entire request is considered bad. We can also get here + # if one of the attributes is read-only. The error will + # contain sufficient details, so just return it as the reason. bad_request(response, str(error)) return elif attribute not in ATTRIBUTES: - bad_request(response, b'Unknown attribute: {}'.format(attribute)) + # Here we're PUTting to a specific resource, but that attribute is + # bogus so the URL is considered pointing to a missing resource. + not_found(response, 'Unknown attribute: {}'.format(attribute)) return elif ATTRIBUTES[attribute].decoder is None: bad_request( - response, b'Read-only attribute: {}'.format(attribute)) + response, 'Read-only attribute: {}'.format(attribute)) return else: + # We're PUTting to a specific configuration sub-resource. validator = Validator(**{attribute: VALIDATORS[attribute]}) try: validator.update(self._mlist, request) @@ -203,15 +217,42 @@ class ListConfiguration: def on_patch(self, request, response): """Patch the configuration (i.e. partial update).""" + if self._attribute is None: + # We're PATCHing one or more of the attributes on the list's + # configuration resource, so all the writable attributes are valid + # candidates for updating. + converters = ATTRIBUTES + else: + # We're PATCHing a specific list configuration attribute + # sub-resource. Because the request data must be a dictionary, we + # restrict it to containing only a single key, which must match + # the attribute name. First, check for any extra attributes in + # the request. + keys = [key for key, value in request.params.items()] + if len(keys) > 1: + bad_request(response, 'Expected 1 attribute, got {}'.format( + len(keys))) + return + converter = ATTRIBUTES.get(self._attribute) + if converter is None: + # This is the case where the URL points to a nonexisting list + # configuration attribute sub-resource. + not_found(response, 'Unknown attribute: {}'.format( + self._attribute)) + return + converters = {self._attribute: converter} try: - validator = PatchValidator(request, ATTRIBUTES) + validator = PatchValidator(request, converters) except UnknownPATCHRequestError as error: + # This is the case where the URL points to the list's entire + # configuration resource, but the request dictionary contains a + # nonexistent attribute. bad_request( - response, b'Unknown attribute: {}'.format(error.attribute)) + response, 'Unknown attribute: {}'.format(error.attribute)) return except ReadOnlyPATCHRequestError as error: bad_request( - response, b'Read-only attribute: {}'.format(error.attribute)) + response, 'Read-only attribute: {}'.format(error.attribute)) return try: validator.update(self._mlist, request) ===================================== src/mailman/rest/tests/test_listconf.py ===================================== --- a/src/mailman/rest/tests/test_listconf.py +++ b/src/mailman/rest/tests/test_listconf.py @@ -30,6 +30,51 @@ from mailman.interfaces.mailinglist import ( IAcceptableAliasSet, SubscriptionPolicy) from mailman.testing.helpers import call_api from mailman.testing.layers import RESTLayer +from urllib.error import HTTPError + + +# The representation of the listconf resource as a dictionary. This is used +# when PUTting to the list's configuration resource. +RESOURCE = dict( + acceptable_aliases=[ + 'a...@example.com', + 'b...@example.com', + 'c...@example.com', + ], + admin_immed_notify=False, + admin_notify_mchanges=True, + administrivia=False, + advertised=False, + anonymous_list=True, + archive_policy='never', + autorespond_owner='respond_and_discard', + autorespond_postings='respond_and_continue', + autorespond_requests='respond_and_discard', + autoresponse_grace_period='45d', + autoresponse_owner_text='the owner', + autoresponse_postings_text='the mailing list', + autoresponse_request_text='the robot', + display_name='Fnords', + description='This is my mailing list', + include_rfc2369_headers=False, + allow_list_posts=False, + #digest_send_periodic='yes', + digest_size_threshold=10.5, + #digest_volume_frequency=1, + posting_pipeline='virgin', + filter_content=True, + first_strip_reply_to=True, + convert_html_to_plaintext=True, + collapse_alternatives=False, + reply_goes_to_list='point_to_list', + reply_to_address='b...@example.com', + send_welcome_message=False, + subject_prefix='[ant]', + subscription_policy='confirm_then_moderate', + welcome_message_uri='mailman:///welcome.txt', + default_member_action='hold', + default_nonmember_action='discard', + ) @@ -40,71 +85,173 @@ class TestConfiguration(unittest.TestCase): def setUp(self): with transaction(): - self._mlist = create_list('t...@example.com') + self._mlist = create_list('a...@example.com') + + def test_get_missing_attribute(self): + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus') + self.assertEqual(cm.exception.code, 404) + self.assertEqual(cm.exception.reason, b'Unknown attribute: bogus') def test_put_configuration(self): - aliases = [ - 'a...@example.com', - 'b...@example.com', - 'c...@example.com', - ] # When using PUT, all writable attributes must be included. resource, response = call_api( - 'http://localhost:9001/3.0/lists/t...@example.com/config', - dict( - acceptable_aliases=aliases, - admin_immed_notify=False, - admin_notify_mchanges=True, - administrivia=False, - advertised=False, - anonymous_list=True, - archive_policy='never', - autorespond_owner='respond_and_discard', - autorespond_postings='respond_and_continue', - autorespond_requests='respond_and_discard', - autoresponse_grace_period='45d', - autoresponse_owner_text='the owner', - autoresponse_postings_text='the mailing list', - autoresponse_request_text='the robot', - display_name='Fnords', - description='This is my mailing list', - include_rfc2369_headers=False, - allow_list_posts=False, - digest_size_threshold=10.5, - posting_pipeline='virgin', - filter_content=True, - first_strip_reply_to=True, - convert_html_to_plaintext=True, - collapse_alternatives=False, - reply_goes_to_list='point_to_list', - reply_to_address='b...@example.com', - send_welcome_message=False, - subject_prefix='[ant]', - subscription_policy='confirm_then_moderate', - welcome_message_uri='mailman:///welcome.txt', - default_member_action='hold', - default_nonmember_action='discard', - ), + 'http://localhost:9001/3.0/lists/ant.example.com/config', + RESOURCE, 'PUT') self.assertEqual(response.status, 204) self.assertEqual(self._mlist.display_name, 'Fnords') # All three acceptable aliases were set. self.assertEqual(set(IAcceptableAliasSet(self._mlist).aliases), - set(aliases)) + set(RESOURCE['acceptable_aliases'])) + + def test_put_attribute(self): + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com' + '/config/reply_to_address') + self.assertEqual(resource['reply_to_address'], '') + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com' + '/config/reply_to_address', + dict(reply_to_address='b...@ant.example.com'), + 'PUT') + self.assertEqual(response.status, 204) + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com' + '/config/reply_to_address') + self.assertEqual(resource['reply_to_address'], 'b...@ant.example.com') + + def test_put_extra_attribute(self): + bogus_resource = RESOURCE.copy() + bogus_resource['bogus'] = 'yes' + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/config', + bogus_resource, + 'PUT') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, b'Unexpected parameters: bogus') + + def test_put_attribute_mismatch(self): + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com' + '/config/reply_to_address') + self.assertEqual(resource['reply_to_address'], '') + with self.assertRaises(HTTPError) as cm: + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com' + '/config/reply_to_address', + dict(display_name='b...@ant.example.com'), + 'PUT') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'Unexpected parameters: display_name') + + def test_put_attribute_double(self): + with self.assertRaises(HTTPError) as cm: + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com' + '/config/reply_to_address', + dict(display_name='b...@ant.example.com', + reply_to_address='f...@example.com'), + 'PUT') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'Unexpected parameters: display_name') + + def test_put_read_only_attribute(self): + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant.example.com' + '/config/mail_host', + dict(mail_host='foo.example.com'), + 'PUT') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'Read-only attribute: mail_host') + + def test_put_missing_attribute(self): + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus', + dict(bogus='no matter'), + 'PUT') + self.assertEqual(cm.exception.code, 404) + self.assertEqual(cm.exception.reason, b'Unknown attribute: bogus') def test_patch_subscription_policy(self): # The new subscription_policy value can be patched. # # To start with, the subscription policy is confirm by default. resource, response = call_api( - 'http://localhost:9001/3.0/lists/t...@example.com/config') + 'http://localhost:9001/3.0/lists/ant.example.com/config') self.assertEqual(resource['subscription_policy'], 'confirm') # Let's patch it to do some moderation. resource, response = call_api( - 'http://localhost:9001/3.0/lists/t...@example.com/config', dict( + 'http://localhost:9001/3.0/lists/ant.example.com/config', dict( subscription_policy='confirm_then_moderate'), method='PATCH') self.assertEqual(response.status, 204) # And now we verify that it has the requested setting. self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.confirm_then_moderate) + + def test_patch_attribute_double(self): + with self.assertRaises(HTTPError) as cm: + resource, response = call_api( + 'http://localhost:9001/3.0/lists/ant.example.com' + '/config/reply_to_address', + dict(display_name='b...@ant.example.com', + reply_to_address='foo'), + 'PATCH') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, b'Expected 1 attribute, got 2') + + def test_unknown_patch_attribute(self): + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant.example.com/config', + dict(bogus=1), + 'PATCH') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, b'Unknown attribute: bogus') + + def test_read_only_patch_attribute(self): + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/lists/ant.example.com' + '/config/mail_host', + dict(mail_host='foo.example.com'), + 'PATCH') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'Read-only attribute: mail_host') + + def test_patch_missing_attribute(self): + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/config/bogus', + dict(bogus='no matter'), + 'PATCH') + self.assertEqual(cm.exception.code, 404) + self.assertEqual(cm.exception.reason, b'Unknown attribute: bogus') + + def test_patch_bad_value(self): + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/config' + '/archive_policy', + dict(archive_policy='not a valid archive policy'), + 'PATCH') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'Cannot convert parameters: archive_policy') + + def test_bad_pipeline_name(self): + with self.assertRaises(HTTPError) as cm: + call_api( + 'http://localhost:9001/3.0/lists/ant.example.com/config' + '/posting_pipeline', + dict(posting_pipeline='not a valid pipeline'), + 'PATCH') + self.assertEqual(cm.exception.code, 400) + self.assertEqual(cm.exception.reason, + b'Cannot convert parameters: posting_pipeline') View it on GitLab: https://gitlab.com/mailman/mailman/commit/e3bc060b51d86209a16c7e89964b7f5dab4ab190
_______________________________________________ Mailman-checkins mailing list Mailman-checkins@python.org Unsubscribe: https://mail.python.org/mailman/options/mailman-checkins/archive%40jab.org