Repository: freedomofpress/securedrop

Issue:
Test database teardown is not done correctly
For our `TestCase`s we generally call the following functions from `tests.utils.env` in the `setUp` and `tearDown` methods:

```py
def setup():
    """Set up the file system, GPG, and database."""
    create_directories()
    init_gpg()
    init_db()
    # Do tests that should always run on app startup
    crypto_util.do_runtime_tests()
    # Start the Python-RQ worker if it's not already running
    if not os.path.exists(TEST_WORKER_PIDFILE):
        subprocess.Popen(["rqworker",
                          "-P", config.SECUREDROP_ROOT,
                          "--pid", TEST_WORKER_PIDFILE])


def teardown():
    shutil.rmtree(config.SECUREDROP_DATA_ROOT)
```

As you can see, we basically just blow away the test data root. Surprisingly, this has been sufficient to get all our tests passing. Working on my `encrypted-at-rest-tagging` branch I discovered that this approach is actually pretty problematic. For example, in the code below the second test will fail with an `ObjectDeletedError` when `self.admin.db_key_salt` is accessed unless you comment out the first. This is because after the database has been deleted and re-created once, we're still using the same session, engine, and connection, which seems to be the problem. I tried playing around with `sqlalchemy.inspect` to get a better idea of the problem and also refreshing and expiring the session, but don't have any particular successful results to share. I think more important than getting a strong handle of exactly what the problem is considering the complexity of the SQLAlchemy package, that we just follow best practices outlined in their documentation such as http://docs.sqlalchemy.org/en/latest/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites. 

```py
class TestColumnEncryption(unittest.TestCase):
    def setUp(self):
        env.setup()
        self.db_key = crypto_util.gen_db_key()
        self.admin, self.admin_pw = \
                db_helper.init_journalist(is_admin=True, db_key=self.db_key)
        self.source, _ = db_helper.init_source()

    def tearDown(self):
        env.teardown()

    def test_decrypt_db_key(self):
        self.assertEqual(self.admin.decrypt_db_key(self.admin_pw), self.db_key)

    def test_decrypt_db_key_after_password_change(self):
        old_salt = self.admin.db_key_salt
        old_encrypted_db_key = self.admin.encrypted_db_key
        new_pw = crypto_util.genrandomid()
        self.admin.set_password(new_pw, db_key=self.db_key)
        new_salt = self.admin.db_key_salt
        new_encrypted_db_key = self.admin.encrypted_db_key

        self.assertNotEqual(self.admin.decrypt_db_key(self.admin_pw), self.db_key)
        self.assertEqual(self.admin.decrypt_db_key(new_pw), self.db_key)
        self.assertNotEqual(old_salt, new_salt)
        self.assertNotEqual(old_encrypted_db_key, new_encrypted_db_key)
```


Discussion:

Resolving patch:
diff --git a/securedrop/crypto_util.py b/securedrop/crypto_util.py
--- a/securedrop/crypto_util.py
+++ b/securedrop/crypto_util.py
@@ -1,7 +1,9 @@
+#!/usr/bin/env python
 # -*- coding: utf-8 -*-
+
+from base64 import b32encode
 import os
 import subprocess
-from base64 import b32encode
 
 from Crypto.Random import random
 import gnupg
@@ -70,7 +72,7 @@ def clean(s, also=''):
     >>> clean("Hello, world!")
     Traceback (most recent call last):
       ...
-    CryptoException: invalid input
+    CryptoException: invalid input: Hello, world!
     >>> clean("Helloworld")
     'Helloworld'
     """
@@ -94,20 +96,34 @@ def display_id():
 
 
 def hash_codename(codename, salt=SCRYPT_ID_PEPPER):
-    """
-    >>> hash_codename('Hello, world!')
-    'EQZGCJBRGISGOTC2NZVWG6LILJBHEV3CINNEWSCLLFTUWZLFHBTS6WLCHFHTOLRSGQXUQLRQHFMXKOKKOQ4WQ6SXGZXDAS3Z'
+    """Salts and hashes a codename using scrypt.
+
+    :param str codename: A source's codename.
+    :param str salt: The salt to mix with the codename when hashing.
+    :returns: A base32 encoded string; the salted codename hash.
     """
     return b32encode(scrypt.hash(clean(codename), salt, **SCRYPT_PARAMS))
 
 
 def genkeypair(name, secret):
-    """
+    """Generate a GPG key through batch file key generation. A source's
+    codename is salted with SCRYPT_GPG_PEPPER and hashed with scrypt to
+    provide the passphrase used to encrypt their private key. Their name
+    should be their filesystem id.
+
     >>> if not gpg.list_keys(hash_codename('randomid')):
     ...     genkeypair(hash_codename('randomid'), 'randomid').type
     ... else:
     ...     u'P'
     u'P'
+
+    :param str name: The source's filesystem id (their codename, salted
+                     with SCRYPT_ID_PEPPER, and hashed with scrypt).
+    :param str secret: The source's codename.
+    :returns: a :class:`GenKey <gnupg._parser.GenKey>` object, on which
+              the ``__str__()`` method may be called to return the
+              generated key's fingeprint.
+
     """
     name = clean(name)
     secret = hash_codename(secret, salt=SCRYPT_GPG_PEPPER)
@@ -168,8 +184,8 @@ def encrypt(plaintext, fingerprints, output=None):
 def decrypt(secret, ciphertext):
     """
     >>> key = genkeypair('randomid', 'randomid')
-    >>> decrypt('randomid', 'randomid',
-    ...   encrypt('randomid', 'Goodbye, cruel world!')
+    >>> decrypt('randomid',
+    ...   encrypt('Goodbye, cruel world!', str(key))
     ... )
     'Goodbye, cruel world!'
     """
diff --git a/securedrop/manage.py b/securedrop/manage.py
--- a/securedrop/manage.py
+++ b/securedrop/manage.py
@@ -1,210 +1,181 @@
 #!/usr/bin/env python
+# -*- coding: utf-8 -*-
 
-import sys
+import argparse
+from getpass import getpass
 import os
 import shutil
-import subprocess
 import signal
-import qrcode
+import sys
+import traceback
+
 import psutil
+import qrcode
 from sqlalchemy.orm.exc import NoResultFound
 
-from getpass import getpass
-from argparse import ArgumentParser
-from db import db_session, Journalist
-from management import run
-
-# We need to import config in each function because we're running the tests
-# directly, so it's important to set the environment correctly, depending on
-# development or testing, before importing config.
 os.environ['SECUREDROP_ENV'] = 'dev'
-
-# TODO: the PID file for the redis worker is hard-coded below.
-# Ideally this constant would be provided by a test harness.
-# It has been intentionally omitted from `config.py.example`
-# in order to isolate the test vars from prod vars.
-# When refactoring the test suite, the TEST_WORKER_PIDFILE
-# TEST_WORKER_PIDFILE is also hard-coded in `tests/common.py`.
-TEST_WORKER_PIDFILE = "/tmp/securedrop_test_worker.pid"
-
-
-def get_pid_from_pidfile(pid_file_name):
-    with open(pid_file_name) as fp:
-        return int(fp.read())
+import config
+from db import db_session, init_db, Journalist
+from management import run
 
 
-def _start_test_rqworker(config):  # pragma: no cover
-    # needed to determine the directory to run the worker in
-    worker_running = False
+def reset(): # pragma: no cover
+    """Clears the SecureDrop development applications' state, restoring them to
+    the way they were immediately after running `setup_dev.sh`. This command:
+    1. Erases the development sqlite database file.
+    2. Regenerates the database.
+    3. Erases stored submissions and replies from the store dir.
+    """
+    # Erase the development db file
+    assert hasattr(config, 'DATABASE_FILE'), ("TODO: ./manage.py doesn't know "
+                                              'how to clear the db if the '
+                                              'backend is not sqlite')
     try:
-        if psutil.pid_exists(get_pid_from_pidfile(TEST_WORKER_PIDFILE)):
-            worker_running = True
-    except IOError:
+        os.remove(config.DATABASE_FILE)
+    except OSError as exc:
         pass
 
-    if not worker_running:
-        tmp_logfile = open("/tmp/test_rqworker.log", "w")
-        subprocess.Popen(
-            [
-                "rqworker", "test",
-                "-P", config.SECUREDROP_ROOT,
-                "--pid", TEST_WORKER_PIDFILE,
-            ],
-            stdout=tmp_logfile,
-            stderr=subprocess.STDOUT)
-
-
-def _stop_test_rqworker():  # pragma: no cover
-    os.kill(get_pid_from_pidfile(TEST_WORKER_PIDFILE), signal.SIGTERM)
-
-
-def test():  # pragma: no cover
-    """
-    Runs the test suite
-    """
-    os.environ['SECUREDROP_ENV'] = 'test'
-    import config
-    _start_test_rqworker(config)
-    test_cmds = [["py.test", "--cov"], "./test.sh"]
-    test_rc = int(any([subprocess.call(cmd) for cmd in test_cmds]))
-    _stop_test_rqworker()
-    sys.exit(test_rc)
+    # Regenerate the database
+    init_db()
 
+    # Clear submission/reply storage
+    try:
+        os.stat(config.STORE_DIR)
+    except OSError as exc:
+        pass
+    else:
+        for source_dir in os.listdir(config.STORE_DIR):
+            try:
+                # Each entry in STORE_DIR is a directory corresponding to a source
+                shutil.rmtree(os.path.join(config.STORE_DIR, source_dir))
+            except OSError as exc:
+                pass
+    return 0
 
-def test_unit():  # pragma: no cover
-    """
-    Runs the unit tests.
-    """
-    os.environ['SECUREDROP_ENV'] = 'test'
-    import config
-    _start_test_rqworker(config)
-    test_rc = int(subprocess.call(["py.test", "--cov"]))
-    _stop_test_rqworker()
-    sys.exit(test_rc)
 
+def add_admin(): # pragma: no cover
+    return _add_user(is_admin=True)
 
-def reset():
-    """
-    Clears the SecureDrop development application's state, restoring it to the
-    way it was immediately after running `setup_dev.sh`. This command:
-    1. Erases the development sqlite database file
-    2. Regenerates the database
-    3. Erases stored submissions and replies from the store dir
-    """
-    import config
-    import db
 
-    # Erase the development db file
-    assert hasattr(
-        config, 'DATABASE_FILE'), "TODO: ./manage.py doesn't know how to clear the db if the backend is not sqlite"
-    os.remove(config.DATABASE_FILE)
+def add_journalist(): # pragma: no cover
+    return _add_user()
 
-    # Regenerate the database
-    db.init_db()
 
-    # Clear submission/reply storage
-    for source_dir in os.listdir(config.STORE_DIR):
-        # Each entry in STORE_DIR is a directory corresponding to a source
-        shutil.rmtree(os.path.join(config.STORE_DIR, source_dir))
-
-
-def add_admin():
+def _add_user(is_admin=False): # pragma: no cover
     while True:
-        username = raw_input("Username: ")
+        username = raw_input('Username: ')
         if Journalist.query.filter_by(username=username).first():
-            print "Sorry, that username is already in use."
+            print('Sorry, that username is already in use.')
         else:
             break
 
     while True:
-        password = getpass("Password: ")
-        password_again = getpass("Confirm Password: ")
+        password = getpass('Password: ')
+        password_again = getpass('Confirm Password: ')
 
         if len(password) > Journalist.MAX_PASSWORD_LEN:
-            print ("Your password is too long (maximum length {} characters). "
-                   "Please pick a shorter password.".format(
-                   Journalist.MAX_PASSWORD_LEN))
+            print('Your password is too long (maximum length {} characters). '
+                  'Please pick a shorter '
+                  'password.'.format(Journalist.MAX_PASSWORD_LEN))
             continue
 
         if password == password_again:
             break
-        print "Passwords didn't match!"
+        print("Passwords didn't match!")
 
-    hotp_input = raw_input("Is this admin using a YubiKey [HOTP]? (y/N): ")
+    hotp_input = raw_input('Will this user be using a YubiKey [HOTP]? (y/N): ')
     otp_secret = None
-    if hotp_input.lower() == "y" or hotp_input.lower() == "yes":
+    if hotp_input.lower() in ('y', 'yes'):
         while True:
             otp_secret = raw_input(
-                "Please configure your YubiKey and enter the secret: ")
+                'Please configure your YubiKey and enter the secret: ')
             if otp_secret:
                 break
 
     try:
-        admin = Journalist(username=username,
-                           password=password,
-                           is_admin=True,
-                           otp_secret=otp_secret)
-        db_session.add(admin)
+        user = Journalist(username=username,
+                          password=password,
+                          is_admin=is_admin,
+                          otp_secret=otp_secret)
+        db_session.add(user)
         db_session.commit()
-    except Exception as e:
-        if "username is not unique" in str(e):
-            print "ERROR: That username is already taken!"
+    except Exception as exc:
+        if 'username is not unique' in exc:
+            print('ERROR: That username is already taken!')
         else:
-            print "ERROR: An unexpected error occurred, traceback: \n{}".format(e)
+            exc_type, exc_value, exc_traceback = sys.exc_info()
+            print(repr(traceback.format_exception(exc_type, exc_value,
+                                                  exc_traceback)))
+        return 1
     else:
-        print "Admin '{}' successfully added".format(username)
+        print('User "{}" successfully added'.format(username))
         if not otp_secret:
-            # Print the QR code for Google Authenticator
-            print
-            print "Scan the QR code below with Google Authenticator:"
-            print
-            uri = admin.totp.provisioning_uri(
-                username,
-                issuer_name="SecureDrop")
+            # Print the QR code for FreeOTP/ Google Authenticator
+            print('\nScan the QR code below with FreeOTP or Google '
+                  'Authenticator:\n')
+            uri = user.totp.provisioning_uri(username,
+                                             issuer_name='SecureDrop')
             qr = qrcode.QRCode()
             qr.add_data(uri)
             qr.print_ascii(tty=sys.stdout.isatty())
-            print
-            print "If the barcode does not render correctly, try changing your terminal's font, (Monospace for Linux, Menlo for OS X)."
-            print "If you are using iTerm on Mac OS X, you will need to change the \"Non-ASCII Font\", which is your profile's Text settings."
-            print
-            print "Can't scan the barcode? Enter following shared secret manually:"
-            print admin.formatted_otp_secret
-            print
-
-
-def delete_user():
-    """
-    Deletes a journalist or administrator from the application.
-    """
-
-    while True:
-        username = raw_input("Username to delete: ")
+            print('\nIf the barcode does not render correctly, try changing '
+                  "your terminal's font (Monospace for Linux, Menlo for OS "
+                  'X). If you are using iTerm on Mac OS X, you will need to '
+                  'change the "Non-ASCII Font", which is your profile\'s Text '
+                  "settings.\n\nCan't scan the barcode? Enter following "
+                  'shared secret '
+                  'manually:\n{}\n'.format(user.formatted_otp_secret))
+        return 0
+
+
+def delete_user(): # pragma: no cover
+    """Deletes a journalist or administrator from the application."""
+    # Select user to delete
+    username = raw_input('Username to delete: ')
+    try:
+        selected_user = Journalist.query.filter_by(username=username).one()
+    except NoResultFound:
+        print('ERROR: That user was not found!')
+        return 0
+
+    # Confirm deletion if user is found
+    confirmation = raw_input('Are you sure you want to delete user '
+                             '{} (y/n)?'.format(selected_user))
+    if confirmation.lower() != 'y':
+        print('Confirmation not received: user "{}" was NOT '
+              'deleted'.format(username))
+        return 0
+
+    # Try to delete user from the database
+    try:
+        db_session.delete(selected_user)
+        db_session.commit()
+    except:
+        # If the user was deleted between the user selection and confirmation,
+        # (e.g., through the web app), we don't report any errors. If the user
+        # is still there, but there was a error deleting them from the
+        # database, we do report it.
         try:
             selected_user = Journalist.query.filter_by(username=username).one()
-            break
         except NoResultFound:
-            print "ERROR: That user was not found!"
-
-    db_session.delete(selected_user)
-    db_session.commit()
-    print "User '{}' successfully deleted".format(username)
-
-
-def clean_tmp():
-    """Cleanup the SecureDrop temp directory. This is intended to be run as an
-    automated cron job. We skip files that are currently in use to avoid
-    deleting files that are currently being downloaded."""
+            pass
+        else: 
+            raise
+        
+    print('User "{}" successfully deleted'.format(username))
+    return 0
+
+
+def clean_tmp(): # pragma: no cover
+    """Cleanup the SecureDrop temp directory. This is intended to be run
+    as an automated cron job. We skip files that are currently in use to
+    avoid deleting files that are currently being downloaded."""
     # Inspired by http://stackoverflow.com/a/11115521/1093000
-    import config
-
     def file_in_use(fname):
-        in_use = False
-
         for proc in psutil.process_iter():
             try:
                 open_files = proc.open_files()
-                in_use = in_use or any([open_file.path == fname
+                in_use = False or any([open_file.path == fname
                                         for open_file in open_files])
                 # Early return for perf
                 if in_use:
@@ -221,46 +192,59 @@ def listdir_fullpath(d):
         # Thanks to http://stackoverflow.com/a/120948/1093000
         return [os.path.join(d, f) for f in os.listdir(d)]
 
-    for path in listdir_fullpath(config.TEMP_DIR):
-        if not file_in_use(path):
-            os.remove(path)
-
-
-def get_args():  # pragma: no cover
-    parser = ArgumentParser(prog=__file__,
-                            description='A tool to help admins manage and devs hack')
-
-    subparsers = parser.add_subparsers()
-
-    run_subparser = subparsers.add_parser('run', help='Run the dev webserver (source & journalist)')
-    run_subparser.set_defaults(func=run)
-
-    unit_test_subparser = subparsers.add_parser('unit-test', help='Run the unit tests')
-    unit_test_subparser.set_defaults(func=test_unit)
-
-    test_subparser = subparsers.add_parser('test', help='Run the full test suite')
-    test_subparser.set_defaults(func=test)
-
-    reset_subparser = subparsers.add_parser('reset', help="DANGER!!! Clears the SecureDrop application's state")
-    reset_subparser.set_defaults(func=reset)
-
-    add_admin_subparser = subparsers.add_parser('add-admin', help='Add a new admin to the application')
-    add_admin_subparser.set_defaults(func=add_admin)
-
-    delete_user_subparser = subparsers.add_parser('delete-user', help='Delete a user from the application')
-    delete_user_subparser.set_defaults(func=delete_user)
-
-    clean_tmp_subparser = subparsers.add_parser('clean-tmp', help='Cleanup the SecureDrop temp directory')
-    clean_tmp_subparser.set_defaults(func=clean_tmp)
+    try:
+        os.stat(config.TEMP_DIR)
+    except OSError as exc:
+        pass
+    else:
+        for path in listdir_fullpath(config.TEMP_DIR):
+            if not file_in_use(path):
+                os.remove(path)
+
+    return 0
+
+
+def get_args():
+    parser = argparse.ArgumentParser(prog=__file__, description='Management '
+                                     'and testing utility for SecureDrop.')
+    subps = parser.add_subparsers()
+    # Run WSGI app
+    run_subp = subps.add_parser('run', help='Run the Werkzeug source & '
+                                'journalist WSGI apps. WARNING!!! For '
+                                'development only, not to be used in '
+                                'production.')
+    run_subp.set_defaults(func=run)
+    # Add/remove journalists + admins
+    admin_subp = subps.add_parser('add-admin', help='Add an admin to the '
+                                  'application.')
+    admin_subp.set_defaults(func=add_admin)
+    journalist_subp = subps.add_parser('add-journalist', help='Add a '
+                                       'journalist to the application.')
+    journalist_subp.set_defaults(func=add_journalist)
+    delete_user_subp = subps.add_parser('delete-user', help='Delete a user '
+                                        'from the application.')
+    delete_user_subp.set_defaults(func=delete_user)
+
+    # Reset application state
+    reset_subp = subps.add_parser('reset', help='DANGER!!! Clears the '
+                                  "SecureDrop application's state.")
+    reset_subp.set_defaults(func=reset)
+    # Cleanup the SD temp dir
+    clean_tmp_subp = subps.add_parser('clean-tmp', help='Cleanup the '
+                                      'SecureDrop temp directory.')
+    clean_tmp_subp.set_defaults(func=clean_tmp)
 
     return parser
 
 
-if __name__ == "__main__":  # pragma: no cover
+def _run_from_commandline(): # pragma: no cover
     try:
         args = get_args().parse_args()
-        # calling like this works because all functions take zero arguments
-        args.func()
+        rc = args.func()
+        sys.exit(rc)
     except KeyboardInterrupt:
-        print  # So our prompt appears on a nice new line
-        exit(1)
+        sys.exit(signal.SIGINT)
+
+
+if __name__ == '__main__': # pragma: no cover
+    _run_from_commandline()
diff --git a/securedrop/management/run.py b/securedrop/management/run.py
--- a/securedrop/management/run.py
+++ b/securedrop/management/run.py
@@ -153,6 +153,19 @@ def run():  # pragma: no cover
     * https://stackoverflow.com/questions/22565606/python-asynhronously-print-stdout-from-multiple-subprocesses
 
     """
+    print \
+"""
+ ____                                        ____                           
+/\\  _`\\                                     /\\  _`\\                         
+\\ \\,\\L\\_\\     __    ___   __  __  _ __    __\\ \\ \\/\\ \\  _ __   ___   _____   
+ \\/_\\__ \\   /'__`\\ /'___\\/\\ \\/\\ \\/\\`'__\\/'__`\\ \\ \\ \\ \\/\\`'__\\/ __`\\/\\ '__`\\ 
+   /\\ \\L\\ \\/\\  __//\\ \\__/\\ \\ \\_\\ \\ \\ \\//\\  __/\\ \\ \\_\\ \\ \\ \\//\\ \\L\\ \\ \\ \\L\\ \\
+   \\ `\\____\\ \\____\\ \\____\\\\ \\____/\\ \\_\\\\ \\____\\\\ \\____/\\ \\_\\\\ \\____/\\ \\ ,__/
+    \\/_____/\\/____/\\/____/ \\/___/  \\/_/ \\/____/ \\/___/  \\/_/ \\/___/  \\ \\ \\/ 
+                                                                      \\ \\_\\ 
+                                                                       \\/_/ 
+"""
+
     procs = [
         lambda: DevServerProcess('Source Interface',
                                  ['python', 'source.py'],


Test patch:
diff --git a/securedrop/test.sh b/securedrop/test.sh
deleted file mode 100755
--- a/securedrop/test.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/bin/bash
-
-if [ $(which vagrant) ] ; then
-    echo ""
-    echo "*** You probably want to run tests from vagrant. Run 'vagrant ssh', then 'cd /vagrant/securedrop' and re-run this script***"
-    echo ""
-fi
-
-export PYTHONPATH=./tests
-export SECUREDROP_ENV=test
-
-# -f makes unittest fail fast, so we can use && to avoid burying test failures
-python -m unittest -fv tests.functional.submit_and_retrieve_message && \
-python -m unittest -fv tests.functional.submit_and_retrieve_file && \
-python -m unittest -fv tests.functional.admin_interface
-
diff --git a/securedrop/tests/functional/__init__.py b/securedrop/tests/functional/__init__.py
deleted file mode 100644
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
deleted file mode 100644
--- a/securedrop/tests/test_journalist.py
+++ /dev/null
@@ -1,123 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-import os
-from mock import patch, ANY, MagicMock
-import unittest
-
-from db import db_session, InvalidPasswordLength, Journalist
-import journalist
-import utils
-
-class TestJournalistApp(unittest.TestCase):
-
-    def setUp(self):
-        journalist.logged_in = MagicMock()
-        journalist.request = MagicMock()
-        journalist.url_for = MagicMock()
-        journalist.redirect = MagicMock()
-        journalist.abort = MagicMock()
-        journalist.db_session = MagicMock()
-        journalist.get_docs = MagicMock()
-        journalist.get_or_else = MagicMock()
-
-    def _set_up_request(self, cols_selected, action):
-        journalist.request.form.__contains__.return_value = True
-        journalist.request.form.getlist = MagicMock(return_value=cols_selected)
-        journalist.request.form.__getitem__.return_value = action
-
-    @patch("journalist.col_delete")
-    def test_col_process_delegates_to_col_delete(self, col_delete):
-        cols_selected = ['source_id']
-        self._set_up_request(cols_selected, 'delete')
-
-        journalist.col_process()
-
-        col_delete.assert_called_with(cols_selected)
-
-    @patch("journalist.col_star")
-    def test_col_process_delegates_to_col_star(self, col_star):
-        cols_selected = ['source_id']
-        self._set_up_request(cols_selected, 'star')
-
-        journalist.col_process()
-
-        col_star.assert_called_with(cols_selected)
-
-    @patch("journalist.col_un_star")
-    def test_col_process_delegates_to_col_un_star(self, col_un_star):
-        cols_selected = ['source_id']
-        self._set_up_request(cols_selected, 'un-star')
-
-        journalist.col_process()
-
-        col_un_star.assert_called_with(cols_selected)
-
-    @patch("journalist.abort")
-    def test_col_process_returns_404_with_bad_action(self, abort):
-        cols_selected = ['source_id']
-        self._set_up_request(cols_selected, 'something-random')
-
-        journalist.col_process()
-
-        abort.assert_called_with(ANY)
-
-    @patch("journalist.make_star_true")
-    @patch("journalist.db_session")
-    def test_col_star_call_db_(self, db_session, make_star_true):
-        journalist.col_star(['sid'])
-
-        make_star_true.assert_called_with('sid')
-
-    @patch("journalist.db_session")
-    def test_col_un_star_call_db(self, db_session):
-        journalist.col_un_star([])
-
-        db_session.commit.assert_called_with()
-
-
-    @classmethod
-    def tearDownClass(cls):
-        # Reset the module variables that were changed to mocks so we don't
-        # break other tests
-        reload(journalist)
-
-
-class TestJournalistLogin(unittest.TestCase):
-
-    def setUp(self):
-        utils.env.setup()
-
-        # Patch the two-factor verification so it always succeeds
-        utils.db_helper.mock_verify_token(self)
-
-        self.user, self.user_pw = utils.db_helper.init_journalist()
-
-    def tearDown(self):
-        utils.env.teardown()
-        # TODO: figure out why this is necessary here, but unnecessary in all
-        # of the tests in `tests/test_unit_*.py`. Without this, the session
-        # continues to return values even if the underlying database is deleted
-        # (as in `shared_teardown`).
-        db_session.remove()
-
-    @patch('db.Journalist._scrypt_hash')
-    @patch('db.Journalist.valid_password', return_value=True)
-    def test_valid_login_calls_scrypt(self, mock_scrypt_hash, mock_valid_password):
-        Journalist.login(self.user.username, self.user_pw, 'mocked')
-        self.assertTrue(mock_scrypt_hash.called,
-                "Failed to call _scrypt_hash for password w/ valid length")
-
-    @patch('db.Journalist._scrypt_hash')
-    def test_login_with_invalid_password_doesnt_call_scrypt(self, mock_scrypt_hash):
-        invalid_pw = 'a'*(Journalist.MAX_PASSWORD_LEN + 1)
-        with self.assertRaises(InvalidPasswordLength):
-            Journalist.login(self.user.username, invalid_pw, 'mocked')
-        self.assertFalse(mock_scrypt_hash.called,
-                "Called _scrypt_hash for password w/ invalid length")
-
-    @classmethod
-    def tearDownClass(cls):
-        # Reset the module variables that were changed to mocks so we don't
-        # break other tests
-        reload(journalist)
diff --git a/securedrop/tests/test_single_star.py b/securedrop/tests/test_single_star.py
deleted file mode 100644
--- a/securedrop/tests/test_single_star.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import unittest
-import journalist
-from mock import patch, ANY, MagicMock
-
-
-class TestJournalist(unittest.TestCase):
-
-    def setUp(self):
-        journalist.logged_in = MagicMock()
-        journalist.make_star_true = MagicMock()
-        journalist.db_session = MagicMock()
-        journalist.url_for = MagicMock()
-        journalist.redirect = MagicMock()
-        journalist.get_one_or_else = MagicMock()
-
-    @patch('journalist.url_for')
-    @patch('journalist.redirect')
-    def test_add_star_renders_template(self, redirect, url_for):
-        redirect_template = journalist.add_star('sid')
-
-        self.assertEqual(redirect_template, redirect(url_for('index')))
-
-    @patch('journalist.db_session')
-    def test_add_star_makes_commits(self, db_session):
-        journalist.add_star('sid')
-
-        db_session.commit.assert_called_with()
-
-    @patch('journalist.make_star_true')
-    def test_single_delegates_to_make_star_true(self, make_star_true):
-        sid = 'sid'
-
-        journalist.add_star(sid)
-
-        make_star_true.assert_called_with(sid)
-
-    @patch('journalist.url_for')
-    @patch('journalist.redirect')
-    def test_remove_star_renders_template(self, redirect, url_for):
-        redirect_template = journalist.remove_star('sid')
-
-        self.assertEqual(redirect_template, redirect(url_for('index')))
-
-    @patch('journalist.db_session')
-    def test_remove_star_makes_commits(self, db_session):
-        journalist.remove_star('sid')
-
-        db_session.commit.assert_called_with()
-
-    @patch('journalist.make_star_false')
-    def test_remove_star_delegates_to_make_star_false(self, make_star_false):
-        sid = 'sid'
-
-        journalist.remove_star(sid)
-
-        make_star_false.assert_called_with(sid)
-
-    @classmethod
-    def tearDownClass(cls):
-        # Reset the module variables that were changed to mocks so we don't
-        # break other tests
-        reload(journalist)
diff --git a/tests/.coveragerc b/tests/.coveragerc
new file mode 100644
--- /dev/null
+++ b/tests/.coveragerc
@@ -0,0 +1,3 @@
+[run]
+branch = True
+source = ../securedrop
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from os.path import abspath, dirname, join, realpath
+import sys
+
+# The tests directory should be adjacent to the securedrop directory. By adding
+# the securedrop directory to sys.path here, all test modules are able to
+# directly import modules in the securedrop directory.
+sys.path.append(abspath(join(dirname(realpath(__file__)), '..', 'securedrop')))
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+import os
+import shutil
+import signal
+import subprocess
+
+import psutil
+import pytest
+
+os.environ['SECUREDROP_ENV'] = 'test'
+import config
+
+# TODO: the PID file for the redis worker is hard-coded below.
+# Ideally this constant would be provided by a test harness.
+# It has been intentionally omitted from `config.py.example`
+# in order to isolate the test vars from prod vars.
+TEST_WORKER_PIDFILE = '/tmp/securedrop_test_worker.pid'
+
+@pytest.fixture(scope='session')
+def setUptearDown():
+    _start_test_rqworker(config)
+    yield
+    _stop_test_rqworker()
+    _cleanup_test_securedrop_dataroot(config)
+
+
+def _start_test_rqworker(config):
+    if not psutil.pid_exists(_get_pid_from_file(TEST_WORKER_PIDFILE)):
+        tmp_logfile = open('/tmp/test_rqworker.log', 'w')
+        subprocess.Popen(['rqworker', 'test',
+                          '-P', config.SECUREDROP_ROOT,
+                          '--pid', TEST_WORKER_PIDFILE],
+                         stdout=tmp_logfile,
+                         stderr=subprocess.STDOUT)
+
+
+def _stop_test_rqworker():
+    rqworker_pid = _get_pid_from_file(TEST_WORKER_PIDFILE)
+    if rqworker_pid:
+        os.kill(rqworker_pid, signal.SIGTERM)
+        try:
+            os.remove(TEST_WORKER_PIDFILE)
+        except OSError:
+            pass
+
+
+def _get_pid_from_file(pid_file_name):
+    try:
+        return int(open(pid_file_name).read())
+    except IOError:
+        return None
+
+
+def _cleanup_test_securedrop_dataroot(config):
+    # Keyboard interrupts or dropping to pdb after a test failure sometimes
+    # result in the temporary test SecureDrop data root not being deleted.
+    try:
+        shutil.rmtree(config.SECUREDROP_DATA_ROOT)
+    except OSError:
+        pass
diff --git a/securedrop/test_journalist_key.pub b/tests/files/test_journalist_key.pub
similarity index 100%
rename from securedrop/test_journalist_key.pub
rename to tests/files/test_journalist_key.pub
diff --git a/securedrop/test_journalist_key.sec b/tests/files/test_journalist_key.sec
similarity index 100%
rename from securedrop/test_journalist_key.sec
rename to tests/files/test_journalist_key.sec
diff --git a/securedrop/tests/__init__.py b/tests/functional/__init__.py
similarity index 100%
rename from securedrop/tests/__init__.py
rename to tests/functional/__init__.py
diff --git a/securedrop/tests/functional/functional_test.py b/tests/functional/functional_test.py
similarity index 81%
rename from securedrop/tests/functional/functional_test.py
rename to tests/functional/functional_test.py
--- a/securedrop/tests/functional/functional_test.py
+++ b/tests/functional/functional_test.py
@@ -4,6 +4,7 @@
 import mock
 from multiprocessing import Process
 import os
+from os.path import abspath, dirname, join, realpath
 import shutil
 import signal
 import socket
@@ -13,19 +14,20 @@
 import unittest
 import urllib2
 
+from Crypto import Random
 import gnupg
 from selenium import webdriver
 from selenium.common.exceptions import WebDriverException
 from selenium.webdriver.firefox import firefox_binary
 
-# Set environment variable so config.py uses a test environment
 os.environ['SECUREDROP_ENV'] = 'test'
 import config
 import db
 import journalist
 import source
-import tests.utils as utils
+import tests.utils.env as env
 
+LOG_DIR = abspath(join(dirname(realpath(__file__)), '..', 'log'))
 
 class FunctionalTest():
 
@@ -37,7 +39,7 @@ def _unused_port(self):
         return port
 
     def _create_webdriver(self):
-        log_file = open('tests/log/firefox.log', 'a')
+        log_file = open(join(LOG_DIR, 'firefox.log'), 'a')
         log_file.write(
             '\n\n[%s] Running Functional Tests\n' % str(
                 datetime.now()))
@@ -54,8 +56,8 @@ def setUp(self):
 
         signal.signal(signal.SIGUSR1, lambda _, s: traceback.print_stack(s))
 
-        utils.env.create_directories()
-        self.gpg = utils.env.init_gpg()
+        env.create_directories()
+        self.gpg = env.init_gpg()
         db.init_db()
 
         source_port = self._unused_port()
@@ -65,6 +67,13 @@ def setUp(self):
         self.journalist_location = "http://localhost:%d" % journalist_port
 
         def start_source_server():
+            # We call Random.atfork() here because we fork the source and
+            # journalist server from the main Python process we use to drive
+            # our browser with multiprocessing.Process() below. These child
+            # processes inherit the same RNG state as the parent process, which
+            # is a problem because they would produce identical output if we
+            # didn't re-seed them after forking.
+            Random.atfork()
             source.app.run(
                 port=source_port,
                 debug=True,
@@ -72,6 +81,7 @@ def start_source_server():
                 threaded=True)
 
         def start_journalist_server():
+            Random.atfork()
             journalist.app.run(
                 port=journalist_port,
                 debug=True,
@@ -99,7 +109,7 @@ def start_journalist_server():
         self.secret_message = 'blah blah blah'
 
     def tearDown(self):
-        utils.env.teardown()
+        env.teardown()
         self.driver.quit()
         self.source_process.terminate()
         self.journalist_process.terminate()
diff --git a/securedrop/tests/functional/journalist_navigation_steps.py b/tests/functional/journalist_navigation_steps.py
similarity index 91%
rename from securedrop/tests/functional/journalist_navigation_steps.py
rename to tests/functional/journalist_navigation_steps.py
--- a/securedrop/tests/functional/journalist_navigation_steps.py
+++ b/tests/functional/journalist_navigation_steps.py
@@ -6,7 +6,8 @@
 
 from selenium.common.exceptions import NoSuchElementException
 
-from db import db_session, Journalist
+import tests.utils.db_helper as db_helper
+from db import Journalist
 
 
 class JournalistNavigationSteps():
@@ -48,37 +49,15 @@ def _login_user(self, username, password, token):
 
     def _journalist_logs_in(self):
         # Create a test user for logging in
-        test_user_info = dict(
-            username='test',
-            password='test')
-        test_user = Journalist(**test_user_info)
-        db_session.add(test_user)
-        db_session.commit()
-
-        self._login_user(test_user_info['username'],
-                         test_user_info['password'],
-                         'mocked')
+        self.user, self.user_pw = db_helper.init_journalist()
+        self._login_user(self.user.username, self.user_pw, 'mocked')
 
         headline = self.driver.find_element_by_css_selector('span.headline')
         self.assertIn('Sources', headline.text)
 
     def _admin_logs_in(self):
-        # Create a test admin user for logging in
-        admin_user_info = dict(
-            username='admin',
-            password='admin',
-            is_admin=True)
-        admin_user = Journalist(**admin_user_info)
-        db_session.add(admin_user)
-        db_session.commit()
-
-        # Stash the admin user on self so we can use it in later tests
-        self.admin_user = admin_user_info
-        self.admin_user['orm_obj'] = admin_user
-
-        self._login_user(admin_user_info['username'],
-                         admin_user_info['password'],
-                         'mocked')
+        self.admin, self.admin_pw = db_helper.init_journalist(is_admin=True)
+        self._login_user(self.admin.username, self.admin_pw, 'mocked')
 
         # Admin user should log in to the same interface as a normal user,
         # since there may be users who wish to be both journalists and admins.
@@ -208,9 +187,7 @@ def _admin_can_edit_new_user(self):
         # Log the new user out
         self._logout()
 
-        self._login_user(self.admin_user['username'],
-                         self.admin_user['password'],
-                         'mocked')
+        self._login_user(self.admin.username, self.admin_pw, 'mocked')
 
         # Go to the admin interface
         admin_interface_link = self.driver.find_element_by_link_text('Admin')
@@ -257,9 +234,7 @@ def _admin_can_edit_new_user(self):
 
         # Log the admin user back in
         self._logout()
-        self._login_user(self.admin_user['username'],
-                         self.admin_user['password'],
-                         'mocked')
+        self._login_user(self.admin.username, self.admin_pw, 'mocked')
 
         # Go to the admin interface
         admin_interface_link = self.driver.find_element_by_link_text('Admin')
diff --git a/securedrop/tests/functional/source_navigation_steps.py b/tests/functional/source_navigation_steps.py
similarity index 100%
rename from securedrop/tests/functional/source_navigation_steps.py
rename to tests/functional/source_navigation_steps.py
diff --git a/securedrop/tests/functional/submission_not_in_memory.py b/tests/functional/submission_not_in_memory.py
similarity index 100%
rename from securedrop/tests/functional/submission_not_in_memory.py
rename to tests/functional/submission_not_in_memory.py
diff --git a/securedrop/tests/functional/admin_interface.py b/tests/functional/test_admin_interface.py
similarity index 91%
rename from securedrop/tests/functional/admin_interface.py
rename to tests/functional/test_admin_interface.py
--- a/securedrop/tests/functional/admin_interface.py
+++ b/tests/functional/test_admin_interface.py
@@ -20,6 +20,3 @@ def test_admin_interface(self):
         self._admin_adds_a_user()
         self._new_user_can_log_in()
         self._admin_can_edit_new_user()
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/functional/submit_and_retrieve_file.py b/tests/functional/test_submit_and_retrieve_file.py
similarity index 96%
rename from securedrop/tests/functional/submit_and_retrieve_file.py
rename to tests/functional/test_submit_and_retrieve_file.py
--- a/securedrop/tests/functional/submit_and_retrieve_file.py
+++ b/tests/functional/test_submit_and_retrieve_file.py
@@ -38,6 +38,3 @@ def test_source_cancels_at_submit_page(self):
         self._source_chooses_to_submit_documents()
         self._source_continues_to_submit_page()
         self._source_hits_cancel_at_submit_page()
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/functional/submit_and_retrieve_message.py b/tests/functional/test_submit_and_retrieve_message.py
similarity index 93%
rename from securedrop/tests/functional/submit_and_retrieve_message.py
rename to tests/functional/test_submit_and_retrieve_message.py
--- a/securedrop/tests/functional/submit_and_retrieve_message.py
+++ b/tests/functional/test_submit_and_retrieve_message.py
@@ -26,7 +26,3 @@ def test_submit_and_retrieve_happy_path(self):
         self._journalist_logs_in()
         self._journalist_checks_messages()
         self._journalist_downloads_message()
-
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/log/.gitignore b/tests/log/.gitignore
similarity index 100%
rename from securedrop/tests/log/.gitignore
rename to tests/log/.gitignore
diff --git a/tests/pytest.ini b/tests/pytest.ini
new file mode 100644
--- /dev/null
+++ b/tests/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+testpaths = . functional
+usefixtures = setUptearDown
+addopts = --cov=../securedrop/
diff --git a/securedrop/tests/test_unit_crypto_util.py b/tests/test_crypto_util.py
similarity index 81%
rename from securedrop/tests/test_unit_crypto_util.py
rename to tests/test_crypto_util.py
--- a/securedrop/tests/test_unit_crypto_util.py
+++ b/tests/test_crypto_util.py
@@ -1,12 +1,8 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
-
 import os
 import unittest
 
-# Set environment variable so config.py uses a test environment
 os.environ['SECUREDROP_ENV'] = 'test'
-
 import config
 import crypto_util
 import utils
@@ -27,6 +23,3 @@ def test_clean(self):
             crypto_util.clean('foo bar`') # backtick is not currently allowed
         with self.assertRaises(crypto_util.CryptoException):
             crypto_util.clean('bar baz~') # tilde is not currently allowed
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/test_unit_db.py b/tests/test_db.py
similarity index 97%
rename from securedrop/tests/test_unit_db.py
rename to tests/test_db.py
--- a/securedrop/tests/test_unit_db.py
+++ b/tests/test_db.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
 import os
 import unittest
@@ -79,7 +78,3 @@ def test_journalist_string_representation(self):
     def test_source_string_representation(self):
         test_source, _ = db_helper.init_source()
         test_source.__repr__()
-
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/test_unit_integration.py b/tests/test_integration.py
similarity index 99%
rename from securedrop/tests/test_unit_integration.py
rename to tests/test_integration.py
--- a/securedrop/tests/test_unit_integration.py
+++ b/tests/test_integration.py
@@ -1,6 +1,4 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
-
 from cStringIO import StringIO
 import gzip
 import mock
@@ -16,7 +14,6 @@
 from flask import session, g, escape
 import gnupg
 
-# Set environment variable so config.py uses a test environment
 os.environ['SECUREDROP_ENV'] = 'test'
 import config
 import crypto_util
@@ -615,7 +612,3 @@ def helper_filenames_delete(self, soup, i):
         # Make sure the files were deleted from the filesystem
         utils.async.wait_for_assertion(lambda: self.assertFalse(
             any([os.path.exists(store.path(sid, doc_name)) for doc_name in checkbox_values])))
-
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/test_unit_journalist.py b/tests/test_journalist.py
similarity index 83%
rename from securedrop/tests/test_unit_journalist.py
rename to tests/test_journalist.py
--- a/securedrop/tests/test_unit_journalist.py
+++ b/tests/test_journalist.py
@@ -1,6 +1,4 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
-
 from cStringIO import StringIO
 import os
 import random
@@ -10,8 +8,8 @@
 
 from flask import url_for, escape
 from flask_testing import TestCase
+from mock import patch, ANY, MagicMock
 
-# Set environment variable so config.py uses a test environment
 os.environ['SECUREDROP_ENV'] = 'test'
 import config
 import crypto_util
@@ -732,5 +730,174 @@ def test_add_star_redirects_to_index(self):
         self.assertRedirects(resp, url_for('index'))
 
 
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
+class TestJournalistAppTwo(unittest.TestCase):
+
+    def setUp(self):
+        journalist.logged_in = MagicMock()
+        journalist.request = MagicMock()
+        journalist.url_for = MagicMock()
+        journalist.redirect = MagicMock()
+        journalist.abort = MagicMock()
+        journalist.db_session = MagicMock()
+        journalist.get_docs = MagicMock()
+        journalist.get_or_else = MagicMock()
+
+    def _set_up_request(self, cols_selected, action):
+        journalist.request.form.__contains__.return_value = True
+        journalist.request.form.getlist = MagicMock(return_value=cols_selected)
+        journalist.request.form.__getitem__.return_value = action
+
+    @patch("journalist.col_delete")
+    def test_col_process_delegates_to_col_delete(self, col_delete):
+        cols_selected = ['source_id']
+        self._set_up_request(cols_selected, 'delete')
+
+        journalist.col_process()
+
+        col_delete.assert_called_with(cols_selected)
+
+    @patch("journalist.col_star")
+    def test_col_process_delegates_to_col_star(self, col_star):
+        cols_selected = ['source_id']
+        self._set_up_request(cols_selected, 'star')
+
+        journalist.col_process()
+
+        col_star.assert_called_with(cols_selected)
+
+    @patch("journalist.col_un_star")
+    def test_col_process_delegates_to_col_un_star(self, col_un_star):
+        cols_selected = ['source_id']
+        self._set_up_request(cols_selected, 'un-star')
+
+        journalist.col_process()
+
+        col_un_star.assert_called_with(cols_selected)
+
+    @patch("journalist.abort")
+    def test_col_process_returns_404_with_bad_action(self, abort):
+        cols_selected = ['source_id']
+        self._set_up_request(cols_selected, 'something-random')
+
+        journalist.col_process()
+
+        abort.assert_called_with(ANY)
+
+    @patch("journalist.make_star_true")
+    @patch("journalist.db_session")
+    def test_col_star_call_db_(self, db_session, make_star_true):
+        journalist.col_star(['sid'])
+
+        make_star_true.assert_called_with('sid')
+
+    @patch("journalist.db_session")
+    def test_col_un_star_call_db(self, db_session):
+        journalist.col_un_star([])
+
+        db_session.commit.assert_called_with()
+
+
+    @classmethod
+    def tearDownClass(cls):
+        # Reset the module variables that were changed to mocks so we don't
+        # break other tests
+        reload(journalist)
+
+
+class TestJournalistLogin(unittest.TestCase):
+
+    def setUp(self):
+        utils.env.setup()
+
+        # Patch the two-factor verification so it always succeeds
+        utils.db_helper.mock_verify_token(self)
+
+        self.user, self.user_pw = utils.db_helper.init_journalist()
+
+    def tearDown(self):
+        utils.env.teardown()
+        # TODO: figure out why this is necessary here, but unnecessary in all
+        # of the tests in `tests/test_unit_*.py`. Without this, the session
+        # continues to return values even if the underlying database is deleted
+        # (as in `shared_teardown`).
+        db_session.remove()
+
+    @patch('db.Journalist._scrypt_hash')
+    @patch('db.Journalist.valid_password', return_value=True)
+    def test_valid_login_calls_scrypt(self, mock_scrypt_hash, mock_valid_password):
+        Journalist.login(self.user.username, self.user_pw, 'mocked')
+        self.assertTrue(mock_scrypt_hash.called,
+                "Failed to call _scrypt_hash for password w/ valid length")
+
+    @patch('db.Journalist._scrypt_hash')
+    def test_login_with_invalid_password_doesnt_call_scrypt(self, mock_scrypt_hash):
+        invalid_pw = 'a'*(Journalist.MAX_PASSWORD_LEN + 1)
+        with self.assertRaises(InvalidPasswordLength):
+            Journalist.login(self.user.username, invalid_pw, 'mocked')
+        self.assertFalse(mock_scrypt_hash.called,
+                "Called _scrypt_hash for password w/ invalid length")
+
+    @classmethod
+    def tearDownClass(cls):
+        # Reset the module variables that were changed to mocks so we don't
+        # break other tests
+        reload(journalist)
+
+
+class TestJournalist(unittest.TestCase):
+
+    def setUp(self):
+        journalist.logged_in = MagicMock()
+        journalist.make_star_true = MagicMock()
+        journalist.db_session = MagicMock()
+        journalist.url_for = MagicMock()
+        journalist.redirect = MagicMock()
+        journalist.get_one_or_else = MagicMock()
+
+    @patch('journalist.url_for')
+    @patch('journalist.redirect')
+    def test_add_star_renders_template(self, redirect, url_for):
+        redirect_template = journalist.add_star('sid')
+
+        self.assertEqual(redirect_template, redirect(url_for('index')))
+
+    @patch('journalist.db_session')
+    def test_add_star_makes_commits(self, db_session):
+        journalist.add_star('sid')
+
+        db_session.commit.assert_called_with()
+
+    @patch('journalist.make_star_true')
+    def test_single_delegates_to_make_star_true(self, make_star_true):
+        sid = 'sid'
+
+        journalist.add_star(sid)
+
+        make_star_true.assert_called_with(sid)
+
+    @patch('journalist.url_for')
+    @patch('journalist.redirect')
+    def test_remove_star_renders_template(self, redirect, url_for):
+        redirect_template = journalist.remove_star('sid')
+
+        self.assertEqual(redirect_template, redirect(url_for('index')))
+
+    @patch('journalist.db_session')
+    def test_remove_star_makes_commits(self, db_session):
+        journalist.remove_star('sid')
+
+        db_session.commit.assert_called_with()
+
+    @patch('journalist.make_star_false')
+    def test_remove_star_delegates_to_make_star_false(self, make_star_false):
+        sid = 'sid'
+
+        journalist.remove_star(sid)
+
+        make_star_false.assert_called_with(sid)
+
+    @classmethod
+    def tearDownClass(cls):
+        # Reset the module variables that were changed to mocks so we don't
+        # break other tests
+        reload(journalist)
diff --git a/securedrop/tests/test_unit_manage.py b/tests/test_manage.py
similarity index 89%
rename from securedrop/tests/test_unit_manage.py
rename to tests/test_manage.py
--- a/securedrop/tests/test_unit_manage.py
+++ b/tests/test_manage.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
 
 import manage
@@ -6,7 +5,6 @@
 
 
 class TestManagePy(unittest.TestCase):
-
     def test_parse_args(self):
         # just test that the arg parser is stable
         manage.get_args()
diff --git a/securedrop/tests/test_unit_source.py b/tests/test_source.py
similarity index 99%
rename from securedrop/tests/test_unit_source.py
rename to tests/test_source.py
--- a/securedrop/tests/test_unit_source.py
+++ b/tests/test_source.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
 from cStringIO import StringIO
 from mock import patch, ANY
@@ -285,7 +284,3 @@ def test_login_with_overly_long_codename(self, mock_hash_codename):
             self.assertFalse(mock_hash_codename.called,
                              "Called hash_codename for codename w/ invalid "
                              "length")
-
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/test_unit_store.py b/tests/test_store.py
similarity index 93%
rename from securedrop/tests/test_unit_store.py
rename to tests/test_store.py
--- a/securedrop/tests/test_unit_store.py
+++ b/tests/test_store.py
@@ -1,12 +1,9 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
-
 import os
 import unittest
 import zipfile
 
 import crypto_util
-# Set environment variable so config.py uses a test environment
 os.environ['SECUREDROP_ENV'] = 'test'
 import config
 from db import db_session, Source
@@ -57,6 +54,3 @@ def test_rename_valid_submission(self):
         actual_filename = store.rename_submission(source.filesystem_id, old_filename,
                                                   new_journalist_filename)
         self.assertEquals(actual_filename, expected_filename)
-
-if __name__ == "__main__":
-    unittest.main(verbosity=2)
diff --git a/securedrop/tests/test_unit_template_filters.py b/tests/test_template_filters.py
similarity index 93%
rename from securedrop/tests/test_unit_template_filters.py
rename to tests/test_template_filters.py
--- a/securedrop/tests/test_unit_template_filters.py
+++ b/tests/test_template_filters.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
 from datetime import datetime, timedelta
 import os
@@ -6,10 +5,6 @@
 
 import template_filters
 
-# Set environment variable so config.py uses a test environment
-os.environ['SECUREDROP_ENV'] = 'test'
-
-
 class TestTemplateFilters(unittest.TestCase):
 
     def test_datetimeformat_default_fmt(self):
@@ -59,4 +54,4 @@ def test_relative_timestamp_days(self):
     def test_relative_timestamp_none(self):
         test_time = datetime.utcnow() - timedelta(days=999)
         result = template_filters._relative_timestamp(test_time)
-        self.assertEquals(None, result)
\ No newline at end of file
+        self.assertEquals(None, result)
diff --git a/securedrop/tests/utils/__init__.py b/tests/utils/__init__.py
similarity index 100%
rename from securedrop/tests/utils/__init__.py
rename to tests/utils/__init__.py
diff --git a/securedrop/tests/utils/async.py b/tests/utils/async.py
similarity index 100%
rename from securedrop/tests/utils/async.py
rename to tests/utils/async.py
diff --git a/securedrop/tests/utils/db_helper.py b/tests/utils/db_helper.py
similarity index 98%
rename from securedrop/tests/utils/db_helper.py
rename to tests/utils/db_helper.py
--- a/securedrop/tests/utils/db_helper.py
+++ b/tests/utils/db_helper.py
@@ -5,7 +5,6 @@
 import mock
 import os
 
-# Set environment variable so config.py uses a test environment
 os.environ['SECUREDROP_ENV'] = 'test'
 import config
 import crypto_util
diff --git a/securedrop/tests/utils/env.py b/tests/utils/env.py
similarity index 74%
rename from securedrop/tests/utils/env.py
rename to tests/utils/env.py
--- a/securedrop/tests/utils/env.py
+++ b/tests/utils/env.py
@@ -2,16 +2,18 @@
 """Testing utilities related to setup and teardown of test environment.
 """
 import os
+from os.path import abspath, dirname, exists, isdir, join, realpath
 import shutil
 import subprocess
 
 import gnupg
 
-# Set environment variable so config.py uses a test environment
 os.environ['SECUREDROP_ENV'] = 'test'
 import config
 import crypto_util
-from db import init_db
+from db import init_db, db_session
+
+FILES_DIR = abspath(join(dirname(realpath(__file__)), '..', 'files'))
 
 # TODO: the PID file for the redis worker is hard-coded below.  Ideally this
 # constant would be provided by a test harness.  It has been intentionally
@@ -26,7 +28,7 @@ def create_directories():
     """
     for d in (config.SECUREDROP_DATA_ROOT, config.STORE_DIR,
               config.GPG_KEY_DIR, config.TEMP_DIR):
-        if not os.path.isdir(d):
+        if not isdir(d):
             os.mkdir(d)
 
 
@@ -36,7 +38,8 @@ def init_gpg():
     """
     gpg = gnupg.GPG(homedir=config.GPG_KEY_DIR)
     # Faster to import a pre-generated key than to gen a new one every time.
-    for keyfile in ("test_journalist_key.pub", "test_journalist_key.sec"):
+    for keyfile in (join(FILES_DIR, "test_journalist_key.pub"),
+                    join(FILES_DIR, "test_journalist_key.sec")):
         gpg.import_keys(open(keyfile).read())
     return gpg
 
@@ -49,11 +52,16 @@ def setup():
     # Do tests that should always run on app startup
     crypto_util.do_runtime_tests()
     # Start the Python-RQ worker if it's not already running
-    if not os.path.exists(TEST_WORKER_PIDFILE):
+    if not exists(TEST_WORKER_PIDFILE):
         subprocess.Popen(["rqworker",
                           "-P", config.SECUREDROP_ROOT,
                           "--pid", TEST_WORKER_PIDFILE])
 
 
 def teardown():
-    shutil.rmtree(config.SECUREDROP_DATA_ROOT)
+    db_session.remove()
+    try:
+        shutil.rmtree(config.SECUREDROP_DATA_ROOT)
+    except OSError as exc:
+        if 'No such file or directory' not in exc:
+            raise