diff --git a/Makefile b/Makefile index 7fd0fd6..62a7439 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,7 @@ lint: ## check style with flake8 flake8 ordr tests test: ## run tests quickly with the default Python, ignoring functional tests - py.test + py.test -x coverage: ## check code coverage quickly with the default Python coverage run --source ordr -m pytest --ignore tests/_functional/ diff --git a/ordr/events.py b/ordr/events.py index bb4b9fb..b98da50 100644 --- a/ordr/events.py +++ b/ordr/events.py @@ -14,6 +14,7 @@ class UserNotification(object): :param pyramid.request.Request request: current request object :param ordr.models.account.Users account: send notification to this user :param dict data: additional data to pass to the mail template + :param str send_to: optional email address overriding user's email address ''' #: subject of the notification @@ -22,10 +23,11 @@ class UserNotification(object): #: template to render template = None - def __init__(self, request, account, data=None): + def __init__(self, request, account, data=None, send_to=None): self.request = request self.account = account self.data = data + self.send_to = send_to or account.email class ActivationNotification(UserNotification): @@ -35,6 +37,13 @@ class ActivationNotification(UserNotification): template = 'ordr:templates/emails/activation.jinja2' +class ChangeEmailNotification(UserNotification): + ''' user notification for verifying a change of the mail address ''' + + subject = '[ordr] Verify New Email Address' + template = 'ordr:templates/emails/email_change.jinja2' + + class OrderStatusNotification(UserNotification): ''' user notification for order status change ''' @@ -69,7 +78,7 @@ def notify_user(event): message = Message( subject=event.subject, sender=event.request.registry.settings['mail.default_sender'], - recipients=[event.account.email], + recipients=[event.send_to], html=body ) mailer = get_mailer(event.request.registry) diff --git a/ordr/models/account.py b/ordr/models/account.py index e35affe..0745bcf 100644 --- a/ordr/models/account.py +++ b/ordr/models/account.py @@ -53,6 +53,7 @@ class TokenSubject(enum.Enum): REGISTRATION = 'registration' #: validate email for new user RESET_PASSWORD = 'reset_password' #: reset a forgotten password + CHANGE_EMAIL = 'change_email' #: validate an email change # database driven models diff --git a/ordr/resources/__init__.py b/ordr/resources/__init__.py index 9069530..be6927b 100644 --- a/ordr/resources/__init__.py +++ b/ordr/resources/__init__.py @@ -2,7 +2,11 @@ from pyramid.security import Allow, Everyone, DENY_ALL -from .account import RegistrationResource, PasswordResetResource +from .account import ( + RegistrationResource, + PasswordResetResource, + AccountResource + ) class RootResource: @@ -36,6 +40,7 @@ class RootResource: map = { 'register': RegistrationResource, 'forgot': PasswordResetResource, + 'account': AccountResource, } child_class = map[key] return child_class(name=key, parent=self) diff --git a/ordr/resources/account.py b/ordr/resources/account.py index d1b38d9..a7689e5 100644 --- a/ordr/resources/account.py +++ b/ordr/resources/account.py @@ -2,10 +2,16 @@ import deform -from pyramid.security import Allow, Everyone, DENY_ALL +from pyramid.security import Allow, Authenticated, Everyone, DENY_ALL from ordr.models.account import Token, TokenSubject -from ordr.schemas.account import RegistrationSchema, ResetPasswordSchema +from ordr.schemas.account import ( + ChangePasswordSchema, + RegistrationSchema, + ResetPasswordSchema, + SettingsSchema + ) + from .helpers import BaseChildResource @@ -17,9 +23,9 @@ class RegistrationTokenResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = 'registration' - + def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] @@ -32,20 +38,20 @@ class RegistrationResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = 'registration' def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] - + def __getitem__(self, key): ''' returns a resource for a valid registration token ''' token = Token.retrieve(self.request, key, TokenSubject.REGISTRATION) if token is None: raise KeyError(f'Token {key} not found') return RegistrationTokenResource(name=key, parent=self, model=token) - + def get_registration_form(self, **kwargs): ''' returns the registration form''' settings = { @@ -70,9 +76,9 @@ class PasswordResetTokenResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = None - + def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] @@ -96,16 +102,87 @@ class PasswordResetResource(BaseChildResource): :param str name: the name of the resource :param parent: the parent resouce ''' - + nav_active = None def __acl__(self): ''' access controll list for the resource ''' return [(Allow, Everyone, 'view'), DENY_ALL] - + def __getitem__(self, key): ''' returns a resource for a valid reset password token ''' token = Token.retrieve(self.request, key, TokenSubject.RESET_PASSWORD) if token is None: raise KeyError(f'Token {key} not found') return PasswordResetTokenResource(name=key, parent=self, model=token) + + +class ChangeEmailTokenResource(BaseChildResource): + ''' Resource for changing the email address + + :param pyramid.request.Request request: the current request object + :param str name: the name of the resource + :param parent: the parent resouce + ''' + + nav_active = None + + def __acl__(self): + ''' access controll list for the resource ''' + return [(Allow, self.model.owner.principal, 'edit'), DENY_ALL] + + +class AccountResource(BaseChildResource): + ''' The resource for changing account settings and passwords + + :param pyramid.request.Request request: the current request object + :param str name: the name of the resource + :param parent: the parent resouce + ''' + + nav_active = None + + def __init__(self, name, parent, model=None): + ''' Create the resource + + :param str name: the name of the resource + :param parent: the parent resouce + :param model: optional data model for the resource + + If model is not set, the current user will be used + ''' + super().__init__(name, parent, model) + self.model = model or getattr(self.request, 'user', None) + + def __acl__(self): + ''' access controll list for the resource ''' + return [(Allow, Authenticated, 'edit'), DENY_ALL] + + def __getitem__(self, key): + ''' returns a resource for a valid change email token ''' + token = Token.retrieve(self.request, key, TokenSubject.CHANGE_EMAIL) + if token is None: + raise KeyError(f'Token {key} not found') + return ChangeEmailTokenResource(name=key, parent=self, model=token) + + def get_settings_form(self, **kwargs): + ''' returns the account settings form ''' + settings = { + 'buttons': ( + deform.Button(name='change', title='Change Settings'), + deform.Button(name='cancel', title='Cancel'), + ) + } + settings.update(kwargs) + return self._prepare_form(SettingsSchema, **settings) + + def get_password_form(self, **kwargs): + ''' returns the change password form ''' + settings = { + 'buttons': ( + deform.Button(name='change', title='Change Password'), + deform.Button(name='cancel', title='Cancel'), + ) + } + settings.update(kwargs) + return self._prepare_form(ChangePasswordSchema, **settings) diff --git a/ordr/schemas/__init__.py b/ordr/schemas/__init__.py index a6fa136..cf710f3 100644 --- a/ordr/schemas/__init__.py +++ b/ordr/schemas/__init__.py @@ -5,7 +5,7 @@ import deform from deform.renderer import configure_zpt_renderer -from .helpers import ( +from .validators import ( deferred_csrf_default, deferred_csrf_validator ) diff --git a/ordr/schemas/account.py b/ordr/schemas/account.py index 215bd18..6f87c48 100644 --- a/ordr/schemas/account.py +++ b/ordr/schemas/account.py @@ -3,9 +3,10 @@ import deform from . import CSRFSchema -from .helpers import ( +from .validators import ( deferred_unique_email_validator, deferred_unique_username_validator, + deferred_password_validator ) @@ -47,10 +48,54 @@ class RegistrationSchema(CSRFSchema): class ResetPasswordSchema(CSRFSchema): - ''' reset a forgotten password registration ''' + ''' reset a forgotten password ''' password = colander.SchemaNode( colander.String(), widget=deform.widget.CheckedPasswordWidget(), validator=colander.Length(min=8) ) + + +class SettingsSchema(CSRFSchema): + ''' new user registration ''' + + username = colander.SchemaNode( + colander.String(), + widget=deform.widget.TextInputWidget(readonly=True) + ) + + first_name = colander.SchemaNode( + colander.String() + ) + + last_name = colander.SchemaNode( + colander.String() + ) + + email = colander.SchemaNode( + colander.String(), + validator=deferred_unique_email_validator + ) + + confirmation = colander.SchemaNode( + colander.String(), + widget=deform.widget.PasswordWidget(), + validator=deferred_password_validator + ) + + +class ChangePasswordSchema(CSRFSchema): + ''' change the password ''' + + password = colander.SchemaNode( + colander.String(), + widget=deform.widget.CheckedPasswordWidget(), + validator=colander.Length(min=8) + ) + + confirmation = colander.SchemaNode( + colander.String(), + widget=deform.widget.PasswordWidget(), + validator=deferred_password_validator + ) diff --git a/ordr/schemas/helpers.py b/ordr/schemas/validators.py similarity index 100% rename from ordr/schemas/helpers.py rename to ordr/schemas/validators.py diff --git a/ordr/templates/account/password_changed.jinja2 b/ordr/templates/account/password_changed.jinja2 new file mode 100644 index 0000000..a68c143 --- /dev/null +++ b/ordr/templates/account/password_changed.jinja2 @@ -0,0 +1,16 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
+
+

Change Your Password

+
+
+
+
+

Your password was changed successfully

+

You can now log in with your new password.

+

Happy ordering +

+
+{% endblock content %} diff --git a/ordr/templates/account/password_form.jinja2 b/ordr/templates/account/password_form.jinja2 new file mode 100644 index 0000000..0ca855d --- /dev/null +++ b/ordr/templates/account/password_form.jinja2 @@ -0,0 +1,14 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
+
+

Change Your Password

+
+
+
+
+ {{ form.render()|safe }} +
+
+{% endblock content %} diff --git a/ordr/templates/account/settings_form.jinja2 b/ordr/templates/account/settings_form.jinja2 new file mode 100644 index 0000000..dcf0fcb --- /dev/null +++ b/ordr/templates/account/settings_form.jinja2 @@ -0,0 +1,14 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
+
+

Change Settings

+
+
+
+
+ {{ form.render()|safe }} +
+
+{% endblock content %} diff --git a/ordr/templates/account/settings_mail_changed.jinja2 b/ordr/templates/account/settings_mail_changed.jinja2 new file mode 100644 index 0000000..4f45e0a --- /dev/null +++ b/ordr/templates/account/settings_mail_changed.jinja2 @@ -0,0 +1,16 @@ +{% extends "ordr:templates/layout.jinja2" %} + +{% block content %} +
+
+

