Repository: freedomofpress/securedrop

Issue:
deleted journalist causes 500 on GET /replies API endpoint 
## Description

Deleted journalist causes GET /replies endpoint to 500 if there are any replies from that deleted journalist. 

## Steps to Reproduce

0. Submit a document or message as a source.
1. Reply to the source from journalist account A.
2. Delete the account of journalist account A (from another admin account). 
3. `GET /api/v1/replies`

## Expected Behavior

200 OK 

## Actual Behavior

```
172.17.0.1 - - [31/Mar/2020 20:57:10] "GET /api/v1/replies HTTP/1.1" 500 -
Traceback (most recent call last):
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 2309, in __call__
    return self.wsgi_app(environ, start_response)
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 2295, in wsgi_app
    response = self.handle_exception(e)
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 1741, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/_compat.py", line 35, in reraise
    raise value
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 2292, in wsgi_app
    response = self.full_dispatch_request()
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 1815, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 1718, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/_compat.py", line 35, in reraise
    raise value
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 1813, in full_dispatch_request
    rv = self.dispatch_request()
  File "/opt/venvs/securedrop-app-code/lib/python3.5/site-packages/flask/app.py", line 1799, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/Users/redshiftzero/Documents/Github/securedrop-1.2.0-rc2/securedrop/securedrop/journalist_app/api.py", line 48, in decorated_function
    return f(*args, **kwargs)
  File "/Users/redshiftzero/Documents/Github/securedrop-1.2.0-rc2/securedrop/securedrop/journalist_app/api.py", line 310, in get_all_replies
    {'replies': [reply.to_json() for reply in replies]}), 200
  File "/Users/redshiftzero/Documents/Github/securedrop-1.2.0-rc2/securedrop/securedrop/journalist_app/api.py", line 310, in <listcomp>
    {'replies': [reply.to_json() for reply in replies]}), 200
  File "/Users/redshiftzero/Documents/Github/securedrop-1.2.0-rc2/securedrop/securedrop/models.py", line 289, in to_json
    'journalist_username': self.journalist.username,
AttributeError: 'NoneType' object has no attribute 'username'
```

## Comments

We should handle where the `journalist` is `None` [here](https://github.com/freedomofpress/securedrop/blob/develop/securedrop/models.py#L289-L292)

It would be wise to also add test data to `create-dev-data.py` for the deleted journalist scenario (since that is used for development of securedrop-client). 


Discussion:
FYI: in my testing, the SecureDrop Client will not currently handle it gracefully if the journalist-related JSON keys are missing from the API response, or if the value is NULL. If the values are empty strings, it will create an empty record in its USERS table, but apparently operate as expected.

Resolving patch:
diff --git a/securedrop/create-dev-data.py b/securedrop/create-dev-data.py
--- a/securedrop/create-dev-data.py
+++ b/securedrop/create-dev-data.py
@@ -49,29 +49,48 @@ def main(staging=False):
                       test_otp_secret,
                       is_admin=False)
 
+        journalist_tobe_deleted = add_test_user("clarkkent",
+                                                test_password,
+                                                test_otp_secret,
+                                                is_admin=False,
+                                                first_name="Clark",
+                                                last_name="Kent")
+
         # Add test sources and submissions
         num_sources = int(os.getenv('NUM_SOURCES', 2))
-        for _ in range(num_sources):
+        for i in range(num_sources):
+            if i == 0:
+                # For the first source, the journalist who replied will be deleted
+                create_source_and_submissions(journalist_who_replied=journalist_tobe_deleted)
+                continue
             create_source_and_submissions()
+        # Now let us delete one journalist
+        db.session.delete(journalist_tobe_deleted)
+        db.session.commit()
 
 
-def add_test_user(username, password, otp_secret, is_admin=False):
+def add_test_user(username, password, otp_secret, is_admin=False,
+                  first_name="", last_name=""):
     try:
         user = Journalist(username=username,
                           password=password,
-                          is_admin=is_admin)
+                          is_admin=is_admin,
+                          first_name=first_name,
+                          last_name=last_name)
         user.otp_secret = otp_secret
         db.session.add(user)
         db.session.commit()
         print('Test user successfully added: '
               'username={}, password={}, otp_secret={}, is_admin={}'
               ''.format(username, password, otp_secret, is_admin))
+        return user
     except IntegrityError:
         print("Test user already added")
         db.session.rollback()
 
 
-def create_source_and_submissions(num_submissions=2, num_replies=2):
+def create_source_and_submissions(num_submissions=2, num_replies=2,
+                                  journalist_who_replied=None):
     # Store source in database
     codename = current_app.crypto_util.genrandomid()
     filesystem_id = current_app.crypto_util.hash_codename(codename)
@@ -109,7 +128,10 @@ def create_source_and_submissions(num_submissions=2, num_replies=2):
              config.JOURNALIST_KEY],
             current_app.storage.path(source.filesystem_id, fname))
 
