fxa-auth-server/test/load/loadtests.py

271 строка
9.8 KiB
Python

import os
import random
import hashlib
import fxa.errors
from fxa._utils import APIClient
from fxa.core import Client
from loads import TestCase
# Parameters to affect the proportion of various reqs in the loadtest.
#
# This is loosely modelled on production traffic analysis performed in:
#
# https://bugzilla.mozilla.org/show_bug.cgi?id=1097584
#
# Based on the above we aim for the following breakdown:
#
# * 95% of tests are a login flow that does various session operations:
# * 70% of these use an existing account
# * 30% of these create a new account
# * 10% of those call the resend_code endpoint
# * 80% of those call the verify_code endpoint
# * 2% of those will delete the account when they're done
# * each flow does an average of 500 status poll operations
# (this is by far the most frequent request in current prod traffic)
# * each flow fetches the keys exactly once
# * each flow does an average of 50 cert sign requests
# * 20% of flows will explicitly check the session status
# * 10% of flows will explicitly tear down the session once complete
# * 5% of flows will generate some random bytes
# * 3% of tests exercise the password reset flow
# * 1% of tests exercise are the password change flow
# * 1% of are simple requests for the browserid support document
PERCENT_TEST_LOGIN = 95
PERCENT_TEST_RESET = 3
PERCENT_TEST_CHANGE = 1
PERCENT_TEST_SUPPORTDOC = 1
PERCENT_LOGIN_CREATE = 30
PERCENT_LOGIN_CREATE_RESEND = 10
PERCENT_LOGIN_CREATE_VERIFY = 80
PERCENT_LOGIN_CREATE_DESTROY = 2
PERCENT_LOGIN_STATUS = 20
PERCENT_LOGIN_TEARDOWN = 10
PERCENT_LOGIN_RANDBYTES = 5
LOGIN_POLL_REQS_MIN = 10
LOGIN_POLL_REQS_MAX = 1000
LOGIN_SIGN_REQS_MIN = 10
LOGIN_SIGN_REQS_MAX = 90
# Error constants used by the fxa-auth-server API.
ERROR_ACCOUNT_EXISTS = 101
ERROR_UNKNOWN_ACCOUNT = 102
ERROR_INVALID_CODE = 105
ERROR_INVALID_TOKEN = 110
# The tests need a public key for the server to sign, but we don't actually
# do anything with it. It suffices to use a fixed dummy key throughout.
DUMMY_PUBLIC_KEY = {
'algorithm': 'RS',
'n': '475938596723561050357149433919674961454460669256778579'
'095393476820271428065297309134131686299358278907987200'
'7974809511698859885077002492642203267408776123',
'e': '65537',
}
def uniq(size=10):
"""Generate a short random hex string."""
return os.urandom(size // 2 + 1).encode('hex')[:size]
class LoadTest(TestCase):
server_url = 'https://api-accounts.stage.mozaws.net'
def setUp(self):
super(LoadTest, self).setUp()
self.client = Client(APIClient(self.server_url, session=self.session))
def _pick(self, *choices):
"""Pick one from a list of (item, weighting) options."""
sum_weights = sum(choice[1] for choice in choices)
remainder = random.randint(0, sum_weights - 1)
for choice, weight in choices:
remainder -= weight
if remainder < 0:
return choice
assert False, "somehow failed to pick from {}".format(choices)
def _perc(self, percent):
"""Decide whether to do something, given desired percentage of runs."""
return random.randint(0, 99) < percent
def test_auth_server(self):
"""Top-level method to run a randomly-secleted auth-server test."""
which_test = self._pick(
(self.test_login_session_flow, PERCENT_TEST_LOGIN),
(self.test_password_reset_flow, PERCENT_TEST_RESET),
(self.test_password_change_flow, PERCENT_TEST_CHANGE),
(self.test_support_doc_flow, PERCENT_TEST_SUPPORTDOC),
)
which_test()
def test_login_session_flow(self):
"""Do a full login-flow with cert signing etc."""
# Login as either a new or existing user.
if self._perc(PERCENT_LOGIN_CREATE):
session = self._authenticate_as_new_user()
can_delete = True
else:
session = self._authenticate_as_existing_user()
can_delete = False
try:
# Do a whole lot of account status polling.
n_poll_reqs = random.randint(LOGIN_POLL_REQS_MIN,
LOGIN_POLL_REQS_MAX)
for i in xrange(n_poll_reqs):
session.get_email_status()
# Always fetch the keys.
session.fetch_keys()
# Sometimes check the session status.
if self._perc(PERCENT_LOGIN_STATUS):
session.check_session_status()
# Sometimes get some random bytes.
if self._perc(PERCENT_LOGIN_RANDBYTES):
session.get_random_bytes()
# Always do some number of signing requests.
n_sign_reqs = random.randint(LOGIN_SIGN_REQS_MIN,
LOGIN_SIGN_REQS_MAX)
for i in xrange(n_sign_reqs):
session.sign_certificate(DUMMY_PUBLIC_KEY)
# Sometimes tear down the session.
if self._perc(PERCENT_LOGIN_TEARDOWN):
session.destroy_session()
except fxa.errors.ClientError as e:
# There's a small chance this could fail due to concurrent
# password change destroying the session token.
if e.errno != ERROR_INVALID_TOKEN:
raise
# Sometimes destroy the account.
if can_delete:
if self._perc(PERCENT_LOGIN_CREATE_DESTROY):
self.client.destroy_account(
email=session.email,
stretchpwd=self._get_stretchpwd(session.email),
)
def _get_stretchpwd(self, email):
return hashlib.sha256(email).hexdigest()
def _get_new_user_email(self):
uid = uniq()
return "loads-fxa-{}-new@restmail.lcip.org".format(uid)
def _get_existing_user_email(self):
uid = random.randint(1, 999)
return "loads-fxa-{}-old@restmail.lcip.org".format(uid)
def _authenticate_as_new_user(self):
# Authenticate as a brand-new user account.
# Assume it doesn't exist, and try to create the account.
# But it's not big deal if it happens to already exist.
email = self._get_new_user_email()
kwds = {
"email": email,
"stretchpwd": self._get_stretchpwd(email),
"keys": True,
"preVerified": True,
}
try:
session = self.client.create_account(**kwds)
except fxa.errors.ClientError as e:
if e.errno != ERROR_ACCOUNT_EXISTS:
raise
kwds.pop("preVerified")
session = self.client.login(**kwds)
# Sometimes resend the confirmation email.
if self._perc(PERCENT_LOGIN_CREATE_RESEND):
session.resend_email_code()
# Sometimes (pretend to) verify the confirmation code.
if self._perc(PERCENT_LOGIN_CREATE_VERIFY):
try:
session.verify_email_code(uniq(32))
except fxa.errors.ClientError as e:
if e.errno != ERROR_INVALID_CODE:
raise
return session
def _authenticate_as_existing_user(self):
# Authenticate as an existing user account.
# We select from a small pool of known accounts, creating it
# if it does not exist. This should mean that all the accounts
# are created quickly at the start of the loadtest run.
email = self._get_existing_user_email()
kwds = {
"email": email,
"stretchpwd": self._get_stretchpwd(email),
"keys": True,
}
try:
return self.client.login(**kwds)
except fxa.errors.ClientError as e:
if e.errno != ERROR_UNKNOWN_ACCOUNT:
raise
kwds["preVerified"] = True
# Account creation might likewise fail due to a race.
try:
return self.client.create_account(**kwds)
except fxa.errors.ClientError as e:
if e.errno != ERROR_ACCOUNT_EXISTS:
raise
# Assume a normal login will now succeed.
kwds.pop("preVerified")
return self.client.login(**kwds)
def test_password_reset_flow(self):
email = self._get_existing_user_email()
pft = self.client.send_reset_code(email)
# XXX TODO: how to get the reset code?
# I don't want to actually poll restmail during a loadtest...
pft.get_status()
try:
pft.verify_code("0" * 32)
except fxa.errors.ClientError as e:
if e.errno != ERROR_INVALID_CODE:
raise
pft.get_status()
def test_password_change_flow(self):
email = self._get_existing_user_email()
stretchpwd = self._get_stretchpwd(email)
try:
self.client.change_password(
email,
oldstretchpwd=stretchpwd,
newstretchpwd=stretchpwd,
)
except fxa.errors.ClientError as e:
if e.errno != ERROR_UNKNOWN_ACCOUNT:
raise
# Create the "existing" account if it doens't yet exist.
kwds = {
"email": email,
"stretchpwd": stretchpwd,
"preVerified": True,
}
try:
self.client.create_account(**kwds)
except fxa.errors.ClientError as e:
if e.errno != ERROR_UNKNOWN_ACCOUNT:
raise
else:
self.client.change_password(
email,
oldstretchpwd=stretchpwd,
newstretchpwd=stretchpwd,
)
def test_support_doc_flow(self):
base_url = self.server_url[:-3]
self.session.get(base_url + "/.well-known/browserid")