Repository: freedomofpress/securedrop

Issue:
Deprecate Ansible dev environment in favor of Docker dev environment
## Description

Currently we have two development environments: one provisioned with Ansible and one using Docker. Since the purpose of the development environment is to allow developers to iterate quickly on the application code, I think we should deprecate the Ansible-based environment in favor of the Docker environment once #2861 is merged. 

## User Stories

As a SecureDrop maintainer, I want to maintain a single development environment. 

As a SecureDrop developer, I want a development environment that spins up quickly with minimal dependencies so I can get to writing code as quickly as possible. 


Discussion:

Resolving patch:
diff --git a/securedrop/create-demo-user.py b/securedrop/create-demo-user.py
new file mode 100755
--- /dev/null
+++ b/securedrop/create-demo-user.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import os
+from sqlalchemy.exc import IntegrityError
+
+os.environ["SECUREDROP_ENV"] = "dev"  # noqa
+import journalist_app
+import config
+from db import db
+from models import Journalist
+
+
+def add_test_user(username, password, otp_secret, is_admin=False):
+    context = journalist_app.create_app(config).app_context()
+    context.push()
+    valid_password = "correct horse battery staple profanity oil chewy"
+
+    try:
+        user = Journalist(username=username,
+                          password=valid_password,
+                          is_admin=is_admin)
+        user.otp_secret = otp_secret
+        user.pw_salt = user._gen_salt()
+        user.pw_hash = user._scrypt_hash(password, user.pw_salt)
+        db.session.add(user)
+        db.session.commit()
+        print('Test user successfully added: '
+              'username={}, password={}, otp_secret={}'
+              ''.format(username, password, otp_secret))
+    except IntegrityError:
+        print("Test user already added")
+        db.session.rollback()
+
+    context.pop()
+
+
+if __name__ == "__main__":  # pragma: no cover
+    add_test_user("journalist",
+                  "WEjwn8ZyczDhQSK24YKM8C9a",
+                  "JHCOGO7VCER3EJ4L")


Test patch:
diff --git a/docs/development/testing_application_tests.rst b/docs/development/testing_application_tests.rst
--- a/docs/development/testing_application_tests.rst
+++ b/docs/development/testing_application_tests.rst
@@ -39,9 +39,7 @@ The tests can be run inside the development VM:
 
 .. code:: sh
 
-    vagrant ssh development
-    cd /vagrant/securedrop
-    pytest -v tests
+    make -C securedrop test
 
 Or the app-staging VM:
 
@@ -64,18 +62,15 @@ If you just want to run the functional tests, you can use:
 
 .. code:: sh
 
-    pytest -v tests/functional/
+    securedrop/bin/dev-shell bin/run-test -v tests/functional
 
 Similarly, if you want to run a single test, you can specify it through the
 file, class, and test name:
 
 .. code:: sh
 
-    pytest tests/test_journalist.py::TestJournalistApp::test_invalid_credentials
-
-Some Selenium tests are decorated to produce before and after screenshots to aid
-in debugging. This behavior is enabled with the ``SCREENSHOTS_ENABLED`` environment
-variable. Output PNG files will be placed in the ``tests/log/`` directory.
+    securedrop/bin/dev-shell bin/run-test \
+        tests/test_journalist.py::TestJournalistApp::test_invalid_credentials
 
 The `gnupg
 <https://pythonhosted.org/python-gnupg>`_ library can be quite verbose in its
@@ -84,10 +79,6 @@ be controlled via the ``GNUPG_LOG_LEVEL`` environment variable. It can have valu
 such as ``INFO`` or ``DEBUG`` if some particular test case or test run needs
 greater verbosity.
 
-.. code:: sh
-
-    SCREENSHOTS_ENABLED=1 pytest tests/functional/
-
 Page Layout Tests
 ~~~~~~~~~~~~~~~~~
 
@@ -101,7 +92,7 @@ option:
 
 .. code:: sh
 
-    pytest tests/ --page-layout
+    securedrop/bin/dev-shell bin/run-test --page-layout tests
 
 
 Updating the application tests
diff --git a/docs/development/testing_configuration_tests.rst b/docs/development/testing_configuration_tests.rst
--- a/docs/development/testing_configuration_tests.rst
+++ b/docs/development/testing_configuration_tests.rst
@@ -20,11 +20,7 @@ Running the config tests
 ------------------------
 
 In order to run the tests, first create and provision the VM you intend
