Repository: freedomofpress/securedrop

Issue:
Journalist API get_all_replies endpoint should omit disconnected replies
## Description

Similar to #5315, if there are replies whose sources do not exist in the database, the API should omit them from the response of `get_all_replies`. In most of the API's single-object or source-derived endpoints, we explicitly `get_or_404` to avoid nonexistent sources, so this will make this endpoint consistent.

## Steps to Reproduce

1. `make dev`
1. docker exec into the container, e.g. `docker exec -it securedrop-dev-0 bash`
1. `cd /var/lib/securedrop/`
1. `sqlite3 db.sqlite`
1. `delete from sources where id=1;`
1. While authenticated to the journalist API, `GET /api/v1/replies`

## Expected Behavior

That you'd get a JSON document containing only complete and valid replies.

## Actual Behavior

<details>
<summary>A 500 internal server error as in #5315</summary>

```
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 "/home/user/src/fpf/securedrop/securedrop/journalist_app/api.py", line 48, in decorated_function
    return f(*args, **kwargs)
  File "/home/user/src/fpf/securedrop/securedrop/journalist_app/api.py", line 310, in get_all_replies
    {'replies': [reply.to_json() for reply in replies]}), 200
  File "/home/user/src/fpf/securedrop/securedrop/journalist_app/api.py", line 310, in <listcomp>
    {'replies': [reply.to_json() for reply in replies]}), 200
  File "/home/user/src/fpf/securedrop/securedrop/models.py", line 288, in to_json
    source_uuid=self.source.uuid),
AttributeError: 'NoneType' object has no attribute 'uuid'
```
</details>

## Comments

It's good but not sufficient to make the JSON rendition of the reply robust by making `source_url` `null` (as done in the [fix for #5315](https://github.com/freedomofpress/securedrop/pull/5345), as the SDK currently derives the source UUID from that value without checking, so throws an exception when it's `None`.

The simplest fix would be to change line 309 of `journalist_app/api.py` to:
```python
        return jsonify(
            {'replies': [reply.to_json() for reply in replies if reply.source]}), 200
```


Discussion:
@rmol Will take this one up as well.
You're awesome. 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
@@ -307,7 +307,7 @@ def get_all_submissions():
     def get_all_replies():
         replies = Reply.query.all()
         return jsonify(
-            {'replies': [reply.to_json() for reply in replies]}), 200
+            {'replies': [reply.to_json() for reply in replies if reply.source]}), 200
 
     @api.route('/user', methods=['GET'])
     @token_required
diff --git a/securedrop/models.py b/securedrop/models.py
--- a/securedrop/models.py
+++ b/securedrop/models.py
@@ -285,10 +285,10 @@ def to_json(self) -> 'Dict[str, Union[str, int, bool]]':
             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,


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
@@ -415,6 +415,20 @@ def test_authorized_user_can_get_single_submission(journalist_app,
             test_submissions['source'].submissions[0].size
 
 
+def test_authorized_user_can_get_all_replies_with_disconnected_replies(journalist_app,
+                                                                       test_files,
+                                                                       journalist_api_token):
+    with journalist_app.test_client() as app:
+        db.session.execute(
+            "DELETE FROM sources WHERE id = :id",
+            {"id": test_files["source"].id}
+        )
+        response = app.get(url_for('api.get_all_replies'),
+                           headers=get_api_headers(journalist_api_token))
+
+        assert response.status_code == 200
+
+
 def test_authorized_user_can_get_all_replies(journalist_app, test_files,
                                              journalist_api_token):
     with journalist_app.test_client() as app: