def test_double_delete(self): # You cannot delete a domain twice. content, response = call_api("http://localhost:9001/3.0/domains/example.com", method="DELETE") self.assertEqual(response.status, 204) with self.assertRaises(HTTPError) as cm: call_api("http://localhost:9001/3.0/domains/example.com", method="DELETE") self.assertEqual(cm.exception.code, 404)
def test_get_missing_header_match(self): with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/lists/ant.example.com' '/header-matches/0') self.assertEqual(cm.exception.code, 404) self.assertEqual(cm.exception.reason, b'No header match at this position: 0')
def test_system_preferences_are_read_only(self): # /system/preferences are read-only. try: # For Python 2.6. call_api('http://localhost:9001/3.0/system/preferences', { 'acknowledge_posts': True, }, method='PATCH') except HTTPError as exc: self.assertEqual(exc.code, 405) else: raise AssertionError('Expected HTTPError') # /system/preferences are read-only. try: # For Python 2.6. call_api('http://localhost:9001/3.0/system/preferences', { 'acknowledge_posts': False, 'delivery_mode': 'regular', 'delivery_status': 'enabled', 'hide_address': True, 'preferred_language': 'en', 'receive_list_copy': True, 'receive_own_postings': True, }, method='PUT') except HTTPError as exc: self.assertEqual(exc.code, 405) else: raise AssertionError('Expected HTTPError')
def test_bad_delete(self): # Send DELETE with any data. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com', }, method='DELETE') self.assertEqual(cm.exception.code, 400)
def test_bad_post(self): # Send POST data with an invalid attribute. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com'), ('gal', '*****@*****.**'), )) self.assertEqual(cm.exception.code, 400)
def test_bad_preferences_url(self): with transaction(): subscribe(self._mlist, 'Anne') with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com/preferences/bogus') self.assertEqual(cm.exception.code, 404)
def test_post_to_missing_domain_owners(self): # Try to add owners to a missing domain. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com'), ('owner', '*****@*****.**'), )) self.assertEqual(cm.exception.code, 404)
def test_try_to_leave_missing_list(self): # A user tries to leave a non-existent list. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com' '/member/[email protected]', method='DELETE') self.assertEqual(cm.exception.code, 404)
def test_successful_login_updates_password(self): # Passlib supports updating the hash when the hash algorithm changes. # When a user logs in successfully, the password will be updated if # necessary. # # Start by hashing Anne's password with a different hashing algorithm # than the one that the REST runner uses by default during testing. config_file = os.path.join(config.VAR_DIR, 'passlib-tmp.config') with open(config_file, 'w') as fp: print("""\ [passlib] schemes = hex_md5 """, file=fp) with configuration('passwords', configuration=config_file): with transaction(): self.anne.password = config.password_context.encrypt('abc123') # Just ensure Anne's password is hashed correctly. self.assertEqual(self.anne.password, 'e99a18c428cb38d5f260853678922e03') # Now, Anne logs in with a successful password. This should change it # back to the plaintext hash. call_api('http://localhost:9001/3.0/users/1/login', { 'cleartext_password': '******', }) self.assertEqual(self.anne.password, '{plaintext}abc123')
def test_member_changes_preferred_address(self): with transaction(): anne = self._usermanager.create_user('*****@*****.**') preferred = list(anne.addresses)[0] preferred.verified_on = now() anne.preferred_address = preferred self._mlist.subscribe(anne) # Take a look at Anne's current membership. content, response = call_api('http://*****:*****@example.com') self.assertEqual( entry_0['address'], 'http://*****:*****@example.com') # Anne registers a new address and makes it her preferred address. # There are no changes to her membership. with transaction(): new_preferred = anne.register('*****@*****.**') new_preferred.verified_on = now() anne.preferred_address = new_preferred # Take another look at Anne's current membership. content, response = call_api('http://*****:*****@example.com') self.assertEqual( entry_0['address'], 'http://*****:*****@example.com')
def test_bad_delete(self): # Send DELETE with any data. with self.assertRaises(HTTPError) as cm: call_api( "http://*****:*****@example.com"}, method="DELETE" ) self.assertEqual(cm.exception.code, 400)
def test_reject_bad_token(self): # Try to accept a request with a bogus token. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com' '/requests/bogus', dict(action='reject')) self.assertEqual(cm.exception.code, 404)
def test_reject(self): # POST to the request to reject it. This leaves a bounce message in # the virgin queue. with transaction(): token, token_owner, member = self._registrar.register(self._anne) # Anne's subscription request got held. self.assertIsNone(member) # Clear out the virgin queue, which currently contains the # confirmation message sent to Anne. get_queue_messages('virgin') url = 'http://*****:*****@example.com/requests/{}' content, response = call_api(url.format(token), dict( action='reject', )) self.assertEqual(response.status, 204) # Anne is not a member. self.assertIsNone(self._mlist.members.get_member('*****@*****.**')) # The request URL no longer exists. with self.assertRaises(HTTPError) as cm: call_api(url.format(token), dict( action='reject', )) self.assertEqual(cm.exception.code, 404) # And the rejection message to Anne is now in the virgin queue. items = get_queue_messages('virgin') self.assertEqual(len(items), 1) message = items[0].msg self.assertEqual(message['From'], '*****@*****.**') self.assertEqual(message['To'], '*****@*****.**') self.assertEqual(message['Subject'], 'Request to mailing list "Ant" rejected')
def test_bad_held_message_request_id_post(self): # Bad request when request_id is not an integer. with self.assertRaises(HTTPError) as cm: call_api( 'http://*****:*****@example.com/held/bogus', dict(action='defer')) self.assertEqual(cm.exception.code, 404)
def test_defer(self): # Defer the decision for some other moderator. with transaction(): token, token_owner, member = self._registrar.register(self._anne) # Anne's subscription request got held. self.assertIsNone(member) url = 'http://*****:*****@example.com/requests/{}' content, response = call_api(url.format(token), dict( action='defer', )) self.assertEqual(response.status, 204) # Anne is not a member. self.assertIsNone(self._mlist.members.get_member('*****@*****.**')) # The request URL still exists. content, response = call_api(url.format(token), dict( action='defer', )) self.assertEqual(response.status, 204) # And now we can accept it. content, response = call_api(url.format(token), dict( action='accept', )) self.assertEqual(response.status, 204) # Anne is a member. self.assertEqual( self._mlist.members.get_member('*****@*****.**').address, self._anne) # The request URL no longer exists. with self.assertRaises(HTTPError) as cm: call_api(url.format(token), dict( action='accept', )) self.assertEqual(cm.exception.code, 404)
def test_login_missing_user_by_address(self): # Verify a password for a non-existing user, by address. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.org/login', { 'cleartext_password': '******', }) self.assertEqual(cm.exception.code, 404)
def test_missing_domain_lists(self): # You get a 404 if you try to access the mailing lists of a # nonexisting domain. with self.assertRaises(HTTPError) as cm: call_api( 'http://localhost:9001/3.0/domains/does-not-exist.com/lists') self.assertEqual(cm.exception.code, 404)
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_section_read_only(self): # Sections are also read-only. url = 'http://localhost:9001/3.0/system/configuration/mailman' with self.assertRaises(HTTPError) as cm: call_api(url, {'foo': 'bar'}) # 405 is Method Not Allowed. self.assertEqual(cm.exception.code, 405)
def test_reserved_bad_subpath(self): # Only <api>/reserved/uids/orphans is a defined resource. DELETEing # anything else gives a 404. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/reserved/uids/assigned', method='DELETE') self.assertEqual(cm.exception.code, 404)
def test_wrong_parameter(self): # A bad request because it is mistyped the required attribute. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/users/1/login', { 'hashed_password': '******', }) self.assertEqual(cm.exception.code, 400)
def test_too_many_path_components(self): # More than two path components is an error, even if they name a valid # configuration variable. url = 'http://localhost:9001/3.0/system/configuration/mailman/layout' with self.assertRaises(HTTPError) as cm: call_api(url) self.assertEqual(cm.exception.code, 400)
def test_accept_by_moderator_clears_request_queue(self): # After accepting a message held for moderator approval, there are no # more requests to handle. # # We start with nothing in the queue. content, response = call_api( 'http://*****:*****@example.com/requests') self.assertEqual(content['total_size'], 0) # Anne tries to subscribe to a list that only requests moderator # approval. with transaction(): self._mlist.subscription_policy = SubscriptionPolicy.moderate token, token_owner, member = self._registrar.register( self._anne, pre_verified=True, pre_confirmed=True) # There's now one request in the queue, and it's waiting on moderator # approval. content, response = call_api( 'http://*****:*****@example.com/requests') self.assertEqual(content['total_size'], 1) json = content['entries'][0] self.assertEqual(json['token_owner'], 'moderator') self.assertEqual(json['email'], '*****@*****.**') # The moderator approves the request. url = 'http://*****:*****@example.com/requests/{}' content, response = call_api(url.format(token), {'action': 'accept'}) self.assertEqual(response.status, 204) # And now the request queue is empty. content, response = call_api( 'http://*****:*****@example.com/requests') self.assertEqual(content['total_size'], 0)
def test_patch_missing_user_by_address(self): # You can't PATCH a missing user by user address. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.org', { 'display_name': 'Bob Dobbs', }, method='PATCH') self.assertEqual(cm.exception.code, 404)
def test_inject(self): # Injecting a message leaves the message in the queue. starting_messages = get_queue_messages('bad') self.assertEqual(len(starting_messages), 0) content, response = call_api('http://localhost:9001/3.0/queues/bad', { 'list_id': 'test.example.com', 'text': TEXT}) self.assertEqual(response.status, 201) location = response['location'] filebase = location.split('/')[-1] # The message is in the 'bad' queue. content, response = call_api('http://localhost:9001/3.0/queues/bad') files = content['files'] self.assertEqual(len(files), 1) self.assertEqual(files[0], filebase) # Verify the files directly. files = list(config.switchboards['bad'].files) self.assertEqual(len(files), 1) self.assertEqual(files[0], filebase) # Verify the content. items = get_queue_messages('bad') self.assertEqual(len(items), 1) msg = items[0].msg # Remove some headers that get added by Mailman. del msg['date'] self.assertEqual(msg['message-id-hash'], 'MS6QLWERIJLGCRF44J7USBFDELMNT2BW') del msg['message-id-hash'] del msg['x-message-id-hash'] self.assertMultiLineEqual(msg.as_string(), TEXT)
def test_add_address_to_missing_user(self): # The user that the address is being added to must exist. with self.assertRaises(HTTPError) as cm: call_api( 'http://*****:*****@example.com/addresses', { 'email': '*****@*****.**', }) self.assertEqual(cm.exception.code, 404)
def test_create_server_owner_bogus(self): # Issue #136: Creating a user with is_server_owner=bogus should throw # an exception. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com', is_server_owner='bogus')) self.assertEqual(cm.exception.code, 400)
def test_put_missing_user_by_id(self): # You can't PUT a missing user by user id. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/users/99', { 'display_name': 'Bob Dobbs', 'cleartext_password': '******', }, method='PUT') self.assertEqual(cm.exception.code, 404)
def test_too_many_parameters(self): # A bad request because it has too many attributes. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/users/1/login', { 'cleartext_password': '******', 'display_name': 'Annie Personhood', }) self.assertEqual(cm.exception.code, 400)
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_user_subresource_put_create(self): # PUTing to the 'user' resource creates the user, just like with POST. user_manager = getUtility(IUserManager) with transaction(): anne = user_manager.create_user('*****@*****.**', 'Anne') json, response = call_api( 'http://*****:*****@example.com/user', { 'email': '*****@*****.**', }, method='PUT') self.assertEqual(response.status_code, 201) self.assertEqual(anne.addresses, []) anne_person = user_manager.get_user('*****@*****.**') self.assertIsNotNone(anne_person) self.assertEqual( sorted([address.email for address in anne_person.addresses]), ['*****@*****.**', '*****@*****.**']) anne_addr = user_manager.get_address('*****@*****.**') self.assertIsNotNone(anne_addr) self.assertEqual(anne_addr.user, anne_person)
def test_delete_all_uris(self): manager = getUtility(ITemplateManager) with transaction(): manager.set('list:user:notice:welcome', 'example.com', 'http://example.com/welcome') manager.set( 'list:user:notice:goodbye', 'example.com', 'http://example.com/goodbye', 'a user', 'the password', ) resource, response = call_api( 'http://localhost:9001/3.1/domains/example.com/uris', method='DELETE') self.assertEqual(response.status, 204) self.assertIsNone( manager.raw('list:user:notice:welcome', 'example.com')) self.assertIsNone( manager.raw('list:user:notice:goodbye', 'example.com'))
def test_get_all_uris(self): manager = getUtility(ITemplateManager) with transaction(): manager.set('list:user:notice:welcome', None, 'http://example.com/welcome') manager.set( 'list:user:notice:goodbye', None, 'http://example.com/goodbye', 'a user', 'the password', ) resource, response = call_api('http://localhost:9001/3.1/uris') self.assertEqual(response.status, 200) self.assertEqual(resource['start'], 0) self.assertEqual(resource['total_size'], 2) self.assertEqual(resource['self_link'], 'http://localhost:9001/3.1/uris') self.assertEqual(resource['entries'], [{ 'http_etag': '"063fd6635a6035a4b7e939a304fcbd16571aa662"', 'name': 'list:user:notice:goodbye', 'password': '******', 'self_link': ('http://localhost:9001/3.1' '/uris/list:user:notice:goodbye'), 'uri': 'http://example.com/goodbye', 'username': '******', }, { 'http_etag': '"5c4ec63b2a0a50f96483ec85b94b80ee092af792"', 'name': 'list:user:notice:welcome', 'self_link': ('http://localhost:9001/3.1' '/uris/list:user:notice:welcome'), 'uri': 'http://example.com/welcome', }])
def test_list_mass_unsubscribe_mixed_success(self): with transaction(): aperson = self._usermanager.create_address('*****@*****.**') bperson = self._usermanager.create_address('*****@*****.**') cperson = self._usermanager.create_address('*****@*****.**') self._mlist.subscribe(aperson) self._mlist.subscribe(bperson) self._mlist.subscribe(cperson) resource, response = call_api( 'http://*****:*****@example.com', '*****@*****.**', ]}, 'DELETE') self.assertEqual(response.status, 200) # Remove variable data. resource.pop('http_etag') self.assertEqual(resource, {'*****@*****.**': True, '*****@*****.**': False, })
def test_basic_system_configuration(self): # Read some basic system configuration value, just to prove that the # infrastructure works. url = 'http://*****:*****@example.com', ))
def test_add_unlinked_address_to_user_with_ignored_display_name(self): user_manager = getUtility(IUserManager) with transaction(): anne = user_manager.create_user('*****@*****.**') user_manager.create_address('*****@*****.**') response, content = call_api( 'http://*****:*****@example.com/addresses', { 'email': '*****@*****.**', 'display_name': 'Anne Person', }) self.assertIn('*****@*****.**', [address.email for address in anne.addresses]) self.assertEqual(content['status'], '201') self.assertEqual( content['location'], 'http://*****:*****@example.com') # Even though a display_name was given in the POST data, because the # address already existed, it still has no display name. anne_person = user_manager.get_address('*****@*****.**') self.assertEqual(anne_person.display_name, '')
def test_create_user_twice(self): # LP: #1418280. No additional users should be created when an address # that already exists is given. json, response = call_api('http://*****:*****@example.com')) # There is now one user. json, response = call_api('http://*****:*****@example.com')) self.assertEqual(cm.exception.code, 400) self.assertEqual(cm.exception.reason, 'User already exists: [email protected]') # But at least no new users was created. json, response = call_api('http://localhost:9001/3.0/users') self.assertEqual(json['total_size'], 1)
def call_http(url, data=None, method=None, username=None, password=None): """'Call a URL with a given HTTP method and return the resulting object. The object will have been JSON decoded. :param url: The url to open, read, and print. :type url: string :param data: Data to use to POST to a URL. :type data: dict :param method: Alternative HTTP method to use. :type method: str :param username: The HTTP Basic Auth user name. None means use the value from the configuration. :type username: str :param password: The HTTP Basic Auth password. None means use the value from the configuration. :type username: str :return: The decoded JSON data structure. :raises HTTPError: when a non-2xx return code is received. """ content, response = call_api(url, data, method, username, password) if content is None: # We used to use httplib2 here, which included the status code in the # response headers in the `status` key. requests doesn't do this, but # the doctests expect it so for backward compatibility, include the # status code in the printed response. headers = dict(status=response.status_code) headers.update({ field.lower(): response.headers[field] for field in response.headers }) # Remove the connection: close header from the response. headers.pop('connection') for field in sorted(headers): print('{}: {}'.format(field, headers[field])) return None return content
def test_get_after_delete(self): # You cannot GET a user record after deleting them. with transaction(): anne = getUtility(IUserManager).create_user( '*****@*****.**', 'Anne Person') user_id = anne.user_id # You can still GET the user record. json, response = call_api( 'http://*****:*****@example.com') self.assertEqual(response.status_code, 200) # Delete the user. json, response = call_api( 'http://*****:*****@example.com', method='DELETE') self.assertEqual(response.status_code, 204) # The user record can no longer be retrieved. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com') self.assertEqual(cm.exception.code, 404) with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/users/{}'.format(user_id)) self.assertEqual(cm.exception.code, 404)
def test_missing_domain(self): # You get a 404 if you try to access a nonexisting domain. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/domains/does-not-exist.com') self.assertEqual(cm.exception.code, 404)
def test_bogus_endpoint(self): # /domains/<domain>/<!lists> does not exist. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/domains/example.com/wrong') self.assertEqual(cm.exception.code, 404)
def test_bogus_endpoint_extension(self): # /domains/<domain>/lists/<anything> is not a valid endpoint. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/domains/example.com' '/lists/wrong') self.assertEqual(cm.exception.code, 400)
def test_delete_missing_domain_owners(self): # Try to delete the owners of a missing domain. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/domains/example.net/owners', method='DELETE') self.assertEqual(cm.exception.code, 404)
def test_member_count_with_no_members(self): # The list initially has 0 members. resource, response = call_api( 'http://*****:*****@example.com') self.assertEqual(response.status, 200) self.assertEqual(resource['member_count'], 0)
def test_plugin_raises_exception(self): with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.1/plugins/example/no') self.assertEqual(cm.exception.code, 400)
def test_missing_list_roster_moderator_404(self): # /lists/<missing>/roster/member gives 404 with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com' '/roster/moderator') self.assertEqual(cm.exception.code, 404)
def test_archiver_statuses_on_missing_lists(self): # You cannot get the archiver statuses on a list that doesn't exist. with self.assertRaises(HTTPError) as cm: call_api( 'http://localhost:9001/3.0/lists/bee.example.com/archivers') self.assertEqual(cm.exception.code, 404)
def test_cannot_delete_missing_list(self): # You cannot delete a list that does not exist. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/lists/bee.example.com', method='DELETE') self.assertEqual(cm.exception.code, 404)
def test_query_for_lists_in_missing_domain(self): # You cannot ask all the mailing lists in a non-existent domain. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/domains/no.example.org/lists') self.assertEqual(cm.exception.code, 404)
def test_not_enough_parameters(self): # A bad request because it is missing the required attribute. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/users/1/login', {}) self.assertEqual(cm.exception.code, 400)
def test_addresses_of_missing_user_address(self): # Trying to get the /addresses of a missing user id results in error. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.net/addresses') self.assertEqual(cm.exception.code, 404)
def test_missing_list_configuration_404(self): # /lists/<missing>/config gives 404 with self.assertRaises(HTTPError) as cm: call_api( 'http://*****:*****@example.com/config') self.assertEqual(cm.exception.code, 404)
def test_lp_1074374(self): # Specific steps to reproduce the bug: # - create a user through the REST API (well, we did that outside the # REST API here, but that should be fine) # - delete that user through the API # - repeating step 1 gives a 500 status code # - /3.0/addresses still contains the original address # - /3.0/members gives a 500 with transaction(): user_id = self.anne.user_id address = list(self.anne.addresses)[0] self.mlist.subscribe(address) call_api('http://*****:*****@example.com', method='DELETE') json, response = call_api('http://*****:*****@example.com', password='******')) call_api( 'http://*****:*****@example.com', role='member', pre_verified=True, pre_confirmed=True, pre_approved=True)) # This is not the Anne you're looking for. (IOW, the new Anne is a # different user). json, response = call_api( 'http://*****:*****@example.com') self.assertNotEqual(user_id, json['user_id']) # Anne has an address record. json, response = call_api('http://*****:*****@example.com') # Anne is also a member of the mailing list. json, response = call_api('http://*****:*****@example.com') self.assertEqual(member['email'], '*****@*****.**') self.assertEqual(member['delivery_mode'], 'regular') self.assertEqual(member['list_id'], 'test.example.com') self.assertEqual(member['role'], 'member')
def test_cannot_get_user_by_int(self): with transaction(): getUtility(IUserManager).create_user('*****@*****.**') with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.1/users/1') self.assertEqual(cm.exception.code, 404)
def test_get_missing_user_by_id(self): # You can't GET a missing user by user id. with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/users/99') self.assertEqual(cm.exception.code, 404)
def test_bad_held_message_request_id(self): # Bad request when request_id is not an integer. with self.assertRaises(HTTPError) as cm: call_api( 'http://*****:*****@example.com/held/bogus') self.assertEqual(cm.exception.code, 404)
def test_get_missing_user_by_address(self): # You can't GET a missing user by address. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.org') self.assertEqual(cm.exception.code, 404)
def test_missing_held_message_request_id(self): # Not found when the request_id is not in the database. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com/held/99') self.assertEqual(cm.exception.code, 404)
def test_delete_missing_user_by_address(self): # You can't DELETE a missing user by user address. with self.assertRaises(HTTPError) as cm: call_api('http://*****:*****@example.com', method='DELETE') self.assertEqual(cm.exception.code, 404)
def test_header_match_on_missing_list(self): with self.assertRaises(HTTPError) as cm: call_api('http://localhost:9001/3.0/lists/bee.example.com' '/header-matches/') self.assertEqual(cm.exception.code, 404)