Repository: freedomofpress/securedrop

Issue:
Source session expiry notification should notify them to close or restart Tor Browser
## Description

Currently we flash:

![screen shot 2017-10-04 at 5 20 24 pm](https://user-images.githubusercontent.com/7832803/31246736-3643e16c-a9c3-11e7-8b4d-4f5df30e6e95.png)

But we should probably flash a message similar to this one: 

![Logout](https://docs.securedrop.org/en/latest/_images/source-logout_flashed_message.png)

## User Stories

As a SecureDrop source, I want to be clearly notified of additional steps that I should be taking for my security. 


Discussion:

Resolving patch:
diff --git a/securedrop/source_app/__init__.py b/securedrop/source_app/__init__.py
--- a/securedrop/source_app/__init__.py
+++ b/securedrop/source_app/__init__.py
@@ -79,8 +79,8 @@ def setup_g():
 
         if 'expires' in session and datetime.utcnow() >= session['expires']:
             session.clear()
-            flash(gettext('You have been logged out due to inactivity'),
-                  'error')
+            msg = render_template('session_timeout.html')
+            flash(Markup(msg), "important")
 
         session['expires'] = datetime.utcnow() + \
             timedelta(minutes=getattr(config,


Test patch:
diff --git a/securedrop/tests/functional/functional_test.py b/securedrop/tests/functional/functional_test.py
--- a/securedrop/tests/functional/functional_test.py
+++ b/securedrop/tests/functional/functional_test.py
@@ -21,9 +21,10 @@
 from selenium.webdriver.support import expected_conditions
 
 os.environ['SECUREDROP_ENV'] = 'test'  # noqa
+import config
 import db
 import journalist
-import source
+from source_app import create_app
 import tests.utils.env as env
 
 LOG_DIR = abspath(join(dirname(realpath(__file__)), '..', 'log'))
@@ -41,7 +42,7 @@ def __call__(self, driver):
             return True
 
 
-class FunctionalTest():
+class FunctionalTest(object):
 
     def _unused_port(self):
         s = socket.socket()
@@ -80,7 +81,7 @@ def _prepare_webdriver(self):
         log_file.flush()
         return firefox_binary.FirefoxBinary(log_file=log_file)
 
-    def setup(self):
+    def setup(self, session_expiration=30):
         # Patch the two-factor verification to avoid intermittent errors
         self.patcher = mock.patch('journalist.Journalist.verify_token')
         self.mock_journalist_verify_token = self.patcher.start()
@@ -98,6 +99,9 @@ def setup(self):
         self.source_location = "http://localhost:%d" % source_port
         self.journalist_location = "http://localhost:%d" % journalist_port
 
+        # Allow custom session expiration lengths
+        self.session_expiration = session_expiration
+
         def start_source_server():
             # We call Random.atfork() here because we fork the source and
             # journalist server from the main Python process we use to drive
@@ -106,7 +110,12 @@ def start_source_server():
             # is a problem because they would produce identical output if we
             # didn't re-seed them after forking.
             Random.atfork()
-            source.app.run(
+
+            config.SESSION_EXPIRATION_MINUTES = self.session_expiration
+
+            source_app = create_app(config)
+
+            source_app.run(
                 port=source_port,
                 debug=True,
                 use_reloader=False,
diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py
--- a/securedrop/tests/functional/source_navigation_steps.py
+++ b/securedrop/tests/functional/source_navigation_steps.py
@@ -1,4 +1,5 @@
 import tempfile
+import time
 
 from selenium.webdriver.common.action_chains import ActionChains
 from step_helpers import screenshots
@@ -14,8 +15,7 @@ def _source_visits_source_homepage(self):
             assert ("SecureDrop | Protecting Journalists and Sources" ==
                     self.driver.title)
 
-    @screenshots
-    def _source_chooses_to_submit_documents(self):
+    def _source_clicks_submit_documents_on_homepage(self):
         # First move the cursor to a known position in case it happens to
         # be hovering over one of the buttons we are testing below.
         header_image = self.driver.find_element_by_css_selector('.header')
@@ -44,6 +44,10 @@ def _source_chooses_to_submit_documents(self):
         # The source clicks the submit button.
         submit_button.click()
 
+    @screenshots
+    def _source_chooses_to_submit_documents(self):
+        self._source_clicks_submit_documents_on_homepage()
+
         codename = self.driver.find_element_by_css_selector('#codename')
 
         assert len(codename.text) > 0
@@ -170,12 +174,8 @@ def _source_submits_a_file(self):
 
     @screenshots
     def _source_submits_a_message(self):
-        text_box = self.driver.find_element_by_css_selector('[name=msg]')
-        # send_keys = type into text box
-        text_box.send_keys(self.secret_message)
-
-        submit_button = self.driver.find_element_by_id('submit-doc-button')
-        submit_button.click()
+        self._source_enters_text_in_message_field()
+        self._source_clicks_submit_button_on_submission_page()
 
         if not hasattr(self, 'accept_languages'):
             notification = self.driver.find_element_by_css_selector(
@@ -186,6 +186,10 @@ def _source_enters_text_in_message_field(self):
         text_box = self.driver.find_element_by_css_selector('[name=msg]')
         text_box.send_keys(self.secret_message)
 
+    def _source_clicks_submit_button_on_submission_page(self):
+        submit_button = self.driver.find_element_by_id('submit-doc-button')
+        submit_button.click()
+
     @screenshots
     def _source_deletes_a_journalist_reply(self):
         # Get the reply filename so we can use IDs to select the delete buttons
@@ -226,3 +230,13 @@ def _source_tor2web_warning(self):
 
     def _source_why_journalist_key(self):
         self.driver.get(self.source_location + "/why-journalist-key")
+
+    def _source_waits_for_session_to_timeout(self, session_length_minutes):
+        time.sleep(session_length_minutes * 60 + 0.1)
+
+    def _source_sees_session_timeout_message(self):
+        notification = self.driver.find_element_by_css_selector('.important')
+
+        if not hasattr(self, 'accept_languages'):
+            expected_text = 'Your session timed out due to inactivity.'
+            assert expected_text in notification.text
diff --git a/securedrop/tests/functional/test_source.py b/securedrop/tests/functional/test_source.py
--- a/securedrop/tests/functional/test_source.py
+++ b/securedrop/tests/functional/test_source.py
@@ -2,7 +2,7 @@
 import functional_test
 
 
-class TestSourceInterfaceBannerWarnings(
+class TestSourceInterface(
         functional_test.FunctionalTest,
         source_navigation_steps.SourceNavigationSteps):
 
diff --git a/securedrop/tests/functional/test_source_notfound.py b/securedrop/tests/functional/test_source_notfound.py
--- a/securedrop/tests/functional/test_source_notfound.py
+++ b/securedrop/tests/functional/test_source_notfound.py
@@ -2,7 +2,7 @@
 import functional_test
 
 
-class TestSourceInterfaceBannerWarnings(
+class TestSourceInterfaceNotFound(
         functional_test.FunctionalTest,
         source_navigation_steps.SourceNavigationSteps):
 
diff --git a/securedrop/tests/functional/test_source_session_timeout.py b/securedrop/tests/functional/test_source_session_timeout.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/functional/test_source_session_timeout.py
@@ -0,0 +1,26 @@
+import source_navigation_steps
+import functional_test
+
+
+class TestSourceSessions(
+        functional_test.FunctionalTest,
+        source_navigation_steps.SourceNavigationSteps):
+
+    def setup(self):
+        # The session expiration here cannot be set to -1
+        # as it will trigger an exception in /create.
+        # Instead, we pick a 1-2s value to allow the account
+        # to be generated.
+        self.session_length_minutes = 0.03
+        super(TestSourceSessions, self).setup(
+            session_expiration=self.session_length_minutes)
+
+    def test_source_session_timeout(self):
+        self._source_visits_source_homepage()
+        self._source_clicks_submit_documents_on_homepage()
+        self._source_continues_to_submit_page()
+        self._source_waits_for_session_to_timeout(
+            self.session_length_minutes)
+        self._source_enters_text_in_message_field()
+        self._source_clicks_submit_button_on_submission_page()
+        self._source_sees_session_timeout_message()
diff --git a/securedrop/tests/pages-layout/test_source.py b/securedrop/tests/pages-layout/test_source.py
--- a/securedrop/tests/pages-layout/test_source.py
+++ b/securedrop/tests/pages-layout/test_source.py
@@ -136,3 +136,25 @@ def test_tor2web_warning(self):
     def test_why_journalist_key(self):
         self._source_why_journalist_key()
         self._screenshot('source-why_journalist_key.png')
+
+
+@pytest.mark.pagelayout
+class TestSourceSessionLayout(
+        functional_test.FunctionalTest,
+        source_navigation_steps.SourceNavigationSteps,
+        journalist_navigation_steps.JournalistNavigationSteps):
+
+    def setup(self):
+        self.session_length_minutes = 0.03
+        super(TestSourceSessionLayout, self).setup(
+            session_expiration=self.session_length_minutes)
+
+    def test_source_session_timeout(self):
+        self._source_visits_source_homepage()
+        self._source_clicks_submit_documents_on_homepage()
+        self._source_continues_to_submit_page()
+        self._source_waits_for_session_to_timeout(self.session_length_minutes)
+        self._source_enters_text_in_message_field()
+        self._source_clicks_submit_button_on_submission_page()
+        self._source_sees_session_timeout_message()
+        self._screenshot('source-session_timeout.png')