Change Settings

+
+
+
+
+

Your email was changed successfully

+

New notifications will be sent to {{request.user.email}}.

+

Happy ordering +

+
+{% endblock content %} diff --git a/ordr/templates/deform/password.pt b/ordr/templates/deform/password.pt new file mode 100644 index 0000000..6ffe83d --- /dev/null +++ b/ordr/templates/deform/password.pt @@ -0,0 +1,18 @@ + + + diff --git a/ordr/templates/deform/textinput.pt b/ordr/templates/deform/textinput.pt index 580829b..0078877 100644 --- a/ordr/templates/deform/textinput.pt +++ b/ordr/templates/deform/textinput.pt @@ -1,10 +1,10 @@ + [ordr] verify your new email address + + + +

Hi there!

+

+ Please verify your new email address for the account "{{ user.username }}" by following this link + {{ request.resource_url(context, data.token.hash) }} +

+

The link will expire on {{ data.token.expires.strftime('%d.%m.%y at %H:%M') }}. +

+ Regards, +
+ ordr +

+

+ Please don't respont to this email! This is an automatically generated notification by the system. + +

+ + diff --git a/ordr/templates/layout.jinja2 b/ordr/templates/layout.jinja2 index ff44b19..12fa022 100644 --- a/ordr/templates/layout.jinja2 +++ b/ordr/templates/layout.jinja2 @@ -51,19 +51,19 @@ {% endif %} -
{% block content %}

No content