-to test. For the development VM:
-
-.. code:: sh
-
-    vagrant up development
+to test.
 
 For the staging VMs:
 
@@ -39,7 +35,6 @@ machines for faster testing:
 
 .. code:: sh
 
-    ./testinfra/test.py development
     ./testinfra/test.py app-staging
     ./testinfra/test.py mon-staging
 
@@ -65,7 +60,6 @@ than the Ansible playbooks: ::
     ├── app-prod.yml
     ├── app-staging.yml
     ├── build.yml
-    ├── development.yml
     ├── mon-prod.yml
     └── mon-staging.yml
 
@@ -102,7 +96,6 @@ Vagrantfile: ::
     ├── app-code
     ├── build
     ├── common
-    ├── development
     └── mon
 
 Ideally the config tests would be broken up according to roles,
diff --git a/securedrop/bin/test b/securedrop/bin/run-test
similarity index 81%
rename from securedrop/bin/test
rename to securedrop/bin/run-test
--- a/securedrop/bin/test
+++ b/securedrop/bin/run-test
@@ -1,13 +1,20 @@
 #!/bin/bash
+# shellcheck disable=SC1090
+
 set -euo pipefail
-# shellcheck source=./dev-deps
-. "${BASH_SOURCE%/*}/dev-deps"
+
+source "${BASH_SOURCE%/*}/dev-deps"
+
+run_xvfb &
+run_redis &
+urandom
+run_sass --update
+maybe_create_config_py
 
 if [ -n "${CIRCLE_BRANCH:-}" ] ; then
     touch tests/log/firefox.log
     function finish {
         cp tests/log/firefox.log /tmp/test-results/logs/
-        cp /tmp/securedrop*log /tmp/test-results/logs/
         bash <(curl -s https://codecov.io/bash)
     }
     trap finish EXIT
diff --git a/testinfra/development/test_development_application_settings.py b/testinfra/development/test_development_application_settings.py
deleted file mode 100644
--- a/testinfra/development/test_development_application_settings.py
+++ /dev/null
@@ -1,129 +0,0 @@
-import pytest
-import os
-
-hostenv = os.environ['SECUREDROP_TESTINFRA_TARGET_HOST']
-
-sd_test_vars = pytest.securedrop_test_vars
-
-
-@pytest.mark.parametrize('package', [
-    "securedrop-app-code",
-    "apache2-mpm-worker",
-    "libapache2-mod-wsgi",
-    "libapache2-mod-xsendfile",
-])
-def test_development_lacks_deb_packages(Command, package):
-    """
-    The development machine does not use Apache, but rather the Flask runner,
-    for standing up dev-friendly servers inside the VM. Therefore the
-    app-code-related deb packages should be absent.
-    """
-    # The TestInfra `Package` module doesn't offer state=absent checks,
-    # so let's call `dpkg -l` and inspect that output.
-    c = Command("dpkg -l {}".format(package))
-    assert c.rc == 1
-    assert c.stdout == ""
-    stderr = c.stderr.rstrip()
-    assert stderr == "dpkg-query: no packages found matching {}".format(
-        package)
-
-
-def test_development_apparmor_no_complain_mode(Command, Sudo):
-    """
-    Ensure that AppArmor profiles are not set to complain mode in development.
-    The app-staging host sets profiles to complain, viz.
-
-      * usr.sbin.apache2
-      * usr.sbin.tor
-
-    but those changes should not land on the development machine.
-    """
-
-    with Sudo():
-        c = Command("aa-status")
-        if hostenv == "travis":
-            assert c.rc == 3
-            assert 'apparmor filesystem is not mounted' in c.stderr
-        else:
-            assert c.rc == 0
-            assert '0 profiles are in complain mode.' in c.stdout
-
-
-@pytest.mark.parametrize('unwanted_file', [
-    "/var/www/html",
-    "/var/www/source.wsgi",
-    "/var/www/document.wsgi",
-])
-def test_development_apache_docroot_absent(File, unwanted_file):
-    """
-    Ensure the default HTML document root is missing.
-    Development environment does not serve out of /var/www,
-    since it uses the Flask dev server, not Apache.
-    """
-    f = File(unwanted_file)
-    assert not f.exists
-
-
-@pytest.mark.parametrize('data_dir', [
-    "/var/lib/securedrop",
-    "/var/lib/securedrop/keys",
-    "/var/lib/securedrop/tmp",
-    "/var/lib/securedrop/store",
-])
-def test_development_data_directories_exist(File, data_dir):
-    """
-    Ensure that application code directories are created
-    under /vagrant for the development environment, rather than
-    /var/www as in staging and prod.
-    """
-    f = File(data_dir)
-    assert f.is_directory
-    assert f.user == sd_test_vars.securedrop_user
-    assert f.group == sd_test_vars.securedrop_user
-    assert oct(f.mode) == "0700"
-
-
-def test_development_app_directories_exist(File):
-    """
-    Ensure that application code directories are created
-    under /vagrant for the development environment, rather than
-    /var/www as in staging and prod.
-
-    Using a separate check from the data directories because /vagrant
-    will be mounted with different mode.
-    """
-    f = File(sd_test_vars.securedrop_code)
-    assert f.is_directory
-    assert f.user == sd_test_vars.securedrop_user
-    assert f.group == sd_test_vars.securedrop_user
-
-
-def test_development_clean_tmp_cron_job(Command, Sudo):
-    """
-    Ensure cron job for cleaning the temporary directory for the app code
-    exists. Also, ensure that the older format for the cron job is absent,
-    since we updated manage.py subcommands to use hyphens instead of
-    underscores (e.g. `clean_tmp` -> `clean-tmp`).
-    """
-
-    with Sudo():
-        c = Command.check_output('crontab -l')
-    assert "@daily {}/manage.py clean-tmp".format(
-        sd_test_vars.securedrop_code) in c
-    assert "@daily {}/manage.py clean_tmp".format(
-        sd_test_vars.securedrop_code) not in c
-    assert "clean_tmp".format(sd_test_vars.securedrop_code) not in c
-    # Make sure that the only cron lines are a comment and the actual job.
-    # We don't want any duplicates.
-    assert len(c.split("\n")) == 2
-
-
-def test_development_default_logo_exists(File):
-    """
-    Checks for default SecureDrop logo file.
-    """
-
-    f = File("{}/static/i/logo.png".format(sd_test_vars.securedrop_code))
-    assert f.is_file
-    assert f.user == sd_test_vars.securedrop_user
-    assert f.group == sd_test_vars.securedrop_user
diff --git a/testinfra/development/test_development_environment.py b/testinfra/development/test_development_environment.py
deleted file mode 100644
--- a/testinfra/development/test_development_environment.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import pytest
-import getpass
-
-
-def test_development_app_dependencies(Package):
-    """
-    Ensure development apt dependencies are installed.
-    """
-    development_apt_dependencies = [
-      'libssl-dev',
-      'ntp',
-      'python-dev',
-      'python-pip',
-    ]
-    for dependency in development_apt_dependencies:
-        p = Package(dependency)
-        assert p.is_installed
-
-
-@pytest.mark.parametrize('pip_package,version', [
-    ('Flask-Testing', '0.7.1'),
-    ('Flask', '0.12.2'),
-    ('Jinja2', '2.10'),
-    ('MarkupSafe', '1.0'),
-    ('Werkzeug', '0.12.2'),
-    ('beautifulsoup4', '4.6.0'),
-    ('click', '6.7'),
-    ('coverage', '4.4.2'),
-    ('first', '2.0.1'),
-    ('funcsigs', '1.0.2'),
-    ('itsdangerous', '0.24'),
-    ('mock', '2.0.0'),
-    ('pbr', '3.1.1'),
-    ('pip-tools', '1.11.0'),
-    ('py', '1.5.2'),
-    ('pytest-cov', '2.5.1'),
-    ('pytest', '3.3.2'),
-    ('selenium', '2.53.6'),
-    ('six', '1.11.0'),
-])
-def test_development_pip_dependencies(Command, Sudo, pip_package, version):
-    """
-    Declare SecureDrop app pip requirements. On the development VM,
-    the pip dependencies should be installed directly via pip, rather
-    than relying on the deb packages with pip-wheel inclusions.
-    Versions here are intentionally hardcoded to track changes.
-    """
-    # Using elevated privileges to list the Python packages, since
-    # the playbooks use sudo to install the pip packages system-wide.
-    # In Travis, lack of sudo here hides a number of dependencies.
-    with Sudo():
-        c = Command('pip freeze')
-        assert "{}=={}".format(pip_package, version) in c.stdout.rstrip()
-
-
-@pytest.mark.skipif(getpass.getuser() != 'vagrant',
-                    reason="vagrant bashrc checks dont make sense in CI")
-def test_development_securedrop_env_var(File):
-    """
-    Ensure that the SECUREDROP_ENV var is set to "dev".
-
-
-    TODO: this isn't really checking that the env var is set,
-    just that it's declared in the bashrc. spec_helper ignores
-    env vars via ssh by default, so start there.
-    """
-
-    f = File('/home/vagrant/.bashrc')
-    assert f.is_file
-    assert f.user == 'vagrant'
-    assert f.contains('^export SECUREDROP_ENV=dev$')
diff --git a/testinfra/development/test_development_networking.py b/testinfra/development/test_development_networking.py
deleted file mode 100644
--- a/testinfra/development/test_development_networking.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import pytest
-import os
-
-hostenv = os.environ['SECUREDROP_TESTINFRA_TARGET_HOST']
-
-
-@pytest.mark.skipif(hostenv == 'travis',
-                    reason="Custom networking in Travis")
-def test_development_iptables_rules(Command, Sudo):
-    """
-    Declare desired iptables rules
-    The 'development' machine doesn't have any custom
-    iptables rules, so just check for the default chains.
-    """
-    desired_iptables_rules = [
-      '-P INPUT ACCEPT',
-      '-P FORWARD ACCEPT',
-      '-P OUTPUT ACCEPT',
-    ]
-    with Sudo():
-        c = Command.check_output('iptables -S')
-    for rule in desired_iptables_rules:
-        assert rule in c
-
-    # If any iptables rules are ever added, this test will
-    # fail, so tests can be written for the new rules.
-    # Counting newlines in the output simply to avoid calling
-    # `iptables -S` again and piping to `wc -l`.
-    assert c.count("\n") == len(desired_iptables_rules) - 1
-
-
-def test_development_ssh_listening(Socket):
-    """
-    Check for ssh listening on all interfaces. In prod environment,
-    SSH will be listening only on localhost, i.e. SSH over ATHS.
-    """
-    s = Socket("tcp://0.0.0.0:22")
-    assert s.is_listening
-
-
-def test_development_redis_worker(Socket):
-    """
-    Ensure that Redis worker is listening on localhost.
-    This worker is used to handle incoming submissions.
-    """
-
-    s = Socket("tcp://127.0.0.1:6379")
-    assert s.is_listening
-
-# The Flask runners for the source and journalist interfaces
-# aren't configured to run by default, e.g. on boot. Nor
-# do the app tests cause them to be run. So, we shouldn't
-# really expected them to be running.
-# check for source interface flask port listening
-# describe port(8080) do
-#  it { should be_listening.on('0.0.0.0').with('tcp') }
-# end
-#
-# check for journalist interface flask port listening
-# describe port(8081) do
-#  it { should be_listening.on('0.0.0.0').with('tcp') }
-# end
diff --git a/testinfra/test.py b/testinfra/test.py
--- a/testinfra/test.py
+++ b/testinfra/test.py
@@ -9,11 +9,7 @@
 import sys
 import tempfile
 
-# By default let's assume we're testing against the development VM.
-try:
-    target_host = sys.argv[1]
-except IndexError:
-    target_host = "development"
+target_host = sys.argv[1]
 
 # Set env var so that `testinfra/conftest.py` can read in a YAML vars file
 # specific to the host being tested.
@@ -24,9 +20,7 @@ def get_target_roles(target_host):
     """
     Assemble list of role tests to run. Hard-coded per host.
     """
-    target_roles = {"development": ['testinfra/app-code',
-                                    'testinfra/development'],
-                    "app-staging": ['testinfra/app',
+    target_roles = {"app-staging": ['testinfra/app',
                                     'testinfra/app-code',
                                     'testinfra/common',
                                     'testinfra/development/test_xvfb.py'],
@@ -84,13 +78,8 @@ def run_testinfra(target_host, verbose=True):
 """.lstrip().rstrip()
 
     elif os.environ.get("FPF_CI", 'false') == 'true':
-        if os.environ.get("CI_SD_ENV", "development") == "development":
-            os.environ['SECUREDROP_TESTINFRA_TARGET_HOST'] = "travis"
-            ssh_config_path = ""
-            testinfra_command_template = "testinfra -vv {target_roles}"
-        else:
-            ssh_config_path = os.environ["CI_SSH_CONFIG"]
-            testinfra_command_template = """
+        ssh_config_path = os.environ["CI_SSH_CONFIG"]
+        testinfra_command_template = """
 testinfra \
     -vv \
     -n 8 \