Repository: freedomofpress/securedrop

Issue:
Attempting to delete a reply whose corresponding GPG file is missing generates a 500 in the JI
## Description

Attempting to delete a reply whose corresponding GPG file is missing generates a 500 in the JI

## Steps to Reproduce

In the JI:
- reply to a source
On the app server:
- delete the corresponding reply file in `/var/lib/securedrop/store`
In the JI:
- delete the reply

## Expected Behavior
Good question! I would err on the side of deleting the db entry and calling it done. But it could flag the issue to the user in more detail than that provided by the 500 error page text.

This also has issues for deleting multiple files - if multiples are selected, deletion stops at the missing reply file. Expected behaviour in that case would be to continue with the operation.

## Actual Behavior
500 error thrown. If multiple files selected for deletion, all files listed after the buster reply file remain in place.



Discussion:
possibly related to https://github.com/freedomofpress/securedrop/issues/4787
I was going to agree that the file being gone is the desired state, so we could just purge the `Reply` record and continue, but a missing file for a reply probably indicates a problem which should be brought to the attention of the admin. Either there's a hardware or filesystem problem, or someone's been mucking about in the store. 

This situation is going to cause the same problem as #4787 but right now the cleanup tool we'd tell the admin to run does not address replies, just submissions. 
Just adding my standard caveat, let's try for a simple fix for 1.6.0 as discussed, but if scope expands significantly we may have to defer. :)
I think the  recco in #4787 for the related case is the best option overall - delete the entry and flash a message like "the database and file store are in an inconsistent state - this can be manually resolved by an administrator, so go get one."
@rmol are you sure the cleanup tool doesn't sort out replies? ISTR you adding that later in.
@zenmonkeykstop Pretty sure it [only checks submissions](https://github.com/freedomofpress/securedrop/blob/f81e41539bc5e5ae8b9a443ead719513622553c8/securedrop/management/submissions.py#L14). I think you're remembering that the inverse operation, looking for files without database records, [needed improving](https://github.com/freedomofpress/securedrop/commit/0644b954576fad2fa7c9636422137506cf5943e9) so that reply files wouldn't be counted as disconnected because they didn't have submission records.

Resolving patch:
diff --git a/securedrop/journalist_app/utils.py b/securedrop/journalist_app/utils.py
--- a/securedrop/journalist_app/utils.py
+++ b/securedrop/journalist_app/utils.py
@@ -218,22 +218,34 @@ def download(zip_basename: str, submissions: List[Union[Submission, Reply]]) ->
 
 def delete_file_object(file_object: Union[Submission, Reply]) -> None:
     path = current_app.storage.path(file_object.source.filesystem_id, file_object.filename)
-    current_app.storage.move_to_shredder(path)
-    db.session.delete(file_object)
-    db.session.commit()
+    try:
+        current_app.storage.move_to_shredder(path)
+    except ValueError as e:
+        current_app.logger.error("could not queue file for deletion: %s", e)
+        raise
+    finally:
+        db.session.delete(file_object)
+        db.session.commit()
 
 
 def bulk_delete(
     filesystem_id: str,
     items_selected: List[Union[Submission, Reply]]
 ) -> werkzeug.Response:
+    deletion_errors = 0
     for item in items_selected:
-        delete_file_object(item)
+        try:
+            delete_file_object(item)
+        except ValueError:
+            deletion_errors += 1
 
     flash(ngettext("Submission deleted.",
                    "{num} submissions deleted.".format(
                        num=len(items_selected)),
                    len(items_selected)), "notification")
+    if deletion_errors > 0:
+        current_app.logger.error("Disconnected submission entries (%d) were detected",
+                                 deletion_errors)
     return redirect(url_for('col.col', filesystem_id=filesystem_id))
 
 


Test patch:
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
--- a/securedrop/tests/test_journalist.py
+++ b/securedrop/tests/test_journalist.py
@@ -1940,6 +1940,94 @@ def assertion():
         utils.asynchronous.wait_for_assertion(assertion)
 
 
+def test_bulk_delete_deletes_db_entries(journalist_app,
+                                        test_source,
+                                        test_journo,
+                                        config):
+    """
+    Verify that when files are deleted, the corresponding db entries are
+    also deleted.
+    """
+
+    with journalist_app.app_context():
+        source = Source.query.get(test_source['id'])
+        journo = Journalist.query.get(test_journo['id'])
+
+        utils.db_helper.submit(source, 2)
+        utils.db_helper.reply(journo, source, 2)
+
+        dir_source_docs = os.path.join(config.STORE_DIR, test_source['filesystem_id'])
+        assert os.path.exists(dir_source_docs)
+
+        subs = Submission.query.filter_by(source_id=source.id).all()
+        assert subs
+
+        replies = Reply.query.filter_by(source_id=source.id).all()
+        assert replies
+
+        file_list = []
+        file_list.extend(subs)
+        file_list.extend(replies)
+
+        with journalist_app.test_request_context('/'):
+            journalist_app_module.utils.bulk_delete(test_source['filesystem_id'],
+                                                    file_list)
+
+        def db_assertion():
+            subs = Submission.query.filter_by(source_id=source.id).all()
+            assert not subs
+
+            replies = Reply.query.filter_by(source_id=source.id).all()
+            assert not replies
+
+        utils.asynchronous.wait_for_assertion(db_assertion)
+
+
+def test_bulk_delete_works_when_files_absent(journalist_app,
+                                             test_source,
+                                             test_journo,
+                                             config):
+    """
+    Verify that when files are deleted but are already missing,
+    the corresponding db entries are still
+    """
+
+    with journalist_app.app_context():
+        source = Source.query.get(test_source['id'])
+        journo = Journalist.query.get(test_journo['id'])
+
+        utils.db_helper.submit(source, 2)
+        utils.db_helper.reply(journo, source, 2)
+
+        dir_source_docs = os.path.join(config.STORE_DIR, test_source['filesystem_id'])
+        assert os.path.exists(dir_source_docs)
+
+        subs = Submission.query.filter_by(source_id=source.id).all()
+        assert subs
+
+        replies = Reply.query.filter_by(source_id=source.id).all()
+        assert replies
+
+        file_list = []
+        file_list.extend(subs)
+        file_list.extend(replies)
+
+        with journalist_app.test_request_context('/'):
+            with patch("store.Storage.move_to_shredder") as delMock:
+                delMock.side_effect = ValueError
+                journalist_app_module.utils.bulk_delete(test_source['filesystem_id'],
+                                                        file_list)
+
+        def db_assertion():
+            subs = Submission.query.filter_by(source_id=source.id).all()
+            assert not subs
+
+            replies = Reply.query.filter_by(source_id=source.id).all()
+            assert not replies
+
+        utils.asynchronous.wait_for_assertion(db_assertion)
+
+
 def test_login_with_invalid_password_doesnt_call_argon2(mocker, test_journo):
     mock_argon2 = mocker.patch('models.argon2.verify')
     invalid_pw = 'a'*(Journalist.MAX_PASSWORD_LEN + 1)