diff --git a/ordr/templates/pages/login.jinja2 b/ordr/templates/pages/login.jinja2 index 5e88ce6..5513381 100644 --- a/ordr/templates/pages/login.jinja2 +++ b/ordr/templates/pages/login.jinja2 @@ -18,7 +18,7 @@
- + {% if loginerror %}
Username and password do not match, or account is not activated. diff --git a/ordr/views/account.py b/ordr/views/account.py new file mode 100644 index 0000000..22f55cc --- /dev/null +++ b/ordr/views/account.py @@ -0,0 +1,142 @@ +import deform + +from pyramid.httpexceptions import HTTPFound +from pyramid.view import view_config + +from ordr.events import ChangeEmailNotification +from ordr.models.account import TokenSubject + + +@view_config( + context='ordr.resources.account.AccountResource', + permission='edit' + ) +def account(context, request): + ''' redirect if '/account' was requested directly ''' + return HTTPFound(request.resource_url(request.root)) + + +@view_config( + context='ordr.resources.account.AccountResource', + permission='edit', + name='settings', + request_method='GET', + renderer='ordr:templates/account/settings_form.jinja2' + ) +def settings_form(context, request): + ''' show the settings form ''' + prefill = { + 'username': request.user.username, + 'first_name': request.user.first_name, + 'last_name': request.user.last_name, + 'email': request.user.email, + } + form = context.get_settings_form(prefill=prefill) + return {'form': form} + + +@view_config( + context='ordr.resources.account.AccountResource', + permission='edit', + name='settings', + request_method='POST', + renderer='ordr:templates/account/settings_form.jinja2' + ) +def settings_form_processing(context, request): + ''' process the settings form ''' + if 'change' not in request.POST: + return HTTPFound(request.resource_url(request.root)) + + form = context.get_settings_form() + data = request.POST.items() + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # form validation successfull, change user + request.user.first_name = appstruct['first_name'] + request.user.last_name = appstruct['last_name'] + + if appstruct['email'] == request.user.email: + # email was not changed + return HTTPFound(request.resource_url(request.root)) + + # create a verify-new-email token and send email + token = request.user.issue_token( + request, + TokenSubject.CHANGE_EMAIL, + payload={'email': appstruct['email']} + ) + notification = ChangeEmailNotification( + request, + account, + {'token': token}, + send_to=appstruct['email'] + ) + request.registry.notify(notification) + + return HTTPFound(request.resource_url(context, 'verify')) + + +@view_config( + context='ordr.resources.account.ChangeEmailTokenResource', + permission='edit', + request_method='GET', + renderer='ordr:templates/account/settings_mail_changed.jinja2' + ) +def verify_email(context, request): + ''' show email verification text ''' + payload = context.model.payload + request.user.email = payload['email'] + request.dbsession.delete(context.model) + return {} + + +@view_config( + context='ordr.resources.account.AccountResource', + permission='edit', + name='password', + request_method='GET', + renderer='ordr:templates/account/password_form.jinja2' + ) +def password_form(context, request): + ''' show the change password form ''' + form = context.get_password_form() + return {'form': form} + + +@view_config( + context='ordr.resources.account.AccountResource', + permission='edit', + name='password', + request_method='POST', + renderer='ordr:templates/account/password_form.jinja2' + ) +def password_form_processing(context, request): + ''' process the change password form ''' + if 'change' not in request.POST: + return HTTPFound(request.resource_url(request.root)) + + form = context.get_password_form() + data = request.POST.items() + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # form validation successfull, change the password + request.user.set_password(appstruct['password']) + return HTTPFound(request.resource_url(context, 'changed')) + + +@view_config( + context='ordr.resources.account.AccountResource', + permission='edit', + name='changed', + request_method='GET', + renderer='ordr:templates/account/password_changed.jinja2' + ) +def password_changed(context, request): + ''' the password changed message ''' + return {} diff --git a/tests/__init__.py b/tests/__init__.py index c12ecba..2e168f3 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -79,11 +79,11 @@ def get_example_user(role): return user -def get_post_request(dbsession, data): +def get_post_request(data, **kwargs): ''' returns a dummy request with csrf_token for validating deform forms ''' request = testing.DummyRequest() post_data = {'csrf_token': get_csrf_token(request)} post_data.update(data) - return testing.DummyRequest(dbsession=dbsession, POST=post_data) + return testing.DummyRequest(POST=post_data, **kwargs) diff --git a/tests/_functional/account.py b/tests/_functional/account.py new file mode 100644 index 0000000..7e71149 --- /dev/null +++ b/tests/_functional/account.py @@ -0,0 +1,132 @@ +''' functional tests for ordr2.views.account.py ''' + +from pyramid_mailer import get_mailer + +from . import testappsetup, testapp, get_token_url # noqa: F401 + + +def test_account_root(testapp): # noqa: F811 + ''' check the redirect if '/account' is requested ''' + testapp.login('TerryGilliam', 'Terry') + response = testapp.get('/account') + assert response.location == 'http://localhost/' + + +def test_account_change_settings(testapp): # noqa: F811 + testapp.login('TerryGilliam', 'Terry') + + response = testapp.get('/account/settings') + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Change Settings' in response + assert 'value="gilliam@example.com"' in response + assert 'Wrong Password' not in response + + # fill out the form without confirmation password + form = response.form + form['first_name'] = 'Amy' + form['last_name'] = 'McDonald' + response = form.submit(name='change') + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Change Settings' in response + assert 'required' in response + + # fill out the form with invalid data but correct password + response = testapp.get('/account/settings') + form = response.form + form['first_name'] = 'Amy' + form['last_name'] = 'McDonald' + form['email'] = 'this is not an email address' + form['confirmation'] = 'Terry' + response = form.submit(name='change') + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Change Settings' in response + assert 'Invalid email address' in response + + # fill out the form with valid data and correct password + response = testapp.get('/account/settings') + form = response.form + form['first_name'] = 'Amy' + form['last_name'] = 'McDonald' + form['confirmation'] = 'Terry' + response = form.submit(name='change') + assert response.location == 'http://localhost/' + + response = testapp.get('/account/settings') + assert 'value="Amy"' in response + + +def test_account_change_email(testapp): # noqa: F811 + testapp.login('TerryGilliam', 'Terry') + response = testapp.get('/account/settings') + + # fill out the form with valid data and correct password + form = response.form + form['email'] = 'amy@example.com' + form['confirmation'] = 'Terry' + response = form.submit(name='change') + assert response.location == 'http://localhost/account/verify' + + # click the email verification token + mailer = get_mailer(testapp.app.registry) + email = mailer.outbox[-1] + assert email.subject == '[ordr] Verify New Email Address' + assert email.recipients == ['amy@example.com'] + + token_link = get_token_url(email, prefix='/account/') + response = testapp.get(token_link) + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Change Settings' in response + assert 'changed sucessfully' not in response + + +def test_account_change_password(testapp): # noqa: F811 + testapp.login('TerryGilliam', 'Terry') + + response = testapp.get('/account/password') + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Change Password' in response + assert 'Wrong Password' not in response + + # fill out the form with incorrect confirmation password + form = response.form + form['password'] = 'Lost in La Mancha' + form['password-confirm'] = 'Lost in La Mancha' + form['confirmation'] = 'Unknown Password' + response = form.submit(name='change') + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Change Password' in response + assert 'Wrong password' in response + + # fill out the form with invalid data but correct password + response = testapp.get('/account/password') + form = response.form + form['password'] = 'Lost in La Mancha' + form['password-confirm'] = 'confirmation does not match' + form['confirmation'] = 'Terry' + response = form.submit(name='change') + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Change Password' in response + assert 'Password did not match confirm' in response + + # fill out the form with valid data and correct password + response = testapp.get('/account/password') + form = response.form + form['password'] = 'Lost in La Mancha' + form['password-confirm'] = 'Lost in La Mancha' + form['confirmation'] = 'Terry' + response = form.submit(name='change') + assert response.location == 'http://localhost/account/changed' + + response = response.follow() + active_nav = response.html.find('li', class_='active') + assert active_nav is None + assert 'Your password was changed successfully' in response + + assert testapp.login('TerryGilliam', 'Lost in La Mancha') diff --git a/tests/_functional/layout.py b/tests/_functional/layout.py index b693df2..c41c7a2 100644 --- a/tests/_functional/layout.py +++ b/tests/_functional/layout.py @@ -36,7 +36,7 @@ def test_navbar_with_user(testapp, username, password, extras): hrefs = [a['href'] for a in navbar.find_all('a')] expected = ['/', '/orders', '/faq'] expected.extend(extras) - expected.extend(['#', '/logout', '/account']) + expected.extend(['#', '/logout', '/account/settings', '/account/password']) assert expected == hrefs assert 'nav-item dropdown' in response diff --git a/tests/_functional/registration.py b/tests/_functional/registration.py index 208d8be..bd5d39a 100644 --- a/tests/_functional/registration.py +++ b/tests/_functional/registration.py @@ -32,7 +32,7 @@ def test_registration_process(testapp): # noqa: F811 form = response.form form['username'] = 'AmyMcDonald', form['first_name'] = 'Amy', - form['last_name'] = 'Mc Donald', + form['last_name'] = 'McDonald', form['email'] = 'amy.mcdonald@example.com', form['password'] = 'Make Amy McDonald A Rich Girl Fund', form['password-confirm'] = 'Make Amy McDonald A Rich Girl Fund', diff --git a/tests/events.py b/tests/events.py index 266b014..0073c61 100644 --- a/tests/events.py +++ b/tests/events.py @@ -18,6 +18,21 @@ def test_user_notification_init(app_config): # noqa: F811 assert notification.request == 'request' assert notification.account == user assert notification.data == 'data' + assert notification.send_to == user.email + + +def test_user_notification_init_send_to_override(app_config): # noqa: F811 + ''' test creation of user notification events ''' + from ordr.events import UserNotification + from ordr.models.account import Role + + user = get_example_user(Role.USER) + notification = UserNotification('request', user, 'data', 'amy@example.com') + + assert notification.request == 'request' + assert notification.account == user + assert notification.data == 'data' + assert notification.send_to == 'amy@example.com' def test_notify_user(app_config): # noqa: F811 diff --git a/tests/resources/account.py b/tests/resources/account.py index 6c84675..6be5e7e 100644 --- a/tests/resources/account.py +++ b/tests/resources/account.py @@ -31,7 +31,6 @@ def test_registration_acl(): def test_registration_get_registration_form(): ''' test 'get_registration_form()' method of RegistrationResource ''' - from pyramid.security import Allow, Everyone, DENY_ALL from ordr.resources.account import RegistrationResource import deform @@ -170,3 +169,122 @@ def test_password_reset_getitem_not_found(dbsession): # noqa: F811 with pytest.raises(KeyError): resource['unknown hash'] + + +def test_change_email_reset_token_acl(dbsession): # noqa: F811 + ''' test access controll list for PasswordResetTokenResource ''' + from pyramid.security import Allow, DENY_ALL + from ordr.models.account import Role, Token, TokenSubject + from ordr.resources.account import ChangeEmailTokenResource + + request = DummyRequest() + + user = get_example_user(Role.USER) + dbsession.add(user) + user.issue_token(request, TokenSubject.CHANGE_EMAIL) + dbsession.flush() + token = dbsession.query(Token).first() + + parent = DummyResource(request='request') + resource = ChangeEmailTokenResource('name', parent, model=token) + + assert resource.__acl__() == [(Allow, 'user:3', 'edit'), DENY_ALL] + + +def test_account_resource_set_model_from_request(): + ''' test access controll list for PasswordResetResource ''' + from ordr.resources.account import AccountResource + + request = DummyRequest(user='Amy McDonald') + parent = DummyResource(request=request) + resource = AccountResource('a name', parent) + + assert resource.model == 'Amy McDonald' + + +def test_account_resource_acl(): + ''' test access controll list for PasswordResetResource ''' + from pyramid.security import Allow, Authenticated, DENY_ALL + from ordr.resources.account import AccountResource + + request = DummyRequest() + parent = DummyResource(request=request) + resource = AccountResource('a name', parent) + + assert resource.__acl__() == [(Allow, Authenticated, 'edit'), DENY_ALL] + + +def test_account_resource_getitem_found(dbsession): # noqa: F811 + ''' test '__getitem__()' method returns child resource ''' + from ordr.models.account import Role, TokenSubject + from ordr.resources.account import ( + AccountResource, + ChangeEmailTokenResource + ) + + request = DummyRequest(dbsession=dbsession) + + user = get_example_user(Role.NEW) + token = user.issue_token(request, TokenSubject.CHANGE_EMAIL) + dbsession.add(user) + dbsession.flush() + + parent = DummyResource(request=request) + resource = AccountResource('a name', parent) + result = resource[token.hash] + + assert isinstance(result, ChangeEmailTokenResource) + assert result.__name__ == token.hash + assert result.__parent__ == resource + assert result.model == token + + +def test_account_resource_getitem_not_found(dbsession): # noqa: F811 + ''' test '__getitem__()' method raises KeyError ''' + from ordr.models.account import Role, TokenSubject + from ordr.resources.account import AccountResource + + request = DummyRequest(dbsession=dbsession) + + user = get_example_user(Role.NEW) + user.issue_token(request, TokenSubject.CHANGE_EMAIL) + dbsession.add(user) + dbsession.flush() + + parent = DummyResource(request=request) + resource = AccountResource('a name', parent) + + with pytest.raises(KeyError): + resource['unknown hash'] + + +def test_account_resource_get_settings_form(): + ''' test the setup of the settings form''' + from ordr.resources.account import AccountResource + import deform + + request = DummyRequest() + parent = DummyResource(request=request) + resource = AccountResource('some name', parent) + form = resource.get_settings_form() + + assert isinstance(form, deform.Form) + assert len(form.buttons) == 2 + assert form.buttons[0].title == 'Change Settings' + assert form.buttons[1].title == 'Cancel' + + +def test_account_resource_get_password_form(): + ''' test the setup of the change password form''' + from ordr.resources.account import AccountResource + import deform + + request = DummyRequest() + parent = DummyResource(request=request) + resource = AccountResource('some name', parent) + form = resource.get_password_form() + + assert isinstance(form, deform.Form) + assert len(form.buttons) == 2 + assert form.buttons[0].title == 'Change Password' + assert form.buttons[1].title == 'Cancel' diff --git a/tests/resources/root.py b/tests/resources/root.py index cd9aa4f..7592aeb 100644 --- a/tests/resources/root.py +++ b/tests/resources/root.py @@ -2,7 +2,11 @@ import pytest -from ordr.resources.account import RegistrationResource, PasswordResetResource +from ordr.resources.account import ( + RegistrationResource, + PasswordResetResource, + AccountResource + ) def test_root_init(): @@ -25,7 +29,8 @@ def test_root_acl(): @pytest.mark.parametrize( 'key,resource_class', [ ('register', RegistrationResource), - ('forgot', PasswordResetResource) + ('forgot', PasswordResetResource), + ('account', AccountResource) ] ) def test_root_getitem(key, resource_class): diff --git a/tests/schemas/helpers.py b/tests/schemas/validators.py similarity index 87% rename from tests/schemas/helpers.py rename to tests/schemas/validators.py index d27d808..e453cb4 100644 --- a/tests/schemas/helpers.py +++ b/tests/schemas/validators.py @@ -9,7 +9,7 @@ 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 ordr.schemas.validators import deferred_csrf_default from pyramid.csrf import get_csrf_token request = DummyRequest() @@ -20,7 +20,7 @@ def test_deferred_csrf_default(): def test_deferred_csrf_validator_ok(): ''' test deferred_csrf_validator with valid csrf token ''' - from ordr.schemas.helpers import deferred_csrf_validator + from ordr.schemas.validators import deferred_csrf_validator from pyramid.csrf import get_csrf_token request = DummyRequest() @@ -34,7 +34,7 @@ def test_deferred_csrf_validator_ok(): @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 ordr.schemas.validators import deferred_csrf_validator from colander import Invalid request = DummyRequest() @@ -47,7 +47,7 @@ def test_deferred_csrf_validator_fails_on_no_csrf_token(post): 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.schemas.validators import deferred_unique_username_validator from ordr.models.account import Role request = DummyRequest(dbsession=dbsession) @@ -63,7 +63,7 @@ def test_deferred_unique_username_validator_ok(dbsession): # noqa: F811 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.schemas.validators import deferred_unique_username_validator from ordr.models.account import Role from colander import Invalid @@ -81,7 +81,7 @@ def test_deferred_unique_username_validator_fails(dbsession): # noqa: F811 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.schemas.validators import deferred_unique_email_validator from ordr.models.account import Role context = DummyResource(model=None) @@ -102,7 +102,7 @@ def test_deferred_unique_email_validator_ok_same_user(dbsession): # noqa: F811 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.schemas.validators import deferred_unique_email_validator from ordr.models.account import Role user = get_example_user(Role.USER) @@ -122,7 +122,7 @@ def test_deferred_unique_email_validator_ok_same_user(dbsession): # noqa: F811 ) 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.schemas.validators import deferred_unique_email_validator from ordr.models.account import Role from colander import Invalid @@ -141,7 +141,7 @@ def test_deferred_unique_email_validator_fails(dbsession, email): def test_deferred_password_validator_ok(): ''' correct password should not raise invalidation error ''' - from ordr.schemas.helpers import deferred_password_validator + from ordr.schemas.validators import deferred_password_validator from ordr.models.account import Role user = get_example_user(Role.USER) @@ -153,7 +153,7 @@ def test_deferred_password_validator_ok(): def test_deferred_password_validator_fails(): ''' incorrect password should raise invalidation error ''' - from ordr.schemas.helpers import deferred_password_validator + from ordr.schemas.validators import deferred_password_validator from ordr.models.account import Role from colander import Invalid diff --git a/tests/views/account.py b/tests/views/account.py new file mode 100644 index 0000000..5e2a3ea --- /dev/null +++ b/tests/views/account.py @@ -0,0 +1,311 @@ +import deform + +from pyramid.httpexceptions import HTTPFound +from pyramid.testing import DummyRequest, DummyResource + +from .. import ( # noqa: F401 + app_config, + dbsession, + get_example_user, + get_post_request + ) + + +def test_account_redirect(): + ''' redirect on root of account resource ''' + from ordr.views.account import account + + request = DummyRequest() + result = account(None, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//' + + +def test_settings_form(): + ''' tests for displaying the settings form ''' + from ordr.models.account import Role + from ordr.resources.account import AccountResource + from ordr.schemas.account import SettingsSchema + from ordr.views.account import settings_form + + user = get_example_user(Role.USER) + request = DummyRequest(user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + result = settings_form(context, request) + form = result['form'] + + assert isinstance(form, deform.Form) + assert isinstance(form.schema, SettingsSchema) + + +def test_settings_form_processing_valid_data(dbsession): # noqa: F811 + ''' tests for processing the settings form + + The data is valid, but no email change requested + ''' + from ordr.models.account import Role, Token, User + from ordr.resources.account import AccountResource + from ordr.views.account import settings_form_processing + + data = { + 'username': 'TerryG', + 'first_name': 'Amy', + 'last_name': 'McDonald', + 'email': 'gilliam@example.com', + 'confirmation': 'Terry', + 'change': 'Change Settings' + } + + user = get_example_user(Role.USER) + dbsession.add(user) + dbsession.flush() + request = get_post_request(data=data, dbsession=dbsession, user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + request.context = context + result = settings_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//' + + account = dbsession.query(User).first() + assert account.username == 'TerryGilliam' + assert account.first_name == 'Amy' + assert account.last_name == 'McDonald' + assert account.email == 'gilliam@example.com' + assert dbsession.query(Token).count() == 0 + + +def test_settings_form_processing_mail_change(dbsession): # noqa: F811 + ''' tests for processing the settings form + + The data is valid and an email change is requested + ''' + from ordr.models.account import Role, Token, TokenSubject, User + from ordr.resources.account import AccountResource + from ordr.views.account import settings_form_processing + + data = { + 'username': 'TerryG', + 'first_name': 'Amy', + 'last_name': 'McDonald', + 'email': 'amy@example.com', + 'confirmation': 'Terry', + 'change': 'Change Settings' + } + + user = get_example_user(Role.USER) + dbsession.add(user) + request = get_post_request(data=data, dbsession=dbsession, user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + request.context = context + result = settings_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//verify' + + account = dbsession.query(User).first() + assert account.username == 'TerryGilliam' + assert account.first_name == 'Amy' + assert account.last_name == 'McDonald' + assert account.email == 'gilliam@example.com' + + token = dbsession.query(Token).first() + assert token.subject == TokenSubject.CHANGE_EMAIL + assert token.payload == {'email': 'amy@example.com'} + + # a verification email should be sent + # this is tested in the functional test since request.registry.notify + # doesn't know about event subscribers in the unittest + + +def test_settings_form_processing_invalid_data(dbsession): # noqa: F811 + ''' tests for processing the settings form with invalid data ''' + from ordr.models.account import Role + from ordr.resources.account import AccountResource + from ordr.schemas.account import SettingsSchema + from ordr.views.account import settings_form_processing + + data = { + 'username': 'TerryG', + 'first_name': 'Amy', + 'last_name': 'McDonald', + 'email': 'this is not an email address', + 'confirmation': 'Terry', + 'change': 'Change Settings' + } + + user = get_example_user(Role.USER) + dbsession.add(user) + request = get_post_request(data=data, dbsession=dbsession, user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + request.context = context + result = settings_form_processing(context, request) + form = result['form'] + + assert isinstance(form, deform.Form) + assert isinstance(form.schema, SettingsSchema) + + +def test_settings_form_processing_cancel(dbsession): # noqa: F811 + ''' tests for processing the settings form with invalid data ''' + from ordr.models.account import Role, User + from ordr.resources.account import AccountResource + from ordr.views.account import settings_form_processing + + data = { + 'username': 'TerryG', + 'first_name': 'Amy', + 'last_name': 'McDonald', + 'email': 'this is not an email address', + 'confirmation': 'Terry', + 'cancel': 'cancel' + } + + user = get_example_user(Role.USER) + dbsession.add(user) + request = get_post_request(data=data, dbsession=dbsession, user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + request.context = context + result = settings_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//' + + account = dbsession.query(User).first() + assert account.first_name == 'Terry' + + +def test_verify_email(dbsession): # noqa: F811 + ''' tests for processing the change password form ''' + from ordr.models.account import Role, Token, TokenSubject + from ordr.views.account import verify_email + + user = get_example_user(Role.USER) + request = DummyRequest(dbsession=dbsession, user=user) + + user.issue_token( + request, + TokenSubject.CHANGE_EMAIL, + {'email': 'amy@example.com'} + ) + dbsession.add(user) + dbsession.flush() + token = dbsession.query(Token).first() + context = DummyResource(model=token) + + result = verify_email(context, request) + assert result == {} + assert user.email == 'amy@example.com' + assert dbsession.query(Token).count() == 0 + + +def test_password_form(): + ''' tests for displaying the change password form ''' + from ordr.models.account import Role + from ordr.resources.account import AccountResource + from ordr.schemas.account import ChangePasswordSchema + from ordr.views.account import password_form + + user = get_example_user(Role.USER) + request = DummyRequest(user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + result = password_form(context, request) + form = result['form'] + + assert isinstance(form, deform.Form) + assert isinstance(form.schema, ChangePasswordSchema) + + +def test_password_form_processing_valid(dbsession): # noqa: F811 + ''' tests for processing the change password form ''' + from ordr.models.account import Role + from ordr.resources.account import AccountResource + from ordr.views.account import password_form_processing + + data = { + '__start__': 'password:mapping', + 'password': 'Make Amy McDonald A Rich Girl Fund', + 'password-confirm': 'Make Amy McDonald A Rich Girl Fund', + '__end__': 'password:mapping', + 'confirmation': 'Terry', + 'change': 'Change Password' + } + + user = get_example_user(Role.USER) + request = get_post_request(data=data, user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + result = password_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//changed' + assert not user.check_password('Terry') + assert user.check_password('Make Amy McDonald A Rich Girl Fund') + + +def test_password_form_processing_invalid(dbsession): # noqa: F811 + ''' tests for processing the change password form ''' + from ordr.models.account import Role + from ordr.resources.account import AccountResource + from ordr.schemas.account import ChangePasswordSchema + from ordr.views.account import password_form_processing + + data = { + '__start__': 'password:mapping', + 'password': 'Make Amy McDonald A Rich Girl Fund', + 'password-confirm': 'Make Amy McDonald A Rich Girl Fund', + '__end__': 'password:mapping', + 'confirmation': 'not the right password for confirmation', + 'change': 'Change Password' + } + + user = get_example_user(Role.USER) + request = get_post_request(data=data, user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + result = password_form_processing(context, request) + form = result['form'] + + assert isinstance(form, deform.Form) + assert isinstance(form.schema, ChangePasswordSchema) + assert user.check_password('Terry') + + +def test_password_form_processing_cancel(dbsession): # noqa: F811 + ''' tests canceling the change password form ''' + from ordr.models.account import Role + from ordr.resources.account import AccountResource + from ordr.views.account import password_form_processing + + data = { + '__start__': 'password:mapping', + 'password': 'Make Amy McDonald A Rich Girl Fund', + 'password-confirm': 'Make Amy McDonald A Rich Girl Fund', + '__end__': 'password:mapping', + 'confirmation': 'Terry', + 'cancel': 'cancel' + } + + user = get_example_user(Role.USER) + request = get_post_request(data=data, user=user) + parent = DummyResource(request=request) + context = AccountResource(None, parent) + result = password_form_processing(context, request) + + assert isinstance(result, HTTPFound) + assert result.location == 'http://example.com//' + assert user.check_password('Terry') + + +def test_password_changed(): + ''' show password has changed message ''' + from ordr.views.account import password_changed + result = password_changed(None, None) + assert result == {} diff --git a/tests/views/forgotten_password.py b/tests/views/forgotten_password.py index f1076de..cbdbd50 100644 --- a/tests/views/forgotten_password.py +++ b/tests/views/forgotten_password.py @@ -156,7 +156,7 @@ def test_reset_password_form_processing_valid(dbsession): # noqa: F811 '__end__': 'password:mapping', 'change': 'Set New Password' } - request = get_post_request(dbsession, data) + request = get_post_request(data, dbsession=dbsession) user = get_example_user(Role.USER) dbsession.add(user) @@ -194,7 +194,7 @@ def test_reset_password_form_processing_invalid_data(dbsession): # noqa: F811 '__end__': 'password:mapping', 'change': 'Set New Password' } - request = get_post_request(dbsession, data) + request = get_post_request(data, dbsession=dbsession) user = get_example_user(Role.USER) dbsession.add(user) @@ -225,7 +225,7 @@ def test_reset_password_form_processing_cancel(dbsession): # noqa: F811 '__end__': 'password:mapping', 'cancel': 'Cancel' } - request = get_post_request(dbsession, data) + request = get_post_request(data, dbsession=dbsession) user = get_example_user(Role.USER) dbsession.add(user) diff --git a/tests/views/registration.py b/tests/views/registration.py index 5424ff8..e9cd540 100644 --- a/tests/views/registration.py +++ b/tests/views/registration.py @@ -14,7 +14,7 @@ from .. import ( # noqa: F401 REGISTRATION_FORM_DATA = { 'username': 'AmyMcDonald', 'first_name': 'Amy', - 'last_name': 'Mc Donald', + 'last_name': 'McDonald', 'email': 'amy.mcdonald@example.com', '__start__': 'password:mapping', 'password': 'Make Amy McDonald A Rich Girl Fund', @@ -47,7 +47,7 @@ def test_registration_form_valid(dbsession): # noqa: F811 from ordr.views.registration import registration_form_processing data = REGISTRATION_FORM_DATA.copy() - request = get_post_request(dbsession, data) + request = get_post_request(data, dbsession=dbsession) parent = DummyResource(request=request) context = RegistrationResource(name=None, parent=parent) result = registration_form_processing(context, request) @@ -81,7 +81,7 @@ def test_registration_form_invalid(dbsession): # noqa: F811 data = REGISTRATION_FORM_DATA.copy() data['email'] = 'not an email address' - request = get_post_request(dbsession, data) + request = get_post_request(data, dbsession=dbsession) parent = DummyResource(request=request) context = RegistrationResource(name=None, parent=parent) result = registration_form_processing(context, request) @@ -96,7 +96,7 @@ def test_registration_form_no_create_button(dbsession): # noqa: F811 data = REGISTRATION_FORM_DATA.copy() data.pop('create') - request = get_post_request(dbsession, data) + request = get_post_request(data, dbsession=dbsession) parent = DummyResource(request=request) context = RegistrationResource(name=None, parent=parent) result = registration_form_processing(context, request)