diff --git a/ordr/__init__.py b/ordr/__init__.py index 2034961..2a89ec2 100644 --- a/ordr/__init__.py +++ b/ordr/__init__.py @@ -17,6 +17,7 @@ def main(global_config, **settings): config.include('pyramid_jinja2') config.include('.models') config.include('.resources') + config.include('.schemas') config.include('.security') config.add_static_view('static', 'static', cache_max_age=3600) config.scan() diff --git a/ordr/resources/__init__.py b/ordr/resources/__init__.py index b395b9a..5f13291 100644 --- a/ordr/resources/__init__.py +++ b/ordr/resources/__init__.py @@ -34,7 +34,7 @@ class RootResource: :raises: KeyError if child resource is not found ''' map = { - 'registration': RegistrationResource + 'register': RegistrationResource } child_class = map[key] return child_class(request=self.request, name=key, parent=self) diff --git a/ordr/resources/account.py b/ordr/resources/account.py index 662504c..cbcb891 100644 --- a/ordr/resources/account.py +++ b/ordr/resources/account.py @@ -1,9 +1,12 @@ ''' Resources (sub) package, used to connect URLs to views ''' from pyramid.security import Allow, Everyone, DENY_ALL +from ordr.schemas.account import RegistrationSchema +from .helpers import BaseChildResource -class RegistrationResource: + +class RegistrationResource(BaseChildResource): ''' The resource for new user registration :param pyramid.request.Request request: the current request object @@ -11,19 +14,16 @@ class RegistrationResource: :param parent: the parent resouce ''' - nav_active = 'welcome' - - def __init__(self, request, name, parent): - ''' Create registration resource - - :param pyramid.request.Request request: the current request object - :param str name: the name of the resource - :param parent: the parent resouce - ''' - self.request = request - self.__name__ = name - self.__parent__ = parent + nav_active = 'registration' def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] + + def get_registration_form(self, **override): + ''' returns the registration form''' + settings = { + 'buttons': ('Create account', 'Cancel'), + } + settings.update(override) + return self._prepare_form(RegistrationSchema, **settings) diff --git a/ordr/resources/helpers.py b/ordr/resources/helpers.py new file mode 100644 index 0000000..9efb2df --- /dev/null +++ b/ordr/resources/helpers.py @@ -0,0 +1,28 @@ +''' Resources (sub) package, used to connect URLs to views ''' + +from pyramid.security import Allow, Everyone, DENY_ALL + + +class BaseChildResource: + + def __init__(self, request, name, parent): + ''' Create a child resource + + :param pyramid.request.Request request: the current request object + :param str name: the name of the resource + :param parent: the parent resouce + ''' + self.request = request + self.__name__ = name + self.__parent__ = parent + + def __acl__(self): + ''' access controll list for the resource ''' + raise NotImplementedError() + + def _prepare_form(self, schema, prefill=None, **settings): + ''' prepares a deform form for the resource''' + form = schema.as_form(self.request, **settings) + if prefill is not None: + form.set_appstruct(prefill) + return form diff --git a/ordr/schemas/__init__.py b/ordr/schemas/__init__.py new file mode 100644 index 0000000..7989d24 --- /dev/null +++ b/ordr/schemas/__init__.py @@ -0,0 +1,53 @@ +''' Schemas (sub) package, for form rendering and validation ''' + +import colander +import deform + +from deform.renderer import configure_zpt_renderer + +from .helpers import ( + deferred_csrf_default, + deferred_csrf_validator + ) + + +# Base Schema + +class CSRFSchema(colander.Schema): + ''' base class for schemas with csrf validation ''' + + csrf_token = colander.SchemaNode( + colander.String(), + default=deferred_csrf_default, + validator=deferred_csrf_validator, + widget=deform.widget.HiddenWidget(), + ) + + @classmethod + def as_form(cls, request, url=None, **kwargs): + ''' returns the schema as a form + + :param pyramid.request.Request request: the current request + :param str url: + form action url, + url is not set, the current context and view name will be used to + constuct a url for the form + :param kwargs: + additional parameters for the form rendering. + ''' + if url is None: + url = request.resource_url(request.context, request.view_name) + schema = cls().bind(request=request) + form = deform.Form(schema, action=url, **kwargs) + return form + + +def includeme(config): + ''' + Initialize the form schemas + + Activate this setup using ``config.include('ordr.schemas')``. + + ''' + # Make Deform widgets aware of our widget template paths + configure_zpt_renderer(['ordr:templates/deform']) diff --git a/ordr/schemas/account.py b/ordr/schemas/account.py new file mode 100644 index 0000000..e1eb3a4 --- /dev/null +++ b/ordr/schemas/account.py @@ -0,0 +1,36 @@ +import colander +import deform + + +from . import CSRFSchema +from .helpers import ( + deferred_unique_email_validator, + deferred_unique_username_validator, + ) + + +# schema for user registration + +class RegistrationSchema(CSRFSchema): + ''' new user registration ''' + + username = colander.SchemaNode( + colander.String(), + widget=deform.widget.TextInputWidget(readonly=True), + description='automagically generated for you', + validator=deferred_unique_username_validator, + ) + first_name = colander.SchemaNode( + colander.String() + ) + last_name = colander.SchemaNode( + colander.String() + ) + email = colander.SchemaNode( + colander.String(), + validator=deferred_unique_email_validator + ) + password = colander.SchemaNode( + colander.String(), + widget=deform.widget.CheckedPasswordWidget() + ) diff --git a/ordr/schemas/helpers.py b/ordr/schemas/helpers.py new file mode 100644 index 0000000..efa1798 --- /dev/null +++ b/ordr/schemas/helpers.py @@ -0,0 +1,63 @@ +''' helper functions for schemas ''' + +import colander + +from pyramid.csrf import get_csrf_token, check_csrf_token + +from ordr.models import User + + +@colander.deferred +def deferred_csrf_default(node, kw): + ''' sets the current csrf token ''' + request = kw.get('request') + return get_csrf_token(request) + + +@colander.deferred +def deferred_csrf_validator(node, kw): + ''' validates a submitted csrf token ''' + def validate_csrf(node, value): + request = kw.get('request') + if not check_csrf_token(request, raises=False): + raise colander.Invalid(node, 'Bad CSRF token') + return validate_csrf + + +@colander.deferred +def deferred_unique_username_validator(node, kw): + ''' checks if an username is not registered already ''' + + def validate_unique_username(node, value): + request = kw.get('request') + user = request.dbsession.query(User).filter_by(username=value).first() + if user is not None: + raise colander.Invalid(node, 'User name already registered') + return validate_unique_username + + +@colander.deferred +def deferred_unique_email_validator(node, kw): + ''' checks if an email is not registered already ''' + email_validator = colander.Email() + + def validate_unique_email(node, value): + email_validator(node, value) # raises exception on invalid address + request = kw.get('request') + user = request.dbsession.query(User).filter_by(email=value).first() + if user not in (None, request.context.model): + # allow existing email addresses if + # it belongs to the user that is currently edited + raise colander.Invalid(node, 'Email address in use') + return validate_unique_email + + +@colander.deferred +def deferred_password_validator(node, kw): + ''' checks password confirmation for settings ''' + + def validate_password_confirmation(node, value): + request = kw.get('request') + if request.user is None or not request.user.check_password(value): + raise colander.Invalid(node, 'Wrong password') + return validate_password_confirmation diff --git a/ordr/templates/account/registration_form.jinja2 b/ordr/templates/account/registration_form.jinja2 new file mode 100644 index 0000000..806a9d9 --- /dev/null +++ b/ordr/templates/account/registration_form.jinja2 @@ -0,0 +1,5 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} + {{ context.get_registration_form().render()|safe }} +{% endblock content %} diff --git a/ordr/templates/layout.jinja2 b/ordr/templates/layout.jinja2 index 35c0138..617cda6 100644 --- a/ordr/templates/layout.jinja2 +++ b/ordr/templates/layout.jinja2 @@ -30,7 +30,7 @@ - diff --git a/ordr/views/registration.py b/ordr/views/registration.py new file mode 100644 index 0000000..232de9f --- /dev/null +++ b/ordr/views/registration.py @@ -0,0 +1,14 @@ +# from pyramid.httpexceptions import HTTPFound +from pyramid.view import view_config + +# from ordr.models import User + + +@view_config( + context='ordr.resources.account.RegistrationResource', + permission='view', + request_method='GET', + renderer='ordr:templates/account/registration_form.jinja2' + ) +def registration_form(context, request): + return {} diff --git a/setup.py b/setup.py index c8b4fe2..98c1526 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: requires = [ 'argon2_cffi', 'bcrypt', + 'deform', 'passlib', 'plaster_pastedeploy', 'pyramid >= 1.9a', diff --git a/tests/_functional/registration.py b/tests/_functional/registration.py new file mode 100644 index 0000000..44b5c07 --- /dev/null +++ b/tests/_functional/registration.py @@ -0,0 +1,9 @@ +''' functional tests for ordr2.views.registration ''' + +from . import testappsetup, testapp # noqa: F401 + + +def test_registration_form(testapp): # noqa: F811 + result = testapp.get('/register') + active = result.html.find('li', class_='active') + assert active.a['href'] == '/register' diff --git a/tests/resources/account.py b/tests/resources/account.py index 03c6cce..1fba2f0 100644 --- a/tests/resources/account.py +++ b/tests/resources/account.py @@ -1,20 +1,22 @@ ''' Tests for the account resources ''' - -def test_registration_init(): - from ordr.resources.account import RegistrationResource - resource = RegistrationResource( - request='some request', - name='a name', - parent='the parent' - ) - assert resource.__name__ == 'a name' - assert resource.__parent__ == 'the parent' - assert resource.request == 'some request' - +from pyramid.testing import DummyRequest + def test_registration_acl(): from pyramid.security import Allow, Everyone, DENY_ALL from ordr.resources.account import RegistrationResource resource = RegistrationResource('some request', 'a name', 'the parent') assert resource.__acl__() == [(Allow, Everyone, 'view'), DENY_ALL] + +def test_registration_get_registration_form(): + from pyramid.security import Allow, Everyone, DENY_ALL + from ordr.resources.account import RegistrationResource + import deform + request = DummyRequest() + resource = RegistrationResource(request, 'a name', 'the parent') + form = resource.get_registration_form() + assert isinstance(form, deform.Form) + assert len(form.buttons) == 2 + assert form.buttons[0].title == 'Create account' + assert form.buttons[1].title == 'Cancel' diff --git a/tests/resources/base_child_resource.py b/tests/resources/base_child_resource.py new file mode 100644 index 0000000..94753ca --- /dev/null +++ b/tests/resources/base_child_resource.py @@ -0,0 +1,65 @@ +''' Tests for the root resource ''' + +import pytest + +from pyramid.testing import DummyRequest, DummyResource + + +def test_base_child_init(): + from ordr.resources.helpers import BaseChildResource + resource = BaseChildResource( + request='some request', + name='a name', + parent='the parent' + ) + assert resource.__name__ == 'a name' + assert resource.__parent__ == 'the parent' + assert resource.request == 'some request' + + +def test_base_child_acl(): + from ordr.resources.helpers import BaseChildResource + resource = BaseChildResource( + request='some request', + name='a name', + parent='the parent' + ) + with pytest.raises(NotImplementedError): + resource.__acl__() + + +def test_base_child_prepare_form(): + from ordr.resources.helpers import BaseChildResource + from ordr.schemas.account import RegistrationSchema + import deform + parent = DummyResource() + request = DummyRequest() + resource = BaseChildResource(request, 'a name', parent) + form = resource._prepare_form(RegistrationSchema) + assert isinstance(form, deform.Form) + assert form.action == 'http://example.com//' + assert len(form.buttons) == 0 + + +def test_base_child_prepare_form_url(): + from ordr.resources.helpers import BaseChildResource + from ordr.schemas.account import RegistrationSchema + parent = DummyResource() + request = DummyRequest() + resource = BaseChildResource(request, 'a name', parent) + form = resource._prepare_form(RegistrationSchema, url='/foo') + assert form.action == '/foo' + + +def test_base_child_prepare_form_settings(): + from ordr.resources.helpers import BaseChildResource + from ordr.schemas.account import RegistrationSchema + import deform + parent = DummyResource() + request = DummyRequest() + resource = BaseChildResource(request, 'a name', parent) + settings = {'buttons': ('ok', 'cancel')} + form = resource._prepare_form(RegistrationSchema, **settings) + assert len(form.buttons) == 2 + assert isinstance(form.buttons[0], deform.Button) + assert isinstance(form.buttons[1], deform.Button) diff --git a/tests/resources/root.py b/tests/resources/root.py index 4926532..8c6ff63 100644 --- a/tests/resources/root.py +++ b/tests/resources/root.py @@ -22,9 +22,9 @@ def test_root_getitem(): from ordr.resources import RootResource from ordr.resources.account import RegistrationResource root = RootResource(None) - child = root['registration'] + child = root['register'] assert isinstance(child, RegistrationResource) - assert child.__name__ == 'registration' + assert child.__name__ == 'register' assert child.__parent__ == root assert child.request == root.request diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py new file mode 100644 index 0000000..2257fd7 --- /dev/null +++ b/tests/schemas/__init__.py @@ -0,0 +1,28 @@ +''' Test package for ordr.schemas ''' + +from pyramid.testing import DummyRequest, DummyResource + + +def test_csrf_schema_form_with_custom_url(): + ''' test for creation with custom url ''' + from ordr.schemas import CSRFSchema + + request = DummyRequest() + form = CSRFSchema.as_form(request, url='/Nudge/Nudge') + + assert form.action == '/Nudge/Nudge' + assert form.buttons == [] + + +def test_csrf_schema_form_with_automatic_url(): + ''' test for creation with custom url ''' + from ordr.schemas import CSRFSchema + root = DummyResource() + context = DummyResource('Crunchy', root) + + request = DummyRequest(context=context, view_name='Frog') + form = CSRFSchema.as_form(request, buttons=['submit']) + + assert 'http://example.com/Crunchy/Frog' == form.action + assert len(form.buttons) == 1 + assert form.buttons[0].type == 'submit' diff --git a/tests/schemas/helpers.py b/tests/schemas/helpers.py new file mode 100644 index 0000000..d27d808 --- /dev/null +++ b/tests/schemas/helpers.py @@ -0,0 +1,165 @@ +''' Tests for ordr.schemas.helpers ''' + +import pytest + +from pyramid.testing import DummyRequest, DummyResource + +from .. import app_config, dbsession, get_example_user # noqa: F401 + + +def test_deferred_csrf_default(): + ''' deferred_csrf_default should return a csrf token ''' + from ordr.schemas.helpers import deferred_csrf_default + from pyramid.csrf import get_csrf_token + + request = DummyRequest() + token = deferred_csrf_default(None, {'request': request}) + + assert token == get_csrf_token(request) + + +def test_deferred_csrf_validator_ok(): + ''' test deferred_csrf_validator with valid csrf token ''' + from ordr.schemas.helpers import deferred_csrf_validator + from pyramid.csrf import get_csrf_token + + request = DummyRequest() + token = get_csrf_token(request) + request.POST = {'csrf_token': token} + validation_func = deferred_csrf_validator(None, {'request': request}) + + assert validation_func(None, None) is None + + +@pytest.mark.parametrize('post', [{}, {'csrf_token': 'Albatross!'}]) +def test_deferred_csrf_validator_fails_on_no_csrf_token(post): + ''' test deferred_csrf_validator with invalid or missing csrf token ''' + from ordr.schemas.helpers import deferred_csrf_validator + from colander import Invalid + + request = DummyRequest() + request.POST = post + validation_func = deferred_csrf_validator(None, {'request': request}) + + with pytest.raises(Invalid): + assert validation_func(None, None) is None + + +def test_deferred_unique_username_validator_ok(dbsession): # noqa: F811 + ''' unknown usernames should not raise an invalidation error ''' + from ordr.schemas.helpers import deferred_unique_username_validator + from ordr.models.account import Role + + request = DummyRequest(dbsession=dbsession) + user = get_example_user(Role.USER) + dbsession.add(user) + validation_func = deferred_unique_username_validator( + None, + {'request': request} + ) + + assert validation_func(None, 'AnneElk') is None + + +def test_deferred_unique_username_validator_fails(dbsession): # noqa: F811 + ''' known username should raise an invalidation error ''' + from ordr.schemas.helpers import deferred_unique_username_validator + from ordr.models.account import Role + from colander import Invalid + + request = DummyRequest(dbsession=dbsession) + user = get_example_user(Role.USER) + dbsession.add(user) + validation_func = deferred_unique_username_validator( + None, + {'request': request} + ) + + with pytest.raises(Invalid): + assert validation_func(None, 'TerryGilliam') is None + + +def test_deferred_unique_email_validator_ok(dbsession): # noqa: F811 + ''' unknown emails should not raise an invalidation error ''' + from ordr.schemas.helpers import deferred_unique_email_validator + from ordr.models.account import Role + + context = DummyResource(model=None) + request = DummyRequest(dbsession=dbsession, context=context) + user = get_example_user(Role.USER) + dbsession.add(user) + validation_func = deferred_unique_email_validator( + None, + {'request': request} + ) + + assert validation_func(None, 'elk@example.com') is None + + +def test_deferred_unique_email_validator_ok_same_user(dbsession): # noqa: F811 + ''' known emails of a user might not raise an error + + if a user is edited and the mail address is not change, no invalidation + error should be raised + ''' + from ordr.schemas.helpers import deferred_unique_email_validator + from ordr.models.account import Role + + user = get_example_user(Role.USER) + context = DummyResource(model=user) + request = DummyRequest(dbsession=dbsession, context=context) + dbsession.add(user) + validation_func = deferred_unique_email_validator( + None, + {'request': request} + ) + + assert validation_func(None, user.email) is None + + +@pytest.mark.parametrize( # noqa: F811 + 'email', ['', 'gilliam@example.com', 'malformed'] + ) +def test_deferred_unique_email_validator_fails(dbsession, email): + ''' known, empty or malformed emails should raise an invalidation error ''' + from ordr.schemas.helpers import deferred_unique_email_validator + from ordr.models.account import Role + from colander import Invalid + + context = DummyResource(model=None) + request = DummyRequest(dbsession=dbsession, context=context) + user = get_example_user(Role.USER) + dbsession.add(user) + validation_func = deferred_unique_email_validator( + None, + {'request': request} + ) + + with pytest.raises(Invalid): + assert validation_func(None, email) is None + + +def test_deferred_password_validator_ok(): + ''' correct password should not raise invalidation error ''' + from ordr.schemas.helpers import deferred_password_validator + from ordr.models.account import Role + + user = get_example_user(Role.USER) + request = DummyRequest(user=user) + validation_func = deferred_password_validator(None, {'request': request}) + + assert validation_func(None, 'Terry') is None + + +def test_deferred_password_validator_fails(): + ''' incorrect password should raise invalidation error ''' + from ordr.schemas.helpers import deferred_password_validator + from ordr.models.account import Role + from colander import Invalid + + user = get_example_user(Role.USER) + request = DummyRequest(user=user) + validation_func = deferred_password_validator(None, {'request': request}) + + with pytest.raises(Invalid): + assert validation_func(None, 'Wrong Password') is None diff --git a/tests/views/registration.py b/tests/views/registration.py new file mode 100644 index 0000000..255543c --- /dev/null +++ b/tests/views/registration.py @@ -0,0 +1,12 @@ +import pytest + +from pyramid.httpexceptions import HTTPFound +from pyramid.testing import DummyRequest + +from .. import app_config, dbsession # noqa: F401 + + +def test_faq(): + from ordr.views.registration import registration_form + result = registration_form(None, None) + assert result == {}