diff --git a/ordr2/__init__.py b/ordr2/__init__.py index 2b528e1..8f95ea3 100644 --- a/ordr2/__init__.py +++ b/ordr2/__init__.py @@ -14,11 +14,14 @@ def main(global_config, **settings): config.include('.models') config.include('.resources') + config.include('.schemas') config.include('.security') config.include('.session') - config.include('.schemas') config.include('.views') + # explicit include for jinja2 to enable rendering in events module + config.include('pyramid_jinja2') + config.scan() return config.make_wsgi_app() diff --git a/ordr2/templates/account/register.jinja2 b/ordr2/templates/account/register.jinja2 index 6164320..a9e405b 100644 --- a/ordr2/templates/account/register.jinja2 +++ b/ordr2/templates/account/register.jinja2 @@ -3,12 +3,12 @@ {% block title %} Ordr | Account Registration {% endblock title %} {% block content %} -
-
+
+
+
-
-

Account Registration

-
+

Account Registration

+ {{ form.render()|safe }}
diff --git a/ordr2/views/account.py b/ordr2/views/account.py index d9ba83c..fcadb9f 100644 --- a/ordr2/views/account.py +++ b/ordr2/views/account.py @@ -1,28 +1,24 @@ ''' account registration, login, logout and settings ''' +import deform + from pyramid.httpexceptions import HTTPFound from pyramid.security import remember, forget from pyramid.view import view_config -from ordr2.models import User +from ordr2.events import CompleteRegistration +from ordr2.models.account import User, Role, TokenSubject +from ordr2.schemas.account import RegistrationSchema -@view_config( - context='ordr2:resources.account.AccountResource', - name='register', - permission='register', - renderer='ordr2:templates/account/register.jinja2' - ) -def register(context, request): - ''' the new user registraion page ''' - return {} +PROPOSED_PASSWORD_LENGTH = 12 @view_config( context='ordr2:resources.account.AccountResource', name='login', - permission='login', request_method='POST', + permission='login', renderer='ordr2:templates/account/login.jinja2' ) def login(context, request): @@ -52,3 +48,62 @@ def logout(context, request): ''' log out an user ''' headers = forget(request) return HTTPFound(request.resource_url(request.root), headers=headers) + + +@view_config( + context='ordr2:resources.account.AccountResource', + name='register', + request_method='GET', + permission='register', + renderer='ordr2:templates/account/register.jinja2' + ) +def registration_form(context, request): + ''' the new user registraion page ''' + form = RegistrationSchema.as_form(request) + return {'form':form} + + +@view_config( + context='ordr2:resources.account.AccountResource', + name='register', + request_method='POST', + permission='register', + renderer='ordr2:templates/account/register.jinja2' + ) +def registration_form_processing(context, request): + ''' registration form processing ''' + if 'Cancel' in request.POST: + return HTTPFound(request.resource_url(request.root)) + + # validate the form data + form = RegistrationSchema.as_form(request) + data = request.POST.items() + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # form validation successfull, create user + account = User( + username=appstruct['username'], + first_name=appstruct['first_name'], + last_name=appstruct['last_name'], + email=appstruct['email'], + role=Role.UNVALIDATED + ) + account.set_password(appstruct['password']) + request.dbsession.add(account) + + # create a verify-new-account token and send email + hash = account.issue_token(request, TokenSubject.USER_REGISTRATION) + notification = CompleteRegistration(request, account, {'hash': hash}) + request.registry.notify(notification) + + # issue a warning on a short password + if len(appstruct['password']) < PROPOSED_PASSWORD_LENGTH: + request.session.flash( + 'warning', + 'You should really consider a longer password' + ) + + return HTTPFound(request.resource_url(context, 'registered')) diff --git a/tests/__init__.py b/tests/__init__.py index d3e20fb..48fb794 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,6 +5,8 @@ import pytest import transaction from pyramid import testing +from pyramid.csrf import get_csrf_token +from webob.multidict import MultiDict # some path mangling to get the path to passlib.ini @@ -22,7 +24,8 @@ APP_SETTINGS = { 'passlib.config': passlib_config_path, 'pyramid.includes': [ 'pyramid_jinja2', - ] + ], + 'mail.default_sender': 'ordr@example.com' } EXAMPLE_USER_DATA = { @@ -41,7 +44,8 @@ EXAMPLE_USER_DATA = { def app_config(): ''' fixture for tests requiring a pyramid.testing setup ''' with testing.testConfig(settings=APP_SETTINGS) as config: - #config.include('pyramid_mailer.testing') + config.include('pyramid_mailer.testing') + config.include('pyramid_jinja2') from ordr2.models.account import passlib_context passlib_context.update(schemes=['argon2']) @@ -71,7 +75,6 @@ def dbsession(app_config): Base.metadata.drop_all(engine) - # helpers def get_user(role_name): @@ -98,4 +101,18 @@ def create_users(db): db.add(user) +def set_deform_data(request, form_data, extra_data=None, **kwargs): + ''' augments the request to include post data as provided by deform ''' + post_dict = MultiDict() + post_dict['__formid__'] = 'deform' + post_dict['_charset_'] = 'UTF-8' + post_dict['csrf_token'] = get_csrf_token(request) + post_dict.update(form_data) + if extra_data: + post_dict.update(extra_data) + post_dict.update(kwargs) + request.POST = post_dict + + + diff --git a/tests/_functional/registration.py b/tests/_functional/registration.py index d2d7555..847ee28 100644 --- a/tests/_functional/registration.py +++ b/tests/_functional/registration.py @@ -21,7 +21,6 @@ def test_account_register_unauthenticated(testapp): testapp.reset() response = testapp.get('/account/register') - # basic content test assert 'Ordr | Account Registration' in response # test the main nav section links and highlighting @@ -30,4 +29,58 @@ def test_account_register_unauthenticated(testapp): assert li_one.find('a').text == 'FAQs' assert 'active' in li_two['class'] assert li_two.find('a').text == 'Register' + # check for the registration form + form = response.html.find('form', class_='registration') + assert form is not None + + +@pytest.mark.xfail +def test_account_registeration_flow(testapp): + ''' test the complete registration process ''' + + # submit the registration form + form = resonse.forms[1] + form['username'] = 'AmyMcDonald' + form['first_name'] = 'Amy' + form['last_name'] = 'McDonald' + form['email'] = 'amy@example.com' + form['password'] = 'Amy' + form['password_confirm'] = 'Amy' + response = form.submitt() + + assert response.location == '/account/verify' + response = response.follow() + assert 'email sent' in response + + # click the email verification token + email = '' + token = email + response = testapp.get('/account/' + token) + assert 'consider a longer password' in response + assert 'activated by an administrator' in response + + # logging in should not work + form = response.forms[0] + form['username'] = 'AmyMcDonald' + form['password'] = 'Amy' + response = form.submit() + assert '' not in response + + # activate the new user + testapp.login('admin') + response = testapp.get('/admin/users?role=new') + response = response.click('edit user') + form = response.forms[1] + form['role'] = 'USER' + form.submit() + testapp.logout() + + # login should now work + response = testapp.get('/') + form = response.forms[0] + form['username'] = 'AmyMcDonald' + form['password'] = 'Amy' + response = form.submit() + assert '' in response + diff --git a/tests/views/account.py b/tests/views/account.py index 335b29a..8e5d238 100644 --- a/tests/views/account.py +++ b/tests/views/account.py @@ -1,12 +1,26 @@ ''' Tests for ordr2.views.account ''' +import deform import pytest from pyramid.httpexceptions import HTTPFound from pyramid.testing import DummyRequest, DummyResource +from pyramid_mailer import get_mailer +from webob.multidict import MultiDict +from .. import app_config, dbsession, get_user, create_users, set_deform_data -from .. import app_config, dbsession, get_user, create_users + +REGISTRATION_FORM_DATA = MultiDict([ + ('username', 'AmyMcDonald'), + ('first_name', 'Amy'), + ('last_name', 'McDonald'), + ('email', 'mcdonald@example.com'), + ('__start__', 'password:mapping'), + ('password', 'Amy'), + ('password-confirm', 'Amy'), + ('__end__', 'password:mapping'), + ]) @pytest.mark.parametrize('rolename', ['user', 'purchaser', 'admin']) @@ -83,3 +97,94 @@ def test_logout(app_config): assert isinstance(result, HTTPFound) assert result.location == 'http://example.com//' + + +def test_registration_form(app_config): + ''' registration form ''' + from ordr2.views.account import registration_form + + request = DummyRequest() + context = DummyResource() + result = registration_form(context, request) + + assert isinstance(result['form'], deform.Form) + + +def test_registration_form_processing_ok(dbsession): + ''' registration form processing with valid data''' + from ordr2.models.account import User, Role, TokenSubject + from ordr2.views.account import registration_form_processing + + user = get_user('user') # intentionally not added to database + context = DummyResource(model=user) + request = DummyRequest(dbsession=dbsession, context=context) + set_deform_data(request, REGISTRATION_FORM_DATA) + result = registration_form_processing(context, request) + + # return value of function call + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com/registered' + + # user should be added to database + user = dbsession.query(User).first() + assert user.username == REGISTRATION_FORM_DATA['username'] + assert user.first_name == REGISTRATION_FORM_DATA['first_name'] + assert user.last_name == REGISTRATION_FORM_DATA['last_name'] + assert user.email == REGISTRATION_FORM_DATA['email'] + assert user.check_password(REGISTRATION_FORM_DATA['password']) + assert user.role == Role.UNVALIDATED + + # a token should be created + token = user.tokens[0] + assert token.subject == TokenSubject.USER_REGISTRATION + + # and a verification email should be sent + # mailer = get_mailer(request.registry) + # last_mail = mailer.outbox[-1] + # assert 'Please verify your email address ' in last_mail.html + # assert 'http://example.com/' + token.hash in last_mail.html + + +def test_registration_form_processing_cancel(app_config): + ''' canceling registration form processing ''' + from ordr2.models.account import User, Role, TokenSubject + from ordr2.views.account import registration_form_processing + + user = get_user('user') # intentionally not added to database + context = DummyResource(model=user) + request = DummyRequest(dbsession=dbsession, context=context) + set_deform_data(request, REGISTRATION_FORM_DATA, {'Cancel': 'Cancel'}) + result = registration_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//' + + +@pytest.mark.parametrize( + 'key,value', [ + ('username', ''), + ('username', 'TerryGilliam'), + ('first_name', ''), + ('last_name', ''), + ('email', ''), + ('email', 'no email'), + ('email', 'gilliam@example.com'), + ('password', ''), + ('password-confirm', ''), + ('password-confirm', 'no match') + ] + ) +def test_registration_form_processing_validation_error(dbsession, key, value): + ''' registration form processing with valid data''' + from ordr2.models.account import User, Role, TokenSubject + from ordr2.views.account import registration_form_processing + + admin = get_user('user') + dbsession.add(admin) + context = DummyResource(model=get_user('admin')) + request = DummyRequest(dbsession=dbsession, context=context) + set_deform_data(request, REGISTRATION_FORM_DATA, {key: value}) + result = registration_form_processing(context, request) + + # return value of function call + assert isinstance(result['form'], deform.Form)