From 494e767b6126880911d7e79a15da2615ce4cfcec Mon Sep 17 00:00:00 2001 From: Michael Z Date: Fri, 6 May 2022 16:14:57 -0400 Subject: [PATCH 01/19] Minimum viable flow inversion, backend implementation Towards #6211 Up until now, SecureDrop had an implicit "sign up" flow, in which sources were assigned a codename/passphrase before they were able to submit any messages/documents. This created a couple of technical pitfalls with regards to session management and (potentially) usability issues as well (user research pending). As sources were asked to write down their codenames/passphrases before they were able to do for what they came to the instance for, we made sure to remind them of their codename repeatedly. Flow inversion seeks to simplify both the backend implementation as well as the source interface's user experience. --- securedrop/source_app/main.py | 200 ++++++++++------------ securedrop/source_templates/generate.html | 28 --- securedrop/source_templates/index.html | 2 +- securedrop/source_templates/lookup.html | 2 + 4 files changed, 90 insertions(+), 142 deletions(-) delete mode 100644 securedrop/source_templates/generate.html diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py index 298c6fab6c..6eeab67c6c 100644 --- a/securedrop/source_app/main.py +++ b/securedrop/source_app/main.py @@ -34,11 +34,14 @@ def make_blueprint(config: SDConfig) -> Blueprint: view = Blueprint('main', __name__) @view.route('/') - def index() -> str: + def index() -> Union[str, werkzeug.Response]: + # Behave like a webmail application + if SessionManager.is_user_logged_in(db_session=db.session): + return redirect(url_for('.lookup')) return render_template('index.html') - @view.route('/generate', methods=('POST', 'GET')) - def generate() -> Union[str, werkzeug.Response]: + @view.route('/lookup', methods=('POST', 'GET')) + def lookup() -> Union[str, werkzeug.Response]: if request.method == 'POST': # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled tor2web_check = request.form.get('tor2web_check') @@ -48,43 +51,72 @@ def generate() -> Union[str, werkzeug.Response]: elif tor2web_check != 'href="fake.onion"': return redirect(url_for('info.tor2web_warning')) - if SessionManager.is_user_logged_in(db_session=db.session): - flash_msg("notification", None, gettext( - "You were redirected because you are already logged in. " - "If you want to create a new account, you should log out first.")) - return redirect(url_for('.lookup')) - codename = PassphraseGenerator.get_default().generate_passphrase( - preferred_language=g.localeinfo.language - ) - - # Generate a unique id for each browser tab and associate the codename with this id. - # This will allow retrieval of the codename displayed in the tab from which the source has - # clicked to proceed to /generate (ref. issue #4458) - tab_id = urlsafe_b64encode(os.urandom(64)).decode() - codenames = session.get('codenames', {}) - codenames[tab_id] = codename - session['codenames'] = fit_codenames_into_cookie(codenames) - session["codenames_expire"] = datetime.now(timezone.utc) + timedelta( - minutes=config.SESSION_EXPIRATION_MINUTES - ) - return render_template('generate.html', codename=codename, tab_id=tab_id) + is_user_logged_in = SessionManager.is_user_logged_in(db_session=db.session) + replies = [] + if is_user_logged_in: + logged_in_source = SessionManager.get_logged_in_user(db_session=db.session) + logged_in_source_in_db = logged_in_source.get_db_record() + source_inbox = Reply.query.filter_by( + source_id=logged_in_source_in_db.id, deleted_by_source=False + ).all() + + for reply in source_inbox: + reply_path = Storage.get_default().path( + logged_in_source.filesystem_id, + reply.filename, + ) + try: + with io.open(reply_path, "rb") as f: + contents = f.read() + decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply( + for_source_user=logged_in_source, + ciphertext_in=contents + ) + reply.decrypted = decrypted_reply + except UnicodeDecodeError: + current_app.logger.error("Could not decode reply %s" % + reply.filename) + except FileNotFoundError: + current_app.logger.error("Reply file missing: %s" % + reply.filename) + else: + reply.date = datetime.utcfromtimestamp( + os.stat(reply_path).st_mtime) + replies.append(reply) + + # Sort the replies by date + replies.sort(key=operator.attrgetter('date'), reverse=True) + + # If not done yet, generate a keypair to encrypt replies from the journalist + encryption_mgr = EncryptionManager.get_default() + try: + encryption_mgr.get_source_public_key(logged_in_source.filesystem_id) + except GpgKeyNotFoundError: + encryption_mgr.generate_source_key_pair(logged_in_source) - @view.route('/create', methods=['POST']) - def create() -> werkzeug.Response: - if SessionManager.is_user_logged_in(db_session=db.session): - flash_msg("notification", None, gettext( - "You are already logged in. Please verify your codename as it " - "may differ from the one displayed on the previous page.")) + # If this user is logged in, they already submitted at least once + min_message_length = 0 else: - # Ensure the codenames have not expired - date_codenames_expire = session.get("codenames_expire") - if not date_codenames_expire or datetime.now(timezone.utc) >= date_codenames_expire: - return clear_session_and_redirect_to_logged_out_page(flask_session=session) + min_message_length = InstanceConfig.get_default().initial_message_min_len - tab_id = request.form['tab_id'] - codename = session['codenames'][tab_id] - del session['codenames'] + return render_template( + 'lookup.html', + is_user_logged_in=is_user_logged_in, + allow_document_uploads=InstanceConfig.get_default().allow_document_uploads, + replies=replies, + min_len=min_message_length, + new_user_codename=session.get('new_user_codename', None), + form=SubmissionForm(), + ) + @view.route('/submit', methods=('POST',)) + def submit() -> werkzeug.Response: + # Flow inversion: generate codename and create user before processing their submission + # rather than on the screen before + if not SessionManager.is_user_logged_in(db_session=db.session): + codename = PassphraseGenerator.get_default().generate_passphrase( + preferred_language=g.localeinfo.language + ) try: current_app.logger.info("Creating new source user...") create_source_user( @@ -100,75 +132,15 @@ def create() -> werkzeug.Response: # All done - source user was successfully created current_app.logger.info("New source user created") + # Track that this user was generated during this session session['new_user_codename'] = codename - SessionManager.log_user_in(db_session=db.session, - supplied_passphrase=DicewarePassphrase(codename)) - - return redirect(url_for('.lookup')) - - @view.route('/lookup', methods=('GET',)) - @login_required - def lookup(logged_in_source: SourceUser) -> str: - replies = [] - logged_in_source_in_db = logged_in_source.get_db_record() - source_inbox = Reply.query.filter_by( - source_id=logged_in_source_in_db.id, deleted_by_source=False - ).all() - - first_submission = logged_in_source_in_db.interaction_count == 0 - - if first_submission: - min_message_length = InstanceConfig.get_default().initial_message_min_len - else: - min_message_length = 0 - - for reply in source_inbox: - reply_path = Storage.get_default().path( - logged_in_source.filesystem_id, - reply.filename, + logged_in_source = SessionManager.log_user_in( + db_session=db.session, + supplied_passphrase=DicewarePassphrase(codename) ) - try: - with io.open(reply_path, "rb") as f: - contents = f.read() - decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply( - for_source_user=logged_in_source, - ciphertext_in=contents - ) - reply.decrypted = decrypted_reply - except UnicodeDecodeError: - current_app.logger.error("Could not decode reply %s" % - reply.filename) - except FileNotFoundError: - current_app.logger.error("Reply file missing: %s" % - reply.filename) - else: - reply.date = datetime.utcfromtimestamp( - os.stat(reply_path).st_mtime) - replies.append(reply) - - # Sort the replies by date - replies.sort(key=operator.attrgetter('date'), reverse=True) - - # If not done yet, generate a keypair to encrypt replies from the journalist - encryption_mgr = EncryptionManager.get_default() - try: - encryption_mgr.get_source_public_key(logged_in_source.filesystem_id) - except GpgKeyNotFoundError: - encryption_mgr.generate_source_key_pair(logged_in_source) - - return render_template( - 'lookup.html', - is_user_logged_in=True, - allow_document_uploads=InstanceConfig.get_default().allow_document_uploads, - replies=replies, - min_len=min_message_length, - new_user_codename=session.get('new_user_codename', None), - form=SubmissionForm(), - ) + else: + logged_in_source = SessionManager.get_logged_in_user(db_session=db.session) - @view.route('/submit', methods=('POST',)) - @login_required - def submit(logged_in_source: SourceUser) -> werkzeug.Response: allow_document_uploads = InstanceConfig.get_default().allow_document_uploads form = SubmissionForm() if not form.validate(): @@ -192,7 +164,6 @@ def submit(logged_in_source: SourceUser) -> werkzeug.Response: flash_msg("error", None, html_contents) return redirect(url_for('main.lookup')) - fnames = [] logged_in_source_in_db = logged_in_source.get_db_record() first_submission = logged_in_source_in_db.interaction_count == 0 @@ -203,23 +174,25 @@ def submit(logged_in_source: SourceUser) -> werkzeug.Response: "Your first message must be at least {} characters long.").format(min_len)) return redirect(url_for('main.lookup')) - # if the new_user_codename key is not present in the session, this is - # not a first session - new_codename = session.get('new_user_codename', None) + # if the new_user_codename key is not present in the session, this is + # not a first session - during the first session, the codename is displayed + # throughout but we want to discourage sources from sharing it + new_codename = session.get('new_user_codename', None) + codenames_rejected = InstanceConfig.get_default().reject_message_with_codename - codenames_rejected = InstanceConfig.get_default().reject_message_with_codename - if new_codename is not None: - if codenames_rejected and codename_detected(msg, new_codename): - flash_msg("error", None, gettext("Please do not submit your codename!"), - gettext("Keep your codename secret, and use it to log in later to " - "check for replies.")) - return redirect(url_for('main.lookup')) + if new_codename is not None and codenames_rejected and codename_detected(msg, new_codename): + flash_msg("error", None, gettext("Please do not submit your codename!"), + gettext("Keep your codename secret, and use it to log in later to " + "check for replies.")) + return redirect(url_for('main.lookup')) if not os.path.exists(Storage.get_default().path(logged_in_source.filesystem_id)): current_app.logger.debug("Store directory not found for source '{}', creating one." .format(logged_in_source_in_db.journalist_designation)) os.mkdir(Storage.get_default().path(logged_in_source.filesystem_id)) + fnames = [] + if msg: logged_in_source_in_db.interaction_count += 1 fnames.append( @@ -252,6 +225,7 @@ def submit(logged_in_source: SourceUser) -> werkzeug.Response: flash_msg("success", gettext("Success!"), html_contents) new_submissions = [] + for fname in fnames: submission = Submission(logged_in_source_in_db, fname, Storage.get_default()) db.session.add(submission) diff --git a/securedrop/source_templates/generate.html b/securedrop/source_templates/generate.html deleted file mode 100644 index 7f2e381fd7..0000000000 --- a/securedrop/source_templates/generate.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} -{% import 'utils.html' as utils %} - -{% block body %} -

{{ gettext('Get Your Codename') }}

