Repository: freedomofpress/securedrop

Issue:
api: GET /api/v1/submissions produces 500 when there are disconnected submissions
## Description

The API's submission endpoint should gracefully handle the case where there is a "disconnected" submission.

## Steps to Reproduce

0. `make dev`
1. docker exec into the container
2. `cd /var/lib/securedrop/`
3. `sqlite3 db.sqlite`
4. `delete from sources where id=1;`
5. While authenticated to the journalist API, `GET /api/v1/submissions`

## Expected Behavior

OSSEC alert starts appearing indicating that there are disconnected submissions, encouraging the admin to clean up.

## Actual Behavior

500 occurs: 
```
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-rc1/securedrop/securedrop/journalist_app/api.py", line 48, in decorated_function
    return f(*args, **kwargs)
  File "/Users/redshiftzero/Documents/Github/securedrop-1.2.0-rc1/securedrop/securedrop/journalist_app/api.py", line 303, in get_all_submissions
    submission in submissions]}), 200
  File "/Users/redshiftzero/Documents/Github/securedrop-1.2.0-rc1/securedrop/securedrop/journalist_app/api.py", line 303, in <listcomp>
    submission in submissions]}), 200
  File "/Users/redshiftzero/Documents/Github/securedrop-1.2.0-rc1/securedrop/securedrop/models.py", line 218, in to_json
    source_uuid=self.source.uuid),
AttributeError: 'NoneType' object has no attribute 'uuid'
```

## Comments

Bit of a weird STR, but the submissions endpoint producing a 500 is unfortunate.


Discussion:
Something like this might be reasonable but we should check how this interacts w/ the client sync logic and also add a regression test: 

```diff
diff --git a/securedrop/models.py b/securedrop/models.py
index 763fd285f..3e9b05167 100644
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -215,17 +215,17 @@ class Submission(db.Model):
     def to_json(self) -> 'Dict[str, Union[str, int, bool]]':
         json_submission = {
             'source_url': url_for('api.single_source',
-                                  source_uuid=self.source.uuid),
+                                  source_uuid=self.source.uuid) if self.source else None,
             'submission_url': url_for('api.single_submission',
                                       source_uuid=self.source.uuid,
-                                      submission_uuid=self.uuid),
+                                      submission_uuid=self.uuid) if self.source else None,
             'filename': self.filename,
             'size': self.size,
             'is_read': self.downloaded,
             'uuid': self.uuid,
             'download_url': url_for('api.download_submission',
                                     source_uuid=self.source.uuid,
-                                    submission_uuid=self.uuid),
+                                    submission_uuid=self.uuid) if self.source else None,
         }
         return json_submission

@@ -285,10 +285,10 @@ class Reply(db.Model):
             uuid = self.journalist.uuid
         json_submission = {
             'source_url': url_for('api.single_source',
-                                  source_uuid=self.source.uuid),
+                                  source_uuid=self.source.uuid) if self.source else None,
             'reply_url': url_for('api.single_reply',
                                  source_uuid=self.source.uuid,
-                                 reply_uuid=self.uuid),
+                                 reply_uuid=self.uuid) if self.source else None,
             'filename': self.filename,
             'size': self.size,
             'journalist_username': username,
```
@redshiftzero Will work on this.

@prateekj117 We've added this to our current sprint. If you're still interested and have time in the next couple of weeks, it's all yours. Let me know if I can help, or if that timeframe isn't convenient, we can take care of it. Thanks!
@rmol Timeframe is convenient. Will make a PR by the weekend. Thanks.


Resolving patch:
diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py
--- a/securedrop/journalist_app/api.py
+++ b/securedrop/journalist_app/api.py
@@ -300,7 +300,7 @@ def single_reply(source_uuid, reply_uuid):
     def get_all_submissions():
         submissions = Submission.query.all()
         return jsonify({'submissions': [submission.to_json() for
-                                        submission in submissions]}), 200
+                                        submission in submissions if submission.source]}), 200
 
     @api.route('/replies', methods=['GET'])
     @token_required
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -215,17 +215,17 @@ def __repr__(self) -> str:
     def to_json(self) -> 'Dict[str, Union[str, int, bool]]':
         json_submission = {
             'source_url': url_for('api.single_source',
-                                  source_uuid=self.source.uuid),
+                                  source_uuid=self.source.uuid) if self.source else None,
             'submission_url': url_for('api.single_submission',
                                       source_uuid=self.source.uuid,
-                                      submission_uuid=self.uuid),
+                                      submission_uuid=self.uuid) if self.source else None,
             'filename': self.filename,
             'size': self.size,
             'is_read': self.downloaded,
             'uuid': self.uuid,
             'download_url': url_for('api.download_submission',
                                     source_uuid=self.source.uuid,
-                                    submission_uuid=self.uuid),
+                                    submission_uuid=self.uuid) if self.source else None,
         }
         return json_submission
 


Test patch:
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
@@ -376,6 +376,20 @@ def test_authorized_user_can_get_all_submissions(journalist_app,
         assert observed_submissions == expected_submissions
 
 
+def test_authorized_user_get_all_submissions_with_disconnected_submissions(journalist_app,
+                                                                           test_submissions,
+                                                                           journalist_api_token):
+    with journalist_app.test_client() as app:
+        db.session.execute(
+            "DELETE FROM sources WHERE id = :id",
+            {"id": test_submissions["source"].id}
+        )
+        response = app.get(url_for('api.get_all_submissions'),
+                           headers=get_api_headers(journalist_api_token))
+
+        assert response.status_code == 200
+
+
 def test_authorized_user_get_source_submissions(journalist_app,
                                                 test_submissions,
                                                 journalist_api_token):