summaryrefslogtreecommitdiff
path: root/service
diff options
context:
space:
mode:
Diffstat (limited to 'service')
-rw-r--r--service/pixelated/account_recovery.py56
-rw-r--r--service/pixelated/assets/recovery.mail.en-US28
-rw-r--r--service/pixelated/assets/recovery.mail.pt-BR26
-rw-r--r--service/pixelated/resources/account_recovery_resource.py87
-rw-r--r--service/pixelated/resources/backup_account_resource.py25
-rw-r--r--service/pixelated/resources/login_resource.py14
-rw-r--r--service/pixelated/resources/root_resource.py4
-rw-r--r--service/pixelated/support/language.py24
-rw-r--r--service/requirements.txt2
-rw-r--r--service/test/functional/features/account_recovery.feature43
-rw-r--r--service/test/functional/features/environment.py41
-rw-r--r--service/test/functional/features/page_objects/__init__.py20
-rw-r--r--service/test/functional/features/page_objects/account_recovery_page.py57
-rw-r--r--service/test/functional/features/page_objects/backup_account_page.py30
-rw-r--r--service/test/functional/features/page_objects/base_page.py39
-rw-r--r--service/test/functional/features/page_objects/inbox_page.py52
-rw-r--r--service/test/functional/features/smoke.feature8
-rw-r--r--service/test/functional/features/steps/__init__.py2
-rw-r--r--service/test/functional/features/steps/account_recovery.py49
-rw-r--r--service/test/functional/features/steps/backup_account.py20
-rw-r--r--service/test/functional/features/steps/common.py4
-rw-r--r--service/test/functional/features/steps/login.py30
-rw-r--r--service/test/functional/features/steps/mail_list.py18
-rw-r--r--service/test/functional/features/steps/mail_view.py23
-rw-r--r--service/test/functional/features/steps/signup.py21
-rw-r--r--service/test/functional/features/steps/utils.py51
-rw-r--r--service/test/unit/config/test_site.py4
-rw-r--r--service/test/unit/resources/test_account_recovery_resource.py108
-rw-r--r--service/test/unit/resources/test_backup_account_resource.py29
-rw-r--r--service/test/unit/resources/test_login_resource.py33
-rw-r--r--service/test/unit/support/test_language.py40
-rw-r--r--service/test/unit/test_account_recovery.py93
32 files changed, 974 insertions, 107 deletions
diff --git a/service/pixelated/account_recovery.py b/service/pixelated/account_recovery.py
index 234bb1fe..58242a7d 100644
--- a/service/pixelated/account_recovery.py
+++ b/service/pixelated/account_recovery.py
@@ -13,23 +13,73 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+import pkg_resources
+import binascii
+from email import message_from_string
+
from twisted.internet.defer import inlineCallbacks, returnValue
from twisted.logger import Logger
+from twisted.mail import smtp
+
+from pixelated.support import date
log = Logger()
class AccountRecovery(object):
- def __init__(self, session, soledad):
+ def __init__(self, session, soledad, smtp_config, backup_email, domain, language='en-US'):
self._bonafide_session = session
self._soledad = soledad
+ self._smtp_config = smtp_config
+ self._backup_email = backup_email
+ self._domain = domain
+ self._language = language
@inlineCallbacks
def update_recovery_code(self):
+ log.info('Updating user\'s recovery code')
+
try:
code = self._soledad.create_recovery_code()
response = yield self._bonafide_session.update_recovery_code(code)
+ yield self._send_mail(code, self._backup_email)
+
returnValue(response)
+
+ except Exception as e:
+ log.error('Something went wrong when trying to save the recovery code')
+ log.error(e)
+ raise e
+
+ @inlineCallbacks
+ def _send_mail(self, code, backup_email):
+ log.info('Sending mail containing the user\'s recovery code')
+
+ sender = 'team@{}'.format(self._domain)
+ msg = self._get_recovery_mail(code, sender, backup_email)
+
+ try:
+ send_mail_result = yield smtp.sendmail(
+ str(self._smtp_config.remote_smtp_host),
+ sender,
+ [backup_email],
+ msg.as_string())
+ returnValue(send_mail_result)
except Exception as e:
- log.warn('Something went wrong when trying to save the recovery code')
- raise
+ log.error('Failed trying to send the email with the recovery code')
+ raise e
+
+ def _get_recovery_mail(self, code, sender, backup_email):
+ email_date = date.mail_date_now()
+ recovery_mail = pkg_resources.resource_filename(
+ 'pixelated.assets',
+ 'recovery.mail.%s' % (self._language))
+
+ with open(recovery_mail) as mail_template_file:
+ return message_from_string(mail_template_file.read().format(
+ domain=self._domain,
+ recovery_code=binascii.hexlify(code),
+ backup_email=backup_email,
+ sender=sender,
+ date=email_date))
diff --git a/service/pixelated/assets/recovery.mail.en-US b/service/pixelated/assets/recovery.mail.en-US
new file mode 100644
index 00000000..e7a09f1b
--- /dev/null
+++ b/service/pixelated/assets/recovery.mail.en-US
@@ -0,0 +1,28 @@
+From: {sender}
+Date: {date}
+Subject: Recovery Code for {domain}
+To: {backup_email}
+Content-Type: text/plain; charset=UTF-8
+
+Hello,
+
+You are receiving this message because you registered an email account at https://{domain}.
+If you ever forget your password, you'll need the code below to recover it. Save it! It is the only way to access to your account again.
+
+{recovery_code}
+
+Save this message or write this code in a safe place.
+
+--
+Why is this so important?
+
+Pixelated is an email client that respects your privacy and uses PGP Encryption to do so. Your password also gives you access to your keys, so if you forget it you will lose access to your account and the ability to read your messages.
+Forgetting passwords is a common thing, so we developed a more secure way to recover access to your account.
+
+1) This code is half of a big code to recover your account.
+2) The other half is with the account administrator.
+3) In case you forget your password, use this code and your administrator code to recover access to your account. It's like those locks with two keys :)
+
+
+
+PS: If you didn't create an account at https://{domain}, please ignore this email.
diff --git a/service/pixelated/assets/recovery.mail.pt-BR b/service/pixelated/assets/recovery.mail.pt-BR
new file mode 100644
index 00000000..558c6905
--- /dev/null
+++ b/service/pixelated/assets/recovery.mail.pt-BR
@@ -0,0 +1,26 @@
+From: {sender}
+Date: {date}
+Subject: Codigo de Recuperacao de {domain}
+To: {backup_email}
+Content-Type: text/plain; charset=UTF-8
+
+Olá,
+
+Você está recebendo isso porque você registrou um email no https://{domain}.
+Guarde o código abaixo para se um dia esquecer sua senha. Ele é a única forma de recuperar uma senha para acessar sua conta outra vez:
+
+{recovery_code}
+
+Salve essa mensagem ou anote o código em um lugar seguro.
+
+--
+Por que isso é importante?
+
+O Pixelated é um cliente de email que respeita sua privacidade e usa criptografia PGP para isso. Sua senha também dá acesso às suas chaves, então se você esquecê-la você perderá acesso a sua conta e a habilidade de ler suas mensagens. Esquecer a senha é algo comum, por isso desenvolvemos uma forma mais segura de recuperar sua conta.
+
+1) Esse código é uma metade de um código necessário para recuperar a conta.
+2) A outra metade está com o administrador da conta.
+3) Se você esquecer a senha, use esse código e o do administrador para recuperar acesso a conta. É como se fosse um cadeado com duas chaves :)
+
+
+PS: Se você não criou uma conta no site https://{domain}, por favor ignore esse email.
diff --git a/service/pixelated/resources/account_recovery_resource.py b/service/pixelated/resources/account_recovery_resource.py
new file mode 100644
index 00000000..209a7693
--- /dev/null
+++ b/service/pixelated/resources/account_recovery_resource.py
@@ -0,0 +1,87 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import json
+
+from twisted.python.filepath import FilePath
+from twisted.web.http import OK, INTERNAL_SERVER_ERROR
+from twisted.web.template import Element, XMLFile, renderElement
+from twisted.web.server import NOT_DONE_YET
+from twisted.internet import defer
+from twisted.logger import Logger
+
+from pixelated.resources import BaseResource
+from pixelated.resources import get_public_static_folder
+
+log = Logger()
+
+
+class InvalidPasswordError(Exception):
+ pass
+
+
+class AccountRecoveryPage(Element):
+ loader = XMLFile(FilePath(os.path.join(get_public_static_folder(), 'account_recovery.html')))
+
+ def __init__(self):
+ super(AccountRecoveryPage, self).__init__()
+
+
+class AccountRecoveryResource(BaseResource):
+ BASE_URL = 'account-recovery'
+ isLeaf = True
+
+ def __init__(self, services_factory):
+ BaseResource.__init__(self, services_factory)
+
+ def render_GET(self, request):
+ request.setResponseCode(OK)
+ return self._render_template(request)
+
+ def _render_template(self, request):
+ site = AccountRecoveryPage()
+ return renderElement(request, site)
+
+ def render_POST(self, request):
+ def success_response(response):
+ request.setResponseCode(OK)
+ request.finish()
+
+ def error_response(failure):
+ log.warn(failure)
+ request.setResponseCode(INTERNAL_SERVER_ERROR)
+ request.finish()
+
+ d = self._handle_post(request)
+ d.addCallbacks(success_response, error_response)
+ return NOT_DONE_YET
+
+ def _get_post_form(self, request):
+ return json.loads(request.content.getvalue())
+
+ def _validate_password(self, password, confirm_password):
+ return password == confirm_password and len(password) >= 8 and len(password) <= 9999
+
+ def _handle_post(self, request):
+ form = self._get_post_form(request)
+ password = form.get('password')
+ confirm_password = form.get('confirmPassword')
+
+ if not self._validate_password(password, confirm_password):
+ return defer.fail(InvalidPasswordError('The user entered an invalid password or confirmation'))
+
+ return defer.succeed('Done!')
diff --git a/service/pixelated/resources/backup_account_resource.py b/service/pixelated/resources/backup_account_resource.py
index b752b4c7..94129122 100644
--- a/service/pixelated/resources/backup_account_resource.py
+++ b/service/pixelated/resources/backup_account_resource.py
@@ -15,16 +15,18 @@
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
import os
-from xml.sax import SAXParseException
+import json
-from pixelated.resources import BaseResource
from twisted.python.filepath import FilePath
-from pixelated.resources import get_protected_static_folder
-from pixelated.account_recovery import AccountRecovery
from twisted.web.http import OK, NO_CONTENT, INTERNAL_SERVER_ERROR
from twisted.web.server import NOT_DONE_YET
from twisted.web.template import Element, XMLFile, renderElement
+from pixelated.resources import BaseResource
+from pixelated.resources import get_protected_static_folder
+from pixelated.account_recovery import AccountRecovery
+from pixelated.support.language import parse_accept_language
+
class BackupAccountPage(Element):
loader = XMLFile(FilePath(os.path.join(get_protected_static_folder(), 'backup_account.html')))
@@ -36,9 +38,10 @@ class BackupAccountPage(Element):
class BackupAccountResource(BaseResource):
isLeaf = True
- def __init__(self, services_factory, authenticator):
+ def __init__(self, services_factory, authenticator, leap_provider):
BaseResource.__init__(self, services_factory)
self._authenticator = authenticator
+ self._leap_provider = leap_provider
def render_GET(self, request):
request.setResponseCode(OK)
@@ -51,7 +54,11 @@ class BackupAccountResource(BaseResource):
def render_POST(self, request):
account_recovery = AccountRecovery(
self._authenticator.bonafide_session,
- self.soledad(request))
+ self.soledad(request),
+ self._service(request, '_leap_session').smtp_config,
+ self._get_backup_email(request),
+ self._leap_provider.server_name,
+ language=self._get_language(request))
def update_response(response):
request.setResponseCode(NO_CONTENT)
@@ -64,3 +71,9 @@ class BackupAccountResource(BaseResource):
d = account_recovery.update_recovery_code()
d.addCallbacks(update_response, error_response)
return NOT_DONE_YET
+
+ def _get_backup_email(self, request):
+ return json.loads(request.content.getvalue()).get('backupEmail')
+
+ def _get_language(self, request):
+ return parse_accept_language(request.getAllHeaders())
diff --git a/service/pixelated/resources/login_resource.py b/service/pixelated/resources/login_resource.py
index 3e1200d7..5b0b70d0 100644
--- a/service/pixelated/resources/login_resource.py
+++ b/service/pixelated/resources/login_resource.py
@@ -20,7 +20,10 @@ from xml.sax import SAXParseException
from pixelated.authentication import Authenticator
from pixelated.config.leap import BootstrapUserServices
from pixelated.resources import BaseResource, UnAuthorizedResource, IPixelatedSession
+from pixelated.resources.account_recovery_resource import AccountRecoveryResource
from pixelated.resources import get_public_static_folder, respond_json
+from pixelated.support.language import parse_accept_language
+
from twisted.cred.error import UnauthorizedLogin
from twisted.internet import defer
from twisted.logger import Logger
@@ -35,15 +38,6 @@ from twisted.web.template import Element, XMLFile, renderElement, renderer
log = Logger()
-def parse_accept_language(all_headers):
- accepted_languages = ['pt-BR', 'en-US']
- languages = all_headers.get('accept-language', '').split(';')[0]
- for language in accepted_languages:
- if language in languages:
- return language
- return 'pt-BR'
-
-
class DisclaimerElement(Element):
loader = XMLFile(FilePath(os.path.join(get_public_static_folder(), '_login_disclaimer_banner.html')))
@@ -101,6 +95,8 @@ class LoginResource(BaseResource):
return self
if path == 'status':
return LoginStatusResource(self._services_factory)
+ if path == AccountRecoveryResource.BASE_URL:
+ return AccountRecoveryResource(self._services_factory)
if not self.is_logged_in(request):
return UnAuthorizedResource()
return NoResource()
diff --git a/service/pixelated/resources/root_resource.py b/service/pixelated/resources/root_resource.py
index 10d57c6f..896bc24b 100644
--- a/service/pixelated/resources/root_resource.py
+++ b/service/pixelated/resources/root_resource.py
@@ -23,6 +23,7 @@ from pixelated.resources import BaseResource, UnAuthorizedResource, UnavailableR
from pixelated.resources import get_public_static_folder, get_protected_static_folder
from pixelated.resources.attachments_resource import AttachmentsResource
from pixelated.resources.sandbox_resource import SandboxResource
+from pixelated.resources.account_recovery_resource import AccountRecoveryResource
from pixelated.resources.backup_account_resource import BackupAccountResource
from pixelated.resources.contacts_resource import ContactsResource
from pixelated.resources.features_resource import FeaturesResource
@@ -91,7 +92,8 @@ class RootResource(BaseResource):
def initialize(self, provider=None, disclaimer_banner=None, authenticator=None):
self._child_resources.add('assets', File(self._protected_static_folder))
- self._child_resources.add('backup-account', BackupAccountResource(self._services_factory, authenticator))
+ self._child_resources.add(AccountRecoveryResource.BASE_URL, AccountRecoveryResource(self._services_factory))
+ self._child_resources.add('backup-account', BackupAccountResource(self._services_factory, authenticator, provider))
self._child_resources.add('sandbox', SandboxResource(self._protected_static_folder))
self._child_resources.add('keys', KeysResource(self._services_factory))
self._child_resources.add(AttachmentsResource.BASE_URL, AttachmentsResource(self._services_factory))
diff --git a/service/pixelated/support/language.py b/service/pixelated/support/language.py
new file mode 100644
index 00000000..cd455f89
--- /dev/null
+++ b/service/pixelated/support/language.py
@@ -0,0 +1,24 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+
+def parse_accept_language(all_headers):
+ accepted_languages = ['pt-BR', 'en-US']
+ languages = all_headers.get('accept-language', '').split(';')[0]
+ for language in accepted_languages:
+ if language in languages:
+ return language
+ return 'en-US'
diff --git a/service/requirements.txt b/service/requirements.txt
index 3ddb02df..01a2df5b 100644
--- a/service/requirements.txt
+++ b/service/requirements.txt
@@ -1,6 +1,6 @@
--index-url https://pypi.python.org/simple/
--e 'git+https://github.com/pixelated/python-gnupg.git@key_extension_and_sign#egg=gnupg'
+-e 'git+https://github.com/pixelated/python-gnupg.git@develop#egg=gnupg'
pyasn1==0.1.9
requests==2.11.1
srp==1.0.6
diff --git a/service/test/functional/features/account_recovery.feature b/service/test/functional/features/account_recovery.feature
new file mode 100644
index 00000000..da167d31
--- /dev/null
+++ b/service/test/functional/features/account_recovery.feature
@@ -0,0 +1,43 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+@smoke @require_user
+Feature: Account Recovery
+ As a user of Pixelated
+ I want to recover my account
+ So that I can see my emails if I lose my password
+
+ Scenario: Sending recovery code
+ Given I am logged in Pixelated
+ When I go to the backup account page
+ And I submit my backup account
+ Then I see the confirmation of this submission
+ And I logout from the header
+ And I should see the login page
+
+ Scenario: Confirming I received the recovery code at my backup email
+ Given I am logged in Pixelated
+ When I open the mail with the recovery code
+ Then I see the mail has the recovery code
+ Then I logout
+
+ Scenario: Recovering an account
+ Given I am on the account recovery page
+ When I submit admin recovery code
+ And I submit user recovery code
+ And I submit new password
+ And I click on the backup account link
+ Then I see the backup account page
diff --git a/service/test/functional/features/environment.py b/service/test/functional/features/environment.py
index 9f8507b2..98c7fa99 100644
--- a/service/test/functional/features/environment.py
+++ b/service/test/functional/features/environment.py
@@ -29,6 +29,8 @@ from pixelated.config.site import PixelatedSite
from pixelated.resources.features_resource import FeaturesResource
from test.support.integration import AppTestClient
from steps.common import DEFAULT_IMPLICIT_WAIT_TIMEOUT_IN_S
+from steps import utils
+from ..page_objects import BackupAccountPage
class UnsuportedWebDriverError(Exception):
@@ -51,19 +53,30 @@ def before_all(context):
if not context.host.startswith('http'):
context.host = 'https://{}'.format(context.host)
- hostname = urlparse(context.host).hostname
- context.signup_url = 'https://{}/signup'.format(hostname)
- context.login_url = 'https://mail.{}/login'.format(hostname)
- context.backup_account_url = 'https://mail.{}/backup-account'.format(hostname)
+ context.hostname = urlparse(context.host).hostname
+ context.signup_url = 'https://{}/signup'.format(context.hostname)
+ context.inbox_url = 'https://mail.{}'.format(context.hostname)
+ context.login_url = 'https://mail.{}/login'.format(context.hostname)
+ context.backup_account_url = 'https://mail.{}/backup-account'.format(context.hostname)
+ context.account_recovery_url = 'https://mail.{}/account-recovery'.format(context.hostname)
context.username = 'testuser_{}'.format(uuid.uuid4())
+ context.user_email = '{}@{}'.format(context.username, context.hostname)
if 'localhost' in context.host:
_mock_user_agent(context)
context.login_url = context.multi_user_url + '/login'
context.backup_account_url = context.single_user_url + '/backup-account'
+ context.account_recovery_url = context.single_user_url + '/account-recovery'
context.username = 'username'
+def before_tag(context, tag):
+ if tag == "require_user":
+ context.username = 'testuser_{}'.format(uuid.uuid4())
+ context.user_email = '{}@{}'.format(context.username, context.hostname)
+ utils.create_user(context)
+
+
def _setup_webdriver(context):
browser = context.config.userdata.get('webdriver', 'chrome')
supported_webdrivers = {
@@ -122,13 +135,24 @@ def after_feature(context, feature):
context.last_mail = None
+def after_scenario(context, scenario):
+ _logout(context)
+ context.browser.refresh()
+
+
def after_step(context, step):
- _debug_on_error(context, step)
- _save_screenshot(context, step)
+ if step.status == 'failed':
+ _debug_on_error(context, step)
+ _save_screenshot(context, step)
+ _logout(context)
+
+
+def _logout(context):
+ context.browser.delete_all_cookies()
def _debug_on_error(context, step):
- if step.status == 'failed' and context.config.userdata.getbool("debug"):
+ if context.config.userdata.getbool("debug"):
try:
import ipdb
ipdb.post_mortem(step.exc_traceback)
@@ -138,8 +162,7 @@ def _debug_on_error(context, step):
def _save_screenshot(context, step):
- if (step.status == 'failed' and
- context.config.userdata.getbool("screenshots", True)):
+ if context.config.userdata.getbool("screenshots", True):
timestamp = time.strftime("%Y-%m-%d-%H-%M-%S")
filename = _slugify('{} failed {}'.format(timestamp, str(step.name)))
filepath = os.path.join('screenshots', filename + '.png')
diff --git a/service/test/functional/features/page_objects/__init__.py b/service/test/functional/features/page_objects/__init__.py
new file mode 100644
index 00000000..af50948c
--- /dev/null
+++ b/service/test/functional/features/page_objects/__init__.py
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from account_recovery_page import AccountRecoveryPage
+from base_page import BasePage
+from inbox_page import InboxPage
+from backup_account_page import BackupAccountPage
diff --git a/service/test/functional/features/page_objects/account_recovery_page.py b/service/test/functional/features/page_objects/account_recovery_page.py
new file mode 100644
index 00000000..8a4e05cd
--- /dev/null
+++ b/service/test/functional/features/page_objects/account_recovery_page.py
@@ -0,0 +1,57 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from base_page import BasePage
+
+
+class AccountRecoveryPage(BasePage):
+ def __init__(self, context):
+ super(AccountRecoveryPage, self).__init__(context, context.account_recovery_url)
+
+ self._locators = {
+ 'admin_form': '.account-recovery-form.admin-code',
+ 'admin_code': 'input[name="admin-code"]',
+ 'user_form': '.account-recovery-form.user-code',
+ 'user_code': 'input[name="user-code"]',
+ 'new_password_form': '.account-recovery-form.new-password',
+ 'new_password': 'input[name="new-password"]',
+ 'confirm_password': 'input[name="confirm-password"]',
+ 'submit_button': '.submit-button button[type="submit"]',
+ 'backup_account_link': 'a[href="/backup-account"]'
+ }
+
+ def submit_admin_recovery_code(self, admin_code):
+ self.find_element_by_css_selector(self._locators['admin_form'])
+ self.fill_by_css_selector(self._locators['admin_code'], admin_code)
+ self.click_submit()
+
+ def submit_user_recovery_code(self, user_code):
+ self.find_element_by_css_selector(self._locators['user_form'])
+ self.fill_by_css_selector(self._locators['user_code'], user_code)
+ self.click_submit()
+
+ def submit_new_password(self, new_password, confirm_password):
+ self.find_element_by_css_selector(self._locators['new_password_form'])
+ self.fill_by_css_selector(self._locators['new_password'], new_password)
+ self.fill_by_css_selector(self._locators['confirm_password'], confirm_password)
+ self.click_submit()
+
+ def go_to_backup_account(self):
+ self.find_element_by_css_selector(self._locators['backup_account_link']).click()
+
+ def click_submit(self):
+ submit_button = self.find_element_by_css_selector(self._locators['submit_button'])
+ submit_button.click()
diff --git a/service/test/functional/features/page_objects/backup_account_page.py b/service/test/functional/features/page_objects/backup_account_page.py
new file mode 100644
index 00000000..d5f84b40
--- /dev/null
+++ b/service/test/functional/features/page_objects/backup_account_page.py
@@ -0,0 +1,30 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from base_page import BasePage
+
+
+class BackupAccountPage(BasePage):
+ def __init__(self, context):
+ super(BackupAccountPage, self).__init__(context, context.backup_account_url)
+
+ self._locators = {
+ 'logout_button': 'button[name="logout"]'
+ }
+
+ def logout(self):
+ logout_button = self.find_element_by_css_selector(self._locators['logout_button'])
+ logout_button.click()
diff --git a/service/test/functional/features/page_objects/base_page.py b/service/test/functional/features/page_objects/base_page.py
new file mode 100644
index 00000000..4756d930
--- /dev/null
+++ b/service/test/functional/features/page_objects/base_page.py
@@ -0,0 +1,39 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from steps.common import (
+ fill_by_css_selector,
+ find_element_by_css_selector,
+ find_element_by_xpath)
+
+
+class BasePage(object):
+ def __init__(self, context, base_url):
+ self.context = context
+ self.timeout = 30
+ self.base_url = base_url
+
+ def find_element_by_css_selector(self, loc):
+ return find_element_by_css_selector(self.context, loc)
+
+ def fill_by_css_selector(self, loc, text):
+ fill_by_css_selector(self.context, loc, text)
+
+ def find_element_by_xpath(self, xpath):
+ return find_element_by_xpath(self.context, xpath)
+
+ def visit(self):
+ self.context.browser.get(self.base_url)
diff --git a/service/test/functional/features/page_objects/inbox_page.py b/service/test/functional/features/page_objects/inbox_page.py
new file mode 100644
index 00000000..a6b5fef7
--- /dev/null
+++ b/service/test/functional/features/page_objects/inbox_page.py
@@ -0,0 +1,52 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from base_page import BasePage
+from steps.common import execute_ignoring_staleness
+
+
+class InboxPage(BasePage):
+ def __init__(self, context):
+ super(InboxPage, self).__init__(context, context.inbox_url)
+
+ self._locators = {
+ 'first_email': '.mail-list-entry__item',
+ 'read_sandbox': '#read-sandbox',
+ 'iframe_body': 'body',
+ }
+
+ def _get_first_mail(self):
+ return self.find_element_by_css_selector(self._locators['first_email'])
+
+ def get_mail_with_subject(self, subject):
+ return self.find_element_by_xpath("//*[@class='mail-list-entry__item-subject' and contains(.,'%s')]" % subject)
+
+ def open_first_mail_in_the_mail_list(self):
+ # it seems page is often still loading so staleness exceptions happen often
+ self.context.current_mail_id = 'mail-' + execute_ignoring_staleness(
+ lambda: self._get_first_mail().get_attribute('href').split('/')[-1])
+ execute_ignoring_staleness(lambda: self._get_first_mail().click())
+
+ def open_mail_with_the_recovery_code(self):
+ self.get_mail_with_subject('Recovery Code').click()
+
+ def get_body_message(self):
+ self.find_element_by_css_selector(self._locators['read_sandbox'])
+ self.context.browser.switch_to_frame('read-sandbox')
+ body_message = self.find_element_by_css_selector(self._locators['iframe_body']).text
+ self.context.browser.switch_to_default_content()
+
+ return body_message
diff --git a/service/test/functional/features/smoke.feature b/service/test/functional/features/smoke.feature
index 1467baf9..d4149334 100644
--- a/service/test/functional/features/smoke.feature
+++ b/service/test/functional/features/smoke.feature
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
-@smoke
+@smoke @require_user
Feature: sign up, login and logout
As a visitor of Pixelated
I want to sign up
@@ -31,7 +31,8 @@ Feature: sign up, login and logout
When I enter username and password as credentials
And I click on the login button
Then I should see the fancy interstitial
- Then I have mails
+ And I should see the inbox
+ And I have mails
When I logout
Then I should see the login page
@@ -40,6 +41,7 @@ Feature: sign up, login and logout
When I enter username and password as credentials
And I click on the login button
Then I should see the fancy interstitial
- Given I am on the backup account page
+ And I should see the inbox
+ Given I go to the backup account page
When I logout from the header
Then I should see the login page
diff --git a/service/test/functional/features/steps/__init__.py b/service/test/functional/features/steps/__init__.py
index 2756a319..5750dc53 100644
--- a/service/test/functional/features/steps/__init__.py
+++ b/service/test/functional/features/steps/__init__.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2014 ThoughtWorks, Inc.
+# Copyright (c) 2017 ThoughtWorks, Inc.
#
# Pixelated is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
diff --git a/service/test/functional/features/steps/account_recovery.py b/service/test/functional/features/steps/account_recovery.py
new file mode 100644
index 00000000..ac66cf76
--- /dev/null
+++ b/service/test/functional/features/steps/account_recovery.py
@@ -0,0 +1,49 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from behave import given, when, then
+
+from ..page_objects import AccountRecoveryPage
+
+
+@given(u'I am on the account recovery page')
+def account_recovery_page(context):
+ AccountRecoveryPage(context).visit()
+
+
+@when(u'I submit admin recovery code')
+def submit_admin_recovery_code(context):
+ AccountRecoveryPage(context).submit_admin_recovery_code('1234')
+
+
+@when(u'I submit user recovery code')
+def submit_user_recovery_code(context):
+ AccountRecoveryPage(context).submit_user_recovery_code('5678')
+
+
+@when(u'I submit new password')
+def submit_new_password(context):
+ AccountRecoveryPage(context).submit_new_password('new test password', 'new test password')
+
+
+@when(u'I click on the backup account link')
+def go_to_backup_account(context):
+ AccountRecoveryPage(context).go_to_backup_account()
+
+
+@then(u'I see the backup account page')
+def verify_backup_account_page(context):
+ assert('/backup-account' in context.browser.current_url)
diff --git a/service/test/functional/features/steps/backup_account.py b/service/test/functional/features/steps/backup_account.py
index 914309f2..5a1052a8 100644
--- a/service/test/functional/features/steps/backup_account.py
+++ b/service/test/functional/features/steps/backup_account.py
@@ -14,9 +14,25 @@
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
-from behave import given
+from behave import given, when, then
+from common import (
+ fill_by_css_selector,
+ find_element_by_css_selector)
-@given(u'I am on the backup account page')
+
+@when(u'I go to the backup account page')
+@given(u'I go to the backup account page')
def backup_account_page(context):
context.browser.get(context.backup_account_url)
+
+
+@when(u'I submit my backup account')
+def submit_backup_email(context):
+ fill_by_css_selector(context, 'input[name="email"]', context.user_email)
+ find_element_by_css_selector(context, '.submit-button button[type="submit"]').click()
+
+
+@then(u'I see the confirmation of this submission')
+def confirmation_page(context):
+ find_element_by_css_selector(context, '.confirmation-container', timeout=50)
diff --git a/service/test/functional/features/steps/common.py b/service/test/functional/features/steps/common.py
index 3e1e995e..ff6e6166 100644
--- a/service/test/functional/features/steps/common.py
+++ b/service/test/functional/features/steps/common.py
@@ -146,10 +146,6 @@ def click_button(context, title, element='button'):
button.click()
-def mail_list_with_subject_exists(context, subject):
- return find_element_by_xpath(context, "//*[@class='mail-list-entry__item-subject' and contains(.,'%s')]" % subject)
-
-
def reply_subject(context):
e = find_element_by_css_selector(context, '#reply-subject')
return e.text
diff --git a/service/test/functional/features/steps/login.py b/service/test/functional/features/steps/login.py
index 2d7be259..0ac27c46 100644
--- a/service/test/functional/features/steps/login.py
+++ b/service/test/functional/features/steps/login.py
@@ -21,16 +21,29 @@ from common import (
find_element_by_css_selector)
+@given('I am logged in Pixelated')
+def login_user(context):
+ login_page(context)
+ enter_credentials(context)
+ click_login(context)
+ see_interstitial(context)
+ _see_inbox(context)
+
+
@given(u'a user is accessing the login page')
@when(u'I open the login page')
def login_page(context):
context.browser.get(context.login_url)
-@when(u'I enter {username} and {password} as credentials')
-def enter_credentials(context, username, password):
+def _see_inbox(context):
+ find_element_by_css_selector(context, '#compose', timeout=40)
+
+
+@when(u'I enter username and password as credentials')
+def enter_credentials(context):
fill_by_css_selector(context, 'input[name="username"]', context.username)
- fill_by_css_selector(context, 'input[name="password"]', password)
+ fill_by_css_selector(context, 'input[name="password"]', 'password')
@when(u'I click on the login button')
@@ -39,20 +52,29 @@ def click_login(context):
@then(u'I should see the fancy interstitial')
-def step_impl(context):
+def see_interstitial(context):
find_element_by_css_selector(context, 'section#hive-section')
+@then(u'I should see the inbox')
+def see_inbox(context):
+ _see_inbox(context)
+
+
+@then(u'I logout')
@when(u'I logout')
def click_logout(context):
+ context.browser.refresh()
find_element_by_css_selector(context, '#logout-form div').click()
+@then(u'I logout from the header') # noqa
@when(u'I logout from the header')
def click_logout(context):
find_element_by_css_selector(context, 'button[name="logout"]').click()
+@when(u'I should see the login page')
@then(u'I should see the login page')
def see_login_page(context):
find_element_by_css_selector(context, 'form#login_form')
diff --git a/service/test/functional/features/steps/mail_list.py b/service/test/functional/features/steps/mail_list.py
index 227aa9ed..21694153 100644
--- a/service/test/functional/features/steps/mail_list.py
+++ b/service/test/functional/features/steps/mail_list.py
@@ -17,13 +17,12 @@
from behave import when, then, given
from selenium.common.exceptions import TimeoutException
+from ..page_objects import InboxPage
from common import (
ImplicitWait,
- execute_ignoring_staleness,
find_element_by_id,
find_element_by_css_selector,
find_elements_by_css_selector,
- mail_list_with_subject_exists,
wait_for_condition,
wait_for_loading_to_finish)
@@ -42,10 +41,6 @@ def open_current_mail(context):
e.click()
-def get_first_email(context):
- return find_element_by_css_selector(context, '.mail-list-entry__item')
-
-
@then('I see that mail under the \'{tag}\' tag')
def impl(context, tag):
context.execute_steps("when I select the tag '%s'" % tag)
@@ -59,9 +54,12 @@ def impl(context):
@when('I open the first mail in the mail list')
def impl(context):
- # it seems page is often still loading so staleness exceptions happen often
- context.current_mail_id = 'mail-' + execute_ignoring_staleness(lambda: get_first_email(context).get_attribute('href').split('/')[-1])
- execute_ignoring_staleness(lambda: get_first_email(context).click())
+ InboxPage(context).open_first_mail_in_the_mail_list()
+
+
+@when('I open the mail with the recovery code')
+def impl(context):
+ InboxPage(context).open_mail_with_the_recovery_code()
@when('I open the first mail in the \'{tag}\'')
@@ -83,7 +81,7 @@ def impl(context):
@then('the deleted mail is there')
def impl(context):
- mail_list_with_subject_exists(context, context.last_subject)
+ InboxPage(context).get_mail_with_subject(context.last_subject)
@given('I have mails')
diff --git a/service/test/functional/features/steps/mail_view.py b/service/test/functional/features/steps/mail_view.py
index 65959b70..26368aaa 100644
--- a/service/test/functional/features/steps/mail_view.py
+++ b/service/test/functional/features/steps/mail_view.py
@@ -17,6 +17,7 @@
from behave import then, when
from selenium.webdriver.common.keys import Keys
+from ..page_objects import InboxPage
from common import (
click_button,
find_element_by_css_selector,
@@ -25,19 +26,17 @@ from common import (
wait_until_button_is_visible)
-@then('I see that the subject reads \'{subject}\'')
-def impl(context, subject):
- e = find_element_by_css_selector(context, '#mail-view .mail-read-view__header-subject')
- assert e.text == subject
+@then('I see that the subject reads \'{expected_subject}\'')
+def impl(context, expected_subject):
+ actual_subject = find_element_by_css_selector(context, '#mail-view .mail-read-view__header-subject').text
+ assert expected_subject == actual_subject
@then('I see that the body reads \'{expected_body}\'')
+@then('I see that the body has \'{expected_body}\'')
def impl(context, expected_body):
- find_element_by_css_selector(context, '#read-sandbox')
- context.browser.switch_to_frame('read-sandbox')
- e = find_element_by_css_selector(context, 'body')
- assert e.text == expected_body
- context.browser.switch_to_default_content()
+ actual_body = InboxPage(context).get_body_message()
+ assert expected_body in actual_body
@then('that email has the \'{tag}\' tag')
@@ -110,3 +109,9 @@ def impl(context):
assert cc is not None
assert bcc is not None
+
+
+@then(u'I see the mail has the recovery code')
+def step_impl(context):
+ expected_body = 'You are receiving this message because you registered an email account at'
+ context.execute_steps(u"Then I see that the body has '%s'" % expected_body)
diff --git a/service/test/functional/features/steps/signup.py b/service/test/functional/features/steps/signup.py
index 43480666..71df1868 100644
--- a/service/test/functional/features/steps/signup.py
+++ b/service/test/functional/features/steps/signup.py
@@ -14,6 +14,8 @@
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+import uuid
+
from behave import given, then, when
from common import (
@@ -23,22 +25,31 @@ from common import (
@given(u'a user is accessing the signup page') # noqa
-def step_impl(context):
+def access_signup_page(context):
context.browser.get(context.signup_url)
+@given(u'I am an existent Pixelated user')
+def setup_user(context):
+ access_signup_page(context)
+ enter_user_information(context)
+ click_signup_button(context)
+ see_user_control_panel(context)
+
+
@when(u'I enter username, password and password confirmation') # noqa
-def step_impl(context):
- fill_by_css_selector(context, '#srp_username', context.username)
+def enter_user_information(context):
+ username = 'testuser_{}'.format(uuid.uuid4())
+ fill_by_css_selector(context, '#srp_username', username)
fill_by_css_selector(context, '#srp_password', 'password')
fill_by_css_selector(context, '#srp_password_confirmation', 'password')
@when(u'I click on the signup button') # noqa
-def step_impl(context):
+def click_signup_button(context):
find_element_by_css_selector(context, 'button[type=submit]').click()
@then(u'I should see the user control panel') # noqa
-def step_impl(context):
+def see_user_control_panel(context):
element_should_have_content(context, 'h1', 'user control panel')
diff --git a/service/test/functional/features/steps/utils.py b/service/test/functional/features/steps/utils.py
new file mode 100644
index 00000000..9ac05928
--- /dev/null
+++ b/service/test/functional/features/steps/utils.py
@@ -0,0 +1,51 @@
+#
+# Copyright (c) 2016 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from common import (
+ element_should_have_content,
+ fill_by_css_selector,
+ find_element_by_css_selector)
+
+
+def access_signup_page(context):
+ context.browser.get(context.signup_url)
+
+
+def create_user(context):
+ context.browser.get(context.login_url)
+ access_signup_page(context)
+ enter_user_information(context)
+ click_signup_button(context)
+ see_user_control_panel(context)
+ log_out(context)
+
+
+def log_out(context):
+ find_element_by_css_selector(context, 'a[href="/logout"]').click()
+
+
+def enter_user_information(context):
+ fill_by_css_selector(context, '#srp_username', context.username)
+ fill_by_css_selector(context, '#srp_password', 'password')
+ fill_by_css_selector(context, '#srp_password_confirmation', 'password')
+
+
+def click_signup_button(context):
+ find_element_by_css_selector(context, 'button[type=submit]').click()
+
+
+def see_user_control_panel(context):
+ element_should_have_content(context, 'h1', 'user control panel')
diff --git a/service/test/unit/config/test_site.py b/service/test/unit/config/test_site.py
index 896126ec..64a8b68a 100644
--- a/service/test/unit/config/test_site.py
+++ b/service/test/unit/config/test_site.py
@@ -17,7 +17,7 @@
from twisted.trial import unittest
from mock import MagicMock
from pixelated.config.site import PixelatedSite
-from twisted.protocols.basic import LineReceiver
+from twisted.web.test.requesthelper import DummyChannel
class TestPixelatedSite(unittest.TestCase):
@@ -52,7 +52,7 @@ class TestPixelatedSite(unittest.TestCase):
self.assertFalse(request.responseHeaders.hasHeader('Strict-Transport-Security'.lower()))
def create_request(self):
- channel = LineReceiver()
+ channel = DummyChannel()
channel.site = PixelatedSite(MagicMock())
request = PixelatedSite.requestFactory(channel=channel, queued=True)
request.method = "GET"
diff --git a/service/test/unit/resources/test_account_recovery_resource.py b/service/test/unit/resources/test_account_recovery_resource.py
new file mode 100644
index 00000000..4e26fc5b
--- /dev/null
+++ b/service/test/unit/resources/test_account_recovery_resource.py
@@ -0,0 +1,108 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+from mock import MagicMock
+from twisted.trial import unittest
+from twisted.web.test.requesthelper import DummyRequest
+from twisted.internet import defer
+
+from pixelated.resources.account_recovery_resource import AccountRecoveryResource, InvalidPasswordError
+from test.unit.resources import DummySite
+
+
+class TestAccountRecoveryResource(unittest.TestCase):
+ def setUp(self):
+ self.services_factory = MagicMock()
+ self.resource = AccountRecoveryResource(self.services_factory)
+ self.web = DummySite(self.resource)
+
+ def test_get(self):
+ request = DummyRequest(['/account-recovery'])
+ request.method = 'GET'
+ d = self.web.get(request)
+
+ def assert_200_when_user_logged_in(_):
+ self.assertEqual(200, request.responseCode)
+ self.assertIn("DOCTYPE html", request.written[0])
+
+ d.addCallback(assert_200_when_user_logged_in)
+ return d
+
+ def test_post_returns_successfully(self):
+ request = DummyRequest(['/account-recovery'])
+ request.method = 'POST'
+ self.resource._handle_post = MagicMock(return_value=defer.succeed(None))
+
+ d = self.web.get(request)
+
+ def assert_successful_response(_):
+ self.assertEqual(200, request.responseCode)
+
+ d.addCallback(assert_successful_response)
+ return d
+
+ def test_post_returns_failure(self):
+ request = DummyRequest(['/account-recovery'])
+ request.method = 'POST'
+ self.resource._handle_post = MagicMock(return_value=defer.fail(InvalidPasswordError))
+
+ d = self.web.get(request)
+
+ def assert_error_response(_):
+ self.assertEqual(500, request.responseCode)
+
+ d.addCallback(assert_error_response)
+ return d
+
+ def test_handle_post_successfully(self):
+ request = MagicMock()
+ self.resource._get_post_form = MagicMock()
+ self.resource._validate_password = MagicMock(return_value=True)
+
+ d = self.resource._handle_post(request)
+
+ def assert_successful(success):
+ self.assertEqual(success, 'Done!')
+
+ d.addCallback(assert_successful)
+ return d
+
+ @defer.inlineCallbacks
+ def test_handle_post_failed(self):
+ request = MagicMock()
+ self.resource._get_post_form = MagicMock()
+ self.resource._validate_password = MagicMock(return_value=False)
+
+ with self.assertRaises(InvalidPasswordError):
+ yield self.resource._handle_post(request)
+
+ def test_get_post_form(self):
+ request = MagicMock()
+ request.content.getvalue.return_value = '{"userCode": "abc", "password": "123", "confirmPassword": "456"}'
+ form = self.resource._get_post_form(request)
+
+ self.assertEqual(form.get('userCode'), 'abc')
+ self.assertEqual(form.get('password'), '123')
+ self.assertEqual(form.get('confirmPassword'), '456')
+
+ def test_validate_password_successfully(self):
+ self.assertTrue(self.resource._validate_password('12345678', '12345678'))
+
+ def test_validate_password_failed_by_confirmation(self):
+ self.assertFalse(self.resource._validate_password('12345678', '1234'))
+
+ def test_validate_password_failed_by_length(self):
+ self.assertFalse(self.resource._validate_password('1234', '1234'))
diff --git a/service/test/unit/resources/test_backup_account_resource.py b/service/test/unit/resources/test_backup_account_resource.py
index 2b68dd1b..e16fa0e1 100644
--- a/service/test/unit/resources/test_backup_account_resource.py
+++ b/service/test/unit/resources/test_backup_account_resource.py
@@ -14,8 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
-import os
-
from mock import MagicMock, patch
from twisted.trial import unittest
from twisted.web.test.requesthelper import DummyRequest
@@ -28,7 +26,10 @@ from test.unit.resources import DummySite
class TestBackupAccountResource(unittest.TestCase):
def setUp(self):
self.services_factory = MagicMock()
- self.resource = BackupAccountResource(self.services_factory, MagicMock())
+ self.authenticator = MagicMock()
+ self.leap_provider = MagicMock()
+ self.leap_provider.server_name = 'test.com'
+ self.resource = BackupAccountResource(self.services_factory, self.authenticator, self.leap_provider)
self.web = DummySite(self.resource)
def test_get(self):
@@ -43,19 +44,27 @@ class TestBackupAccountResource(unittest.TestCase):
d.addCallback(assert_200_when_user_logged_in)
return d
+ @patch('pixelated.resources.backup_account_resource.parse_accept_language')
@patch('pixelated.resources.backup_account_resource.AccountRecovery')
- def test_post_updates_recovery_code(self, mock_account_recovery_init):
+ def test_post_updates_recovery_code(self, mock_account_recovery_init, mock_language):
+ mock_language.return_value = 'pt-BR'
mock_account_recovery = MagicMock()
mock_account_recovery_init.return_value = mock_account_recovery
mock_account_recovery.update_recovery_code.return_value = defer.succeed("Success")
request = DummyRequest(['/backup-account'])
request.method = 'POST'
+ request.content = MagicMock()
+ request.content.getvalue.return_value = '{"email": "test@test.com"}'
d = self.web.get(request)
def assert_update_recovery_code_called(_):
mock_account_recovery_init.assert_called_with(
self.resource._authenticator.bonafide_session,
- self.resource.soledad(request))
+ self.resource.soledad(request),
+ self.resource._service(request, '_leap_session').smtp_config,
+ self.resource._get_backup_email(request),
+ self.leap_provider.server_name,
+ language='pt-BR')
mock_account_recovery.update_recovery_code.assert_called()
d.addCallback(assert_update_recovery_code_called)
@@ -66,6 +75,8 @@ class TestBackupAccountResource(unittest.TestCase):
mock_update_recovery_code.return_value = defer.succeed("Success")
request = DummyRequest(['/backup-account'])
request.method = 'POST'
+ request.content = MagicMock()
+ request.content.getvalue.return_value = '{"email": "test@test.com"}'
d = self.web.get(request)
def assert_successful_response(_):
@@ -79,6 +90,8 @@ class TestBackupAccountResource(unittest.TestCase):
mock_update_recovery_code.return_value = defer.fail(Exception)
request = DummyRequest(['/backup-account'])
request.method = 'POST'
+ request.content = MagicMock()
+ request.content.getvalue.return_value = '{"email": "test@test.com"}'
d = self.web.get(request)
def assert_successful_response(_):
@@ -86,3 +99,9 @@ class TestBackupAccountResource(unittest.TestCase):
d.addCallback(assert_successful_response)
return d
+
+ def test_get_backup_email_from_request(self):
+ request = MagicMock()
+ request.content.getvalue.return_value = '{"backupEmail": "test@test.com"}'
+
+ self.assertEqual(self.resource._get_backup_email(request), 'test@test.com')
diff --git a/service/test/unit/resources/test_login_resource.py b/service/test/unit/resources/test_login_resource.py
index 9f940bc6..03d61758 100644
--- a/service/test/unit/resources/test_login_resource.py
+++ b/service/test/unit/resources/test_login_resource.py
@@ -24,30 +24,9 @@ from twisted.trial import unittest
from twisted.web.test.requesthelper import DummyRequest
from pixelated.resources.login_resource import LoginResource, LoginStatusResource
-from pixelated.resources.login_resource import parse_accept_language
from test.unit.resources import DummySite
-class TestParseAcceptLanguage(unittest.TestCase):
- def test_parse_pt_br_simple(self):
- all_headers = {
- 'accept-language': 'pt-BR,pt;q=0.8,en-US;q=0.5,en;q=0.3'}
- parsed_language = parse_accept_language(all_headers)
- self.assertEqual('pt-BR', parsed_language)
-
- def test_parse_en_us_simple(self):
- all_headers = {
- 'accept-language': 'en-US,en;q=0.8,en-US;q=0.5,en;q=0.3'}
- parsed_language = parse_accept_language(all_headers)
- self.assertEqual('en-US', parsed_language)
-
- def test_parse_pt_br_as_default(self):
- all_headers = {
- 'accept-language': 'de-DE,de;q=0.8,en-US;q=0.5,en;q=0.3'}
- parsed_language = parse_accept_language(all_headers)
- self.assertEqual('pt-BR', parsed_language)
-
-
class TestLoginResource(unittest.TestCase):
def setUp(self):
self.services_factory = mock()
@@ -67,6 +46,16 @@ class TestLoginResource(unittest.TestCase):
d.addCallback(assert_unauthorized_resources)
return d
+ def test_account_recovery_resource_does_not_require_login(self):
+ request = DummyRequest(['account-recovery'])
+ d = self.web.get(request)
+
+ def assert_successful(_):
+ self.assertEqual(200, request.responseCode)
+
+ d.addCallback(assert_successful)
+ return d
+
@patch('pixelated.resources.session.PixelatedSession.is_logged_in')
def test_there_are_no_grand_children_resources_when_logged_in(self, mock_is_logged_in):
request = DummyRequest(['/login/grand_children'])
@@ -242,7 +231,7 @@ class TestLoginPOST(unittest.TestCase):
d = self.web.get(self.request)
def assert_login_setup_service_for_user(_):
- mock_user_bootstrap_setup.assert_called_once_with(self.user_auth, self.password, 'pt-BR')
+ mock_user_bootstrap_setup.assert_called_once_with(self.user_auth, self.password, 'en-US')
d.addCallback(assert_login_setup_service_for_user)
return d
diff --git a/service/test/unit/support/test_language.py b/service/test/unit/support/test_language.py
new file mode 100644
index 00000000..b84f3a23
--- /dev/null
+++ b/service/test/unit/support/test_language.py
@@ -0,0 +1,40 @@
+#
+# Copyright (c) 2017 ThoughtWorks, Inc.
+#
+# Pixelated is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Pixelated is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+
+
+from twisted.trial import unittest
+
+from pixelated.support.language import parse_accept_language
+
+
+class TestParseAcceptLanguage(unittest.TestCase):
+ def test_parse_pt_br_simple(self):
+ all_headers = {
+ 'accept-language': 'pt-BR,pt;q=0.8,en-US;q=0.5,en;q=0.3'}
+ parsed_language = parse_accept_language(all_headers)
+ self.assertEqual('pt-BR', parsed_language)
+
+ def test_parse_en_us_simple(self):
+ all_headers = {
+ 'accept-language': 'en-US,en;q=0.8,en-US;q=0.5,en;q=0.3'}
+ parsed_language = parse_accept_language(all_headers)
+ self.assertEqual('en-US', parsed_language)
+
+ def test_parse_en_us_as_default(self):
+ all_headers = {
+ 'accept-language': 'de-DE,de;q=0.8,en-US;q=0.5,en;q=0.3'}
+ parsed_language = parse_accept_language(all_headers)
+ self.assertEqual('en-US', parsed_language)
diff --git a/service/test/unit/test_account_recovery.py b/service/test/unit/test_account_recovery.py
index af14814a..2a185347 100644
--- a/service/test/unit/test_account_recovery.py
+++ b/service/test/unit/test_account_recovery.py
@@ -13,24 +13,95 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
+from email.mime.text import MIMEText
-from twisted.internet.defer import inlineCallbacks
+from twisted.internet import defer
from twisted.trial import unittest
+from twisted.mail import smtp
-from mock import patch, Mock
+from mock import patch, Mock, mock_open
+from mockito import when, any as ANY
from pixelated.account_recovery import AccountRecovery
class AccountRecoveryTest(unittest.TestCase):
+ def setUp(self):
+ self.generated_code = '4645a2f8997e5d0d'
+ self.mock_bonafide_session = Mock()
+ self.mock_soledad = Mock()
+ self.mock_smtp_config = Mock()
+ self.keymanager = Mock()
+ self.mock_smtp_config.remote_smtp_host = 'localhost'
+ self.mock_soledad.create_recovery_code.return_value = self.generated_code
+ self.backup_email = 'test@test.com'
+ self.domain = 'test.com'
+ self.account_recovery = AccountRecovery(
+ self.mock_bonafide_session,
+ self.mock_soledad,
+ self.mock_smtp_config,
+ self.backup_email,
+ self.domain)
+ self.mock_smtp = Mock()
- @inlineCallbacks
+ @defer.inlineCallbacks
def test_update_recovery_code(self):
- generated_code = '4645a2f8997e5d0d'
- mock_bonafide_session = Mock()
- mock_soledad = Mock()
- mock_soledad.create_recovery_code.return_value = generated_code
- account_recovery = AccountRecovery(mock_bonafide_session, mock_soledad)
-
- yield account_recovery.update_recovery_code()
- mock_bonafide_session.update_recovery_code.assert_called_once_with(generated_code)
+ when(self.account_recovery)._send_mail(ANY).thenReturn(defer.succeed(None))
+ yield self.account_recovery.update_recovery_code()
+ self.mock_bonafide_session.update_recovery_code.assert_called_once_with(self.generated_code)
+
+ @defer.inlineCallbacks
+ def test_creates_recovery_code(self):
+ when(self.account_recovery)._send_mail(ANY).thenReturn(defer.succeed(None))
+ yield self.account_recovery.update_recovery_code()
+ self.mock_soledad.create_recovery_code.assert_called_once()
+
+ @patch('pixelated.account_recovery.smtp.sendmail')
+ @patch('pixelated.account_recovery.pkg_resources.resource_filename')
+ @defer.inlineCallbacks
+ def test_default_email_template(self, mock_resource, mock_sendmail):
+ mock_sendmail.return_value = defer.succeed(None)
+
+ with patch('pixelated.account_recovery.open', mock_open(read_data=''), create=True):
+ yield self.account_recovery.update_recovery_code()
+ mock_resource.assert_called_once_with('pixelated.assets',
+ 'recovery.mail.en-US')
+
+ @patch('pixelated.account_recovery.smtp.sendmail')
+ @patch('pixelated.account_recovery.pkg_resources.resource_filename')
+ @defer.inlineCallbacks
+ def test_portuguese_email_template(self, mock_resource, mock_sendmail):
+ self.account_recovery = AccountRecovery(
+ self.mock_bonafide_session,
+ self.mock_soledad,
+ self.mock_smtp_config,
+ self.backup_email,
+ self.domain,
+ language='pt-BR')
+ mock_sendmail.return_value = defer.succeed(None)
+
+ with patch('pixelated.account_recovery.open', mock_open(read_data=''), create=True):
+ yield self.account_recovery.update_recovery_code()
+ mock_resource.assert_called_once_with('pixelated.assets',
+ 'recovery.mail.pt-BR')
+
+ @patch('pixelated.account_recovery.date.mail_date_now')
+ @patch('pixelated.account_recovery.smtp.sendmail')
+ @patch('pixelated.account_recovery.pkg_resources.resource_filename')
+ @defer.inlineCallbacks
+ def test_send_recovery_code_by_email(self, mock_resource, mock_sendmail, mock_date):
+ mock_sendmail.return_value = defer.succeed(None)
+ mock_date.return_value = 'Sat, 21 Mar 2015 19:30:09 -0300'
+
+ sender = 'team@{}'.format(self.domain)
+ mock_file_content = '{backup_email}, {sender}, {date}, {domain}, {recovery_code}'
+ recovery_code_email = '\ntest@test.com, team@test.com, Sat, 21 Mar 2015 19:30:09 -0300, test.com, 34363435613266383939376535643064'
+
+ with patch('pixelated.account_recovery.open', mock_open(read_data=mock_file_content), create=True):
+ yield self.account_recovery.update_recovery_code()
+
+ mock_sendmail.assert_called_with(
+ self.mock_smtp_config.remote_smtp_host,
+ sender,
+ [self.backup_email],
+ recovery_code_email)