- -

- {{ gettext('A codename in SecureDrop functions as both your username and your password.') }} -

-

- {{ gettext('You will need this codename to log into our SecureDrop later:') }} -

- -{{ utils.codename(codename) }} - - - -
- - - -
-{% endblock %} diff --git a/securedrop/source_templates/index.html b/securedrop/source_templates/index.html index 75c00f1009..79cd7bfde8 100644 --- a/securedrop/source_templates/index.html +++ b/securedrop/source_templates/index.html @@ -58,7 +58,7 @@

{{ gettext('{} logo').format(g.organization_nam
       <section id=

{{ gettext('First submission') }}

{{ gettext('First time submitting to our SecureDrop? Start here.') }}

-
+
- - + +{% endif %} -{% if is_user_logged_in %}
-

{{ gettext('Read Replies') }}

+

{{ gettext('Replies') }}

{% if replies %} @@ -123,4 +90,54 @@

{{ gettext('Are you finished with the replies?') }}<

{% endif %} +
+

{{ gettext('New Message') }}

+ +

+ {% if allow_document_uploads %} + {{ gettext('If you are already familiar with GPG, you can optionally encrypt your files and messages with our public key before submission. Files are encrypted as they are received by SecureDrop.').format(url=url_for('info.download_public_key')) }} + {% else %} + {{ gettext('If you are already familiar with GPG, you can optionally encrypt your messages with our public key before submission.').format(url=url_for('info.download_public_key')) }} + {% endif %} + {{ gettext('Learn more.').format(url=url_for('info.why_download_public_key')) }} +

+ +
+ +
+
+ {% if min_len > 0 %} + {{ form.msg(**{"aria-describedby": "min-char-length"}) }} + {% if allow_document_uploads %} +

{{ gettext('If you are only sending a message, it must be at least {} characters long.').format(min_len) }}

+ {% else %} +

{{ gettext('Your first message must be at least {} characters long.').format(min_len) }}

+ {% endif %} + {% else %} + {{ form.msg() }} + {% endif %} +
+ {% if allow_document_uploads %} +
+ {{ form.fh(**{"aria-describedby": "max-file-size"}) }} +

{{ gettext('Maximum upload size: 500 MB') }}

+
+ {% endif %} +
+ + {{ form.antispam() }} +
+
+ +
+ + {{ gettext('CANCEL') }} + + +
+
+
{% endblock %} diff --git a/securedrop/source_templates/utils.html b/securedrop/source_templates/utils.html index 1aeaf6db53..c01887109d 100644 --- a/securedrop/source_templates/utils.html +++ b/securedrop/source_templates/utils.html @@ -4,8 +4,8 @@ {%- endmacro -%} {%- macro codename(codename, new=True) -%} -
- +
+ @@ -18,5 +18,5 @@ {{ gettext('Show Codename') }} -
+ {%- endmacro -%} From 1e2c68a52aa6437259260214cd6edf7ace61c638 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Mon, 6 Jun 2022 13:12:50 -0400 Subject: [PATCH 05/19] Inverted functional test fixes --- securedrop/tests/functional/app_navigators.py | 10 ++-- .../functional/source_navigation_steps.py | 29 ++++++------ .../tests/functional/test_journalist.py | 9 +--- securedrop/tests/test_source_utils.py | 46 +------------------ 4 files changed, 20 insertions(+), 74 deletions(-) diff --git a/securedrop/tests/functional/app_navigators.py b/securedrop/tests/functional/app_navigators.py index b6e96a032a..3524745513 100644 --- a/securedrop/tests/functional/app_navigators.py +++ b/securedrop/tests/functional/app_navigators.py @@ -195,17 +195,17 @@ def source_visits_source_homepage(self) -> None: self.driver.get(self._source_app_base_url) assert self._is_on_source_homepage() - def _is_on_generate_page(self) -> WebElement: - return self.nav_helper.wait_for(lambda: self.driver.find_element_by_id("source-generate")) + def _is_on_lookup_page(self) -> WebElement: + return self.nav_helper.wait_for(lambda: self.driver.find_element_by_id("source-lookup")) def source_clicks_submit_documents_on_homepage(self) -> None: # It's the source's first time visiting this SecureDrop site, so they # choose to "Submit Documents". self.nav_helper.safe_click_by_css_selector("#started-form button") - # The source should now be on the page where they are presented with - # a diceware codename they can use for subsequent logins - assert self._is_on_generate_page() + # The source should now be on the lookup page where they can submit + # docs/messages. They will get a passphrase after their first submission. + assert self._is_on_lookup_page() def source_continues_to_submit_page(self) -> None: self.nav_helper.safe_click_by_css_selector("#create-form button") diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py index 50d2ea04c9..8a78f0e7ba 100644 --- a/securedrop/tests/functional/source_navigation_steps.py +++ b/securedrop/tests/functional/source_navigation_steps.py @@ -15,9 +15,6 @@ def _is_logged_in(self): def _is_on_lookup_page(self): return self.wait_for(lambda: self.driver.find_element_by_id("source-lookup")) - def _is_on_generate_page(self): - return self.wait_for(lambda: self.driver.find_element_by_id("source-generate")) - def _is_on_logout_page(self): return self.wait_for(lambda: self.driver.find_element_by_id("source-logout")) @@ -35,9 +32,9 @@ def _source_clicks_submit_documents_on_homepage(self, assert_success=True): self.safe_click_by_css_selector("#started-form button") if assert_success: - # The source should now be on the page where they are presented with - # a diceware codename they can use for subsequent logins - assert self._is_on_generate_page() + # The source should now be on the lookup page - codename is not + # yet visible + assert self._is_on_lookup_page() def _source_regenerates_codename(self): self._source_visits_source_homepage() @@ -47,11 +44,8 @@ def _source_regenerates_codename(self): def _source_chooses_to_submit_documents(self): self._source_clicks_submit_documents_on_homepage() - - codename = self.driver.find_element_by_css_selector("#codename span") - - assert len(codename.text) > 0 - self.source_name = codename.text + submit_header = self.driver.find_element_by_css_selector("#welcome-heading") + assert len(submit_header.text) > 0 def _source_shows_codename(self, verify_source_name=True): # We use inputs to change CSS states for subsequent elements in the DOM, if it is unchecked @@ -141,20 +135,16 @@ def _source_submits_a_file(self): self.safe_send_keys_by_id("fh", filename) self.safe_click_by_css_selector(".form-controls button") - self.wait_for_source_key(self.source_name) def file_submitted(): if not self.accept_languages: notification = self.driver.find_element_by_class_name("success") - expected_notification = "Thank you for sending this information to us" + expected_notification = "Success!\nThanks! We received your document." assert expected_notification in notification.text # Allow extra time for file uploads self.wait_for(file_submitted, timeout=(self.timeout * 3)) - # allow time for reply key to be generated - time.sleep(self.timeout) - def _source_submits_a_message( self, verify_notification=False, first_submission=False, first_login=False ): @@ -166,14 +156,21 @@ def message_submitted(): notification = self.driver.find_element_by_class_name("success") assert "Thank" in notification.text + if first_submission: + codename = self.driver.find_element_by_css_selector("#codename span") + self.source_name = codename.text + print("First sub, source is: {}".format(self.source_name)) + if verify_notification: first_submission_text = ( "Please check back later for replies." in notification.text ) if first_submission: + print("First sub, source is: {}".format(self.source_name)) assert first_submission_text else: + print("Not first sub, source is whaaat") assert not first_submission_text self.wait_for(message_submitted) diff --git a/securedrop/tests/functional/test_journalist.py b/securedrop/tests/functional/test_journalist.py index a0d320e12d..7d9918ecb1 100644 --- a/securedrop/tests/functional/test_journalist.py +++ b/securedrop/tests/functional/test_journalist.py @@ -37,7 +37,6 @@ def test_journalist_verifies_deletion_of_one_submission_modal(self): # This deletion button is displayed on the individual source page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() self._source_submits_a_file() self._source_logs_out() self._journalist_logs_in() @@ -48,7 +47,6 @@ def test_journalist_uses_col_delete_collection_button_modal(self): # This delete button is displayed on the individual source page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() self._source_submits_a_file() self._source_logs_out() self._journalist_logs_in() @@ -59,7 +57,6 @@ def test_journalist_uses_index_delete_collections_button_modal(self): # This deletion button is displayed on the index page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() self._source_submits_a_file() self._source_logs_out() self._journalist_logs_in() @@ -69,7 +66,6 @@ def test_journalist_uses_index_delete_files_button_modal(self): # This deletion button is displayed on the index page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() self._source_submits_a_file() self._source_submits_a_message() self._source_logs_out() @@ -77,14 +73,12 @@ def test_journalist_uses_index_delete_files_button_modal(self): self._journalist_uses_index_delete_files_button_confirmation() self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() self._source_submits_a_message() self._source_logs_out() def test_journalist_interface_ui_with_modal(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() self._source_submits_a_file() self._source_logs_out() @@ -113,8 +107,7 @@ def missing_msg_file(self): # Submit a message self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_continues_to_submit_page() - self._source_submits_a_message() + self._source_submits_a_message(first_submission=True) self._source_logs_out() # Remove the message file from the store diff --git a/securedrop/tests/test_source_utils.py b/securedrop/tests/test_source_utils.py index 492ee031a3..1a5b998b00 100644 --- a/securedrop/tests/test_source_utils.py +++ b/securedrop/tests/test_source_utils.py @@ -1,12 +1,7 @@ # -*- coding: utf-8 -*- -import json import os -import pytest -import werkzeug - -from source_app.utils import check_url_file, codename_detected, fit_codenames_into_cookie -from .test_journalist import VALID_PASSWORD +from source_app.utils import check_url_file def test_check_url_file(config): @@ -33,42 +28,3 @@ def write_url_file(path, content): finally: if os.path.exists(url_path): os.unlink(url_path) - - -def test_fit_codenames_into_cookie(config): - # A single codename should never be truncated. - codenames = {'a': VALID_PASSWORD} - assert(fit_codenames_into_cookie(codenames) == codenames) - - # A reasonable number of codenames should never be truncated. - codenames = { - 'a': VALID_PASSWORD, - 'b': VALID_PASSWORD, - 'c': VALID_PASSWORD, - } - assert(fit_codenames_into_cookie(codenames) == codenames) - - # A single gargantuan codename is undefined behavior---but also should not - # be truncated. - codenames = {'a': werkzeug.Response.max_cookie_size*VALID_PASSWORD} - assert(fit_codenames_into_cookie(codenames) == codenames) - - # Too many codenames of the expected length should be truncated. - codenames = {} - too_many = 2*(werkzeug.Response.max_cookie_size // len(VALID_PASSWORD)) - for i in range(too_many): - codenames[i] = VALID_PASSWORD - serialized = json.dumps(codenames).encode() - assert(len(serialized) > werkzeug.Response.max_cookie_size) - serialized = json.dumps(fit_codenames_into_cookie(codenames)).encode() - assert(len(serialized) < werkzeug.Response.max_cookie_size) - - -@pytest.mark.parametrize('message,expected', ( - ('Foo', False), - ('codename', True), - (' codename ', True), - ('foocodenamebar', False), -)) -def test_codename_detected(message, expected): - assert codename_detected(message, 'codename') is expected From add2e663a05e5ff7a733717ad5eb5255c1560a76 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Tue, 7 Jun 2022 10:56:27 -0400 Subject: [PATCH 06/19] fixes for tests/functional/test_admin_interface.py --- .../tests/functional/source_navigation_steps.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py index 8a78f0e7ba..35b3e525e4 100644 --- a/securedrop/tests/functional/source_navigation_steps.py +++ b/securedrop/tests/functional/source_navigation_steps.py @@ -113,17 +113,19 @@ def _source_hits_cancel_at_submit_page(self): assert "Submit Files or Messages" == heading.text def _source_continues_to_submit_page(self, files_allowed=True): - self.safe_click_by_css_selector("#create-form button") - def submit_page_loaded(): + def uploader_is_visible(): + try: + self.driver.find_element_by_class_name("attachment") + except NoSuchElementException: + return False + return True + if not self.accept_languages: - heading = self.driver.find_element_by_id("submit-heading") if files_allowed: - assert "Submit Files or Messages" == heading.text + assert uploader_is_visible() else: - assert "Submit Messages" == heading.text - - self.wait_for(submit_page_loaded) + assert not uploader_is_visible() def _source_submits_a_file(self): with tempfile.NamedTemporaryFile() as file: From 016ab3b9f8bd71fafe3b467c0a4b82b17940f23e Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Tue, 7 Jun 2022 11:41:50 -0400 Subject: [PATCH 07/19] fixes for tests/test_source.py --- securedrop/tests/test_source.py | 209 ++++++++++++---------------- securedrop/tests/utils/db_helper.py | 6 +- 2 files changed, 95 insertions(+), 120 deletions(-) diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py index e0511f5c4d..be96a982f2 100644 --- a/securedrop/tests/test_source.py +++ b/securedrop/tests/test_source.py @@ -106,126 +106,110 @@ def _find_codename(html): return codename_match.group('codename') -def test_generate_already_logged_in(source_app): +def test_index_already_logged_in(source_app): with source_app.test_client() as app: new_codename(app, session) # Make sure it redirects to /lookup when logged in - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + resp = app.get(url_for('main.index')) assert resp.status_code == 302 # Make sure it flashes the message on the lookup page - resp = app.post(url_for('main.generate'), data=GENERATE_DATA, follow_redirects=True) + resp = app.get(url_for('main.index'), follow_redirects=True) # Should redirect to /lookup assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "because you are already logged in." in text + assert "source-lookup" in text -def test_create_new_source(source_app): +def test_lookup_post_and_new_source(source_app): with source_app.test_client() as app: - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + # source is not created when /lookup is first hit + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA) assert resp.status_code == 200 - tab_id = next(iter(session['codenames'].keys())) - resp = app.post(url_for('main.create'), data={'tab_id': tab_id}, follow_redirects=True) - assert SessionManager.is_user_logged_in(db_session=db.session) - # should be redirected to /lookup + assert not SessionManager.is_user_logged_in(db_session=db.session) text = resp.data.decode('utf-8') - assert "Submit Files" in text - assert 'codenames' not in session + assert "Welcome" in text + assert 'new_user_passphrase' not in session - -def test_generate_as_post(source_app): - with source_app.test_client() as app: - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + # source is created on first_submission + resp = app.post(url_for('main.submit'), data={'msg': 'TEST MESSAGE"'}, follow_redirects=True) + text = resp.data.decode('utf-8') + assert "Keep In Touch" in text assert resp.status_code == 200 - session_codename = next(iter(session['codenames'].values())) - - text = resp.data.decode('utf-8') - assert "functions as both your username and your password" in text + assert SessionManager.is_user_logged_in(db_session=db.session) + assert 'new_user_passphrase' in session - codename = _find_codename(resp.data.decode('utf-8')) - # codename is also stored in the session - make sure it matches the - # codename displayed to the source - assert codename == escape(session_codename) -def test_generate_as_get(source_app): +def test_lookup_no_tor2web_check(source_app): with source_app.test_client() as app: - resp = app.get(url_for('main.generate')) - assert resp.status_code == 200 - session_codename = next(iter(session['codenames'].values())) - - text = resp.data.decode('utf-8') - assert "functions as both your username and your password" in text - - codename = _find_codename(resp.data.decode('utf-8')) - # codename is also stored in the session - make sure it matches the - # codename displayed to the source - assert codename == escape(session_codename) + resp = app.post(url_for('main.lookup'), follow_redirects=True) + assert resp.status_code == 403 +def test_lookup_invalid_tor2web_check(source_app): + with source_app.test_client() as app: + resp = app.post(url_for('main.lookup'), data={'tor2web_check': 'href="fake.onion.ly"'}, follow_redirects=True) + assert resp.status_code == 403 -def test_create_duplicate_codename_logged_in_not_in_session(source_app): - with patch.object(source_app.logger, 'error') as logger: - with source_app.test_client() as app: - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) - assert resp.status_code == 200 - tab_id, codename = next(iter(session['codenames'].items())) +def test_lookup_as_get(source_app): + with source_app.test_client() as app: + resp = app.get(url_for('main.lookup')) + assert resp.status_code == 200 + text = resp.data.decode('utf-8') + assert "Welcome" in text + assert 'new_user_passphrase' not in session - # Create a source the first time - resp = app.post(url_for('main.create'), data={'tab_id': tab_id}, follow_redirects=True) - assert resp.status_code == 200 - with source_app.test_client() as app: - # Attempt to add the same source - with app.session_transaction() as sess: - sess['codenames'] = {tab_id: codename} - sess["codenames_expire"] = datetime.utcnow() + timedelta(hours=1) - resp = app.post(url_for('main.create'), data={'tab_id': tab_id}, follow_redirects=True) - logger.assert_called_once() - assert "Could not create a source" in logger.call_args[0][0] - assert resp.status_code == 200 - assert not SessionManager.is_user_logged_in(db_session=db.session) +def test_passphrase_is_created_once_only(source_app): + with source_app.test_client() as app: + # Given a user who opened the /lookup page + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA) + assert resp.status_code == 200 + text = resp.data.decode('utf-8') + assert "Welcome" in text + assert 'new_user_passphrase' not in session + # And then they visited the homepage without submitting + resp = app.get(url_for('main.index'), follow_redirects=True) -def test_create_duplicate_codename_logged_in_in_session(source_app): - with source_app.test_client() as app: - # Given a user who generated a codename in a browser tab - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + # they should be on the homepage, not redirected. assert resp.status_code == 200 - first_tab_id, first_codename = list(session['codenames'].items())[0] + text = resp.data.decode('utf-8') + assert "first-submission-heading" in text - # And then they opened a new browser tab to generate a second codename - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) + # then when they visit /lookup again and submit a message + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA) assert resp.status_code == 200 - second_tab_id, second_codename = list(session['codenames'].items())[1] - assert first_codename != second_codename + text = resp.data.decode('utf-8') + assert "Welcome" in text + resp = app.post(url_for('main.submit'), data={'msg': 'TEST MESSAGE HEY'}, follow_redirects=True) - # And the user then completed the account creation flow in the first tab - resp = app.post( - url_for('main.create'), data={'tab_id': first_tab_id}, follow_redirects=True - ) + # they should see the post-submission lookup page + text = resp.data.decode('utf-8') + assert "Keep In Touch" in text assert resp.status_code == 200 - first_tab_account = SessionManager.get_logged_in_user(db_session=db.session) + assert SessionManager.is_user_logged_in(db_session=db.session) + assert 'new_user_passphrase' in session - # When the user tries to complete the account creation flow again, in the second tab - resp = app.post( - url_for('main.create'), data={'tab_id': second_tab_id}, follow_redirects=True - ) + # and they should see the passphrase available + passphrase = session.get('new_user_passphrase') + assert len(passphrase) > 0 + assert passphrase in text - # Then the user is shown the "already logged in" message + # And then when they visit the homepage again, they should be redirected + # to /lookup, with the passphrase still visible + resp = app.get(url_for('main.index'), follow_redirects=False) + assert resp.status_code == 302 + resp = app.get(url_for('main.index'), follow_redirects=True) assert resp.status_code == 200 - text = resp.data.decode('utf-8') - assert "You are already logged in." in text - - # And no new account was created - second_tab_account = SessionManager.get_logged_in_user(db_session=db.session) - assert second_tab_account.filesystem_id == first_tab_account.filesystem_id + assert "source-lookup" in text + assert passphrase in text def test_lookup(source_app): """Test various elements on the /lookup page.""" with source_app.test_client() as app: - codename = new_codename(app, session) - resp = app.post(url_for('main.login'), data=dict(codename=codename), + passphrase = new_codename(app, session) + resp = app.post(url_for('main.login'), data=dict(passphrase=passphrase), follow_redirects=True) # redirects to /lookup text = resp.data.decode('utf-8') @@ -251,35 +235,35 @@ def test_login_and_logout(source_app): resp = app.get(url_for('main.login')) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Enter Codename" in text + assert "Enter Passphrase" in text - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "source-lookup" in text assert SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename='invalid'), + data=dict(passphrase='invalid'), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert 'Sorry, that is not a recognized codename.' in text + assert 'Sorry, that is not a recognized passphrase.' in text assert not SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) @@ -294,46 +278,37 @@ def test_login_and_logout(source_app): assert 'This will clear your Tor Browser activity data' in text -def test_user_must_log_in_for_protected_views(source_app): - with source_app.test_client() as app: - resp = app.get(url_for('main.lookup'), - follow_redirects=True) - assert resp.status_code == 200 - text = resp.data.decode('utf-8') - assert "Enter Codename" in text - - def test_login_with_whitespace(source_app): """ Test that codenames with leading or trailing whitespace still work """ - def login_test(app, codename): + def login_test(app, passphrase): resp = app.get(url_for('main.login')) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Enter Codename" in text + assert "Enter Passphrase" in text resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "source-lookup" in text assert SessionManager.is_user_logged_in(db_session=db.session) with source_app.test_client() as app: - codename = new_codename(app, session) + passphrase = new_codename(app, session) - codenames = [ - codename + ' ', - ' ' + codename + ' ', - ' ' + codename, + passphrases = [ + passphrase + ' ', + ' ' + passphrase + ' ', + ' ' + passphrase, ] - for codename_ in codenames: + for p in passphrases: with source_app.test_client() as app: - login_test(app, codename_) + login_test(app, p) def test_login_with_missing_reply_files(source_app, app_storage): @@ -341,7 +316,7 @@ def test_login_with_missing_reply_files(source_app, app_storage): Test that source can log in when replies are present in database but missing from storage. """ - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) journalist, _ = utils.db_helper.init_journalist() replies = utils.db_helper.reply(app_storage, journalist, source, 1) assert len(replies) > 0 @@ -354,14 +329,14 @@ def test_login_with_missing_reply_files(source_app, app_storage): resp = app.get(url_for('main.login')) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Enter Codename" in text + assert "Enter Passphrase" in text resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "New Message" in text assert SessionManager.is_user_logged_in(db_session=db.session) @@ -381,15 +356,15 @@ def _dummy_submission(app): def test_initial_submission_notification(source_app): """ Regardless of the type of submission (message, file, or both), the - first submission is always greeted with a notification - reminding sources to check back later for replies. + first submission is always greeted with a notification stemmed with + 'Thanks! We received your"... """ with source_app.test_client() as app: new_codename(app, session) resp = _dummy_submission(app) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Thank you for sending this information to us." in text + assert "Please check back later" in text def test_submit_message(source_app): diff --git a/securedrop/tests/utils/db_helper.py b/securedrop/tests/utils/db_helper.py index 4b7671d884..3fac48796a 100644 --- a/securedrop/tests/utils/db_helper.py +++ b/securedrop/tests/utils/db_helper.py @@ -186,9 +186,9 @@ def submit(storage, source, num_submissions, submission_type="message"): def new_codename(client, session): """Helper function to go through the "generate codename" flow. """ - client.post('/generate', data={'tor2web_check': 'href="fake.onion"'}) - tab_id, codename = next(iter(session['codenames'].items())) - client.post('/create', data={'tab_id': tab_id}) + client.post('/lookup', data={'tor2web_check': 'href="fake.onion"'}) + client.post('/submit', data={'msg': 'TEST MESSAGE"'}) + codename = session.get('new_user_passphrase') return codename From 3eb80e19265fc6a42d29df4ed5caeb7e452dcb6a Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Tue, 7 Jun 2022 22:54:27 -0400 Subject: [PATCH 08/19] fix: initial submissions should get a notification mentioning replies --- securedrop/source_app/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py index 29062a3f99..ccfaf99860 100644 --- a/securedrop/source_app/main.py +++ b/securedrop/source_app/main.py @@ -193,6 +193,7 @@ def submit(logged_in_source: 'Optional[SourceUser]') -> werkzeug.Response: os.mkdir(Storage.get_default().path(logged_in_source.filesystem_id)) fnames = [] + first_submission = logged_in_source_in_db.interaction_count == 0 if msg: logged_in_source_in_db.interaction_count += 1 @@ -212,8 +213,6 @@ def submit(logged_in_source: 'Optional[SourceUser]') -> werkzeug.Response: fh.filename, fh.stream)) - first_submission = logged_in_source_in_db.interaction_count == 0 - if first_submission or msg or fh: if first_submission: html_contents = gettext("Thank you for sending this information to us. Please " From 36949517d778093007c990ede812674b3bea70e6 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Wed, 8 Jun 2022 18:17:03 -0400 Subject: [PATCH 09/19] Fixed validation for overly-long messages, more tests/test_source.py fixes --- securedrop/source_app/forms.py | 2 +- securedrop/tests/test_source.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/securedrop/source_app/forms.py b/securedrop/source_app/forms.py index b21f776887..52658c983b 100644 --- a/securedrop/source_app/forms.py +++ b/securedrop/source_app/forms.py @@ -37,7 +37,7 @@ def validate_msg(self, field: wtforms.Field) -> None: if InstanceConfig.get_default().allow_document_uploads: err = "{} {}".format(err, gettext("If you need to submit large blocks of text, " "you can upload them as a file.")) - raise ValidationError(err, declarative=gettext("OP"), msg_data=field.data) + raise ValidationError(err) def validate_antispam(self, field: wtforms.Field) -> None: """If the antispam field has any contents, abort with a 403""" diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py index be96a982f2..508db23939 100644 --- a/securedrop/tests/test_source.py +++ b/securedrop/tests/test_source.py @@ -30,6 +30,8 @@ from .utils.db_helper import new_codename, submit from .utils.i18n import get_test_locales, language_tag, page_language, xfail_untranslated_messages from .utils.instrument import InstrumentedApp +from wtforms.validators import ValidationError + GENERATE_DATA = {'tor2web_check': 'href="fake.onion"'} @@ -356,8 +358,9 @@ def _dummy_submission(app): def test_initial_submission_notification(source_app): """ Regardless of the type of submission (message, file, or both), the - first submission is always greeted with a notification stemmed with - 'Thanks! We received your"... + first submission is always greeted with a notification prompting + the source to check back for replies - subsequent messages don't + include the same prompt """ with source_app.test_client() as app: new_codename(app, session) @@ -406,7 +409,7 @@ def test_submit_big_message(source_app): follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Message text too long." in text + assert "The message you submit can be" in text def test_submit_initial_short_message(source_app): From a62d9a3c48a72ca9127690befe4277148c4884db Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Wed, 8 Jun 2022 19:09:27 -0400 Subject: [PATCH 10/19] fixed tests in tests/test_source.py --- securedrop/tests/test_source.py | 108 +++++++++++++------------------- 1 file changed, 44 insertions(+), 64 deletions(-) diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py index 508db23939..1a3608399c 100644 --- a/securedrop/tests/test_source.py +++ b/securedrop/tests/test_source.py @@ -419,7 +419,6 @@ def test_submit_initial_short_message(source_app): with source_app.test_client() as app: InstanceConfig.get_default().update_submission_prefs( allow_uploads=True, min_length=10, reject_codenames=False) - new_codename(app, session) resp = app.post( url_for('main.submit'), data=dict(msg="A" * 5, fh=(StringIO(''), '')), @@ -488,21 +487,21 @@ def test_submit_antispam(source_app): assert resp.status_code == 403 -def test_submit_codename_second_login(source_app): +def test_submit_passphrase_second_login(source_app): """ Test codename submissions *not* prevented on second session """ with source_app.test_client() as app: InstanceConfig.get_default().update_submission_prefs( allow_uploads=True, min_length=0, reject_codenames=True) - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Please do not submit your codename!" in text + assert "Please do not submit your passphrase!" in text resp = app.get(url_for('main.logout'), follow_redirects=True) @@ -511,57 +510,57 @@ def test_submit_codename_second_login(source_app): assert 'This will clear your Tor Browser activity data' in text resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 assert SessionManager.is_user_logged_in(db_session=db.session) resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Thank you for sending this information" in text + assert "Thanks! We received your message." in text -def test_submit_codename(source_app): +def test_submit_passphrase(source_app): """ Test preventions against people submitting their codename. """ with source_app.test_client() as app: InstanceConfig.get_default().update_submission_prefs( allow_uploads=True, min_length=0, reject_codenames=True) - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Please do not submit your codename!" in text + assert "Please do not submit your passphrase!" in text # Do a dummy submission _dummy_submission(app) - # Now resubmit the codename, should be accepted. + # Now resubmit the passphrase, should be rejected for the entire session resp = app.post( url_for('main.submit'), - data=dict(msg=codename, fh=(StringIO(''), '')), + data=dict(msg=passphrase, fh=(StringIO(''), '')), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Thanks! We received your message" in text + assert "Please do not submit your passphrase!" in text def test_delete_all_successfully_deletes_replies(source_app, app_storage): with source_app.app_context(): journalist, _ = utils.db_helper.init_journalist() - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) source_id = source.id utils.db_helper.reply(app_storage, journalist, source, 1) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.post(url_for('main.batch_delete'), follow_redirects=True) @@ -581,7 +580,7 @@ def test_delete_all_replies_deleted_by_source_but_not_journalist(source_app, app replies may still exist in the replies table, but no longer be visible.""" with source_app.app_context(): journalist, _ = utils.db_helper.init_journalist() - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) utils.db_helper.reply(app_storage, journalist, source, 1) replies = Reply.query.filter(Reply.source_id == source.id).all() for reply in replies: @@ -592,7 +591,7 @@ def test_delete_all_replies_deleted_by_source_but_not_journalist(source_app, app with source_app.test_client() as app: with patch.object(source_app.logger, 'error') as logger: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.post(url_for('main.batch_delete'), @@ -606,13 +605,13 @@ def test_delete_all_replies_deleted_by_source_but_not_journalist(source_app, app def test_delete_all_replies_already_deleted_by_journalists(source_app, app_storage): with source_app.app_context(): journalist, _ = utils.db_helper.init_journalist() - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) # Note that we are creating the source and no replies with source_app.test_client() as app: with patch.object(source_app.logger, 'error') as logger: resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.post(url_for('main.batch_delete'), @@ -644,7 +643,7 @@ def test_submit_sanitizes_filename(source_app): mtime=0) -@pytest.mark.parametrize("test_url", ['main.index', 'main.create', 'main.submit']) +@pytest.mark.parametrize("test_url", ['main.index', 'main.submit']) def test_redirect_when_tor2web(config, source_app, test_url): with source_app.test_client() as app: resp = app.get( @@ -706,13 +705,13 @@ def test_metadata_v3_url(source_app): assert resp.json.get('v3_source_url') == onion_test_url -def test_login_with_overly_long_codename(source_app): +def test_login_with_overly_long_passphrase(source_app): """Attempting to login with an overly long codename should result in an error to avoid DoS.""" - overly_long_codename = 'a' * (PassphraseGenerator.MAX_PASSPHRASE_LENGTH + 1) + overly_long_passphrase = 'a' * (PassphraseGenerator.MAX_PASSPHRASE_LENGTH + 1) with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=overly_long_codename), + data=dict(passphrase=overly_long_passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') @@ -730,7 +729,7 @@ def test_normalize_timestamps(source_app, app_storage): """ with source_app.test_client() as app: # create a source - source, codename = utils.db_helper.init_source(app_storage) + source, passphrase = utils.db_helper.init_source(app_storage) # create one submission first_submission = submit(app_storage, source, 1)[0] @@ -744,11 +743,11 @@ def test_normalize_timestamps(source_app, app_storage): # log in as the source resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') - assert "Submit Files" in text + assert "source-lookup" in text assert SessionManager.is_user_logged_in(db_session=db.session) # submit another message @@ -792,10 +791,10 @@ def test_failed_normalize_timestamps_logs_warning(source_app): OSSEC alert).""" with patch.object(source_app.logger, 'warning') as logger: - with patch.object(subprocess, 'call', return_value=1): - with source_app.test_client() as app: - new_codename(app, session) - _dummy_submission(app) + with source_app.test_client() as app: + new_codename(app, session) + _dummy_submission(app) + with patch.object(subprocess, 'call', return_value=1): resp = app.post( url_for('main.submit'), data=dict( @@ -817,8 +816,8 @@ def test_source_is_deleted_while_logged_in(source_app): a NoResultFound will occur. The source should be redirected to the index when this happens, and a warning logged.""" with source_app.test_client() as app: - codename = new_codename(app, session) - app.post('login', data=dict(codename=codename), follow_redirects=True) + passphrase = new_codename(app, session) + app.post('login', data=dict(passphrase=passphrase), follow_redirects=True) # Now that the source is logged in, the journalist deletes the source source_user = SessionManager.get_logged_in_user(db_session=db.session) @@ -833,15 +832,15 @@ def test_source_is_deleted_while_logged_in(source_app): assert not SessionManager.is_user_logged_in(db_session=db.session) -def test_login_with_invalid_codename(source_app): +def test_login_with_invalid_passphrase(source_app): """Logging in with a codename with invalid characters should return an informative message to the user.""" - invalid_codename = '[]' + invalid_passphrase = '[]' with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data=dict(codename=invalid_codename), + data=dict(passphrase=invalid_passphrase), follow_redirects=True) assert resp.status_code == 200 text = resp.data.decode('utf-8') @@ -851,9 +850,9 @@ def test_login_with_invalid_codename(source_app): def test_source_session_expiration(source_app): with source_app.test_client() as app: # Given a source user who logs in - codename = new_codename(app, session) + passphrase = new_codename(app, session) resp = app.post(url_for('main.login'), - data=dict(codename=codename), + data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 @@ -870,25 +869,6 @@ def test_source_session_expiration(source_app): assert 'You were logged out due to inactivity' in text -def test_source_session_expiration_create(source_app): - with source_app.test_client() as app: - # Given a source user who is in the middle of the account creation flow - resp = app.post(url_for('main.generate'), data=GENERATE_DATA) - assert resp.status_code == 200 - - # But we're now 6 hours later hence they did not finish the account creation flow in time - with mock.patch("source_app.main.datetime") as mock_datetime: - six_hours_later = datetime.now(timezone.utc) + timedelta(hours=6) - mock_datetime.now.return_value = six_hours_later - - # When the user tries to complete the create flow - resp = app.post(url_for('main.create'), follow_redirects=True) - - # They get redirected to the index page with the "logged out" message - text = resp.data.decode('utf-8') - assert 'You were logged out due to inactivity' in text - - def test_source_no_session_expiration_message_when_not_logged_in(source_app): with source_app.test_client() as app: # Given an unauthenticated source user @@ -912,10 +892,10 @@ def test_csrf_error_page(source_app): source_app.config['WTF_CSRF_ENABLED'] = True with source_app.test_client() as app: with InstrumentedApp(source_app) as ins: - resp = app.post(url_for('main.create')) + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA) ins.assert_redirects(resp, url_for('main.index')) - resp = app.post(url_for('main.create'), follow_redirects=True) + resp = app.post(url_for('main.lookup'), data=GENERATE_DATA, follow_redirects=True) text = resp.data.decode('utf-8') assert 'You were logged out due to inactivity' in text @@ -924,8 +904,8 @@ def test_source_can_only_delete_own_replies(source_app, app_storage): '''This test checks for a bug an authenticated source A could delete replies send to source B by "guessing" the filename. ''' - source0, codename0 = utils.db_helper.init_source(app_storage) - source1, codename1 = utils.db_helper.init_source(app_storage) + source0, phrase0 = utils.db_helper.init_source(app_storage) + source1, phrase1 = utils.db_helper.init_source(app_storage) journalist, _ = utils.db_helper.init_journalist() replies = utils.db_helper.reply(app_storage, journalist, source0, 1) filename = replies[0].filename @@ -933,7 +913,7 @@ def test_source_can_only_delete_own_replies(source_app, app_storage): with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data={'codename': codename1}, + data={'passphrase': phrase1}, follow_redirects=True) assert resp.status_code == 200 assert SessionManager.get_logged_in_user(db_session=db.session).db_record_id == source1.id @@ -949,7 +929,7 @@ def test_source_can_only_delete_own_replies(source_app, app_storage): with source_app.test_client() as app: resp = app.post(url_for('main.login'), - data={'codename': codename0}, + data={'passphrase': phrase0}, follow_redirects=True) assert resp.status_code == 200 assert SessionManager.get_logged_in_user(db_session=db.session).db_record_id == source0.id From 8c48425f9ed46463ff3aa2ae9fd1f962c0983306 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Thu, 9 Jun 2022 16:48:25 -0400 Subject: [PATCH 11/19] fixed tests in tests/integration.py --- securedrop/tests/test_integration.py | 53 ++++++++-------------------- 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/securedrop/tests/test_integration.py b/securedrop/tests/test_integration.py index ac3645bc46..5d6d5349ee 100644 --- a/securedrop/tests/test_integration.py +++ b/securedrop/tests/test_integration.py @@ -45,17 +45,13 @@ def test_submit_message(journalist_app, source_app, test_journo, app_storage): test_msg = "This is a test message." with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}, follow_redirects=True) - source_user = SessionManager.get_logged_in_user(db_session=db.session) - filesystem_id = source_user.filesystem_id - - # redirected to submission form + # on first submission, a user account should be created resp = app.post('/submit', data=dict( msg=test_msg, fh=(BytesIO(b''), ''), ), follow_redirects=True) + source_user = SessionManager.get_logged_in_user(db_session=db.session) + filesystem_id = source_user.filesystem_id assert resp.status_code == 200 app.get('/logout') @@ -134,18 +130,14 @@ def test_submit_file(journalist_app, source_app, test_journo, app_storage): test_filename = "test.txt" with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}, follow_redirects=True) - source_user = SessionManager.get_logged_in_user(db_session=db.session) - filesystem_id = source_user.filesystem_id - - # redirected to submission form + # account is created after first submission resp = app.post('/submit', data=dict( msg="", fh=(BytesIO(test_file_contents), test_filename), ), follow_redirects=True) assert resp.status_code == 200 + source_user = SessionManager.get_logged_in_user(db_session=db.session) + filesystem_id = source_user.filesystem_id app.get('/logout') with journalist_app.test_client() as app: @@ -226,15 +218,13 @@ def _helper_test_reply(journalist_app, source_app, config, test_journo, test_msg = "This is a test message." with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id, codename = next(iter(session['codenames'].items())) - app.post('/create', data={'tab_id': tab_id}, follow_redirects=True) - # redirected to submission form + # user created after first submission resp = app.post('/submit', data=dict( msg=test_msg, fh=(BytesIO(b''), ''), ), follow_redirects=True) assert resp.status_code == 200 + passphrase = session.get("new_user_passphrase") source_user = SessionManager.get_logged_in_user(db_session=db.session) filesystem_id = source_user.filesystem_id app.get('/logout') @@ -298,7 +288,7 @@ def _helper_test_reply(journalist_app, source_app, config, test_journo, _helper_filenames_delete(app, soup, last_reply_number) with source_app.test_client() as app: - resp = app.post('/login', data=dict(codename=codename), + resp = app.post('/login', data=dict(passphrase=passphrase), follow_redirects=True) assert resp.status_code == 200 resp = app.get('/lookup') @@ -406,9 +396,6 @@ def test_delete_collection(mocker, source_app, journalist_app, test_journo): # first, add a source with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}) resp = app.post('/submit', data=dict( msg="This is a test.", fh=(BytesIO(b''), ''), @@ -456,9 +443,6 @@ def test_delete_collections(mocker, journalist_app, source_app, test_journo): with source_app.test_client() as app: num_sources = 2 for i in range(num_sources): - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}) app.post('/submit', data=dict( msg="This is a test " + str(i) + ".", fh=(BytesIO(b''), ''), @@ -494,6 +478,7 @@ def assertion(): def _helper_filenames_submit(app): + # if source account does not exist yet, this submission will create it app.post('/submit', data=dict( msg="This is a test.", fh=(BytesIO(b''), ''), @@ -513,9 +498,6 @@ def test_filenames(source_app, journalist_app, test_journo): and files""" # add a source and submit stuff with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}) _helper_filenames_submit(app) # navigate to the collection page @@ -540,9 +522,6 @@ def test_filenames_delete(journalist_app, source_app, test_journo): """Test pretty, sequential filenames when journalist deletes files""" # add a source and submit stuff with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - app.post('/create', data={'tab_id': tab_id}) _helper_filenames_submit(app) # navigate to the collection page @@ -653,14 +632,12 @@ def test_prevent_document_uploads(source_app, journalist_app, test_admin): # Check that the source interface accepts only messages: with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - resp = app.post('/create', data={'tab_id': tab_id}, follow_redirects=True) + resp = app.post('/lookup', data=GENERATE_DATA) assert resp.status_code == 200 text = resp.data.decode('utf-8') soup = BeautifulSoup(text, 'html.parser') - assert 'Submit Messages' in text + assert 'Welcome' in text assert len(soup.select('input[type="file"]')) == 0 @@ -683,12 +660,10 @@ def test_no_prevent_document_uploads(source_app, journalist_app, test_admin): # Check that the source interface accepts both files and messages: with source_app.test_client() as app: - app.post('/generate', data=GENERATE_DATA) - tab_id = next(iter(session['codenames'].keys())) - resp = app.post('/create', data={'tab_id': tab_id}, follow_redirects=True) + resp = app.post('/lookup', data=GENERATE_DATA) assert resp.status_code == 200 text = resp.data.decode('utf-8') soup = BeautifulSoup(text, 'html.parser') - assert 'Submit Files or Messages' in text + assert 'Welcome' in text assert len(soup.select('input[type="file"]')) == 1 From 1b5bb3e4b417f242cea3cc264e2926040efea73d Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Thu, 9 Jun 2022 20:00:00 -0400 Subject: [PATCH 12/19] fixed tests in functional/test_submit_and_retrieve_file.py --- .../functional/source_navigation_steps.py | 24 +++++++++++-------- .../test_submit_and_retrieve_file.py | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py index 35b3e525e4..dc6ebbe4d5 100644 --- a/securedrop/tests/functional/source_navigation_steps.py +++ b/securedrop/tests/functional/source_navigation_steps.py @@ -91,7 +91,7 @@ def _source_hits_cancel_at_login_page(self): assert self._is_on_source_homepage() def _source_proceeds_to_login(self): - self.safe_send_keys_by_id("codename", self.source_name) + self.safe_send_keys_by_id("passphrase", self.source_name) self.safe_click_by_css_selector(".form-controls button") # Check that we've logged in @@ -109,8 +109,8 @@ def _source_hits_cancel_at_submit_page(self): self.safe_click_by_css_selector(".form-controls a") if not self.accept_languages: - heading = self.driver.find_element_by_id("submit-heading") - assert "Submit Files or Messages" == heading.text + heading = self.driver.find_element_by_id("welcome-heading") + assert "Welcome!" == heading.text def _source_continues_to_submit_page(self, files_allowed=True): def submit_page_loaded(): @@ -127,7 +127,7 @@ def uploader_is_visible(): else: assert not uploader_is_visible() - def _source_submits_a_file(self): + def _source_submits_a_file(self, first_submission=False): with tempfile.NamedTemporaryFile() as file: file.write(self.secret_message.encode("utf-8")) file.seek(0) @@ -138,14 +138,21 @@ def _source_submits_a_file(self): self.safe_click_by_css_selector(".form-controls button") - def file_submitted(): + def file_submitted(first_submission=False): if not self.accept_languages: notification = self.driver.find_element_by_class_name("success") - expected_notification = "Success!\nThanks! We received your document." + if first_submission: + expected_notification = "Please check back later for replies." + else: + expected_notification = "Success!\nThanks! We received your document." assert expected_notification in notification.text # Allow extra time for file uploads - self.wait_for(file_submitted, timeout=(self.timeout * 3)) + self.wait_for(lambda: file_submitted(first_submission), timeout=(self.timeout * 3)) + + if first_submission: + codename = self.driver.find_element_by_css_selector("#codename span") + self.source_name = codename.text def _source_submits_a_message( self, verify_notification=False, first_submission=False, first_login=False @@ -161,7 +168,6 @@ def message_submitted(): if first_submission: codename = self.driver.find_element_by_css_selector("#codename span") self.source_name = codename.text - print("First sub, source is: {}".format(self.source_name)) if verify_notification: first_submission_text = ( @@ -169,10 +175,8 @@ def message_submitted(): ) if first_submission: - print("First sub, source is: {}".format(self.source_name)) assert first_submission_text else: - print("Not first sub, source is whaaat") assert not first_submission_text self.wait_for(message_submitted) diff --git a/securedrop/tests/functional/test_submit_and_retrieve_file.py b/securedrop/tests/functional/test_submit_and_retrieve_file.py index 4bd252aabb..993db7608e 100644 --- a/securedrop/tests/functional/test_submit_and_retrieve_file.py +++ b/securedrop/tests/functional/test_submit_and_retrieve_file.py @@ -12,7 +12,7 @@ def test_submit_and_retrieve_happy_path(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() try: self.switch_to_firefox_driver() From de4d53eb065f9bd660ed29b984be368986390104 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Mon, 13 Jun 2022 10:44:06 -0400 Subject: [PATCH 13/19] fixed tests in functional/test_source.py --- securedrop/tests/functional/app_navigators.py | 13 +-- securedrop/tests/functional/test_source.py | 110 +++--------------- 2 files changed, 24 insertions(+), 99 deletions(-) diff --git a/securedrop/tests/functional/app_navigators.py b/securedrop/tests/functional/app_navigators.py index 3524745513..7c89373503 100644 --- a/securedrop/tests/functional/app_navigators.py +++ b/securedrop/tests/functional/app_navigators.py @@ -208,12 +208,11 @@ def source_clicks_submit_documents_on_homepage(self) -> None: assert self._is_on_lookup_page() def source_continues_to_submit_page(self) -> None: - self.nav_helper.safe_click_by_css_selector("#create-form button") def submit_page_loaded() -> None: if not self.accept_languages: - headline = self.driver.find_element_by_id("submit-heading") - assert "Submit Files or Messages" == headline.text + headline = self.driver.find_element_by_id("welcome-heading") + assert "Welcome!" == headline.text self.nav_helper.wait_for(submit_page_loaded) @@ -228,11 +227,11 @@ def source_logs_out(self) -> None: def source_retrieves_codename_from_hint(self) -> str: # We use inputs to change CSS states for subsequent elements in the DOM, if it is unchecked - # the codename is hidden content = self.driver.find_element_by_id("codename-show-checkbox") - assert content.get_attribute("checked") is None - self.nav_helper.safe_click_by_id("codename-show") + # TODO: should the codename be hidden by default under inverted flow? + # assert content.get_attribute("checked") is None + # self.nav_helper.safe_click_by_id("codename-show") assert content.get_attribute("checked") is not None content_content = self.driver.find_element_by_css_selector("#codename span") @@ -248,7 +247,7 @@ def _is_logged_in(self) -> WebElement: return self.nav_helper.wait_for(lambda: self.driver.find_element_by_id("logout")) def source_proceeds_to_login(self, codename: str) -> None: - self.nav_helper.safe_send_keys_by_id("codename", codename) + self.nav_helper.safe_send_keys_by_id("passphrase", codename) self.nav_helper.safe_click_by_css_selector(".form-controls button") # Check that we've logged in diff --git a/securedrop/tests/functional/test_source.py b/securedrop/tests/functional/test_source.py index f27686019b..422d9656c0 100644 --- a/securedrop/tests/functional/test_source.py +++ b/securedrop/tests/functional/test_source.py @@ -1,8 +1,6 @@ import requests -import werkzeug from tests.functional.app_navigators import SourceAppNagivator -from ..test_journalist import VALID_PASSWORD from tests.functional import tor_utils @@ -17,10 +15,11 @@ def test_no_codename_hint_on_second_login(self, sd_servers_v2, tor_browser_web_d ) # Given a source user who creates an account - # When they first login + # When they first submit a message navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message("they're here") # Then they are able to retrieve their codename from the UI source_codename = navigator.source_retrieves_codename_from_hint() @@ -72,10 +71,16 @@ def test_submission_notifications_on_second_login(self, sd_servers_v2, tor_brows web_driver=tor_browser_web_driver, ) - # Given a source user who creates an account + # Given a source user who creates an account by submitting a + # message on first login navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + confirmation_text_first_submission = navigator.source_submits_a_message() + + # And they see the expected confirmation messages for a first submission on second login + assert self.FIRST_SUBMISSION_TEXT in confirmation_text_first_submission + source_codename = navigator.source_retrieves_codename_from_hint() assert source_codename @@ -85,12 +90,6 @@ def test_submission_notifications_on_second_login(self, sd_servers_v2, tor_brows navigator.source_chooses_to_login() navigator.source_proceeds_to_login(codename=source_codename) - # Then it succeeds - confirmation_text_first_submission = navigator.source_submits_a_message() - - # And they see the expected confirmation messages for a first submission on second login - assert self.FIRST_SUBMISSION_TEXT in confirmation_text_first_submission - # And when they submit a second message confirmation_text_second_submission = navigator.source_submits_a_message() @@ -127,11 +126,11 @@ def test_generate_codenames_in_multiple_tabs(self, sd_servers_v2, tor_browser_we web_driver=tor_browser_web_driver, ) - # Given a user who generated a codename in Tab A + # Given a user who opens /lookup in tab A tab_a = navigator.driver.window_handles[0] navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() - codename_a = self._extract_generated_codename(navigator) + navigator.source_continues_to_submit_page() # And they then opened a new tab, Tab B navigator.driver.execute_script("window.open('about:blank', '_blank')") @@ -139,98 +138,25 @@ def test_generate_codenames_in_multiple_tabs(self, sd_servers_v2, tor_browser_we navigator.driver.switch_to.window(tab_b) assert tab_a != tab_b - # And they also generated another codename in Tab B + # And they also opened /lookup in Tab B navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() - codename_b = self._extract_generated_codename(navigator) - assert codename_a != codename_b + navigator.source_continues_to_submit_page() # And they ended up creating their account and submitting documents in Tab A navigator.driver.switch_to.window(tab_a) navigator.source_continues_to_submit_page() self._assert_is_on_lookup_page(navigator) - assert navigator.source_retrieves_codename_from_hint() == codename_a navigator.source_submits_a_message() + passphrase_a = navigator.source_retrieves_codename_from_hint() # When the user tries to create an account and submit documents in Tab B navigator.driver.switch_to.window(tab_b) - navigator.source_continues_to_submit_page() - - # Then the submission fails and the user sees the corresponding flash message in Tab B - self._assert_is_on_lookup_page(navigator) - notification = navigator.source_sees_flash_message() - if not navigator.accept_languages: - assert "You are already logged in." in notification.text - - # And the user's actual codename is the one initially generated in Tab A - assert navigator.source_retrieves_codename_from_hint() == codename_a - - def test_generate_and_refresh_codenames_in_multiple_tabs( - self, sd_servers_v2, tor_browser_web_driver - ): - navigator = SourceAppNagivator( - source_app_base_url=sd_servers_v2.source_app_base_url, - web_driver=tor_browser_web_driver, - ) - - # Given a user who generated a codename in Tab A - tab_a = navigator.driver.window_handles[0] - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - codename_a1 = self._extract_generated_codename(navigator) - - # And they then re-generated their codename in Tab - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - codename_a2 = self._extract_generated_codename(navigator) - assert codename_a1 != codename_a2 - - # And they then opened a new tab, Tab B - navigator.driver.execute_script("window.open('about:blank', '_blank')") - tab_b = navigator.driver.window_handles[1] - navigator.driver.switch_to.window(tab_b) - assert tab_a != tab_b - - # And they also generated another codename in Tab B - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - codename_b = self._extract_generated_codename(navigator) - assert codename_a2 != codename_b - - # And they ended up creating their account and submitting documents in Tab A - navigator.driver.switch_to.window(tab_a) - navigator.source_continues_to_submit_page() - self._assert_is_on_lookup_page(navigator) - assert navigator.source_retrieves_codename_from_hint() == codename_a2 navigator.source_submits_a_message() + passphrase_b = navigator.source_retrieves_codename_from_hint() - # When they try to re-generate a codename in Tab B - navigator.driver.switch_to.window(tab_b) - navigator.source_visits_source_homepage() - navigator.nav_helper.safe_click_by_css_selector("#started-form button") - - # Then they get redirected to /lookup with the corresponding flash message + # Then the submission succeeds self._assert_is_on_lookup_page(navigator) - notification = navigator.source_sees_flash_message() - if not navigator.accept_languages: - assert "You were redirected because you are already logged in." in notification.text - - # And the user's actual codename is the expected one - assert navigator.source_retrieves_codename_from_hint() == codename_a2 - - # TODO(AD): This test takes ~50s ; we could refactor it to speed it up - def test_codenames_exceed_max_cookie_size(self, sd_servers_v2, tor_browser_web_driver): - """Test generation of enough codenames that the resulting cookie exceeds the recommended - `werkzeug.Response.max_cookie_size` = 4093 bytes. (#6043) - """ - navigator = SourceAppNagivator( - source_app_base_url=sd_servers_v2.source_app_base_url, - web_driver=tor_browser_web_driver, - ) - too_many = 2 * (werkzeug.Response.max_cookie_size // len(VALID_PASSWORD)) - for _ in range(too_many): - navigator.source_visits_source_homepage() - navigator.source_clicks_submit_documents_on_homepage() - - navigator.source_continues_to_submit_page() + # And the user's actual codename is the one initially generated in Tab A + assert passphrase_b == passphrase_a From d00431a6e44e4f7e29fa27099bbfddf7e23b4f11 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Mon, 13 Jun 2022 12:58:24 -0400 Subject: [PATCH 14/19] fixed tests in functional/test_source_designation_collision.py --- securedrop/tests/functional/app_navigators.py | 22 ++++++++++++++++--- .../test_source_designation_collision.py | 4 +++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/securedrop/tests/functional/app_navigators.py b/securedrop/tests/functional/app_navigators.py index 7c89373503..8d7349fc93 100644 --- a/securedrop/tests/functional/app_navigators.py +++ b/securedrop/tests/functional/app_navigators.py @@ -256,7 +256,11 @@ def source_proceeds_to_login(self, codename: str) -> None: replies = self.driver.find_elements_by_id("replies") assert len(replies) == 1 - def source_submits_a_message(self, message: str = "S3cr3t m3ss4ge") -> str: + def source_submits_a_message( + self, + message: str = "S3cr3t m3ss4ge", + will_succeed: bool = True + ) -> str: # Write the message to submit self.nav_helper.safe_send_keys_by_css_selector("[name=msg]", message) @@ -270,9 +274,21 @@ def message_submitted(): assert "Thank" in notification.text return notification.text + # If it's expected that the submission will fail, wait for an error + # notificaition + def message_failed(): + if not self.accept_languages: + notification = self.driver.find_element_by_css_selector(".error") + assert len(notification.text) > 0 + return notification.text + # Return the confirmation notification - notification_text = self.nav_helper.wait_for(message_submitted) - return notification_text + if will_succeed: + notification_text = self.nav_helper.wait_for(message_submitted) + return notification_text + else: + notification_text = self.nav_helper.wait_for(message_failed) + return notification_text def source_sees_flash_message(self) -> str: notification = self.driver.find_element_by_css_selector(".notification") diff --git a/securedrop/tests/functional/test_source_designation_collision.py b/securedrop/tests/functional/test_source_designation_collision.py index 444f28a3b2..1ac84f0f73 100644 --- a/securedrop/tests/functional/test_source_designation_collision.py +++ b/securedrop/tests/functional/test_source_designation_collision.py @@ -46,14 +46,16 @@ def test(self, _sd_servers_with_designation_collisions, tor_browser_web_driver): navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message() navigator.source_logs_out() # When another source user creates an account but gets the same journalist designation navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() + navigator.source_continues_to_submit_page() + navigator.source_submits_a_message(will_succeed=False) # Then the right error message is displayed - navigator.nav_helper.safe_click_by_css_selector("#create-form button") navigator.nav_helper.wait_for( lambda: navigator.driver.find_element_by_css_selector(".error") ) From b64a1589b827381cb3a281a019dad7c203b1281b Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Mon, 13 Jun 2022 13:23:46 -0400 Subject: [PATCH 15/19] fixed tests in functional/test_journalist.py --- securedrop/tests/functional/test_journalist.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/securedrop/tests/functional/test_journalist.py b/securedrop/tests/functional/test_journalist.py index 7d9918ecb1..fe0fb37943 100644 --- a/securedrop/tests/functional/test_journalist.py +++ b/securedrop/tests/functional/test_journalist.py @@ -37,7 +37,7 @@ def test_journalist_verifies_deletion_of_one_submission_modal(self): # This deletion button is displayed on the individual source page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -47,7 +47,7 @@ def test_journalist_uses_col_delete_collection_button_modal(self): # This delete button is displayed on the individual source page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -57,7 +57,7 @@ def test_journalist_uses_index_delete_collections_button_modal(self): # This deletion button is displayed on the index page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_uses_delete_collections_button_confirmation() @@ -66,7 +66,7 @@ def test_journalist_uses_index_delete_files_button_modal(self): # This deletion button is displayed on the index page self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_submits_a_message() self._source_logs_out() self._journalist_logs_in() @@ -79,7 +79,7 @@ def test_journalist_uses_index_delete_files_button_modal(self): def test_journalist_interface_ui_with_modal(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self.set_tbb_securitylevel(SECURITY_LOW) From dadeeacae2103b9dd67e8ea51b0d73d234e11369 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Mon, 13 Jun 2022 15:05:16 -0400 Subject: [PATCH 16/19] fixed tests in pageslayout/ tests --- .../pageslayout/test_journalist_col.py | 8 +++--- .../functional/pageslayout/test_source.py | 3 ++- .../functional/source_navigation_steps.py | 25 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/securedrop/tests/functional/pageslayout/test_journalist_col.py b/securedrop/tests/functional/pageslayout/test_journalist_col.py index 14d966a9ba..72a198ce55 100644 --- a/securedrop/tests/functional/pageslayout/test_journalist_col.py +++ b/securedrop/tests/functional/pageslayout/test_journalist_col.py @@ -32,7 +32,7 @@ def test_col_no_documents(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -45,7 +45,7 @@ def test_col_has_no_key(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._source_delete_key() @@ -57,7 +57,7 @@ def test_col(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() @@ -68,7 +68,7 @@ def test_col_javascript(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_visits_col() diff --git a/securedrop/tests/functional/pageslayout/test_source.py b/securedrop/tests/functional/pageslayout/test_source.py index 6e56dfe70a..8abcd1124c 100644 --- a/securedrop/tests/functional/pageslayout/test_source.py +++ b/securedrop/tests/functional/pageslayout/test_source.py @@ -39,6 +39,7 @@ def test_lookup_shows_codename(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() + self._source_submits_a_message(first_submission=True) self._source_shows_codename() self._screenshot('source-lookup-shows-codename.png') self._save_html('source-lookup-shows-codename.html') @@ -83,7 +84,7 @@ def test_source_checks_for_reply(self): self._source_visits_source_homepage() self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() - self._source_submits_a_file() + self._source_submits_a_file(first_submission=True) self._source_logs_out() self._journalist_logs_in() self._journalist_checks_messages() diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py index dc6ebbe4d5..e0dbaab4ec 100644 --- a/securedrop/tests/functional/source_navigation_steps.py +++ b/securedrop/tests/functional/source_navigation_steps.py @@ -51,13 +51,8 @@ def _source_shows_codename(self, verify_source_name=True): # We use inputs to change CSS states for subsequent elements in the DOM, if it is unchecked # the codename is hidden content = self.driver.find_element_by_id("codename-show-checkbox") - assert content.get_attribute("checked") is None - - # In the UI, the label is actually the element that is being clicked, altering the state - # of the input - self.safe_click_by_id("codename-show") - assert content.get_attribute("checked") is not None + content_content = self.driver.find_element_by_css_selector("#codename span") if verify_source_name: assert content_content.text == self.source_name @@ -102,7 +97,7 @@ def _source_proceeds_to_login(self): def _source_enters_codename_in_login_form(self): self.safe_send_keys_by_id( - "codename", "ascension hypertext concert synopses" + "passphrase", "ascension hypertext concert synopses" ) def _source_hits_cancel_at_submit_page(self): @@ -160,15 +155,12 @@ def _source_submits_a_message( self._source_enters_text_in_message_field() self.safe_click_by_css_selector(".form-controls button") - def message_submitted(): + def message_submitted(first_submission=False, verify_notification=False): + if not self.accept_languages: notification = self.driver.find_element_by_class_name("success") assert "Thank" in notification.text - if first_submission: - codename = self.driver.find_element_by_css_selector("#codename span") - self.source_name = codename.text - if verify_notification: first_submission_text = ( "Please check back later for replies." in notification.text @@ -179,8 +171,15 @@ def message_submitted(): else: assert not first_submission_text - self.wait_for(message_submitted) + self.wait_for(lambda: message_submitted( + verify_notification=verify_notification, + first_submission=first_submission + )) + # passphrase is only available on submission in first session + if first_submission: + codename = self.driver.find_element_by_css_selector("#codename span") + self.source_name = codename.text # allow time for reply key to be generated time.sleep(self.timeout) From f60fe90cfd1e78116b0d1ec9d58d6bf1798c8c03 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Fri, 17 Jun 2022 18:03:40 -0400 Subject: [PATCH 17/19] Replaced "codename" with "passphrase" - updated interface strings - for consistency, updated CSS names as well - updated session key name --- securedrop/sass/source.sass | 24 +++++++++++------------ securedrop/source_app/session_manager.py | 2 +- securedrop/source_templates/error.html | 2 +- securedrop/source_templates/index.html | 2 +- securedrop/source_templates/lookup.html | 10 +++++----- securedrop/source_templates/notfound.html | 2 +- securedrop/source_templates/utils.html | 18 ++++++++--------- 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/securedrop/sass/source.sass b/securedrop/sass/source.sass index 28bc903614..788a8f8773 100644 --- a/securedrop/sass/source.sass +++ b/securedrop/sass/source.sass @@ -470,18 +470,18 @@ mark background: $passphrase_masked_color clip-path: polygon(0% 0%, 0% 100%, 20% 100%, 20% 0%, 23% 0%, 23% 100%, 32% 100%, 32% 0%, 35% 0%, 35% 100%, 42% 100%, 42% 0%, 45% 0%, 45% 100%, 64% 100%, 64% 0%, 67% 0%, 67% 100%, 74% 100%, 74% 0%, 77% 0%, 77% 100%, 90% 100%, 90% 0%, 93% 0%, 93% 100%, 100% 100%, 100% 0%) -#codename-reminder +#passphrase-reminder font-size: 18px color: $heading_alt_color -#codename-show-checkbox +#passphrase-show-checkbox position: absolute @include ltr left: -999rem @include rtl right: -999rem - &~ #codename-show + &~ #passphrase-show font-size: 11px line-height: 14px margin-top: -2px @@ -498,7 +498,7 @@ mark border-color: $checkbox_hover_border box-shadow: inset 0 0 0 1px $checkbox_hover_border_inside - @mixin codename-show-checkbox + @mixin passphrase-show-checkbox content: "" background: $body_bg display: block @@ -508,10 +508,10 @@ mark &::before @include ltr - @include codename-show-checkbox + @include passphrase-show-checkbox &::after @include rtl - @include codename-show-checkbox + @include passphrase-show-checkbox &::before @include ltr @@ -522,7 +522,7 @@ mark margin-left: 12px float: right - &:focus-visible ~ #codename-show + &:focus-visible ~ #passphrase-show box-shadow: 0 0 0 3px $focus_shadow &:checked ~ mark > span @@ -540,14 +540,14 @@ mark height: 13px border: 0 - &:checked ~ #codename-show::before + &:checked ~ #passphrase-show::before @include ltr @include checked - &:checked ~ #codename-show::after + &:checked ~ #passphrase-show::after @include rtl @include checked -#codename-show +#passphrase-show display: block #upload @@ -949,7 +949,7 @@ section[aria-labelledby="submit-heading"] section[aria-labelledby="welcome-heading"] margin-top: 50px -section[aria-labelledby="codename-reminder"] + section h2 +section[aria-labelledby="passphrase-reminder"] + section h2 padding-top: 26px #source-lookup main, #source-logout main @@ -1140,7 +1140,7 @@ section[aria-labelledby="codename-reminder"] + section h2 .info padding: 9px -#flashed + section, #codename-hint + section:not(#flashed) +#flashed + section, #passphrase-hint + section:not(#flashed) margin-top: 3em #source-lookup nav + section:not(#flashed) diff --git a/securedrop/source_app/session_manager.py b/securedrop/source_app/session_manager.py index 7459a58ab6..812649c292 100644 --- a/securedrop/source_app/session_manager.py +++ b/securedrop/source_app/session_manager.py @@ -30,7 +30,7 @@ class SessionManager: """Helper to manage the user's session cookie accessible via flask.session.""" # The keys in flask.session for the user's passphrase and expiration date - _SESSION_COOKIE_KEY_FOR_CODENAME = "codename" + _SESSION_COOKIE_KEY_FOR_CODENAME = "passphrase" _SESSION_COOKIE_KEY_FOR_EXPIRATION_DATE = "expires" @classmethod diff --git a/securedrop/source_templates/error.html b/securedrop/source_templates/error.html index 082832161e..0075847757 100644 --- a/securedrop/source_templates/error.html +++ b/securedrop/source_templates/error.html @@ -4,5 +4,5 @@

{{ gettext('Server error') }}

{{ gettext('Sorry, the website encountered an error and was unable to complete your request.') }}

-

{{ gettext('Look up a codename...') }}

+

{{ gettext('Look up a passphrase...') }}

{% endblock %} diff --git a/securedrop/source_templates/index.html b/securedrop/source_templates/index.html index 79cd7bfde8..60b400c7a8 100644 --- a/securedrop/source_templates/index.html +++ b/securedrop/source_templates/index.html @@ -69,7 +69,7 @@

{{ gettext('First submission') }}

{{ gettext('Return visit') }}

-

{{ gettext('Already have a codename? Check for replies or submit something new.') }}

+

{{ gettext('Already have a passphrase? Check for replies or submit something new.') }}

{{ gettext('LOG IN') }} diff --git a/securedrop/source_templates/lookup.html b/securedrop/source_templates/lookup.html index a8cc330e55..788fc85a90 100644 --- a/securedrop/source_templates/lookup.html +++ b/securedrop/source_templates/lookup.html @@ -20,12 +20,12 @@

{{ gettext('Welcome!') }}

{{ gettext('Keep In Touch') }}

-

{{ gettext('A codename in SecureDrop functions as both your username and your password.') }}

-

{{ gettext('You will need this codename to log into our SecureDrop later:') }}

+

{{ gettext('A passphrase in SecureDrop functions as both your username and your password.') }}

+

{{ gettext('You will need this passphrase to log into our SecureDrop later:') }}

- {{ utils.codename(new_user_passphrase) }} + {{ utils.passphrase(new_user_passphrase) }} -
    +
    • {{ gettext('Keep it secret. Do not share it with anyone.') }}
    • {{ gettext('Keep it safe. There is no account recovery option.') }}
    @@ -38,7 +38,7 @@

    {{ gettext('Replies') }}

    {% if replies %}

    - {{ gettext("You have received a reply. To protect your identity in the unlikely event someone learns your codename, please delete all replies when you're done with them. This also lets us know that you are aware of our reply. You can respond by submitting new files and messages above.") }} + {{ gettext("You have received a reply. To protect your identity in the unlikely event someone learns your passphrase, please delete all replies when you're done with them. This also lets us know that you are aware of our reply. You can respond by submitting new files and messages above.") }}

    {% for reply in replies %} {%- set timestamp = utils.relative_time(reply.date) -%} diff --git a/securedrop/source_templates/notfound.html b/securedrop/source_templates/notfound.html index 9abd23cb4f..1e2966e13d 100644 --- a/securedrop/source_templates/notfound.html +++ b/securedrop/source_templates/notfound.html @@ -4,5 +4,5 @@

    {{ gettext('Page not found') }}

    {{ gettext("Sorry, we couldn't locate what you requested.") }}

    -

    {{ gettext('Look up a codename...') }}

    +

    {{ gettext('Look up a passphrase...') }}

    {% endblock %} diff --git a/securedrop/source_templates/utils.html b/securedrop/source_templates/utils.html index c01887109d..96a40151df 100644 --- a/securedrop/source_templates/utils.html +++ b/securedrop/source_templates/utils.html @@ -3,19 +3,19 @@ datetime="{{ date|html_datetime_format }}">{{ date|rel_datetime_format(relative=True) }} {%- endmacro -%} -{%- macro codename(codename, new=True) -%} -
    - +{%- macro passphrase(passphrase, new=True) -%} +
    + - + - {{ codename }} + {{ passphrase }} -
    From 8440b53eb97388552f392f710857fbfa482f00be Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Mon, 20 Jun 2022 10:34:44 -0400 Subject: [PATCH 18/19] updated 'codename' to 'passphrase' in tests --- securedrop/tests/functional/app_navigators.py | 4 ++-- .../tests/functional/source_navigation_steps.py | 14 +++++++------- securedrop/tests/functional/test_source.py | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/securedrop/tests/functional/app_navigators.py b/securedrop/tests/functional/app_navigators.py index 8d7349fc93..f06c8dbd4c 100644 --- a/securedrop/tests/functional/app_navigators.py +++ b/securedrop/tests/functional/app_navigators.py @@ -227,14 +227,14 @@ def source_logs_out(self) -> None: def source_retrieves_codename_from_hint(self) -> str: # We use inputs to change CSS states for subsequent elements in the DOM, if it is unchecked - content = self.driver.find_element_by_id("codename-show-checkbox") + content = self.driver.find_element_by_id("passphrase-show-checkbox") # TODO: should the codename be hidden by default under inverted flow? # assert content.get_attribute("checked") is None # self.nav_helper.safe_click_by_id("codename-show") assert content.get_attribute("checked") is not None - content_content = self.driver.find_element_by_css_selector("#codename span") + content_content = self.driver.find_element_by_css_selector("#passphrase span") return content_content.text def source_chooses_to_login(self) -> None: diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py index e0dbaab4ec..33c5ecb4c2 100644 --- a/securedrop/tests/functional/source_navigation_steps.py +++ b/securedrop/tests/functional/source_navigation_steps.py @@ -50,27 +50,27 @@ def _source_chooses_to_submit_documents(self): def _source_shows_codename(self, verify_source_name=True): # We use inputs to change CSS states for subsequent elements in the DOM, if it is unchecked # the codename is hidden - content = self.driver.find_element_by_id("codename-show-checkbox") + content = self.driver.find_element_by_id("passphrase-show-checkbox") assert content.get_attribute("checked") is not None - content_content = self.driver.find_element_by_css_selector("#codename span") + content_content = self.driver.find_element_by_css_selector("#passphrase span") if verify_source_name: assert content_content.text == self.source_name def _source_hides_codename(self): # We use inputs to change CSS states for subsequent elements in the DOM, if it is checked # the codename is visible - content = self.driver.find_element_by_id("codename-show-checkbox") + content = self.driver.find_element_by_id("passphrase-show-checkbox") assert content.get_attribute("checked") is not None # In the UI, the label is actually the element that is being clicked, altering the state # of the input - self.safe_click_by_id("codename-show") + self.safe_click_by_id("passphrase-show") assert content.get_attribute("checked") is None def _source_sees_no_codename(self): - codename = self.driver.find_elements_by_css_selector("#codename span") + codename = self.driver.find_elements_by_css_selector("#passphrase span") assert len(codename) == 0 def _source_chooses_to_login(self): @@ -146,7 +146,7 @@ def file_submitted(first_submission=False): self.wait_for(lambda: file_submitted(first_submission), timeout=(self.timeout * 3)) if first_submission: - codename = self.driver.find_element_by_css_selector("#codename span") + codename = self.driver.find_element_by_css_selector("#passphrase span") self.source_name = codename.text def _source_submits_a_message( @@ -178,7 +178,7 @@ def message_submitted(first_submission=False, verify_notification=False): # passphrase is only available on submission in first session if first_submission: - codename = self.driver.find_element_by_css_selector("#codename span") + codename = self.driver.find_element_by_css_selector("#passphrase span") self.source_name = codename.text # allow time for reply key to be generated time.sleep(self.timeout) diff --git a/securedrop/tests/functional/test_source.py b/securedrop/tests/functional/test_source.py index 422d9656c0..984804795e 100644 --- a/securedrop/tests/functional/test_source.py +++ b/securedrop/tests/functional/test_source.py @@ -26,9 +26,9 @@ def test_no_codename_hint_on_second_login(self, sd_servers_v2, tor_browser_web_d assert source_codename # And they are able to close the codename hint UI - content = navigator.driver.find_element_by_id("codename-show-checkbox") + content = navigator.driver.find_element_by_id("passphrase-show-checkbox") assert content.get_attribute("checked") is not None - navigator.nav_helper.safe_click_by_id("codename-show") + navigator.nav_helper.safe_click_by_id("passphrase-show") assert content.get_attribute("checked") is None # And on their second login @@ -38,7 +38,7 @@ def test_no_codename_hint_on_second_login(self, sd_servers_v2, tor_browser_web_d navigator.source_proceeds_to_login(codename=source_codename) # The codename hint UI is no longer present - codename = navigator.driver.find_elements_by_css_selector("#codename-reminder") + codename = navigator.driver.find_elements_by_css_selector("#passphrase-reminder") assert len(codename) == 0 def test_submission_notifications_on_first_login(self, sd_servers_v2, tor_browser_web_driver): From 8fee868e8e2ed14c02dac1620a36b5333eb21f54 Mon Sep 17 00:00:00 2001 From: Kevin O'Gorman Date: Mon, 20 Jun 2022 17:59:40 -0400 Subject: [PATCH 19/19] post-rebase test fixes --- .../functional/pageslayout/test_source_layout_torbrowser.py | 3 ++- .../tests/functional/pageslayout/test_source_session_layout.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py b/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py index 1f28ee03f6..c5b856166c 100644 --- a/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py +++ b/securedrop/tests/functional/pageslayout/test_source_layout_torbrowser.py @@ -38,12 +38,13 @@ def test_index_and_logout(self, locale, sd_servers_v2): # And they have disabled JS in their browser disable_js(navigator.driver) - # When they first login, it succeeds + # When they first submit a file, it succeeds and creates their account navigator.source_visits_source_homepage() save_screenshot_and_html(navigator.driver, locale, "source-index") navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message() # And when they logout, it succeeds navigator.source_logs_out() diff --git a/securedrop/tests/functional/pageslayout/test_source_session_layout.py b/securedrop/tests/functional/pageslayout/test_source_session_layout.py index 2360be899c..b9987576b9 100644 --- a/securedrop/tests/functional/pageslayout/test_source_session_layout.py +++ b/securedrop/tests/functional/pageslayout/test_source_session_layout.py @@ -20,7 +20,7 @@ def _sd_servers_with_short_timeout(setup_journalist_key_and_gpg_folder): """Spawn the source and journalist apps as separate processes with a short session timeout.""" # Generate a securedrop config with a very short session timeout config_with_short_timeout = SecureDropConfigFactory.create( - SESSION_EXPIRATION_MINUTES=SESSION_EXPIRATION_SECONDS / 60, + SESSION_EXPIRATION_MINUTES=SESSION_EXPIRATION_SECONDS / 60.0, SECUREDROP_DATA_ROOT=Path("/tmp/sd-tests/functional-session-timeout"), ) @@ -49,6 +49,7 @@ def test_source_session_timeout(self, locale, _sd_servers_with_short_timeout): navigator.source_visits_source_homepage() navigator.source_clicks_submit_documents_on_homepage() navigator.source_continues_to_submit_page() + navigator.source_submits_a_message() # And their session just expired time.sleep(SESSION_EXPIRATION_SECONDS + 1)