-        journalist = Journalist.query.first()
+        if not journalist_who_replied:
+            journalist = Journalist.query.first()
+        else:
+            journalist = journalist_who_replied
         reply = Reply(journalist, source, fname)
         db.session.add(reply)
 
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -278,6 +278,15 @@ def __repr__(self):
 
     def to_json(self):
         # type: () -> Dict[str, Union[str, int, bool]]
+        username = "deleted"
+        first_name = ""
+        last_name = ""
+        uuid = "deleted"
+        if self.journalist:
+            username = self.journalist.username
+            first_name = self.journalist.first_name
+            last_name = self.journalist.last_name
+            uuid = self.journalist.uuid
         json_submission = {
             'source_url': url_for('api.single_source',
                                   source_uuid=self.source.uuid),
@@ -286,10 +295,10 @@ def to_json(self):
                                  reply_uuid=self.uuid),
             'filename': self.filename,
             'size': self.size,
-            'journalist_username': self.journalist.username,
-            'journalist_first_name': self.journalist.first_name,
-            'journalist_last_name': self.journalist.last_name,
-            'journalist_uuid': self.journalist.uuid,
+            'journalist_username': username,
+            'journalist_first_name': first_name,
+            'journalist_last_name': last_name,
+            'journalist_uuid': uuid,
             'uuid': self.uuid,
             'is_deleted_by_source': self.deleted_by_source,
         }


Test patch:
diff --git a/securedrop/tests/conftest.py b/securedrop/tests/conftest.py
--- a/securedrop/tests/conftest.py
+++ b/securedrop/tests/conftest.py
@@ -218,6 +218,23 @@ def test_files(journalist_app, test_journo):
                 'replies': source.replies}
 
 
+@pytest.fixture(scope='function')
+def test_files_deleted_journalist(journalist_app, test_journo):
+    with journalist_app.app_context():
+        source, codename = utils.db_helper.init_source()
+        utils.db_helper.submit(source, 2)
+        test_journo['journalist']
+        juser, _ = utils.db_helper.init_journalist("f", "l", is_admin=False)
+        utils.db_helper.reply(juser, source, 1)
+        utils.db_helper.delete_journalist(juser)
+        return {'source': source,
+                'codename': codename,
+                'filesystem_id': source.filesystem_id,
+                'uuid': source.uuid,
+                'submissions': source.submissions,
+                'replies': source.replies}
+
+
 @pytest.fixture(scope='function')
 def journalist_api_token(journalist_app, test_journo):
     with journalist_app.test_client() as app:
diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py
--- a/securedrop/tests/test_journalist_api.py
+++ b/securedrop/tests/test_journalist_api.py
@@ -477,6 +477,31 @@ def test_authorized_user_can_get_single_reply(journalist_app, test_files,
             test_files['source'].replies[0].size
 
 
+def test_reply_of_deleted_journalist(journalist_app,
+                                     test_files_deleted_journalist,
+                                     journalist_api_token):
+    with journalist_app.test_client() as app:
+        reply_uuid = test_files_deleted_journalist['source'].replies[0].uuid
+        uuid = test_files_deleted_journalist['source'].uuid
+        response = app.get(url_for('api.single_reply',
+                                   source_uuid=uuid,
+                                   reply_uuid=reply_uuid),
+                           headers=get_api_headers(journalist_api_token))
+
+        assert response.status_code == 200
+
+        assert response.json['uuid'] == reply_uuid
+        assert response.json['journalist_username'] == "deleted"
+        assert response.json['journalist_uuid'] == "deleted"
+        assert response.json['journalist_first_name'] == ""
+        assert response.json['journalist_last_name'] == ""
+        assert response.json['is_deleted_by_source'] is False
+        assert response.json['filename'] == \
+            test_files_deleted_journalist['source'].replies[0].filename
+        assert response.json['size'] == \
+            test_files_deleted_journalist['source'].replies[0].size
+
+
 def test_authorized_user_can_delete_single_submission(journalist_app,
                                                       test_submissions,
                                                       journalist_api_token):
diff --git a/securedrop/tests/utils/db_helper.py b/securedrop/tests/utils/db_helper.py
--- a/securedrop/tests/utils/db_helper.py
+++ b/securedrop/tests/utils/db_helper.py
@@ -39,6 +39,17 @@ def init_journalist(first_name=None, last_name=None, is_admin=False):
     return user, user_pw
 
 
+def delete_journalist(journalist):
+    """Deletes a journalist from the database.
+
+    :param models.Journalist journalist: The journalist to delete
+
+    :returns: None
+    """
+    db.session.delete(journalist)
+    db.session.commit()
+
+
 def reply(journalist, source, num_replies):
     """Generates and submits *num_replies* replies to *source*
     from *journalist*. Returns reply objects as a list.