View
@@ -1,7 +1,10 @@
# pylint: disable=no-self-use
from collections import namedtuple
from mock import patch, Mock, MagicMock
import pytest
import deform
from pyramid import httpexceptions
from pyramid.testing import DummyRequest
from horus.interfaces import (
@@ -16,11 +19,10 @@
from horus.schemas import ProfileSchema
from horus.forms import SubmitForm
from horus.strings import UIStringsBase
import deform
import colander
from h.accounts import schemas
from h.accounts import views
from h.accounts.views import validate_form
from h.accounts.views import RegisterController
from h.accounts.views import ProfileController
from h.accounts.views import AsyncFormViewMapper
@@ -48,44 +50,53 @@ def configure(config):
config.registry.feature.return_value = None
def _get_fake_request(username, password, with_subscriptions=False, active=True):
def _get_fake_request(username, password):
fake_request = DummyRequest()
def get_fake_token():
return 'fake_token'
fake_request.method = 'POST'
fake_request.params['csrf_token'] = 'fake_token'
fake_request.session.get_csrf_token = get_fake_token
fake_request.POST['username'] = username
fake_request.POST['pwd'] = password
if with_subscriptions:
subs = '{"active": activestate, "uri": "username", "id": 1}'
subs = subs.replace('activestate', str(active).lower()).replace('username', username)
fake_request.POST['subscriptions'] = subs
return fake_request
class TestEmailsMustMatchValidator(object):
# A fake version of colander.Invalid for use when testing validate_form
FakeInvalid = namedtuple('FakeInvalid', 'children')
def test_validate_form_passes_data_to_validate():
idata = {}
form = MagicMock()
err, data = validate_form(form, idata)
form.validate.assert_called_with(idata)
def test_validate_form_failure():
invalid = FakeInvalid(children=object())
form = MagicMock()
form.validate.side_effect = deform.ValidationFailure(None, None, invalid)
err, data = validate_form(form, {})
assert err == {'errors': invalid.children}
assert data is None
"""Unit tests for _emails_must_match_validator()."""
def test_validate_form_ok():
form = MagicMock()
form.validate.return_value = {'foo': 'bar'}
def test_it_raises_invalid_if_the_emails_do_not_match(self):
form = deform.Form(schemas.EditProfileSchema())
value = {
"email": "foo",
"emailAgain": "bar"
}
with pytest.raises(colander.Invalid):
views._emails_must_match_validator(form, value)
err, odata = validate_form(form, {})
def test_it_returns_None_if_the_emails_match(self):
form = deform.Form(schemas.EditProfileSchema())
value = {
"email": "foo",
"emailAgain": "foo"
}
assert views._emails_must_match_validator(form, value) is None
assert err is None
assert odata == {'foo': 'bar'}
class TestProfile(object):
@@ -110,125 +121,70 @@ class TestEditProfile(object):
"""Unit tests for ProfileController's edit_profile() method."""
@pytest.mark.usefixtures('activation_model', 'dummy_db_session')
def test_profile_invalid_password(self, config, user_model):
def test_edit_profile_invalid_password(self, form_validator, user_model):
"""Make sure our edit_profile call validates the user password."""
request = _get_fake_request('john', 'doe')
configure(config)
form_validator.return_value = (None, {
"username": "john",
"pwd": "blah",
"subscriptions": "",
})
# With an invalid password, get_user returns None
user_model.get_user.return_value = None
# Mock an invalid password
user_model.validate_user.return_value = False
request = DummyRequest(method='POST')
profile = ProfileController(request)
result = profile.edit_profile()
assert result['code'] == 401
assert any('pwd' in err for err in result['errors'])
@pytest.mark.usefixtures('activation_model', 'dummy_db_session')
def test_edit_profile_with_validation_failure(self, config, user_model):
"""If validation raises edit_profile() should return an error.
If _validate_edit_profile_request() raises an exception then
edit_profile() should return a dict with an "errors" list containing a
list of the error(s) from the exception's .errors property.
def test_edit_profile_with_validation_failure(self, form_validator):
"""If form validation fails, return the error object."""
form_validator.return_value = ({"errors": "BOOM!"}, None)
"""
configure(config)
profile = ProfileController(DummyRequest())
errors = [
("email", ["That email is invalid", "That email is taken"]),
("emailAgain", "The emails must match."),
("password", ["That password is wrong"])
]
with patch(
"h.accounts.views._validate_edit_profile_request") as validate:
validate.side_effect = (
views._InvalidEditProfileRequestError(errors=errors))
result = profile.edit_profile()
request = DummyRequest(method='POST')
profile = ProfileController(request)
result = profile.edit_profile()
assert result["errors"] == errors
assert result == {"errors": "BOOM!"}
@pytest.mark.usefixtures('activation_model', 'dummy_db_session')
def test_edit_profile_successfully(self, config, user_model):
def test_edit_profile_successfully(self, authn_policy, form_validator, user_model):
"""edit_profile() returns a dict with key "form" when successful."""
configure(config)
profile = ProfileController(DummyRequest())
with patch(
"h.accounts.views._validate_edit_profile_request") as validate:
validate.return_value = {
"username": "johndoe",
"pwd": "password",
"subscriptions": []
}
result = profile.edit_profile()
assert "form" in result
assert "errors" not in result
@pytest.mark.usefixtures('activation_model', 'dummy_db_session')
def test_edit_profile_returns_email(self, config, user_model,
authn_policy):
"""edit_profile()'s response should contain the user's current email.
For a valid edit_profile() request
horus.views.ProfileController.edit_profile() returns an HTTPRedirection
object. h.accounts.views.ProfileController.edit_profile() should
add a JSON body to this response containing a "model" dict with the
user's current email address.
AsyncFormViewMapper will pick up this JSON body and preserve it in the
body of the 200 OK response that is finally sent back to the browser.
authn_policy.authenticated_userid.return_value = "johndoe"
form_validator.return_value = (None, {
"username": "johndoe",
"pwd": "password",
"subscriptions": "",
})
user_model.validate_user.return_value = True
user_model.get_by_id.return_value = FakeUser(email="john@doe.com")
request = DummyRequest(method='POST')
profile = ProfileController(request)
result = profile.edit_profile()
The frontend uses this email field to show the user's current email
address in the form.
assert result == {"model": {"email": "john@doe.com"}}
"""
configure(config)
validate_patcher = patch(
"h.accounts.views._validate_edit_profile_request")
edit_profile_patcher = patch(
"horus.views.ProfileController.edit_profile")
get_by_id_patcher = patch("h.accounts.models.User.get_by_id")
result = None
try:
validate = validate_patcher.start()
validate.return_value = {
"username": "fake user name",
"pwd": "fake password",
"subscriptions": []
}
edit_profile = edit_profile_patcher.start()
edit_profile.return_value = httpexceptions.HTTPFound("fake url")
get_by_id = get_by_id_patcher.start()
get_by_id.return_value = FakeUser(email="fake email")
result = ProfileController(DummyRequest()).edit_profile()
assert result.json["model"]["email"] == "fake email"
finally:
validate = validate_patcher.stop()
edit_profile = edit_profile_patcher.stop()
get_by_id = get_by_id_patcher.stop()
@pytest.mark.usefixtures('activation_model', 'user_model')
def test_subscription_update(self, config, dummy_db_session):
def test_subscription_update(self, authn_policy, form_validator,
subscriptions_model, user_model):
"""Make sure that the new status is written into the DB."""
request = _get_fake_request('acct:john@doe', 'smith', True, True)
configure(config)
authn_policy.authenticated_userid.return_value = "acct:john@doe"
form_validator.return_value = (None, {
"username": "acct:john@doe",
"pwd": "smith",
"subscriptions": '{"active":true,"uri":"acct:john@doe","id":1}',
})
mock_sub = Mock(active=False)
subscriptions_model.get_by_id.return_value = mock_sub
request = DummyRequest(method='POST')
profile = ProfileController(request)
result = profile.edit_profile()
assert mock_sub.active == True
assert result == {}
with patch('h.accounts.views.Subscriptions') as mock_subs:
mock_subs.get_by_id = MagicMock()
mock_subs.get_by_id.return_value = Mock(active=True)
profile = ProfileController(request)
profile.edit_profile()
assert dummy_db_session.added
class TestAsyncFormViewMapper(object):
@@ -264,11 +220,12 @@ def edit_profile(self):
@pytest.mark.usefixtures('activation_model',
'dummy_db_session')
def test_disable_invalid_password(config, user_model):
def test_disable_invalid_password(config, form_validator, user_model):
"""
Make sure our disable_user call validates the user password
"""
request = _get_fake_request('john', 'doe')
form_validator.return_value = (None, {"username": "john", "pwd": "doe"})
configure(config)
# With an invalid password, get_user returns None
@@ -283,11 +240,12 @@ def test_disable_invalid_password(config, user_model):
@pytest.mark.usefixtures('activation_model',
'dummy_db_session')
def test_user_disabled(config, user_model):
def test_user_disabled(config, form_validator, user_model):
"""
Check if the user is disabled
"""
request = _get_fake_request('john', 'doe')
form_validator.return_value = (None, {"username": "john", "pwd": "doe"})
configure(config)
user = FakeUser(password='abc')
@@ -320,14 +278,30 @@ def test_registration_does_not_autologin(config, authn_policy):
@pytest.fixture
def user_model(config):
mock = MagicMock()
config.registry.registerUtility(mock, IUserClass)
return mock
def subscriptions_model(request):
patcher = patch('h.accounts.views.Subscriptions', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()
@pytest.fixture
def user_model(config, request):
patcher = patch('h.accounts.views.User', autospec=True)
request.addfinalizer(patcher.stop)
user = patcher.start()
config.registry.registerUtility(user, IUserClass)
return user
@pytest.fixture
def activation_model(config):
mock = MagicMock()
config.registry.registerUtility(mock, IActivationClass)
return mock
@pytest.fixture
def form_validator(request):
patcher = patch('h.accounts.views.validate_form', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()
View
@@ -19,7 +19,7 @@
from h.models import _
from h.notification.models import Subscriptions
from h.resources import Application
import h.accounts.models
from h.accounts.models import User
from . import schemas
from .events import LoginEvent, LogoutEvent
@@ -61,6 +61,16 @@ def ajax_form(request, result):
return result
def validate_form(form, data):
"""Validate POST payload data for a form."""
try:
appstruct = form.validate(data)
except deform.ValidationFailure as err:
return {'errors': err.error.children}, None
else:
return None, appstruct
def view_auth_defaults(fn, *args, **kwargs):
kwargs.setdefault('accept', 'text/html')
kwargs.setdefault('layout', 'auth')
@@ -231,115 +241,99 @@ class AsyncRegisterController(RegisterController):
__view_mapper__ = AsyncFormViewMapper
def _emails_must_match_validator(form, value):
"""Raise colander.Invalid if "email" and "emailAgain" don't match."""
if value.get("email") != value.get("emailAgain"):
exc = colander.Invalid(form, "The emails must match")
exc["emailAgain"] = "The emails must match."
raise exc
class _InvalidEditProfileRequestError(Exception):
"""Raised if validating an edit user profile request fails."""
def __init__(self, errors):
super(_InvalidEditProfileRequestError, self).__init__()
self.errors = errors
def _validate_edit_profile_request(request):
"""Validate the given request using the EditProfileSchema.
:returns: if the request is valid returns a Deform "appstruct" with keys
``"username"``, ``"pwd"`` and ``"email"``
:rtype: dict
:raises _InvalidEditProfileRequestError: if the request is invalid
"""
schema = schemas.EditProfileSchema(
validator=_emails_must_match_validator).bind(request=request)
form = deform.Form(schema)
try:
return form.validate(request.POST.items())
except deform.ValidationFailure as err:
raise _InvalidEditProfileRequestError(errors=err.error.children)
@view_auth_defaults
@view_config(attr='edit_profile', route_name='edit_profile')
@view_config(attr='disable_user', route_name='disable_user')
@view_config(attr='profile', route_name='profile')
class ProfileController(horus.views.ProfileController):
class ProfileController(object):
def __init__(self, request):
self.request = request
self.schema = schemas.ProfileSchema().bind(request=self.request)
self.form = deform.Form(self.schema)
def edit_profile(self):
try:
appstruct = _validate_edit_profile_request(self.request)
except _InvalidEditProfileRequestError as err:
return dict(errors=err.errors)
if self.request.method != 'POST':
return httpexceptions.HTTPMethodNotAllowed()
username = appstruct['username']
pwd = appstruct['pwd']
subscriptions = appstruct['subscriptions']
# Nothing to do here for non logged-in users
if self.request.authenticated_userid is None:
return httpexceptions.HTTPNotAuthorized()
err, appstruct = validate_form(self.form, self.request.POST.items())
if err is not None:
return err
user = User.get_by_id(self.request, self.request.authenticated_userid)
response = {'model': {'email': user.email}}
# We allow updating subscriptions without validating a password
subscriptions = appstruct.get('subscriptions')
if subscriptions:
# Update the subscriptions table
subs = json.loads(subscriptions)
if username == subs['uri']:
s = Subscriptions.get_by_id(self.request, subs['id'])
if s:
s.active = subs['active']
self.db.add(s)
return {}
else:
return dict(
errors=[
{'subscriptions': _('Non existing subscription')}
],
code=404
)
else:
return dict(
errors=[{'username': _('Invalid username')}], code=400
)
data = json.loads(subscriptions)
s = Subscriptions.get_by_id(self.request, data['id'])
if s is None:
return {
'errors': [{'subscriptions': _('Subscription not found')}],
'code': 400
}
# If we're trying to update a subscription for anyone other than
# the currently logged-in user, bail fast.
#
# The error message is deliberately identical to the one above, so
# as not to leak any information about who which subscription ids
# belong to.
if s.uri != self.request.authenticated_userid:
return {
'errors': [{'subscriptions': _('Subscription not found')}],
'code': 400
}
s.active = data.get('active', True)
FlashMessage(self.request, _('Changes saved!'), kind='success')
return response
# Password check
user = self.User.get_user(self.request, username, pwd)
if user:
self.request.context = user
response = super(ProfileController, self).edit_profile()
# Any updates to fields below this point require password validation.
#
# `pwd` is the current password
# `password` (used below) is optional, and is the new password
#
if not User.validate_user(user, appstruct.get('pwd')):
return {'errors': [{'pwd': _('Invalid password')}], 'code': 401}
# Add the user's email into the model dict that eventually gets
# returned to the browser. This is needed so that the edit profile
# forms can show the value of the user's current email.
if self.request.authenticated_userid:
user = h.accounts.models.User.get_by_id(
self.request, self.request.authenticated_userid)
response.json = {"model": {"email": user.email}}
email = appstruct.get('email')
if email:
email_user = User.get_by_email(self.request, email)
return response
else:
return dict(errors=[{'pwd': _('Invalid password')}], code=401)
if email_user:
if email_user.id != user.id:
return {
'errors': [{'pwd': _('That email is already used')}],
}
def disable_user(self):
request = self.request
schema = schemas.EditProfileSchema().bind(request=request)
form = deform.Form(schema)
response['model']['email'] = user.email = email
try:
appstruct = form.validate(request.POST.items())
except deform.ValidationFailure as e:
return dict(errors=e.error.children)
password = appstruct.get('password')
if password:
user.password = password
FlashMessage(self.request, _('Changes saved!'), kind='success')
return response
def disable_user(self):
err, appstruct = validate_form(self.form, self.request.POST.items())
if err is not None:
return err
username = appstruct['username']
pwd = appstruct['pwd']
# Password check
user = self.User.get_user(request, username, pwd)
user = User.get_user(self.request, username, pwd)
if user:
# TODO: maybe have an explicit disabled flag in the status
user.password = self.User.generate_random_password()
self.db.add(user)
user.password = User.generate_random_password()
FlashMessage(self.request, _('Account disabled.'), kind='success')
return {}
else:
@@ -350,7 +344,7 @@ def profile(self):
userid = request.authenticated_userid
model = {}
if userid:
model["email"] = self.User.get_by_id(request, userid).email
model["email"] = User.get_by_id(request, userid).email
if request.registry.feature('notification'):
model['subscriptions'] = Subscriptions.get_subscriptions_for_uri(
request,
@@ -364,7 +358,6 @@ def unsubscribe(self):
subscription = Subscriptions.get_by_id(request, subscription_id)
if subscription:
subscription.active = False
self.db.add(subscription)
return {}
return {}
View
@@ -4,12 +4,17 @@
class WSGIHandler(PyWSGIHandler, WebSocketWSGIHandler):
def finalize_headers(self):
# Middleware may yield from the empty upgrade response, confusing this
# method into sending "Transfer-Encoding: chunked" and, in turn, this
# confuses some strict WebSocket clients.
for name, value in self.response_headers:
if name == 'Upgrade' and value == 'websocket':
return
if self.environ.get('HTTP_UPGRADE') == 'websocket':
# Middleware, like Raven, may yield from the empty upgrade response,
# confusing this method into sending "Transfer-Encoding: chunked"
# and, in turn, this confuses some strict WebSocket clients.
if not hasattr(self.result, '__len__'):
self.result = list(self.result)
# ws4py 0.3.4 will try to pop the websocket from the environ
# even if it doesn't exist, causing a key error.
self.environ.setdefault('ws4py.websocket', None)
super(WSGIHandler, self).finalize_headers()
View
@@ -46,6 +46,7 @@ def run_tests(self):
'cryptography>=0.7',
'deform>=0.9,<1.0',
'elasticsearch>=1.1.0',
'gevent>=1.0.2,<1.1.0',
'gnsq>=0.2.0,<0.3.0',
'gunicorn>=19.2,<20',
'horus>=0.9.15',
@@ -62,7 +63,7 @@ def run_tests(self):
'python-statsd>=1.7.0,<1.8.0',
'pyramid_webassets>=0.9,<1.0',
'pyramid-jinja2>=2.3.3',
'raven>=5.1.1,<5.2.0',
'raven>=5.3.0,<5.4.0',
'requests>=2.2.1',
'ws4py>=0.3,<0.4',