Repository: freedomofpress/securedrop

Issue:
admin CLI: return 1 should eventually exit with a non-zero return code
# Description

On develop and in the 0.8.0 release branch a `return 1` does not cause the securedrop-admin CLI to eventually exit with a non-zero return code. 

# Steps to reproduce

1. It's a little difficult to trigger a `return 1` but one method is to just modify the happy path of `securedrop-admin update` to `return 1` and commit this change in a test branch
2. `securedrop-admin update`
3. `echo $?`

# Expected Behavior

non-zero exit code :)

# Actual Behavior

zero exit code :(

# Comments

This is important to fix during release QA as in 0.7.0 we released the SecureDrop workstation updater, which behind the scenes is calling out to the `securedrop-admin` CLI to do updates, and the logic relies on sensible return codes being returned by the CLI. On the 0.7.0 tag a `return 1` does make `securedrop-admin` eventually exit non-zero. 


Discussion:
Also now that we have integration tests, we should have a regression test that covers this case for the future
@redshiftzero this should fix this.

```
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
index 597114bd..017b0a3a 100755
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -807,9 +807,10 @@ def main(argv):
         except Exception as e:
             raise SystemExit(
                 'ERROR (run with -v for more): {msg}'.format(msg=e))
-        else:
-            sys.exit(EXIT_SUCCESS)
-
+    if return_code == 0:
+        sys.exit(EXIT_SUCCESS)
+    else:
+        sys.exit(EXIT_SUBPROCESS_ERROR)
 
 if __name__ == "__main__":
     main(sys.argv[1:])

```
To test, add `return 1` to the update function in the first line. And then run `./securedrop-admin update` and that should have 1 as exit code.
Sweet, will test today, thanks @kushaldas. We also really should cover this with automated testing (as it would have prevented the introduction of this regression to begin with), another test along the lines of #3481 should do the trick

Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -537,6 +537,7 @@ def setup_logger(verbose=False):
 def sdconfig(args):
     """Configure SD site settings"""
     SiteConfig(args).load_and_update_config()
+    return 0
 
 
 def install_securedrop(args):
@@ -807,8 +808,10 @@ def main(argv):
         except Exception as e:
             raise SystemExit(
                 'ERROR (run with -v for more): {msg}'.format(msg=e))
-        else:
-            sys.exit(EXIT_SUCCESS)
+    if return_code == 0:
+        sys.exit(EXIT_SUCCESS)
+    else:
+        sys.exit(EXIT_SUBPROCESS_ERROR)
 
 
 if __name__ == "__main__":


Test patch:
diff --git a/admin/tests/test_integration.py b/admin/tests/test_integration.py
--- a/admin/tests/test_integration.py
+++ b/admin/tests/test_integration.py
@@ -475,6 +475,19 @@ def securedrop_git_repo(tmpdir):
                                                        test_name)])
 
 
+def set_reliable_keyserver(gpgdir):
+    # If gpg.conf doesn't exist, create it and set a reliable default
+    # keyserver for the tests.
+    gpgconf_path = os.path.join(gpgdir, 'gpg.conf')
+    if not os.path.exists(gpgconf_path):
+        os.mkdir(gpgdir)
+        with open(gpgconf_path, 'a') as f:
+            f.write('keyserver hkp://ipv4.pool.sks-keyservers.net')
+
+        # Ensure correct permissions on .gnupg home directory.
+        os.chmod(gpgdir, 0700)
+
+
 # This class is to test all the git related operations.
 class TestGitOperations:
     def test_check_for_update_when_updates_needed(self, securedrop_git_repo):
@@ -517,17 +530,7 @@ def test_check_for_update_when_updates_not_needed(self,
 
     def test_update(self, securedrop_git_repo):
         gpgdir = os.path.join(os.path.expanduser('~'), '.gnupg')
-
-        # If gpg.conf doesn't exist, create it and set a reliable default
-        # keyserver for the tests.
-        gpgconf_path = os.path.join(gpgdir, 'gpg.conf')
-        if not os.path.exists(gpgconf_path):
-            os.mkdir(gpgdir)
-            with open(gpgconf_path, 'a') as f:
-                f.write('keyserver hkp://ipv4.pool.sks-keyservers.net')
-
-            # Ensure correct permissions on .gnupg home directory.
-            os.chmod(gpgdir, 0700)
+        set_reliable_keyserver(gpgdir)
 
         cmd = os.path.join(os.path.dirname(CURRENT_DIR),
                            'securedrop_admin/__init__.py')
@@ -541,3 +544,33 @@ def test_update(self, securedrop_git_repo):
         child.close()
         assert child.exitstatus == 0
         assert child.signalstatus is None
+
+    def test_update_fails_when_no_signature_present(self, securedrop_git_repo):
+        gpgdir = os.path.join(os.path.expanduser('~'), '.gnupg')
+        set_reliable_keyserver(gpgdir)
+
+        # First we make a very high version tag of SecureDrop so that the
+        # updater will try to update to it. Since the tag is unsigned, it
+        # should fail.
+        subprocess.check_call('git checkout develop'.split())
+        subprocess.check_call('git tag 9999999.0.0'.split())
+
+        # Switch back to an older branch for the test
+        subprocess.check_call('git checkout 0.6'.split())
+
+        cmd = os.path.join(os.path.dirname(CURRENT_DIR),
+                           'securedrop_admin/__init__.py')
+        ansible_base = os.path.join(str(securedrop_git_repo),
+                                    'securedrop/install_files/ansible-base')
+        child = pexpect.spawn('coverage run {0} --root {1} update'.format(
+                              cmd, ansible_base))
+        output = child.read()
+        assert 'Updated to SecureDrop' not in output
+        assert 'Signature verification failed' in output
+
+        child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
+        child.close()
+
+        # Failures should eventually exit non-zero.
+        assert child.exitstatus != 0
+        assert child.signalstatus != 0