diff --git a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 index feab2bd4b0..bac60660ae 100644 --- a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 +++ b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/usr.sbin.apache2 @@ -197,8 +197,7 @@ /var/www/securedrop/journalist_templates/js-strings.html r, /var/www/securedrop/journalist_templates/locales.html r, /var/www/securedrop/journalist_templates/login.html r, - /var/www/securedrop/journalist_templates/logo_upload_flashed.html r, - /var/www/securedrop/journalist_templates/submission_preferences_saved_flash.html r, + /var/www/securedrop/journalist_templates/preferences_saved_flash.html r, /var/www/securedrop/models.py r, /var/www/securedrop/request_that_secures_file_uploads.py r, /var/www/securedrop/rm.py r, diff --git a/securedrop/alembic/versions/92fba0be98e9_added_organization_name_field_in_.py b/securedrop/alembic/versions/92fba0be98e9_added_organization_name_field_in_.py new file mode 100644 index 0000000000..4b06b3c523 --- /dev/null +++ b/securedrop/alembic/versions/92fba0be98e9_added_organization_name_field_in_.py @@ -0,0 +1,32 @@ +"""Added organization_name field in instance_config table + +Revision ID: 92fba0be98e9 +Revises: 48a75abc0121 +Create Date: 2020-11-15 19:36:20.351993 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '92fba0be98e9' +down_revision = '48a75abc0121' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('instance_config', schema=None) as batch_op: + batch_op.add_column(sa.Column('organization_name', sa.String(length=255), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('instance_config', schema=None) as batch_op: + batch_op.drop_column('organization_name') + + # ### end Alembic commands ### diff --git a/securedrop/journalist_app/__init__.py b/securedrop/journalist_app/__init__.py index d0ac1b3403..d8397eab11 100644 --- a/securedrop/journalist_app/__init__.py +++ b/securedrop/journalist_app/__init__.py @@ -156,6 +156,11 @@ def setup_g() -> 'Optional[Response]': g.html_lang = i18n.locale_to_rfc_5646(g.locale) g.locales = i18n.get_locale2name() + if app.instance_config.organization_name: + g.organization_name = app.instance_config.organization_name + else: + g.organization_name = gettext('SecureDrop') + if not app.config['V3_ONION_ENABLED'] or app.config['V2_ONION_ENABLED']: g.show_v2_onion_eol_warning = True diff --git a/securedrop/journalist_app/admin.py b/securedrop/journalist_app/admin.py index b7f481f4c7..52287aec9a 100644 --- a/securedrop/journalist_app/admin.py +++ b/securedrop/journalist_app/admin.py @@ -13,12 +13,13 @@ import i18n from db import db +from html import escape from models import (InstanceConfig, Journalist, InvalidUsernameException, FirstOrLastNameError, PasswordError) from journalist_app.decorators import admin_required from journalist_app.utils import (commit_account_changes, set_diceware_password, validate_hotp_secret, revoke_token) -from journalist_app.forms import LogoForm, NewUserForm, SubmissionPreferencesForm +from journalist_app.forms import LogoForm, NewUserForm, SubmissionPreferencesForm, OrgNameForm from sdconfig import SDConfig from passphrases import PassphraseGenerator @@ -38,6 +39,8 @@ def manage_config() -> Union[str, werkzeug.Response]: # The UI prompt ("prevent") is the opposite of the setting ("allow"): submission_preferences_form = SubmissionPreferencesForm( prevent_document_uploads=not current_app.instance_config.allow_document_uploads) + organization_name_form = OrgNameForm( + organization_name=current_app.instance_config.organization_name) logo_form = LogoForm() if logo_form.validate_on_submit(): f = logo_form.logo.data @@ -53,13 +56,14 @@ def manage_config() -> Union[str, werkzeug.Response]: flash("Unable to process the image file." " Try another one.", "logo-error") finally: - return redirect(url_for("admin.manage_config")) + return redirect(url_for("admin.manage_config") + "#config-logoimage") else: for field, errors in list(logo_form.errors.items()): for error in errors: flash(error, "logo-error") return render_template("config.html", submission_preferences_form=submission_preferences_form, + organization_name_form=organization_name_form, logo_form=logo_form) @view.route('/update-submission-preferences', methods=['POST']) @@ -71,9 +75,31 @@ def update_submission_preferences() -> Optional[werkzeug.Response]: flash(gettext("Preferences saved."), "submission-preferences-success") value = not bool(request.form.get('prevent_document_uploads')) InstanceConfig.set_allow_document_uploads(value) - return redirect(url_for('admin.manage_config')) + return redirect(url_for('admin.manage_config') + "#config-preventuploads") else: - return None + for field, errors in list(form.errors.items()): + for error in errors: + flash(gettext("Preferences not updated.") + " " + error, + "submission-preferences-error") + return redirect(url_for('admin.manage_config') + "#config-preventuploads") + + @view.route('/update-org-name', methods=['POST']) + @admin_required + def update_org_name() -> Union[str, werkzeug.Response]: + form = OrgNameForm() + if form.validate_on_submit(): + try: + value = request.form['organization_name'] + InstanceConfig.set_organization_name(escape(value, quote=True)) + flash(gettext("Preferences saved."), "org-name-success") + except Exception: + flash(gettext('Failed to update organization name.'), 'org-name-error') + return redirect(url_for('admin.manage_config') + "#config-orgname") + else: + for field, errors in list(form.errors.items()): + for error in errors: + flash(error, "org-name-error") + return redirect(url_for('admin.manage_config') + "#config-orgname") @view.route('/add', methods=('GET', 'POST')) @admin_required @@ -276,7 +302,7 @@ def new_password(user_id: int) -> werkzeug.Response: def ossec_test() -> werkzeug.Response: current_app.logger.error('This is a test OSSEC alert') flash(gettext('Test alert sent. Please check your email.'), - 'notification') - return redirect(url_for('admin.manage_config')) + 'testalert-notification') + return redirect(url_for('admin.manage_config') + "#config-testalert") return view diff --git a/securedrop/journalist_app/forms.py b/securedrop/journalist_app/forms.py index 1b7c67f72d..6e49319c4a 100644 --- a/securedrop/journalist_app/forms.py +++ b/securedrop/journalist_app/forms.py @@ -8,7 +8,7 @@ ValidationError) from wtforms.validators import InputRequired, Optional -from models import Journalist +from models import Journalist, InstanceConfig def otp_secret_validation(form: FlaskForm, field: Field) -> None: @@ -46,6 +46,17 @@ def name_length_validation(form: FlaskForm, field: Field) -> None: ) +def check_orgname(form: FlaskForm, field: Field) -> None: + if len(field.data) > InstanceConfig.MAX_ORG_NAME_LEN: + raise ValidationError( + ngettext( + 'Cannot be longer than {num} characters.', + 'Cannot be longer than {num} characters.', + InstanceConfig.MAX_ORG_NAME_LEN + ).format(num=InstanceConfig.MAX_ORG_NAME_LEN) + ) + + def check_invalid_usernames(form: FlaskForm, field: Field) -> None: if field.data in Journalist.INVALID_USERNAMES: raise ValidationError(gettext( @@ -83,6 +94,13 @@ class SubmissionPreferencesForm(FlaskForm): prevent_document_uploads = BooleanField('prevent_document_uploads') +class OrgNameForm(FlaskForm): + organization_name = StringField('organization_name', validators=[ + InputRequired(message=gettext('This field is required.')), + check_orgname + ]) + + class LogoForm(FlaskForm): logo = FileField(validators=[ FileRequired(message=gettext('File required.')), diff --git a/securedrop/journalist_templates/base.html b/securedrop/journalist_templates/base.html index 9b38ebaf92..18e939a1c3 100644 --- a/securedrop/journalist_templates/base.html +++ b/securedrop/journalist_templates/base.html @@ -3,7 +3,7 @@ - SecureDrop + {{ g.organization_name }} @@ -39,7 +39,7 @@
{% block header %} {% endblock %} diff --git a/securedrop/journalist_templates/config.html b/securedrop/journalist_templates/config.html index e203d4434c..efb39e295d 100644 --- a/securedrop/journalist_templates/config.html +++ b/securedrop/journalist_templates/config.html @@ -7,25 +7,30 @@

{{ gettext('Instance Configuration') }}

-

{{ gettext('Alerts') }}

+

{{ gettext('Organization Name') }}

-

{{ gettext('Send an encrypted email to verify if OSSEC alerts work correctly:') }}

- -

- - - {{ gettext('SEND TEST OSSEC ALERT') }} - -

+
+ +

+
+ {{ organization_name_form.organization_name() }} +

+ + {% set prefs_filter = ["org-name-error","org-name-success"] %} + {% include 'preferences_saved_flash.html' %} +

-

{{ gettext('Logo Image') }}

+

{{ gettext('Logo Image') }}

{{ gettext('Here you can update the image displayed on the SecureDrop web interfaces:') }}

- +

@@ -41,12 +46,13 @@
{{ gettext('UPDATE LOGO') }} - {% include 'logo_upload_flashed.html' %} + {% set prefs_filter = ["logo-success","logo-error"] %} + {% include 'preferences_saved_flash.html' %}
-

{{ gettext('Submission Preferences') }}

+

{{ gettext('Submission Preferences') }}

@@ -58,7 +64,23 @@

{{ gettext('Submission Preferences') }}

{{ gettext('UPDATE SUBMISSION PREFERENCES') }} - {% include 'submission_preferences_saved_flash.html' %} + {% set prefs_filter = ["submission-preferences-success","submission-preferences-error"] %} + {% include 'preferences_saved_flash.html' %}
+
+ +

{{ gettext('Alerts') }}

+ +

{{ gettext('Send an encrypted email to verify if OSSEC alerts work correctly:') }}

+ +

+ + + {{ gettext('SEND TEST OSSEC ALERT') }} + + {% set prefs_filter = ["testalert-success","testalert-error","testalert-notification"] %} + {% include 'preferences_saved_flash.html' %} +

+ {% endblock %} diff --git a/securedrop/journalist_templates/flashed.html b/securedrop/journalist_templates/flashed.html index 4987ef5c49..49719fb62d 100644 --- a/securedrop/journalist_templates/flashed.html +++ b/securedrop/journalist_templates/flashed.html @@ -1,7 +1,7 @@ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} - {% if category != "banner-warning" and category != "logo-success" and category != "logo-error" and category != "submission-preferences-success" %} + {% if category not in ["banner-warning","logo-success","logo-error","submission-preferences-success","submission-preferences-error","org-name-success","org-name-error", "testalert-success", "testalert-error", "testalert-notification"] %}
{% if category == "notification" %} diff --git a/securedrop/journalist_templates/logo_upload_flashed.html b/securedrop/journalist_templates/logo_upload_flashed.html deleted file mode 100644 index 00dab9eb53..0000000000 --- a/securedrop/journalist_templates/logo_upload_flashed.html +++ /dev/null @@ -1,14 +0,0 @@ -{# these are flashed messages for the logo upload file verification #} -{% with messages = get_flashed_messages(with_categories=True, category_filter=["logo-success", "logo-error"]) %} - {% for category, message in messages %} - {% set category_status = category[5:] %} -
- {% if category_status == "success" %} - - {% elif category_status == "error" %} - - {% endif %} - {{ message }} -
- {% endfor %} -{% endwith %} diff --git a/securedrop/journalist_templates/preferences_saved_flash.html b/securedrop/journalist_templates/preferences_saved_flash.html new file mode 100644 index 0000000000..5f9ffceac9 --- /dev/null +++ b/securedrop/journalist_templates/preferences_saved_flash.html @@ -0,0 +1,19 @@ +{% if prefs_filter is defined and prefs_filter|length %} + {% with messages = get_flashed_messages(with_categories=True, category_filter=prefs_filter) %} + {% for category, message in messages %} + {# Get the end of the of the category message which + contains the category status.(success/error/notification)#} + {% set category_status = category.split('-')|last %} +
+ {% if category_status == "success" %} + + {% elif category_status == "error" %} + + {% elif category_status == "notification" %} + + {% endif %} + {{ message }} +
+ {% endfor %} + {% endwith %} +{% endif %} diff --git a/securedrop/journalist_templates/submission_preferences_saved_flash.html b/securedrop/journalist_templates/submission_preferences_saved_flash.html deleted file mode 100644 index d88d393af5..0000000000 --- a/securedrop/journalist_templates/submission_preferences_saved_flash.html +++ /dev/null @@ -1,11 +0,0 @@ -{% with messages = get_flashed_messages(with_categories=True, category_filter=["submission-preferences-success"]) %} - {% for category, message in messages %} -{# Get the end of the of the category message which - contains the category status.(success/error)#} - {% set category_status = category[23:] %} -
- - {{ message }} -
- {% endfor %} -{% endwith %} diff --git a/securedrop/models.py b/securedrop/models.py index 65b5938f17..b33f6f2d42 100644 --- a/securedrop/models.py +++ b/securedrop/models.py @@ -825,11 +825,15 @@ class InstanceConfig(db.Model): interface. The current version has valid_until=None. ''' + # Limits length of org name used in SI and JI titles, image alt texts etc. + MAX_ORG_NAME_LEN = 64 + __tablename__ = 'instance_config' version = Column(Integer, primary_key=True) valid_until = Column(DateTime, default=None, unique=True) allow_document_uploads = Column(Boolean, default=True) + organization_name = Column(String(255), nullable=True, default="SecureDrop") # Columns not listed here will be included by InstanceConfig.copy() when # updating the configuration. @@ -868,6 +872,31 @@ def get_current(cls) -> "InstanceConfig": db.session.commit() return current + @classmethod + def check_name_acceptable(cls, name: str) -> None: + # Enforce a reasonable maximum length for names + if name is None or len(name) == 0: + raise InvalidNameLength(name) + if len(name) > cls.MAX_ORG_NAME_LEN: + raise InvalidNameLength(name) + + @classmethod + def set_organization_name(cls, name: str) -> None: + '''Invalidate the current configuration and append a new one with the + new organization name. + ''' + + old = cls.get_current() + old.valid_until = datetime.datetime.utcnow() + db.session.add(old) + + new = old.copy() + cls.check_name_acceptable(name) + new.organization_name = name + db.session.add(new) + + db.session.commit() + @classmethod def set_allow_document_uploads(cls, value: bool) -> None: '''Invalidate the current configuration and append a new one with the diff --git a/securedrop/source_app/__init__.py b/securedrop/source_app/__init__.py index 022b2964a2..8af2ce8987 100644 --- a/securedrop/source_app/__init__.py +++ b/securedrop/source_app/__init__.py @@ -168,6 +168,12 @@ def setup_g() -> Optional[werkzeug.Response]: del session['codename'] return redirect(url_for('main.index')) g.loc = app.storage.path(g.filesystem_id) + + if app.instance_config.organization_name: + g.organization_name = app.instance_config.organization_name + else: + g.organization_name = gettext('SecureDrop') + return None @app.errorhandler(404) diff --git a/securedrop/source_app/api.py b/securedrop/source_app/api.py index ad36685dd1..d8deefaccd 100644 --- a/securedrop/source_app/api.py +++ b/securedrop/source_app/api.py @@ -19,6 +19,7 @@ def make_blueprint(config: SDConfig) -> Blueprint: @view.route('/metadata') def metadata() -> flask.Response: meta = { + 'organization_name': current_app.instance_config.organization_name, 'allow_document_uploads': current_app.instance_config.allow_document_uploads, 'gpg_fpr': config.JOURNALIST_KEY, 'sd_version': version.__version__, diff --git a/securedrop/source_templates/base.html b/securedrop/source_templates/base.html index 68d1be8a5f..fce8367de3 100644 --- a/securedrop/source_templates/base.html +++ b/securedrop/source_templates/base.html @@ -3,7 +3,11 @@ - SecureDrop | {{ gettext('Protecting Journalists and Sources') }} + {% if g.organization_name == "SecureDrop" %} + {{ g.organization_name }} | {{ gettext('Protecting Journalists and Sources') }} + {% else %} + {{ g.organization_name }} | {{ gettext('SecureDrop') }} + {% endif %} @@ -17,7 +21,7 @@ {% block header %} diff --git a/securedrop/source_templates/index.html b/securedrop/source_templates/index.html index 12b4fad33c..e371769518 100644 --- a/securedrop/source_templates/index.html +++ b/securedrop/source_templates/index.html @@ -1,7 +1,11 @@ - SecureDrop | {{ gettext('Protecting Journalists and Sources') }} + {% if g.organization_name == "SecureDrop" %} + {{ g.organization_name }} | {{ gettext('Protecting Journalists and Sources') }} + {% else %} + {{ g.organization_name }} | {{ gettext('SecureDrop') }} + {% endif %} @@ -26,7 +30,7 @@ See _source_index.sass for a more full understanding. #}
- {{ gettext('Logo Image') }} + {{ g.organization_name }} | {{ gettext('Logo Image') }}
{% include 'locales.html' %}
diff --git a/securedrop/tests/functional/functional_test.py b/securedrop/tests/functional/functional_test.py index f936b5cd8e..f4affac3cb 100644 --- a/securedrop/tests/functional/functional_test.py +++ b/securedrop/tests/functional/functional_test.py @@ -62,6 +62,9 @@ class FunctionalTest(object): timeout = 10 poll_frequency = 0.1 + orgname_default = "SecureDrop" + orgname_new = "Walden Inquirer" + accept_languages = None default_driver_name = TORBROWSER driver = None diff --git a/securedrop/tests/functional/journalist_navigation_steps.py b/securedrop/tests/functional/journalist_navigation_steps.py index 4cf7dd4a0c..189a6ba4cb 100644 --- a/securedrop/tests/functional/journalist_navigation_steps.py +++ b/securedrop/tests/functional/journalist_navigation_steps.py @@ -302,6 +302,19 @@ def preferences_saved(): assert "Preferences saved." in flash_msg.text self.wait_for(preferences_saved, timeout=self.timeout * 6) + def _admin_sets_organization_name(self): + assert self.orgname_default == self.driver.title + self.driver.find_element_by_id('organization_name').clear() + self.safe_send_keys_by_id("organization_name", self.orgname_new) + self.safe_click_by_id("submit-update-org-name") + + def preferences_saved(): + flash_msg = self.driver.find_element_by_css_selector(".flash") + assert "Preferences saved." in flash_msg.text + + self.wait_for(preferences_saved, timeout=self.timeout * 6) + assert self.orgname_new == self.driver.title + def _add_user(self, username, first_name="", last_name="", is_admin=False, hotp=None): self.safe_send_keys_by_css_selector('input[name="username"]', username) diff --git a/securedrop/tests/functional/source_navigation_steps.py b/securedrop/tests/functional/source_navigation_steps.py index a4e0304d05..7ef7b921c8 100644 --- a/securedrop/tests/functional/source_navigation_steps.py +++ b/securedrop/tests/functional/source_navigation_steps.py @@ -22,6 +22,9 @@ def _is_on_generate_page(self): def _is_on_logout_page(self): return self.wait_for(lambda: self.driver.find_element_by_id("click-new-identity-tor")) + def _source_sees_orgname(self, name="SecureDrop"): + assert name in self.driver.title + def _source_visits_source_homepage(self): self.driver.get(self.source_location) assert self._is_on_source_homepage() diff --git a/securedrop/tests/functional/test_admin_interface.py b/securedrop/tests/functional/test_admin_interface.py index 870c02404d..c36d9fa9f9 100644 --- a/securedrop/tests/functional/test_admin_interface.py +++ b/securedrop/tests/functional/test_admin_interface.py @@ -97,3 +97,15 @@ def test_allow_file_submission(self): self._source_chooses_to_submit_documents() self._source_continues_to_submit_page() self._source_sees_document_attachment_item() + + def test_orgname_is_changed(self): + self._admin_logs_in() + self._admin_visits_admin_interface() + self._admin_visits_system_config_page() + self._admin_sets_organization_name() + + self._source_visits_source_homepage() + self._source_sees_orgname(name=self.orgname_new) + self._source_chooses_to_submit_documents() + self._source_continues_to_submit_page() + self._source_sees_orgname(name=self.orgname_new) diff --git a/securedrop/tests/migrations/migration_92fba0be98e9.py b/securedrop/tests/migrations/migration_92fba0be98e9.py new file mode 100644 index 0000000000..6a2586d96d --- /dev/null +++ b/securedrop/tests/migrations/migration_92fba0be98e9.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- + +from db import db +from journalist_app import create_app +import sqlalchemy +import pytest + +from .helpers import random_bool, random_datetime + + +class UpgradeTester: + def __init__(self, config): + self.config = config + self.app = create_app(config) + + def load_data(self): + with self.app.app_context(): + self.update_config() + + db.session.commit() + + @staticmethod + def update_config(): + params = { + "valid_until": random_datetime(nullable=True), + "allow_document_uploads": random_bool(), + } + sql = """ + INSERT INTO instance_config ( + valid_until, allow_document_uploads + ) VALUES ( + :valid_until, :allow_document_uploads + ) + """ + + db.engine.execute(sqlalchemy.text(sql), **params) + + def check_upgrade(self): + """ + Check the new `organization_name` column + + Querying `organization_name` shouldn't cause an error, but it should not yet be set. + """ + with self.app.app_context(): + configs = db.engine.execute( + sqlalchemy.text("SELECT * FROM instance_config WHERE organization_name IS NOT NULL") + ).fetchall() + assert len(configs) == 0 + + +class DowngradeTester: + def __init__(self, config): + self.config = config + self.app = create_app(config) + + def load_data(self): + pass + + def check_downgrade(self): + """ + After downgrade, using `organization_name` in a query should raise an exception + """ + with self.app.app_context(): + with pytest.raises(sqlalchemy.exc.OperationalError): + configs = db.engine.execute( + sqlalchemy.text( + "SELECT * FROM instance_config WHERE organization_name IS NOT NULL" + ) + ).fetchall() + assert len(configs) == 0 diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py index 8a97af8124..1c289331cc 100644 --- a/securedrop/tests/test_journalist.py +++ b/securedrop/tests/test_journalist.py @@ -17,6 +17,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.sql.expression import func +from html import escape as htmlescape import journalist_app as journalist_app_module from journalist_app.utils import mark_seen @@ -1495,6 +1496,119 @@ def test_no_prevent_document_uploads(journalist_app, test_admin): app.post(url_for('admin.update_submission_preferences'), follow_redirects=True) ins.assert_message_flashed('Preferences saved.', 'submission-preferences-success') + assert InstanceConfig.get_current().allow_document_uploads is True + + +def test_prevent_document_uploads_invalid(journalist_app, test_admin): + with journalist_app.test_client() as app: + _login_user(app, test_admin['username'], test_admin['password'], + test_admin['otp_secret']) + form_true = journalist_app_module.forms.SubmissionPreferencesForm( + prevent_document_uploads=True) + app.post(url_for('admin.update_submission_preferences'), + data=form_true.data, + follow_redirects=True) + assert InstanceConfig.get_current().allow_document_uploads is False + + with patch('flask_wtf.FlaskForm.validate_on_submit') as fMock: + fMock.return_value = False + form_false = journalist_app_module.forms.SubmissionPreferencesForm( + prevent_document_uploads=False) + app.post(url_for('admin.update_submission_preferences'), + data=form_false.data, + follow_redirects=True) + assert InstanceConfig.get_current().allow_document_uploads is False + + +def test_orgname_default_set(journalist_app, test_admin): + + class dummy_current(): + organization_name = None + + with patch.object(InstanceConfig, 'get_current') as iMock: + with journalist_app.test_client() as app: + iMock.return_value = dummy_current() + _login_user(app, test_admin['username'], test_admin['password'], + test_admin['otp_secret']) + assert g.organization_name == "SecureDrop" + + +def test_orgname_valid_succeeds(journalist_app, test_admin): + test_name = "Walden Inquirer" + with journalist_app.test_client() as app: + _login_user(app, test_admin['username'], test_admin['password'], + test_admin['otp_secret']) + form = journalist_app_module.forms.OrgNameForm( + organization_name=test_name) + assert InstanceConfig.get_current().organization_name == "SecureDrop" + with InstrumentedApp(journalist_app) as ins: + app.post(url_for('admin.update_org_name'), + data=form.data, + follow_redirects=True) + ins.assert_message_flashed('Preferences saved.', 'org-name-success') + assert InstanceConfig.get_current().organization_name == test_name + + +def test_orgname_null_fails(journalist_app, test_admin): + with journalist_app.test_client() as app: + _login_user(app, test_admin['username'], test_admin['password'], + test_admin['otp_secret']) + form = journalist_app_module.forms.OrgNameForm( + organization_name=None) + assert InstanceConfig.get_current().organization_name == "SecureDrop" + with InstrumentedApp(journalist_app) as ins: + app.post(url_for('admin.update_org_name'), + data=form.data, + follow_redirects=True) + ins.assert_message_flashed('This field is required.', 'org-name-error') + assert InstanceConfig.get_current().organization_name == "SecureDrop" + + +def test_orgname_oversized_fails(journalist_app, test_admin): + test_name = "1234567812345678123456781234567812345678123456781234567812345678a" + with journalist_app.test_client() as app: + _login_user(app, test_admin['username'], test_admin['password'], + test_admin['otp_secret']) + form = journalist_app_module.forms.OrgNameForm( + organization_name=test_name) + assert InstanceConfig.get_current().organization_name == "SecureDrop" + with InstrumentedApp(journalist_app) as ins: + app.post(url_for('admin.update_org_name'), + data=form.data, + follow_redirects=True) + ins.assert_message_flashed('Cannot be longer than 64 characters.', 'org-name-error') + assert InstanceConfig.get_current().organization_name == "SecureDrop" + + +def test_orgname_html_escaped(journalist_app, test_admin): + t_name = '"> ' + with journalist_app.test_client() as app: + _login_user(app, test_admin['username'], test_admin['password'], + test_admin['otp_secret']) + form = journalist_app_module.forms.OrgNameForm( + organization_name=t_name) + assert InstanceConfig.get_current().organization_name == "SecureDrop" + with InstrumentedApp(journalist_app) as ins: + app.post(url_for('admin.update_org_name'), + data=form.data, + follow_redirects=True) + ins.assert_message_flashed('Preferences saved.', 'org-name-success') + assert InstanceConfig.get_current().organization_name == htmlescape(t_name, quote=True) + + +def test_logo_default_available(journalist_app): + # if the custom image is available, this test will fail + custom_image_location = os.path.join(config.SECUREDROP_ROOT, "static/i/custom_logo.png") + if os.path.exists(custom_image_location): + os.remove(custom_image_location) + + with journalist_app.test_client() as app: + response = app.get(url_for('main.select_logo'), follow_redirects=False) + + assert response.status_code == 302 + observed_headers = response.headers + assert 'Location' in list(observed_headers.keys()) + assert url_for('static', filename='i/logo.png') in observed_headers['Location'] def test_logo_upload_with_valid_image_succeeds(journalist_app, test_admin): @@ -1520,6 +1634,13 @@ def test_logo_upload_with_valid_image_succeeds(journalist_app, test_admin): follow_redirects=True) ins.assert_message_flashed("Image updated.", "logo-success") + with journalist_app.test_client() as app: + response = app.get(url_for('main.select_logo'), follow_redirects=False) + + assert response.status_code == 302 + observed_headers = response.headers + assert 'Location' in list(observed_headers.keys()) + assert url_for('static', filename='i/custom_logo.png') in observed_headers['Location'] finally: # Restore original image to logo location for subsequent tests with io.open(logo_image_location, 'wb') as logo_file: @@ -1544,6 +1665,38 @@ def test_logo_upload_with_invalid_filetype_fails(journalist_app, test_admin): assert "You can only upload PNG image files." in text +def test_logo_upload_save_fails(journalist_app, test_admin): + # Save original logo to restore after test run + logo_image_location = os.path.join(config.SECUREDROP_ROOT, + "static/i/logo.png") + with io.open(logo_image_location, 'rb') as logo_file: + original_image = logo_file.read() + + try: + with journalist_app.test_client() as app: + _login_user(app, test_admin['username'], test_admin['password'], + test_admin['otp_secret']) + # Create 1px * 1px 'white' PNG file from its base64 string + form = journalist_app_module.forms.LogoForm( + logo=(BytesIO(base64.decodebytes + (b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQ" + b"VR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=")), 'test.png') + ) + with InstrumentedApp(journalist_app) as ins: + with patch('werkzeug.datastructures.FileStorage.save') as sMock: + sMock.side_effect = Exception + app.post(url_for('admin.manage_config'), + data=form.data, + follow_redirects=True) + + ins.assert_message_flashed("Unable to process the image file." + " Try another one.", "logo-error") + finally: + # Restore original image to logo location for subsequent tests + with io.open(logo_image_location, 'wb') as logo_file: + logo_file.write(original_image) + + def test_creation_of_ossec_test_log_event(journalist_app, test_admin, mocker): mocked_error_logger = mocker.patch('journalist.app.logger.error') with journalist_app.test_client() as app: diff --git a/securedrop/tests/test_source.py b/securedrop/tests/test_source.py index 119aea7f7a..bb4275927e 100644 --- a/securedrop/tests/test_source.py +++ b/securedrop/tests/test_source.py @@ -3,6 +3,8 @@ import re import subprocess import time +import os +import shutil from io import BytesIO, StringIO from flask import session, escape, url_for, g, request @@ -21,10 +23,42 @@ from source_app import api as source_app_api from .utils.db_helper import new_codename from .utils.instrument import InstrumentedApp +from sdconfig import config overly_long_codename = 'a' * (PassphraseGenerator.MAX_PASSPHRASE_LENGTH + 1) +def test_logo_default_available(source_app): + # if the custom image is available, this test will fail + custom_image_location = os.path.join(config.SECUREDROP_ROOT, "static/i/custom_logo.png") + if os.path.exists(custom_image_location): + os.remove(custom_image_location) + + with source_app.test_client() as app: + response = app.get(url_for('main.select_logo'), follow_redirects=False) + + assert response.status_code == 302 + observed_headers = response.headers + assert 'Location' in list(observed_headers.keys()) + assert url_for('static', filename='i/logo.png') in observed_headers['Location'] + + +def test_logo_custom_available(source_app): + # if the custom image is available, this test will fail + custom_image = os.path.join(config.SECUREDROP_ROOT, "static/i/custom_logo.png") + default_image = os.path.join(config.SECUREDROP_ROOT, "static/i/logo.png") + if os.path.exists(default_image) and not os.path.exists(custom_image): + shutil.copyfile(default_image, custom_image) + + with source_app.test_client() as app: + response = app.get(url_for('main.select_logo'), follow_redirects=False) + + assert response.status_code == 302 + observed_headers = response.headers + assert 'Location' in list(observed_headers.keys()) + assert url_for('static', filename='i/custom_logo.png') in observed_headers['Location'] + + def test_page_not_found(source_app): """Verify the page not found condition returns the intended template""" with InstrumentedApp(source_app) as ins: @@ -34,6 +68,19 @@ def test_page_not_found(source_app): ins.assert_template_used('notfound.html') +def test_orgname_default_set(source_app): + + class dummy_current(): + organization_name = None + + with patch.object(InstanceConfig, 'get_current') as iMock: + with source_app.test_client() as app: + iMock.return_value = dummy_current() + resp = app.get(url_for('main.index')) + assert resp.status_code == 200 + assert g.organization_name == "SecureDrop" + + def test_index(source_app): """Test that the landing page loads and looks how we expect""" with source_app.test_client() as app: