Refactor integration test.

Add a new tiny client called chisel, in place of test.js. This reduces the
number of language runtimes Boulder depends on for its tests. Also, since chisel
uses the acme Python library, we get more testing of that library, which
underlies Certbot. This also gives us more flexibility to hook different parts
of the issuance flows in our tests.

Reorganize integration-test.py itself. There was not clear separation of
specific test cases. Some test cases were added as part of run_node_test; some
were wrapped around it. There is now much closer to one function per test case.
Eventually we may be able to adopt Python's test infrastructure for these test
cases.

Remove some unused imports; consolidate on urllib2 instead of urllib.

For getting serial number and expiration date, replace shelling out to OpenSSL
with using pyOpenSSL, since we already have an in-memory parsed certificate.

Replace ISSUANCE_FAILED, REVOCATION_FAILED, MAILER_FAILED with simple die, since
we don't use these. Later, I'd like to remove the other specific exit codes. We
don't make very good use of them, and it would be more effective to just use
stack traces or, even better, reporting of which test cases failed.

Make single_ocsp_sign responsible for its own subprocess lifecycle.

Skip running startservers if WFE is already running, to make it easier to
iterate against a running Boulder (saves a few seconds of Boulder startup).
This commit is contained in:
Jacob Hoffman-Andrews 2017-01-20 22:49:53 -08:00
parent 16ab736c07
commit 7705b18a70
4 changed files with 281 additions and 162 deletions

View File

@ -191,7 +191,7 @@ if [[ "$RUN" =~ "integration" ]] ; then
source ${CERTBOT_PATH}/${VENV_NAME:-venv}/bin/activate
fi
run python test/integration-test.py --all
run python test/integration-test.py --chisel
end_context #integration
fi

178
test/chisel.py Normal file
View File

