Repository: freedomofpress/securedrop

Issue:
[qt-journalist-updater] Run securedrop-admin setup
## Description

We rarely _need_ `securedrop-admin update` to be run (though we do in 0.7 as we're adding a dependency to `securedrop-admin`), but since it doesn't hurt, let's run it for each update. We can do this by adding a `QThread` to run `securedrop-admin setup`. Also update the time estimates provided in the updater. 

## User Stories

As a SecureDrop user, I don't want to have to manually run `./securedrop-admin setup` on the command line if everything else in the update happens automatically


Discussion:
I think @redshiftzero [meant](https://github.com/freedomofpress/securedrop/pull/3283/files#r182489560) `./securedrop-admin setup` not update.
Yes, I agree @kushaldas! `./securedrop-admin setup` is what should be run as part of `./securedrop-admin update`. 
The `./securedrop-admin setup` is taking around 3-4 minutes in my vms. I am adding 4 minutes extra in the time estimate as the first step, before the tool actually updates.
> The `./securedrop-admin setup` is taking around 3-4 minutes in my vms. I

But only on _first_ run, right? If no changes are required, then it only takes a few seconds for me.
>But only on first run, right? If no changes are required, then it only takes a few seconds for me.

Yes, otherwise it is taking around 50 seconds in my vm.

Resolving patch:
diff --git a/journalist_gui/journalist_gui/SecureDropUpdater.py b/journalist_gui/journalist_gui/SecureDropUpdater.py
--- a/journalist_gui/journalist_gui/SecureDropUpdater.py
+++ b/journalist_gui/journalist_gui/SecureDropUpdater.py
@@ -8,6 +8,37 @@
 from journalist_gui import updaterUI, strings, resources_rc  # noqa
 
 
+class SetupThread(QThread):
+    signal = pyqtSignal('PyQt_PyObject')
+
+    def __init__(self):
+        QThread.__init__(self)
+        self.output = ""
+        self.update_success = False
+        self.failure_reason = ""
+
+    def run(self):
+        sdadmin_path = '/home/amnesia/Persistent/securedrop/securedrop-admin'
+        update_command = [sdadmin_path, 'setup']
+        try:
+            self.output = subprocess.check_output(
+                update_command,
+                stderr=subprocess.STDOUT).decode('utf-8')
+            if 'Failed to install' in self.output:
+                self.update_success = False
+                self.failure_reason = strings.update_failed_generic_reason
+            else:
+                self.update_success = True
+        except subprocess.CalledProcessError as e:
+            self.output += e.output.decode('utf-8')
+            self.update_success = False
+            self.failure_reason = strings.update_failed_generic_reason
+        result = {'status': self.update_success,
+                  'output': self.output,
+                  'failure_reason': self.failure_reason}
+        self.signal.emit(result)
+
+
 # This thread will handle the ./securedrop-admin update command
 class UpdateThread(QThread):
     signal = pyqtSignal('PyQt_PyObject')
@@ -123,10 +154,30 @@ def __init__(self, parent=None):
         self.update_thread.signal.connect(self.update_status)
         self.tails_thread = TailsconfigThread()
         self.tails_thread.signal.connect(self.tails_status)
+        self.setup_thread = SetupThread()
+        self.setup_thread.signal.connect(self.setup_status)
 
-    # This will update the output text after the git commands.
     # At the end of this function, we will try to do tailsconfig.
     # A new slot will handle tailsconfig output
+    def setup_status(self, result):
+        "This is the slot for setup thread"
+        self.output += result['output']
+        self.update_success = result['status']
+        self.failure_reason = result['failure_reason']
+        self.progressBar.setProperty("value", 60)
+        self.plainTextEdit.setPlainText(self.output)
+        self.plainTextEdit.setReadOnly = True
+        if not self.update_success:  # Failed to do setup update
+            self.pushButton.setEnabled(True)
+            self.pushButton_2.setEnabled(True)
+            self.update_status_bar_and_output(self.failure_reason)
+            self.progressBar.setProperty("value", 0)
+            self.alert_failure(self.failure_reason)
+            return
+        self.progressBar.setProperty("value", 70)
+        self.call_tailsconfig()
+
+    # This will update the output text after the git commands.
     def update_status(self, result):
         "This is the slot for update thread"
         self.output += result['output']
@@ -136,7 +187,8 @@ def update_status(self, result):
         self.plainTextEdit.setPlainText(self.output)
         self.plainTextEdit.setReadOnly = True
         self.progressBar.setProperty("value", 50)
-        self.call_tailsconfig()
+        self.update_status_bar_and_output(strings.doing_setup)
+        self.setup_thread.start()
 
     def update_status_bar_and_output(self, status_message):
         """This method updates the status bar and the output window with the
@@ -185,8 +237,6 @@ def update_securedrop(self):
         self.pushButton.setEnabled(False)
         self.progressBar.setProperty("value", 10)
         self.update_status_bar_and_output(strings.fetching_update)
-        self.progressBar.setProperty("value", 20)
-        # Now start the git and gpg commands
         self.update_thread.start()
 
     def alert_success(self):
diff --git a/journalist_gui/journalist_gui/strings.py b/journalist_gui/journalist_gui/strings.py
--- a/journalist_gui/journalist_gui/strings.py
+++ b/journalist_gui/journalist_gui/strings.py
@@ -10,7 +10,7 @@
                       "automatically appear if you have not "
                       "completed any required updates.\n")
 fetching_update = ('Fetching and verifying latest update...'
-                   ' (4 mins remaining)')
+                   ' (5 mins remaining)')
 updating_tails_env = ('Configuring local Tails environment...'
                       ' (1 min remaining)')
 finished = 'Update successfully completed!'
@@ -40,3 +40,4 @@
 output_tab = 'Detailed Update Progress'
 initial_text_box = ("When the update begins, this area will populate with "
                     "output.\n")
+doing_setup = "Checking dependencies are up to date... (2 mins remaining)"


Test patch:
diff --git a/journalist_gui/test_gui.py b/journalist_gui/test_gui.py
--- a/journalist_gui/test_gui.py
+++ b/journalist_gui/test_gui.py
@@ -53,11 +53,31 @@ def test_output_tab(self):
         self.assertEqual(self.window.tabWidget.currentIndex(),
                          self.window.tabWidget.indexOf(self.window.tab_2))
 
+    @mock.patch('subprocess.check_output',
+                return_value=b'Python dependencies for securedrop-admin')
+    def test_setupThread(self, check_output):
+        with mock.patch.object(self.window, "call_tailsconfig",
+                               return_value=MagicMock()):
+            self.window.setup_thread.run()  # Call run directly
+            self.assertEqual(self.window.update_success, True)
+            self.assertEqual(self.window.progressBar.value(), 70)
+
+    @mock.patch('subprocess.check_output',
+                return_value=b'Failed to install pip dependencies')
+    def test_setupThread_failure(self, check_output):
+        with mock.patch.object(self.window, "call_tailsconfig",
+                               return_value=MagicMock()):
+            self.window.setup_thread.run()  # Call run directly
+            self.assertEqual(self.window.update_success, False)
+            self.assertEqual(self.window.progressBar.value(), 0)
+            self.assertEqual(self.window.failure_reason,
+                             strings.update_failed_generic_reason)
+
     @mock.patch('subprocess.check_output',
                 return_value=b'Signature verification successful')
     def test_updateThread(self, check_output):
-        with mock.patch.object(self.window, "call_tailsconfig",
-                               return_value=""):
+        with mock.patch.object(self.window, "setup_thread",
+                               return_value=MagicMock()):
             self.window.update_thread.run()  # Call run directly
             self.assertEqual(self.window.update_success, True)
             self.assertEqual(self.window.progressBar.value(), 50)
@@ -65,8 +85,8 @@ def test_updateThread(self, check_output):
     @mock.patch('subprocess.check_output',
                 return_value=b'Signature verification failed')
     def test_updateThread_failure(self, check_output):
-        with mock.patch.object(self.window, "call_tailsconfig",
-                               return_value=""):
+        with mock.patch.object(self.window, "setup_thread",
+                               return_value=MagicMock()):
             self.window.update_thread.run()  # Call run directly
             self.assertEqual(self.window.update_success, False)
             self.assertEqual(self.window.failure_reason,
@@ -76,8 +96,8 @@ def test_updateThread_failure(self, check_output):
                 side_effect=subprocess.CalledProcessError(
                     1, 'cmd', b'Generic other failure'))
     def test_updateThread_generic_failure(self, check_output):
-        with mock.patch.object(self.window, "call_tailsconfig",
-                               return_value=""):
+        with mock.patch.object(self.window, "setup_thread",
+                               return_value=MagicMock()):
             self.window.update_thread.run()  # Call run directly
             self.assertEqual(self.window.update_success, False)
             self.assertEqual(self.window.failure_reason,