diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index f27e69081..32a7d1261 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -79,7 +79,7 @@ class AuthHandler(object): self._respond(cont_resp, dv_resp, best_effort) # Just make sure all decisions are complete. - self._verify_authzr_complete() + self.verify_authzr_complete() # Only return valid authorizations return [authzr for authzr in self.authzr.values() if authzr.body.status == messages2.STATUS_VALID] @@ -112,8 +112,7 @@ class AuthHandler(object): logging.critical("Failure in setting up challenges.") logging.info("Attempting to clean up outstanding challenges...") self._cleanup_challenges() - raise errors.AuthorizationError( - "Unable to perform challenges") + raise assert len(cont_resp) == len(self.cont_c) assert len(dv_resp) == len(self.dv_c) @@ -159,12 +158,14 @@ class AuthHandler(object): return active_achalls - def _poll_challenges(self, chall_update, best_effort, min_sleep=3): + def _poll_challenges( + self, chall_update, best_effort, min_sleep=3, max_rounds=15): """Wait for all challenge results to be determined.""" dom_to_check = set(chall_update.keys()) comp_domains = set() + rounds = 0 - while dom_to_check: + while dom_to_check and rounds < max_rounds: # TODO: Use retry-after... time.sleep(min_sleep) for domain in dom_to_check: @@ -181,13 +182,13 @@ class AuthHandler(object): # Right now... just assume a loss and carry on... if best_effort: comp_domains.add(domain) - else: raise errors.AuthorizationError( "Failed Authorization procedure for %s" % domain) dom_to_check -= comp_domains comp_domains.clear() + rounds += 1 def _handle_check(self, domain, achalls): """Returns tuple of ('completed', 'failed').""" @@ -226,7 +227,7 @@ class AuthHandler(object): """ for authzr_challb in authzr.body.challenges: if type(authzr_challb.chall) is type(achall.challb.chall): - return achall.challb.status + return authzr_challb.status raise errors.AuthorizationError( "Target challenge not found in authorization resource") @@ -268,7 +269,13 @@ class AuthHandler(object): for achall in cont_c: self.cont_c.remove(achall) - def _verify_authzr_complete(self): + def verify_authzr_complete(self): + """Verifies that all authorizations have been decided. + + :returns: Whether all authzr are complete + :rtype: bool + + """ for authzr in self.authzr.values(): if (authzr.body.status != messages2.STATUS_VALID and authzr.body.status != messages2.STATUS_INVALID): @@ -298,34 +305,7 @@ class AuthHandler(object): challb = self.authzr[domain].body.challenges[index] chall = challb.chall - if isinstance(chall, challenges.DVSNI): - logging.info(" DVSNI challenge for %s.", domain) - achall = achallenges.DVSNI( - challb=challb, domain=domain, key=self.account.key) - elif isinstance(chall, challenges.SimpleHTTPS): - logging.info(" SimpleHTTPS challenge for %s.", domain) - achall = achallenges.SimpleHTTPS( - challb=challb, domain=domain, key=self.account.key) - elif isinstance(chall, challenges.DNS): - logging.info(" DNS challenge for %s.", domain) - achall = achallenges.DNS(challb=challb, domain=domain) - - elif isinstance(chall, challenges.RecoveryToken): - logging.info(" Recovery Token Challenge for %s.", domain) - achall = achallenges.RecoveryToken(challb=challb, domain=domain) - elif isinstance(chall, challenges.RecoveryContact): - logging.info(" Recovery Contact Challenge for %s.", domain) - achall = achallenges.RecoveryContact( - challb=challb, domain=domain) - elif isinstance(chall, challenges.ProofOfPossession): - logging.info(" Proof-of-Possession Challenge for %s", domain) - achall = achallenges.ProofOfPossession( - challb=challb, domain=domain) - - else: - raise errors.LetsEncryptClientError( - "Received unsupported challenge of type: %s", - chall.typ) + achall = challb_to_achall(challb, self.account.key, domain) if isinstance(chall, challenges.ContinuityChallenge): cont_chall.append(achall) @@ -335,6 +315,53 @@ class AuthHandler(object): return cont_chall, dv_chall +def challb_to_achall(challb, key, domain): + """Converts a ChallengeBody object to an AnnotatedChallenge. + + :param challb: ChallengeBody + :type challb: :class:`letsencrypt.acme.messages2.ChallengeBody` + + :param key: Key + :type key: :class:`letsencrypt.client.le_util.Key` + + :param str domain: Domain of the challb + + :returns: Appropriate AnnotatedChallenge + :rtype: :class:`letsencrypt.client.achallenges.AnnotatedChallenge` + + """ + chall = challb.chall + + if isinstance(chall, challenges.DVSNI): + logging.info(" DVSNI challenge for %s.", domain) + return achallenges.DVSNI( + challb=challb, domain=domain, key=key) + elif isinstance(chall, challenges.SimpleHTTPS): + logging.info(" SimpleHTTPS challenge for %s.", domain) + return achallenges.SimpleHTTPS( + challb=challb, domain=domain, key=key) + elif isinstance(chall, challenges.DNS): + logging.info(" DNS challenge for %s.", domain) + return achallenges.DNS(challb=challb, domain=domain) + + elif isinstance(chall, challenges.RecoveryToken): + logging.info(" Recovery Token Challenge for %s.", domain) + return achallenges.RecoveryToken(challb=challb, domain=domain) + elif isinstance(chall, challenges.RecoveryContact): + logging.info(" Recovery Contact Challenge for %s.", domain) + return achallenges.RecoveryContact( + challb=challb, domain=domain) + elif isinstance(chall, challenges.ProofOfPossession): + logging.info(" Proof-of-Possession Challenge for %s", domain) + return achallenges.ProofOfPossession( + challb=challb, domain=domain) + + else: + raise errors.LetsEncryptClientError( + "Received unsupported challenge of type: %s", + chall.typ) + + def gen_challenge_path(challbs, preferences, combinations): """Generate a plan to get authority over the identity. diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 26de5f865..557446e5c 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -150,6 +150,7 @@ class Network(object): response.links['terms-of-service']['url'] if 'terms-of-service' in response.links else terms_of_service) + # TODO: Consider removing this check based on spec clarifications #93 if new_authzr_uri is None: try: new_authzr_uri = response.links['next']['url'] @@ -321,7 +322,7 @@ class Network(object): # TODO: Right now Boulder responds with the authorization resource # instead of a challenge resource... this can be uncommented # once the error is fixed. - return challb + return None # raise errors.NetworkError('"up" Link header missing') challr = messages2.ChallengeResource( authzr_uri=authzr_uri, diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 28d2f16b3..2a675c15c 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.account.""" +import logging import mock import os import pkg_resources @@ -13,6 +14,7 @@ from letsencrypt.acme import messages2 from letsencrypt.client import account from letsencrypt.client import configuration +from letsencrypt.client import errors from letsencrypt.client import le_util from letsencrypt.client.display import util as display_util @@ -22,6 +24,8 @@ class AccountTest(unittest.TestCase): """Tests letsencrypt.client.account.Account.""" def setUp(self): + logging.disable(logging.CRITICAL) + self.accounts_dir = tempfile.mkdtemp("accounts") self.account_keys_dir = os.path.join(self.accounts_dir, "keys") os.makedirs(self.account_keys_dir, 0o700) @@ -51,6 +55,7 @@ class AccountTest(unittest.TestCase): def tearDown(self): shutil.rmtree(self.accounts_dir) + logging.disable(logging.NOTSET) @mock.patch("letsencrypt.client.account.zope.component.getUtility") @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") @@ -67,6 +72,15 @@ class AccountTest(unittest.TestCase): self.assertEqual(acc.key, self.key) self.assertEqual(acc.config, self.config) + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + def test_prompts_cancel(self, mock_util): + # displayer = display_util.FileDisplay(sys.stdout) + # zope.component.provideUtility(displayer) + + mock_util().input.return_value = (display_util.CANCEL, "") + + self.assertTrue(account.Account.from_prompts(self.config) is None) + def test_save_from_existing_account(self): self.test_account.save() acc = account.Account.from_existing_account(self.config, self.email) @@ -84,12 +98,25 @@ class AccountTest(unittest.TestCase): def test_partial_properties(self): partial = account.Account(self.config, self.key) + regr_no_authzr_uri = messages2.RegistrationResource( + uri="uri", + new_authzr_uri=None, + terms_of_service="terms_of_service", + body=messages2.Registration( + recovery_token="recovery_token", agreement="agreement") + ) + partial2 = account.Account( + self.config, self.key, regr=regr_no_authzr_uri) self.assertTrue(partial.uri is None) self.assertTrue(partial.new_authzr_uri is None) self.assertTrue(partial.terms_of_service is None) self.assertTrue(partial.recovery_token is None) + self.assertEqual( + partial2.new_authzr_uri, + "https://letsencrypt-demo.org/acme/new-authz") + def test_partial_account_default(self): partial = account.Account(self.config, self.key) partial.save() @@ -115,9 +142,23 @@ class AccountTest(unittest.TestCase): accs = account.Account.get_accounts(self.config) self.assertEqual(len(accs), 2) + def test_get_accounts_no_accounts(self): + self.assertEqual(account.Account.get_accounts( + mock.Mock(accounts_dir="non-existant")), []) + + def test_failed_existing_account(self): + self.assertRaises( + errors.LetsEncryptClientError, + account.Account.from_existing_account, + self.config, "non-existant@email.org") class SafeEmailTest(unittest.TestCase): """Test safe_email.""" + def setUp(self): + logging.disable(logging.CRITICAL) + + def tearDown(self): + logging.disable(logging.NOTSET) @classmethod def _call(cls, addr): @@ -131,16 +172,16 @@ class SafeEmailTest(unittest.TestCase): "abc_def.jdk@hotmail.museum" ] for addr in addrs: - self.assertTrue(addr, "%s failed." % addr) + self.assertTrue(self._call(addr), "%s failed." % addr) def test_invalid_emails(self): addrs = [ "letsencrypt@letsencrypt..org", ".tbd.ade@gmail.com", - "~/abc_def.jdk@hotmail.museum" + "~/abc_def.jdk@hotmail.museum", ] for addr in addrs: - self.assertTrue(addr, "%s failed." % addr) + self.assertFalse(self._call(addr), "%s failed." % addr) if __name__ == "__main__": diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 213822466..a81c68aa7 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -72,15 +72,11 @@ def gen_combos(challbs): def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name - """Return ChallengeBody from Challenge. - - :param str status: "valid", "invalid", "pending"... - - """ + """Return ChallengeBody from Challenge.""" kwargs = { "chall": chall, "uri": chall.typ+"_uri", - "status": messages2.Status(status), + "status": status, } if status == "valid": @@ -90,12 +86,12 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name # Pending ChallengeBody objects -DVSNI_P = chall_to_challb(DVSNI, "pending") -SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, "pending") -DNS_P = chall_to_challb(DNS, "pending") -RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, "pending") -RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, "pending") -POP_P = chall_to_challb(POP, "pending") +DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING) +SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING) +DNS_P = chall_to_challb(DNS, messages2.STATUS_PENDING) +RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages2.STATUS_PENDING) +RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages2.STATUS_PENDING) +POP_P = chall_to_challb(POP, messages2.STATUS_PENDING) CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P, RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P] @@ -110,17 +106,18 @@ CONT_CHALLENGES_P = [ def gen_authzr(authz_status, domain, challs, statuses, combos=True): """Generate an authorization resource. - :param str authz_status: "valid", "invalid", "pending"... + :param authz_status: Status object + :type authz_status: :class:`letsencrypt.acme.messages2.Status` :param list challs: Challenge objects - :param list statuses: status of each challenge object e.g. "valid"... + :param list statuses: status of each challenge object :param bool combos: Whether or not to add combinations """ # pylint: disable=redefined-outer-name - challbs = [ + challbs = tuple( chall_to_challb(chall, status) for chall, status in itertools.izip(challs, statuses) - ] + ) authz_kwargs = { "identifier": messages2.Identifier( typ=messages2.IDENTIFIER_FQDN, value=domain), @@ -128,12 +125,16 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): } if combos: authz_kwargs.update({"combinations": gen_combos(challbs)}) - if authz_status == "valid": + if authz_status == messages2.STATUS_VALID: now = datetime.datetime.now() authz_kwargs.update({ - "status": messages2.Status(authz_status), + "status": authz_status, "expires": datetime.datetime(now.year, now.month+1, now.day), }) + else: + authz_kwargs.update({ + "status": authz_status, + }) # pylint: disable=star-args return messages2.AuthorizationResource( diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 1f99c8f59..7445c1666 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -6,6 +6,7 @@ import unittest import mock from letsencrypt.acme import challenges +from letsencrypt.acme import messages2 from letsencrypt.client import errors from letsencrypt.client import le_util @@ -36,7 +37,8 @@ class ChallengeFactoryTest(unittest.TestCase): self.dom = "test" self.handler.authzr[self.dom] = acme_util.gen_authzr( - "pending", self.dom, acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.dom, acme_util.CHALLENGES, + [messages2.STATUS_PENDING]*6, False) def test_all(self): cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) @@ -55,8 +57,9 @@ class ChallengeFactoryTest(unittest.TestCase): def test_unrecognized(self): self.handler.authzr["failure.com"] = acme_util.gen_authzr( - "pending", "failure.com", - [mock.Mock(chall="chall", typ="unrecognized")], ["pending"]) + messages2.STATUS_PENDING, "failure.com", + [mock.Mock(chall="chall", typ="unrecognized")], + [messages2.STATUS_PENDING]) self.assertRaises(errors.LetsEncryptClientError, self.handler._challenge_factory, "failure.com", [0]) @@ -145,6 +148,14 @@ class GetAuthorizationsTest(unittest.TestCase): self.assertEqual(len(authzr), 3) + def test_perform_failure(self): + self.mock_net.request_domain_challenges.side_effect = functools.partial( + gen_dom_authzr, challs=acme_util.CHALLENGES) + self.mock_dv_auth.perform.side_effect = errors.AuthorizationError + + self.assertRaises(errors.AuthorizationError, + self.handler.get_authorizations, ["0"]) + def _get_exp_response(self, domain, path, challs): # pylint: disable=no-self-use exp_resp = [None] * len(challs) @@ -157,28 +168,129 @@ class GetAuthorizationsTest(unittest.TestCase): for dom in self.handler.authzr.keys(): azr = self.handler.authzr[dom] self.handler.authzr[dom] = acme_util.gen_authzr( - "valid", dom, [challb.chall for challb in azr.body.challenges], - ["valid"]*len(azr.body.challenges), azr.body.combinations) + messages2.STATUS_VALID, + dom, + [challb.chall for challb in azr.body.challenges], + [messages2.STATUS_VALID]*len(azr.body.challenges), + azr.body.combinations) class PollChallengesTest(unittest.TestCase): + # pylint: disable=protected-access """Test poll challenges.""" def setUp(self): + from letsencrypt.client.auth_handler import challb_to_achall from letsencrypt.client.auth_handler import AuthHandler - # Account is mocked... + + # Account and network are mocked... + self.mock_net = mock.MagicMock() self.handler = AuthHandler( - None, None, None, mock.Mock(key="mock_key")) + None, None, self.mock_net, mock.Mock(key="mock_key")) self.doms = ["0", "1", "2"] self.handler.authzr[self.doms[0]] = acme_util.gen_authzr( - "pending", self.doms[0], acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.doms[0], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) self.handler.authzr[self.doms[1]] = acme_util.gen_authzr( - "pending", self.doms[1], acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.doms[1], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) self.handler.authzr[self.doms[2]] = acme_util.gen_authzr( - "pending", self.doms[2], acme_util.CHALLENGES, ["pending"]*6, False) + messages2.STATUS_PENDING, self.doms[2], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) + + self.chall_update = {} + for dom in self.doms: + self.chall_update[dom] = [ + challb_to_achall(challb, "dummy_key", dom) + for challb in self.handler.authzr[dom].body.challenges] + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid + self.handler._poll_challenges(self.chall_update, False) + + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_VALID) + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges_failure_best_effort(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid + self.handler._poll_challenges(self.chall_update, True) + + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_PENDING) + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges_failure(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid + self.assertRaises(errors.AuthorizationError, + self.handler._poll_challenges, + self.chall_update, False) + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_unable_to_find_challenge_status(self, unused_mock_time): + from letsencrypt.client.auth_handler import challb_to_achall + self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid + self.chall_update[self.doms[0]].append( + challb_to_achall(acme_util.RECOVERY_CONTACT_P, "key", self.doms[0])) + self.assertRaises( + errors.AuthorizationError, + self.handler._poll_challenges, self.chall_update, False) + + def test_verify_authzr_failure(self): + self.assertRaises( + errors.AuthorizationError, self.handler.verify_authzr_complete) + + + def _mock_poll_solve_one_valid(self, authzr): + # Pending here because my dummy script won't change the full status. + # Basically it didn't raise an error and it stopped earlier than + # Making all challenges invalid which would make mock_poll_solve_one + # change authzr to invalid + return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_VALID) + + def _mock_poll_solve_one_invalid(self, authzr): + return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_INVALID) + + def _mock_poll_solve_one_chall(self, authzr, desired_status): + # pylint: disable=no-self-use + """Dummy method that solves one chall at a time to desired_status. + + When all are solved.. it changes authzr.status to desired_status + + """ + new_challbs = authzr.body.challenges + for challb in authzr.body.challenges: + if challb.status != desired_status: + new_challbs = tuple( + challb_temp if challb_temp != challb + else acme_util.chall_to_challb(challb.chall, desired_status) + for challb_temp in authzr.body.challenges + ) + break + + if all(test_challb.status == desired_status + for test_challb in new_challbs): + status_ = desired_status + else: + status_ = authzr.body.status + + new_authzr = messages2.AuthorizationResource( + uri=authzr.uri, + new_cert_uri=authzr.new_cert_uri, + body=messages2.Authorization( + identifier=authzr.body.identifier, + challenges=new_challbs, + combinations=authzr.body.combinations, + key=authzr.body.key, + contact=authzr.body.contact, + status=status_, + ), + ) + return (new_authzr, "response") class GenChallengePathTest(unittest.TestCase): """Tests for letsencrypt.client.auth_handler.gen_challenge_path. @@ -328,7 +440,8 @@ def gen_auth_resp(chall_list): def gen_dom_authzr(domain, unused_new_authzr_uri, challs): """Generates new authzr for domains.""" return acme_util.gen_authzr( - "pending", domain, challs, ["pending"]*len(challs)) + messages2.STATUS_PENDING, domain, challs, + [messages2.STATUS_PENDING]*len(challs)) def gen_path(required, challs): diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 73b6ba430..9359e53d0 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -85,7 +85,6 @@ class ChooseAccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.display.ops.util") def test_one(self, mock_util): - print self.acc1 mock_util().menu.return_value = (display_util.OK, 0) self.assertEqual(self._call([self.acc1]), self.acc1) diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py index f263dce9e..f2fe32d6d 100644 --- a/letsencrypt/client/tests/network2_test.py +++ b/letsencrypt/client/tests/network2_test.py @@ -233,6 +233,12 @@ class NetworkTest(unittest.TestCase): self.assertRaises( errors.UnexpectedUpdate, self.net.update_registration, self.regr) + def test_agree_to_tos(self): + self.net.update_registration = mock.Mock() + self.net.agree_to_tos(self.regr) + regr = self.net.update_registration.call_args[0][0] + self.assertEqual(self.regr.terms_of_service, regr.body.agreement) + def test_request_challenges(self): self.response.status_code = httplib.CREATED self.response.headers['Location'] = self.authzr.uri @@ -280,6 +286,7 @@ class NetworkTest(unittest.TestCase): @unittest.skip("Skip til challenge_resource boulder issue is resolved") def test_answer_challenge_missing_next(self): + # TODO: Change once acme-spec #93 is resolved/boulder issue self._mock_post_get() self.assertRaises(errors.NetworkError, self.net.answer_challenge, self.challr.body, challenges.DNSResponse()) diff --git a/tox.ini b/tox.ini index fe9da1865..47b509203 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=87 + python setup.py nosetests --with-coverage --cover-min-percentage=89 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187)