@ -0,0 +1,178 @@
"""
A simple client that uses the Python ACME library to run a test issuance against
a local Boulder server. Usage:
$ virtualenv venv
$ . venv/bin/activate
$ pip install -r requirements.txt
$ python chisel.py foo.com bar.com
"""
import json
import logging
import os
import sys
import threading
import time
import urllib2
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
import OpenSSL
from acme import challenges
from acme import client as acme_client
from acme import errors as acme_errors
from acme import jose
from acme import messages
from acme import standalone
logger = logging.getLogger()
logger.setLevel(int(os.getenv('LOGLEVEL', 20)))
def make_client(email=None):
"""Build an acme.Client and register a new account with a random key."""
key = jose.JWKRSA(key=rsa.generate_private_key(65537, 2048, default_backend()))
net = acme_client.ClientNetwork(key, verify_ssl=False,
user_agent="Boulder integration tester")
client = acme_client.Client("http://localhost:4000/directory", key=key, net=net)
account = client.register(messages.NewRegistration.from_data(email=email))
client.agree_to_tos(account)
return client
def get_chall(client, domain):
"""Ask the server for an authz, return the authz and an HTTP-01 challenge."""
authz = client.request_domain_challenges(domain)
for chall_body in authz.body.challenges:
if isinstance(chall_body.chall, challenges.HTTP01):
return authz, chall_body
raise "No HTTP-01 challenge found"
def make_authzs(client, domains):
"""Make authzs for each of the given domains. Return a list of authzs
and challenges."""
authzs, challenges = [], []
for d in domains:
authz, chall_body = get_chall(client, d)
authzs.append(authz)
challenges.append(chall_body)
return authzs, challenges
class ValidationError(Exception):
"""An error that occurs during challenge validation."""
def __init__(self, domain, problem_type, detail, *args, **kwargs):
self.domain = domain
self.problem_type = problem_type
self.detail = detail
def __str__(self):
return "%s: %s: %s" % (self.domain, self.problem_type, self.detail)
def issue(client, authzs, cert_output=None):
"""Given a list of authzs that are being processed by the server,
wait for them to be ready, then request issuance of a cert with a random
key for the given domains."""
domains = [authz.body.identifier.value for authz in authzs]
pkey = OpenSSL.crypto.PKey()
pkey.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
csr = OpenSSL.crypto.X509Req()
csr.add_extensions([
OpenSSL.crypto.X509Extension(
'subjectAltName',
critical=False,
value=', '.join('DNS:' + d for d in domains).encode()
),
])
csr.set_pubkey(pkey)
csr.set_version(2)
csr.sign(pkey, 'sha256')
cert_resource = None
try:
cert_resource, _ = client.poll_and_request_issuance(jose.ComparableX509(csr), authzs)
except acme_errors.PollError as error:
# If we get a PollError, pick the first failed authz and turn it into a more
# useful ValidationError that contains details we can look for in tests.
for authz in error.updated:
updated_authz = json.loads(urllib2.urlopen(authz.uri).read())
domain = authz.body.identifier.value,
for c in updated_authz['challenges']:
if 'error' in c:
err = c['error']
raise ValidationError(domain, err['type'], err['detail'])
# If none of the authz's had an error, just re-raise.
raise
if cert_output != None:
pem = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM,
cert_resource.body)
with open(cert_output, 'w') as f:
f.write(pem)
return cert_resource
def http_01_answer(client, chall_body):
"""Return an HTTP01Resource to server in response to the given challenge."""
response, validation = chall_body.response_and_validation(client.key)
return standalone.HTTP01RequestHandler.HTTP01Resource(
chall=chall_body.chall, response=response,
validation=validation)
def auth_and_issue(domains, email=None, cert_output=None, client=None):
"""Make authzs for each of the given domains, set up a server to answer the
challenges in those authzs, tell the ACME server to validate the challenges,
then poll for the authzs to be ready and issue a cert."""
if client == None:
client = make_client(email)
authzs, challenges = make_authzs(client, domains)
port = 5002
answers = set([http_01_answer(client, c) for c in challenges])
server = standalone.HTTP01Server(("", port), answers)
thread = threading.Thread(target=server.serve_forever)
thread.start()
# Loop until the HTTP01Server is ready.
while True:
try:
urllib2.urlopen("http://localhost:%d" % port)
break
except urllib2.URLError:
time.sleep(0.1)
try:
for chall_body in challenges:
client.answer_challenge(chall_body, chall_body.response(client.key))
cert_resource = issue(client, authzs, cert_output)
return cert_resource
finally:
server.shutdown()
server.server_close()
thread.join()
def expect_problem(problem_type, func):
"""Run a function. If it raises a ValidationError or messages.Error that
contains the given problem_type, return. If it raises no error or the wrong
error, raise an exception."""
ok = False
try:
func()
except ValidationError as e:
if e.problem_type == problem_type:
ok = True
else:
raise
except messages.Error as e:
if problem_type in e.__str__():
ok = True
else:
raise
if not ok:
raise Exception("Expected %s, got no error" % problem_type)
if __name__ == "__main__":
try:
auth_and_issue(sys.argv[1:])
except messages.Error, e:
print e
sys.exit(1)

View File

@ -3,30 +3,27 @@ import argparse
import atexit
import base64
import datetime
import errno
import json
import os
import random
import re
import shutil
import socket
import subprocess
import signal
import sys
import tempfile
import time
import urllib
import urllib2
import startservers
ISSUANCE_FAILED = 1
REVOCATION_FAILED = 2
MAILER_FAILED = 3
import chisel
from chisel import auth_and_issue
class ExitStatus:
OK, PythonFailure, NodeFailure, Error, OCSPFailure, CTFailure, IncorrectCommandLineArgs, RevokerFailure = range(8)
JS_DIR = 'test/js'
class ProcInfo:
"""
Args:
@ -55,7 +52,7 @@ def fetch_ocsp(request_bytes, url):
# Make the OCSP request three different ways: by POST, by GET, and by GET with
# URL-encoded parameters. All three should have an identical response.
get_response = urllib2.urlopen("%s/%s" % (url, ocsp_req_b64)).read()
get_encoded_response = urllib2.urlopen("%s/%s" % (url, urllib.quote(ocsp_req_b64, safe = ""))).read()
get_encoded_response = urllib2.urlopen("%s/%s" % (url, urllib2.quote(ocsp_req_b64, safe = ""))).read()
post_response = urllib2.urlopen("%s/" % (url), request_bytes).read()
return (post_response, get_response, get_encoded_response)
@ -151,39 +148,12 @@ def wait_for_ocsp_good(cert_file, issuer_file, url):
def wait_for_ocsp_revoked(cert_file, issuer_file, url):
fetch_until(cert_file, issuer_file, url, ": good", ": revoked")
def get_expiry_time(cert_file):
try:
output = subprocess.check_output(["openssl", "x509", "-enddate", "-noout", "-in", cert_file])
except subprocess.CalledProcessError as e:
output = e.output
print output
print "subprocess returned non-zero: %s" % e
die(ExitStatus.NodeFailure)
def test_multidomain():
auth_and_issue([random_domain(), random_domain()])
return datetime.datetime.strptime(output.split('\n')[0].split('=')[1], '%b %d %H:%M:%S %Y %Z')
def verify_ct_submission(expectedSubmissions, url):
resp = urllib2.urlopen(url)
submissionStr = resp.read()
if int(submissionStr) != expectedSubmissions:
print "Expected %d submissions, found %d" % (expectedSubmissions, int(submissionStr))
die(ExitStatus.CTFailure)
return 0
def run_node_test(domain, chall_type, expected_ct_submissions):
email_addr = "js.integration.test@letsencrypt.org"
cert_file = os.path.join(tempdir, "cert.der")
def test_ocsp():
cert_file_pem = os.path.join(tempdir, "cert.pem")
key_file = os.path.join(tempdir, "key.pem")
# Issue the certificate and transform it from DER-encoded to PEM-encoded.
if subprocess.Popen('''
node test.js --email %s --domains %s \
--certKey %s --cert %s --challType %s && \
openssl x509 -in %s -out %s -inform der -outform pem
''' % (email_addr, domain, key_file, cert_file, chall_type, cert_file, cert_file_pem),
shell=True, cwd=JS_DIR).wait() != 0:
print("\nIssuing failed")
return ISSUANCE_FAILED
auth_and_issue([random_domain()], cert_output=cert_file_pem)
ee_ocsp_url = "http://localhost:4002"
@ -191,40 +161,65 @@ def run_node_test(domain, chall_type, expected_ct_submissions):
# checking OCSP until we either see a good response or we timeout (5s).
wait_for_ocsp_good(cert_file_pem, "test/test-ca2.pem", ee_ocsp_url)
verify_ct_submission(expected_ct_submissions, "http://localhost:4500/submissions")
def test_ct_submission():
url = "http://localhost:4500/submissions"
submissions = urllib2.urlopen(url).read()
expected_submissions = int(submissions)+1
auth_and_issue([random_domain()])
submissions = urllib2.urlopen(url).read()
if int(submissions) != expected_submissions:
print "Expected %d submissions, found %s" % (expected_submissions, submissions)
die(ExitStatus.CTFailure)
def random_domain():
"""Generate a random domain for testing (to avoid rate limiting)."""
return "rand.%x.xyz" % random.randrange(2**32)
def test_expiration_mailer():
email_addr = "integration.%x@boulder.local" % random.randrange(2**16)
cert = auth_and_issue([random_domain()], email=email_addr).body
# Check that the expiration mailer sends a reminder
expiry = get_expiry_time(cert_file_pem)
expiry = datetime.datetime.strptime(cert.get_notAfter(), '%Y%m%d%H%M%SZ')
no_reminder = expiry + datetime.timedelta(days=-31)
first_reminder = expiry + datetime.timedelta(days=-13)
last_reminder = expiry + datetime.timedelta(days=-2)
try:
urllib2.urlopen("http://localhost:9381/clear", data='')
get_future_output('./bin/expiration-mailer --config %s/expiration-mailer.json' %
print get_future_output('./bin/expiration-mailer --config %s/expiration-mailer.json' %
default_config_dir, no_reminder)
get_future_output('./bin/expiration-mailer --config %s/expiration-mailer.json' %
print get_future_output('./bin/expiration-mailer --config %s/expiration-mailer.json' %
default_config_dir, first_reminder)
get_future_output('./bin/expiration-mailer --config %s/expiration-mailer.json' %
print get_future_output('./bin/expiration-mailer --config %s/expiration-mailer.json' %
default_config_dir, last_reminder)
resp = urllib2.urlopen("http://localhost:9381/count?to=%s" % email_addr)
mailcount = int(resp.read())
if mailcount != 2:
print("\nExpiry mailer failed: expected 2 emails, got %d" % mailcount)
return MAILER_FAILED
die(1)
except Exception as e:
print("\nExpiry mailer failed:")
print(e)
return MAILER_FAILED
die(1)
if subprocess.Popen('''
node revoke.js %s %s http://localhost:4000/acme/revoke-cert
''' % (cert_file, key_file), shell=True, cwd=JS_DIR).wait() != 0:
print("\nRevoking failed")
return REVOCATION_FAILED
def test_revoke_by_account():
cert_file_pem = os.path.join(tempdir, "revokeme.pem")
client = chisel.make_client()
cert = auth_and_issue([random_domain()], client=client).body
client.revoke(cert.body)
wait_for_ocsp_revoked(cert_file_pem, "test/test-ca2.pem", ee_ocsp_url)
return 0
def test_caa():
"""Request issuance for two CAA domains, one where we are permitted and one where we are not."""
auth_and_issue(["good-caa-reserved.com"])
# TODO(#2514): Currently, the gRPC setup doesn't correctly set the error
# field on failed validations. Once #2514 is fixed, remove this if statement.
if os.getenv('BOULDER_CONFIG_DIR') != 'test/config-next':
chisel.expect_problem("urn:acme:error:connection",
lambda: auth_and_issue(["bad-caa-reserved.com"]))
def run_custom(cmd, cwd=None):
if subprocess.Popen(cmd, shell=True, cwd=cwd, executable='/bin/bash').wait() != 0:
die(ExitStatus.PythonFailure)
@ -256,20 +251,18 @@ def single_ocsp_sign():
p = subprocess.Popen(
'./bin/ocsp-responder --config test/issuer-ocsp-responder.json', shell=True)
global ocsp_proc
ocsp_proc = p
# Verify that the static OCSP responder, which answers with a
# pre-signed, long-lived response for the CA cert, works.
wait_for_ocsp_good("test/test-ca2.pem", "test/test-root.pem", "http://localhost:4003")
p.send_signal(signal.SIGTERM)
def get_future_output(cmd, date, cwd=None):
return subprocess.check_output(cmd, cwd=cwd, env={'FAKECLOCK': date.strftime("%a %b %d %H:%M:%S UTC %Y")}, shell=True)
def run_expired_authz_purger_test():
subprocess.check_output('''node test.js --email %s --domains %s --abort-step %s''' %
("purger@test.com", "eap-test.com", "startChallenge"),
shell=True, cwd=JS_DIR)
def test_expired_authz_purger():
# Make an authz, but don't attempt its challenges.
chisel.make_client().request_domain_challenges("eap-test.com")
def expect(target_time, num):
expected_output = 'Deleted a total of %d expired pending authorizations' % num
@ -288,80 +281,36 @@ def run_expired_authz_purger_test():
expect(now, 0)
expect(after_grace_period, 1)
def run_certificates_per_name_test():
try:
# This command will return a non zero error code. In order
# to avoid a CalledProcessException we use Popen.
handle = subprocess.Popen(
'''node test.js --email %s --domains %s''' % ('test@lim.it', 'lim.it'),
shell=True, cwd=JS_DIR, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
handle.wait()
out, err = handle.communicate()
except subprocess.CalledProcessError as e:
print("\nFailure while running certificates per name test %s" % e)
die(ExitStatus.PythonFailure)
expected = [
"urn:acme:error:rateLimited",
"Error creating new cert :: Too many certificates already issued for: lim.it",
"429"
]
for s in expected:
if s not in out:
print("\nCertificates per name test: expected %s not present in output" % s)
die(ExitStatus.Error)
def test_certificates_per_name():
chisel.expect_problem("urn:acme:error:rateLimited",
lambda: auth_and_issue(["lim.it"]))
default_config_dir = os.environ.get('BOULDER_CONFIG_DIR', '')
if default_config_dir == '':
default_config_dir = 'test/config'
def run_admin_revoker_test():
cert_file = os.path.join(tempdir, "ar-cert.der")
def test_admin_revoker_cert():
cert_file_pem = os.path.join(tempdir, "ar-cert.pem")
# Issue certificate for serial-revoke test
if subprocess.Popen('''
node test.js --domains ar-test.com --cert %s && \
openssl x509 -in %s -out %s -inform der -outform pem
''' % (cert_file, cert_file, cert_file_pem),
shell=True, cwd=JS_DIR).wait() != 0:
print("\nIssuing failed")
die(ExitStatus.NodeFailure)
# Extract serial from certificate
try:
serial = subprocess.check_output("openssl x509 -in %s -noout -serial | cut -c 8-" % (cert_file_pem),
shell=True, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
print "Failed to extract serial: %s" % (e.output)
die(ExitStatus.PythonFailure)
serial = serial.rstrip()
cert = auth_and_issue([random_domain()], cert_output=cert_file_pem).body
serial = "%x" % cert.get_serial_number()
# Revoke certificate by serial
config = default_config_dir + "/admin-revoker.json"
if subprocess.Popen("./bin/admin-revoker serial-revoke --config %s %s %d" % (config, serial, 1),
shell=True).wait() != 0:
if subprocess.Popen(
"./bin/admin-revoker serial-revoke --config %s/admin-revoker.json %s %d" % (
default_config_dir, serial, 1), shell=True).wait() != 0:
print("Failed to revoke certificate")
die(ExitStatus.RevokerFailure)
# Wait for OCSP response to indicate revocation took place
ee_ocsp_url = "http://localhost:4002"
wait_for_ocsp_revoked(cert_file_pem, "test/test-ca2.pem", ee_ocsp_url)
# Issue certificate for auth-revoke test
try:
output = subprocess.check_output("node test.js --domains ar-auth-test.com --abort-step startChallenge",
shell=True, cwd=JS_DIR)
except subprocess.CalledProcessError as e:
print "Failed to create authorization: %s" % (e.output)
die(ExitStatus.NodeFailure)
# Get authorization URL from last line of output
lines = output.rstrip().split("\n")
prefix = "authorization-url="
if not lines[-1].startswith(prefix):
print("Failed to extract authorization URL")
die(ExitStatus.NodeFailure)
url = lines[-1][len(prefix):]
def test_admin_revoker_authz():
# Make an authz, but don't attempt its challenges.
authz_resource = chisel.make_client().request_domain_challenges("ar-auth-test.com")
url = authz_resource.uri
# Revoke authorization by domain
try:
output = subprocess.check_output("./bin/admin-revoker auth-revoke --config %s ar-auth-test.com" % (config),
shell=True)
output = subprocess.check_output(
"./bin/admin-revoker auth-revoke --config %s/admin-revoker.json ar-auth-test.com" % (default_config_dir), shell=True)
except subprocess.CalledProcessError as e:
print("Failed to revoke authorization: %s", e)
die(ExitStatus.RevokerFailure)
@ -369,7 +318,7 @@ def run_admin_revoker_test():
print("admin-revoker didn't revoke the expected number of pending and finalized authorizations")
die(ExitStatus.RevokerFailure)
# Check authorization has actually been revoked
response = urllib.urlopen(url)
response = urllib2.urlopen(url)
data = json.loads(response.read())
if data['status'] != "revoked":
print("Authorization wasn't revoked")
@ -384,58 +333,35 @@ def main():
help="run all of the clients' integration tests")
parser.add_argument('--certbot', dest='run_certbot', action='store_true',
help="run the certbot integration tests")
parser.add_argument('--node', dest="run_node", action="store_true",
help="run the node client's integration tests")
parser.add_argument('--chisel', dest="run_chisel", action="store_true",
help="run integration tests using chisel")
# allow any ACME client to run custom command for integration
# testing (without having to implement its own busy-wait loop)
parser.add_argument('--custom', metavar="CMD", help="run custom command")
parser.set_defaults(run_all=False, run_certbot=False, run_node=False)
args = parser.parse_args()
if not (args.run_all or args.run_certbot or args.run_node or args.custom is not None):
print >> sys.stderr, "must run at least one of the letsencrypt or node tests with --all, --certbot, --node, or --custom"
if not (args.run_all or args.run_certbot or args.run_chisel or args.custom is not None):
print >> sys.stderr, "must run at least one of the letsencrypt or node tests with --all, --certbot, --chisel, or --custom"
die(ExitStatus.IncorrectCommandLineArgs)
if not startservers.start(race_detection=True):
die(ExitStatus.Error)
single_ocsp_sign()
if args.run_all or args.run_node:
if subprocess.Popen('npm install', shell=True, cwd=JS_DIR).wait() != 0:
print("\n Installing NPM modules failed")
# Keep track of whether we started the Boulder servers and need to shut them down.
started_servers = False
# Check if WFE is already running.
try:
urllib2.urlopen("http://localhost:4000/directory")
except urllib2.URLError:
# WFE not running, start all of Boulder.
started_servers = True
if not startservers.start(race_detection=True):
die(ExitStatus.Error)
# Pick a random hostname so we don't run into certificate rate limiting.
domains = "www.%x-TEST.com,%x-test.com" % (
random.randrange(2**32), random.randrange(2**32))
challenge_types = ["http-01", "dns-01"]
expected_ct_submissions = 1
resp = urllib2.urlopen("http://localhost:4500/submissions")
submissionStr = resp.read()
if int(submissionStr) > 0:
expected_ct_submissions = int(submissionStr)+1
for chall_type in challenge_types:
if run_node_test(domains, chall_type, expected_ct_submissions) != 0:
die(ExitStatus.NodeFailure)
expected_ct_submissions += 1
if run_node_test("good-caa-reserved.com", challenge_types[0], expected_ct_submissions) != 0:
print("\nDidn't issue certificate for domain with good CAA records")
die(ExitStatus.NodeFailure)
if run_node_test("bad-caa-reserved.com", challenge_types[0], expected_ct_submissions) != ISSUANCE_FAILED:
print("\nIssued certificate for domain with bad CAA records")
die(ExitStatus.NodeFailure)
run_expired_authz_purger_test()
run_certificates_per_name_test()
run_admin_revoker_test()
if args.run_all or args.run_chisel:
run_chisel()
# Simulate a disconnection from RabbitMQ to make sure reconnects work.
startservers.bounce_forward()
if started_servers:
startservers.bounce_forward()
if args.run_all or args.run_certbot:
run_client_tests()
@ -443,10 +369,24 @@ def main():
if args.custom:
run_custom(args.custom)
if not startservers.check():
if started_servers and not startservers.check():
die(ExitStatus.Error)
exit_status = ExitStatus.OK
def run_chisel():
# XXX: Test multiple challenge types
test_expired_authz_purger()
test_multidomain()
test_expiration_mailer()
test_ct_submission()
test_caa()
test_admin_revoker_cert()
test_admin_revoker_authz()
test_certificates_per_name()
test_ocsp()
single_ocsp_sign()
if __name__ == "__main__":
try:
main()
@ -463,5 +403,3 @@ def stop():
else:
if exit_status:
print("\n\nFAILURE %d" % exit_status)
if ocsp_proc.poll() is None:
ocsp_proc.kill()

3
test/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
acme>=0.10.1
cryptography>=0.7
PyOpenSSL