From 2a883270c8a76be0d62bbb5e86068dc2103ab449 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 9 Jan 2020 00:29:12 -0800 Subject: [PATCH 01/31] add method to start export vm to Controller --- securedrop_client/logic.py | 10 ++++++++++ tests/test_logic.py | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index e123bfe8e..f210177ed 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -634,6 +634,14 @@ def on_file_open(self, file: db.File) -> None: process = QProcess(self) process.start(command, args) + def run_start_export_vm(self): + logger.info('Starting Export VM') + + if not self.qubes: + return + + self.export.start_export_vm.emit() + def run_export_preflight_checks(self): ''' Run preflight checks to make sure the Export VM is configured correctly. @@ -641,6 +649,7 @@ def run_export_preflight_checks(self): logger.info('Running export preflight checks') if not self.qubes: + self.export.export_usb_call_success.emit() return self.export.begin_preflight_check.emit() @@ -676,6 +685,7 @@ def print_file(self, file_uuid: str) -> None: return if not self.qubes: + self.export.print_call_success.emit() return self.export.begin_print.emit([file_location]) diff --git a/tests/test_logic.py b/tests/test_logic.py index 097335110..7add05dd7 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -1505,6 +1505,32 @@ def test_Controller_call_update_star_success(homedir, config, mocker, session_ma co.on_update_star_failure, type=Qt.QueuedConnection) +def test_Controller_run_start_export_vm(homedir, mocker, session, source): + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co.export = mocker.MagicMock() + co.export.start_export_vm = mocker.MagicMock() + co.export.start_export_vm.emit = mocker.MagicMock() + + co.run_start_export_vm() + + co.export.start_export_vm.emit.call_count == 1 + + +def test_Controller_run_start_export_vm_not_qubes(homedir, mocker, session, source): + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co.qubes = False + co.export = mocker.MagicMock() + co.export.start_export_vm = mocker.MagicMock() + co.export.start_export_vm.emit = mocker.MagicMock() + co.export.start_export_vm_success = mocker.MagicMock() + co.export.start_export_vm_success.emit = mocker.MagicMock() + + co.run_start_export_vm() + + co.export.start_export_vm.emit.call_count == 0 + co.export.start_export_vm_success.emit.call_count == 1 + + def test_Controller_run_print_file(mocker, session, homedir): co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() From bb961ec463d1d3589b9fcff19f8e6708be62069d Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 9 Jan 2020 01:24:36 -0800 Subject: [PATCH 02/31] connect gui to controller to start export vm --- securedrop_client/export.py | 4 +- securedrop_client/gui/widgets.py | 103 +++++++++++++++++-------------- securedrop_client/logic.py | 6 +- tests/gui/test_widgets.py | 49 +++++---------- tests/test_export.py | 2 +- 5 files changed, 79 insertions(+), 85 deletions(-) diff --git a/securedrop_client/export.py b/securedrop_client/export.py index 104c8fa8f..94b51ca98 100644 --- a/securedrop_client/export.py +++ b/securedrop_client/export.py @@ -75,7 +75,7 @@ class Export(QObject): # Set up signals for communication with the GUI thread preflight_check_call_failure = pyqtSignal(object) - preflight_check_call_success = pyqtSignal(str) + preflight_check_call_success = pyqtSignal() begin_usb_export = pyqtSignal(list, str) begin_preflight_check = pyqtSignal() export_usb_call_failure = pyqtSignal(object) @@ -258,7 +258,7 @@ def run_preflight_checks(self) -> None: self._run_usb_test(temp_dir) self._run_disk_test(temp_dir) logger.debug('completed preflight checks: success') - self.preflight_check_call_success.emit('success') + self.preflight_check_call_success.emit() except ExportError as e: logger.debug('completed preflight checks: failure') self.preflight_check_call_failure.emit(e) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index c0455da84..f26855fd6 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2042,10 +2042,7 @@ def _on_export_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - dialog = ExportDialog(self.controller, self.file.uuid, - self.file.filename) - dialog.show() - dialog.export() + dialog = ExportDialog(self.controller, self.file.uuid, self.file.original_filename) dialog.exec() @pyqtSlot() @@ -2057,8 +2054,6 @@ def _on_print_clicked(self): return dialog = PrintDialog(self.controller, self.file.uuid) - dialog.show() - dialog.print() dialog.exec() def _on_left_click(self): @@ -2161,16 +2156,20 @@ def __init__(self, controller, file_uuid): self.usb_error_message.setWordWrap(True) usb_instructions = SecureQLabel(_('Please connect your printer to a USB port.')) usb_instructions.setWordWrap(True) - buttons = QWidget() + usb_form_layout.addWidget(self.usb_error_message) + usb_form_layout.addWidget(usb_instructions) + + # Buttons + self.buttons = QWidget() buttons_layout = QHBoxLayout() - buttons.setLayout(buttons_layout) + self.buttons.setLayout(buttons_layout) cancel_button = QPushButton(_('CANCEL')) - retry_button = QPushButton(_('CONTINUE')) + cancel_button.clicked.connect(self.close) + self.continue_button = QPushButton(_('CONTINUE')) + self.continue_button.clicked.connect(self._on_continue_clicked) + self.continue_button.setEnabled(False) buttons_layout.addWidget(cancel_button) - buttons_layout.addWidget(retry_button) - usb_form_layout.addWidget(self.usb_error_message) - usb_form_layout.addWidget(usb_instructions) - usb_form_layout.addWidget(buttons, alignment=Qt.AlignRight) + buttons_layout.addWidget(self.continue_button) # Printing message self.printing_message = SecureQLabel(_('Printing...')) @@ -2180,19 +2179,19 @@ def __init__(self, controller, file_uuid): layout.addWidget(self.printing_message) layout.addWidget(self.generic_error) layout.addWidget(self.insert_usb_form) + layout.addWidget(self.buttons) - self.starting_message.show() self.printing_message.hide() self.generic_error.hide() self.insert_usb_form.hide() - cancel_button.clicked.connect(self.close) - retry_button.clicked.connect(self._on_retry_button_clicked) + # Connect controller signals to slots + self.controller.export.start_export_vm_success.connect(self._on_start_export_vm_success) + self.controller.export.start_export_vm_failure.connect(self._on_start_export_vm_failure) + self.controller.export.print_call_success.connect(self._on_print_success) + self.controller.export.print_call_failure.connect(self._on_print_failure) - self.controller.export.print_call_failure.connect( - self._on_print_failure, type=Qt.QueuedConnection) - self.controller.export.print_call_success.connect( - self._on_print_success, type=Qt.QueuedConnection) + self.controller.run_start_export_vm() def print(self): self.starting_message.hide() @@ -2202,9 +2201,17 @@ def print(self): self.controller.print_file(self.file_uuid) @pyqtSlot() - def _on_retry_button_clicked(self): + def _on_continue_clicked(self): self.print() + @pyqtSlot() + def _on_start_export_vm_success(self): + self.continue_button.setEnabled(True) + + @pyqtSlot(object) + def _on_start_export_vm_failure(self, error: ExportError): + self._update(error.status) + @pyqtSlot() def _on_print_success(self): self.close() @@ -2223,6 +2230,7 @@ def _update(self, status): self.printing_message.hide() self.generic_error.show() self.insert_usb_form.hide() + self.buttons.hide() def _request_to_insert_usb_device(self): self.starting_message.hide() @@ -2312,16 +2320,8 @@ def __init__(self, controller, file_uuid, file_name): 'Please insert one of the export drives provisioned specifically ' 'for the SecureDrop Workstation.')) usb_instructions.setWordWrap(True) - buttons = QWidget() - buttons_layout = QHBoxLayout() - buttons.setLayout(buttons_layout) - usb_cancel_button = QPushButton(_('CANCEL')) - retry_export_button = QPushButton(_('OK')) - buttons_layout.addWidget(usb_cancel_button) - buttons_layout.addWidget(retry_export_button) usb_form_layout.addWidget(self.usb_error_message) usb_form_layout.addWidget(usb_instructions) - usb_form_layout.addWidget(buttons, alignment=Qt.AlignRight) # Passphrase Form self.passphrase_form = QWidget() @@ -2337,18 +2337,10 @@ def __init__(self, controller, file_uuid, file_name): passphrase_label.setObjectName('passphrase_label') self.passphrase_field = QLineEdit() self.passphrase_field.setEchoMode(QLineEdit.Password) - buttons = QWidget() - buttons_layout = QHBoxLayout() - buttons.setLayout(buttons_layout) - passphrase_cancel_button = QPushButton(_('CANCEL')) - unlock_disk_button = QPushButton(_('SUBMIT')) - buttons_layout.addWidget(passphrase_cancel_button) - buttons_layout.addWidget(unlock_disk_button) passphrase_form_layout.addWidget(self.passphrase_error_message) passphrase_form_layout.addWidget(self.passphrase_instructions) passphrase_form_layout.addWidget(passphrase_label) passphrase_form_layout.addWidget(self.passphrase_field) - passphrase_form_layout.addWidget(buttons, alignment=Qt.AlignRight) self.passphrase_error_message.hide() # Starting export message @@ -2356,23 +2348,32 @@ def __init__(self, controller, file_uuid, file_name): 'File export in progress:\n' + self.file_name)) self.exporting_message.setWordWrap(True) + # Buttons + self.buttons = QWidget() + buttons_layout = QHBoxLayout() + self.buttons.setLayout(buttons_layout) + cancel_button = QPushButton(_('CANCEL')) + cancel_button.clicked.connect(self.close) + self.continue_button = QPushButton(_('CONTINUE')) + self.continue_button.clicked.connect(self._on_continue_clicked) + self.continue_button.setEnabled(False) + buttons_layout.addWidget(cancel_button) + buttons_layout.addWidget(self.continue_button) + layout.addWidget(self.starting_export_message) layout.addWidget(self.exporting_message) layout.addWidget(self.generic_error) layout.addWidget(self.insert_usb_form) layout.addWidget(self.passphrase_form) + layout.addWidget(self.buttons) - self.starting_export_message.show() self.exporting_message.hide() self.generic_error.hide() self.insert_usb_form.hide() self.passphrase_form.hide() - usb_cancel_button.clicked.connect(self.close) - passphrase_cancel_button.clicked.connect(self.close) - retry_export_button.clicked.connect(self._on_retry_export_button_clicked) - unlock_disk_button.clicked.connect(self._on_unlock_disk_clicked) - + self.controller.export.start_export_vm_success.connect(self._on_start_export_vm_success) + self.controller.export.start_export_vm_failure.connect(self._on_start_export_vm_failure) self.controller.export.preflight_check_call_failure.connect( self._on_preflight_check_call_failure, type=Qt.QueuedConnection) self.controller.export.export_usb_call_failure.connect( @@ -2382,12 +2383,18 @@ def __init__(self, controller, file_uuid, file_name): self.controller.export.export_usb_call_success.connect( self._on_export_success, type=Qt.QueuedConnection) - def export(self): - self.controller.run_export_preflight_checks() + self.controller.run_start_export_vm() @pyqtSlot() - def _on_retry_export_button_clicked(self): - self.starting_export_message.hide() + def _on_start_export_vm_success(self): + self.continue_button.setEnabled(True) + + @pyqtSlot(object) + def _on_start_export_vm_failure(self, error: ExportError): + self._update(error.status) + + @pyqtSlot() + def _on_continue_clicked(self): self.controller.run_export_preflight_checks() @pyqtSlot() @@ -2413,6 +2420,8 @@ def _request_to_insert_usb_device(self, encryption_not_supported: bool = False): @pyqtSlot() def _request_passphrase(self, bad_passphrase: bool = False): + self.continue_button.clicked.connect(self._on_unlock_disk_clicked) + logger.debug('requesting passphrase... ') self.starting_export_message.hide() self.exporting_message.hide() diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index f210177ed..72ab3aaca 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -43,7 +43,7 @@ SendReplyJobTimeoutError from securedrop_client.api_jobs.updatestar import UpdateStarJob, UpdateStarJobException from securedrop_client.crypto import GpgHelper -from securedrop_client.export import Export +from securedrop_client.export import Export, ExportError from securedrop_client.queue import ApiJobQueue from securedrop_client.sync import ApiSync from securedrop_client.utils import check_dir_permissions @@ -638,6 +638,7 @@ def run_start_export_vm(self): logger.info('Starting Export VM') if not self.qubes: + self.export.start_export_vm_success.emit() return self.export.start_export_vm.emit() @@ -649,7 +650,7 @@ def run_export_preflight_checks(self): logger.info('Running export preflight checks') if not self.qubes: - self.export.export_usb_call_success.emit() + self.export.preflight_check_call_success.emit() return self.export.begin_preflight_check.emit() @@ -668,6 +669,7 @@ def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: return if not self.qubes: + self.export.export_usb_call_success.emit() return self.export.begin_usb_export.emit([file_location], passphrase) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 55dd489cd..e08bf7687 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1738,8 +1738,6 @@ def test_FileWidget__on_export_clicked(mocker, session, source): fw._on_export_clicked() - controller.run_export_preflight_checks.assert_called_once_with() - # Also assert that the dialog is initialized dialog = mocker.patch('securedrop_client.gui.widgets.ExportDialog') fw._on_export_clicked() @@ -1789,8 +1787,6 @@ def test_FileWidget__on_print_clicked(mocker, session, source): fw._on_print_clicked() - controller.print_file.assert_called_once_with(file.uuid) - # Also assert that the dialog is initialized dialog = mocker.patch('securedrop_client.gui.widgets.PrintDialog') fw._on_print_clicked() @@ -1821,19 +1817,6 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): dialog.assert_not_called() -def test_ExportDialog_export(mocker): - """ - Ensure happy path runs preflight checks and requests passphrase. - """ - controller = mocker.MagicMock() - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase = mocker.MagicMock() - - export_dialog.export() - - controller.run_export_preflight_checks.assert_called_with() - - def test_ExportDialog_pre_flight_updates_dialog_on_CALLED_PROCESS_ERROR(mocker): """ Ensure CALLED_PROCESS_ERROR during pre-flight updates the dialog with the status code. @@ -1872,16 +1855,16 @@ def test_ExportDialog_export_updates_dialog_on_CALLED_PROCESS_ERROR(mocker): export_dialog._update.assert_called_once_with(called_process_error.status) -def test_ExportDialog__on_retry_export_button_clicked(mocker): - """ - Ensure happy path runs preflight checks. - """ - controller = mocker.MagicMock() - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') +# def test_ExportDialog__on_retry_export_button_clicked(mocker): +# """ +# Ensure happy path runs preflight checks. +# """ +# controller = mocker.MagicMock() +# export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._on_retry_export_button_clicked() +# export_dialog._on_retry_export_button_clicked() - controller.run_export_preflight_checks.assert_called_with() +# controller.run_export_preflight_checks.assert_called_with() def test_ExportDialog__update_export_button_clicked_USB_NOT_CONNECTED(mocker): @@ -2042,16 +2025,16 @@ def test_ExportDialog__update_after_CALLED_PROCESS_ERROR(mocker): export_dialog._request_passphrase.assert_not_called() -def test_PrintDialog__on_retry_button_clicked(mocker): - """ - Ensure happy path prints the file. - """ - controller = mocker.MagicMock() - dialog = PrintDialog(controller, 'mock_uuid') +# def test_PrintDialog__on_retry_button_clicked(mocker): +# """ +# Ensure happy path prints the file. +# """ +# controller = mocker.MagicMock() +# dialog = PrintDialog(controller, 'mock_uuid') - dialog._on_retry_button_clicked() +# dialog._on_retry_button_clicked() - controller.print_file.assert_called_with('mock_uuid') +# controller.print_file.assert_called_with('mock_uuid') def test_PrintDialog__update_print_button_clicked_PRINTER_NOT_FOUND(mocker): diff --git a/tests/test_export.py b/tests/test_export.py index fd9267cae..913c769ee 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -151,7 +151,7 @@ def test_run_preflight_checks(mocker): _run_usb_export.assert_called_once_with('mock_temp_dir') _run_disk_export.assert_called_once_with('mock_temp_dir') - export.preflight_check_call_success.emit.assert_called_once_with('success') + export.preflight_check_call_success.emit.assert_called_once_with() def test_run_preflight_checks_error(mocker): From 43d534f2b2eb72b8af34245f8ce69624a447fb39 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 9 Jan 2020 01:27:01 -0800 Subject: [PATCH 03/31] create FramelessModal and polish dialog --- securedrop_client/gui/widgets.py | 562 ++++++++++-------- .../resources/images/delete_close.svg | 11 + 2 files changed, 312 insertions(+), 261 deletions(-) create mode 100644 securedrop_client/resources/images/delete_close.svg diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index f26855fd6..8e201e251 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -26,11 +26,11 @@ from uuid import uuid4 from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer, QSize, pyqtBoundSignal, \ QObject, QPoint -from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient,\ - QKeySequence, QCursor -from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \ - QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ - QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect +from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient, QKeySequence +from PyQt5.QtWidgets import QApplication, QListWidget, QLabel, QWidget, QListWidgetItem, \ + QHBoxLayout, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ + QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect, QPushButton, \ + QDialogButtonBox, QLayout from securedrop_client.db import DraftReply, Source, Message, File, Reply, User from securedrop_client.storage import source_exists @@ -2053,7 +2053,7 @@ def _on_print_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - dialog = PrintDialog(self.controller, self.file.uuid) + dialog = PrintDialog(self.controller, self.file.uuid, self.file.original_filename) dialog.exec() def _on_left_click(self): @@ -2098,92 +2098,159 @@ def stop_button_animation(self): self.set_button_state() -class PrintDialog(QDialog): +class FramelessModal(QDialog): - CSS_FOR_DIALOG_WITH_ERROR = ''' - #print_dialog { - min-width: 830; - min-height: 430; + CSS = ''' + #frameless_modal { + min-width: 800px; + max-width: 800px; + min-height: 400px; + max-height: 800px; + background-color: #fff; border: 1px solid #2a319d; } - ''' - - CSS = ''' - #print_dialog { - min-width: 400; - max-width: 400; - min-height: 200; - max-height: 200; + #close_button { + border: none; + font-family: 'Source Sans Pro'; + font-weight: 600; + font-size: 12px; + color: #2a319d; + } + #header { + font-family: 'Montserrat'; + font-size: 24px; + color: #2a319d; + } + #header_line { + margin: 20px 0px 20px 0px; + min-height: 2px; + max-height: 2px; + background-color: rgba(42, 49, 157, 0.15); + border: none; + } + #body { + font-family: 'Montserrat'; + font-size: 16px; + color: #302aa3; + } + #button_box QPushButton { + margin: 0px 0px 0px 12px; + height: 40px; + padding-left: 20px; + padding-right: 20px; + border: 2px solid #2a319d; + font-family: 'Montserrat'; + font-weight: 500; + font-size: 15px; + color: #2a319d; + } + #button_box QPushButton::disabled { + border: 2px solid rgba(42, 49, 157, 0.4); + color: rgba(42, 49, 157, 0.4); + } + #button_message { + font-family: 'Source Sans Pro'; + font-weight: 500; + font-size: 16px; + color: #ff3366; + padding-bottom: 6px; } ''' - def __init__(self, controller, file_uuid): - super().__init__() + CONTENT_MARGIN = 40 - self.controller = controller - self.file_uuid = file_uuid + def __init__(self): + parent = QApplication.activeWindow() + super().__init__(parent) - self.setObjectName('print_dialog') + self.setObjectName('frameless_modal') self.setStyleSheet(self.CSS) - self.setWindowFlags(Qt.Popup) + self.setWindowFlags(Qt.Widget | Qt.FramelessWindowHint) self.setWindowModality(Qt.WindowModal) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - layout = QVBoxLayout(self) - self.setLayout(layout) + # Always display center of the application window + application_window_size = parent.geometry() + dialog_size = self.geometry() + x_center = (application_window_size.width() - dialog_size.width()) / 2 + y_center = (application_window_size.height() - dialog_size.height()) / 2 + self.move(x_center, y_center) + + # Set drop shadow effect + effect = QGraphicsDropShadowEffect(self) + effect.setOffset(0, 1) + effect.setBlurRadius(8) + effect.setColor(QColor('#aa000000')) + self.setGraphicsEffect(effect) + self.update() - # Opening VM message - self.starting_message = SecureQLabel(_('Preparing print...')) - self.starting_message.setWordWrap(True) - - # Widget to show error messages that occur during print - self.generic_error = QWidget() - self.generic_error.setObjectName('generic_error') - generic_error_layout = QHBoxLayout() - self.generic_error.setLayout(generic_error_layout) - self.error_status_code = SecureQLabel() - generic_error_message = SecureQLabel(_('See your administrator for help.')) - generic_error_message.setWordWrap(True) - generic_error_layout.addWidget(self.error_status_code) - generic_error_layout.addWidget(generic_error_message) - - # Insert USB Device Form - self.insert_usb_form = QWidget() - self.insert_usb_form.setObjectName('insert_usb_form') - usb_form_layout = QVBoxLayout() - self.insert_usb_form.setLayout(usb_form_layout) - self.usb_error_message = SecureQLabel(_( - 'Please try reconnecting your printer, or see your administrator for help.')) - self.usb_error_message.setWordWrap(True) - usb_instructions = SecureQLabel(_('Please connect your printer to a USB port.')) - usb_instructions.setWordWrap(True) - usb_form_layout.addWidget(self.usb_error_message) - usb_form_layout.addWidget(usb_instructions) + # Custom titlebar for close button + titlebar = QWidget() + titlebar_layout = QVBoxLayout() + titlebar.setLayout(titlebar_layout) + close_button = SvgPushButton('delete_close.svg', svg_size=QSize(10, 10)) + close_button.setObjectName('close_button') + close_button.setText('CLOSE') + close_button.clicked.connect(self.close) + titlebar_layout.addWidget(close_button, alignment=Qt.AlignRight) # Buttons - self.buttons = QWidget() - buttons_layout = QHBoxLayout() - self.buttons.setLayout(buttons_layout) + window_buttons = QWidget() + window_buttons.setObjectName('window_buttons') + button_layout = QVBoxLayout() + window_buttons.setLayout(button_layout) cancel_button = QPushButton(_('CANCEL')) + cancel_button.setAutoDefault(False) cancel_button.clicked.connect(self.close) self.continue_button = QPushButton(_('CONTINUE')) - self.continue_button.clicked.connect(self._on_continue_clicked) - self.continue_button.setEnabled(False) - buttons_layout.addWidget(cancel_button) - buttons_layout.addWidget(self.continue_button) + button_box = QDialogButtonBox(Qt.Horizontal) + button_box.setObjectName('button_box') + button_box.addButton(cancel_button, QDialogButtonBox.ActionRole) + button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) + self.button_message = QLabel() + self.button_message.setObjectName('button_message') + button_layout.addWidget(self.button_message, alignment=Qt.AlignRight) + button_layout.addWidget(button_box, alignment=Qt.AlignRight) + + # Content including: header, body, help menu, and buttons + content = QWidget() + content.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + content_layout = QVBoxLayout() + content.setLayout(content_layout) + content_layout.setContentsMargins( + self.CONTENT_MARGIN, 0, self.CONTENT_MARGIN, self.CONTENT_MARGIN) + self.header = QLabel() + self.header.setObjectName('header') + self.header.setWordWrap(True) + header_line = QWidget() + header_line.setObjectName('header_line') + self.body = QLabel() + self.body.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + self.body.setObjectName('body') + self.body.setWordWrap(True) + self.body.setScaledContents(True) + self.body_layout = QVBoxLayout() + self.body.setLayout(self.body_layout) + content_layout.addWidget(self.header) + content_layout.addWidget(header_line) + content_layout.addWidget(self.body) + content_layout.addWidget(window_buttons) + + # Layout + layout = QVBoxLayout(self) + self.setLayout(layout) + layout.addWidget(titlebar) + layout.addWidget(content) - # Printing message - self.printing_message = SecureQLabel(_('Printing...')) - self.printing_message.setWordWrap(True) - layout.addWidget(self.starting_message) - layout.addWidget(self.printing_message) - layout.addWidget(self.generic_error) - layout.addWidget(self.insert_usb_form) - layout.addWidget(self.buttons) +class PrintDialog(FramelessModal): - self.printing_message.hide() - self.generic_error.hide() - self.insert_usb_form.hide() + def __init__(self, controller: Controller, file_uuid: str, file_name: str): + super().__init__() + + self.controller = controller + self.file_uuid = file_uuid + self.file_name = file_name # Connect controller signals to slots self.controller.export.start_export_vm_success.connect(self._on_start_export_vm_success) @@ -2191,25 +2258,73 @@ def __init__(self, controller, file_uuid): self.controller.export.print_call_success.connect(self._on_print_success) self.controller.export.print_call_failure.connect(self._on_print_failure) + # Connect parent signals to slots + self.continue_button.clicked.connect(self._on_continue_clicked) + self.continue_button.setEnabled(False) + + # Dialog content + self.starting_header = _( + 'Preparing to print:' + '
' + '{}'.format(self.file_name)) + self.insert_usb_header = _('Insert USB printer') + self.error_header = _('Unable to print') + self.starting_message = _( + '

Proceed with caution when exporting files

' + 'Documents submitted by sources may contain information that identifies who they are. ' + 'To protect your sources, please consider redacting documents before printing them.') + self.insert_usb_message = _('Please connect your printer to a USB port.') + self.generic_error_message = _('See your administrator for help.') + self.usb_error_message = _( + 'Please try reconnecting your printer, or see your administrator for help.') + self.continue_disabled_message = _( + 'The CONTINUE button will be disabled until the Export VM is ready') + + self._show_starting_instructions() self.controller.run_start_export_vm() - def print(self): - self.starting_message.hide() - self.printing_message.show() - self.generic_error.hide() - self.insert_usb_form.hide() - self.controller.print_file(self.file_uuid) + def _show_starting_instructions(self): + self.header.setText(self.starting_header) + self.body.setText(self.starting_message) + self.button_message.setText('' + self.continue_disabled_message + '') + + def _show_insert_usb_message(self): + self.header.setText(self.insert_usb_header) + self.body.setText(self.insert_usb_message) + + def _show_generic_error_message(self, error_code: str): + self.header.setText(self.error_header) + if not error_code: + self.body.setText(self.generic_error_message) + else: + self.body.setText(error_code + '\n' + self.generic_error_message) + + def _show_usb_error_message(self, error_code: str): + self.header.setText(self.error_header) + if not error_code: + self.body.setText(self.usb_error_message) + else: + message = error_code + '\n' + self.usb_error_message + self.body.setText(message) + + def _update(self, status: str): + if status == ExportStatus.PRINTER_NOT_FOUND.value: + self._show_insert_usb_message() + else: + self._show_generic_error_message(status) @pyqtSlot() def _on_continue_clicked(self): - self.print() + self.controller.print_file(self.file_uuid) @pyqtSlot() def _on_start_export_vm_success(self): + self.button_message.hide() self.continue_button.setEnabled(True) @pyqtSlot(object) def _on_start_export_vm_failure(self, error: ExportError): + self.button_message.hide() self._update(error.status) @pyqtSlot() @@ -2220,50 +2335,10 @@ def _on_print_success(self): def _on_print_failure(self, error: ExportError): self._update(error.status) - def _update(self, status): - logger.debug('updating status... ') - if status == ExportStatus.PRINTER_NOT_FOUND.value: - self._request_to_insert_usb_device() - else: - self.error_status_code.setText(_(status)) - self.starting_message.hide() - self.printing_message.hide() - self.generic_error.show() - self.insert_usb_form.hide() - self.buttons.hide() - - def _request_to_insert_usb_device(self): - self.starting_message.hide() - self.printing_message.hide() - self.generic_error.hide() - self.insert_usb_form.show() - - -class ExportDialog(QDialog): - - CSS = ''' - #export_dialog { - min-width: 830; - min-height: 330; - border: 1px solid #2a319d; - } - ''' - CSS_FOR_DIALOG_WITH_ERROR = ''' - #export_dialog { - min-width: 830; - min-height: 430; - border: 1px solid #2a319d; - } - ''' +class ExportDialog(FramelessModal): - CSS = ''' - #export_dialog { - min-width: 400; - max-width: 400; - min-height: 200; - max-height: 200; - } + PASSPHRASE_FORM_CSS = ''' #passphrase_label { font-family: 'Montserrat'; font-weight: 500; @@ -2276,186 +2351,151 @@ class ExportDialog(QDialog): } ''' - def __init__(self, controller, file_uuid, file_name): + def __init__(self, controller: Controller, file_uuid: str, file_name: str): super().__init__() self.controller = controller self.file_uuid = file_uuid self.file_name = file_name - self.setObjectName('export_dialog') - self.setStyleSheet(self.CSS) - self.setWindowFlags(Qt.Popup) - self.setWindowModality(Qt.WindowModal) + # Connect controller signals to slots + self.controller.export.start_export_vm_success.connect(self._on_start_export_vm_success) + self.controller.export.start_export_vm_failure.connect(self._on_start_export_vm_failure) + self.controller.export.preflight_check_call_success.connect(self._on_preflight_success) + self.controller.export.preflight_check_call_failure.connect(self._on_preflight_failure) + self.controller.export.export_usb_call_success.connect(self._on_export_success) + self.controller.export.export_usb_call_failure.connect(self._on_export_failure) - layout = QVBoxLayout(self) - self.setLayout(layout) + # Connect parent signals to slots + self.continue_button.clicked.connect(self._on_continue_clicked) + self.continue_button.setEnabled(False) - # Starting export message - self.starting_export_message = SecureQLabel(_( - 'Preparing to export:\n' + self.file_name)) - self.starting_export_message.setWordWrap(True) - - # Widget to show error messages that occur during an export - self.generic_error = QWidget() - self.generic_error.setObjectName('gener_error') - generic_error_layout = QHBoxLayout() - self.generic_error.setLayout(generic_error_layout) - self.error_status_code = SecureQLabel() - generic_error_message = SecureQLabel(_('See your administrator for help.')) - generic_error_message.setWordWrap(True) - generic_error_layout.addWidget(self.error_status_code) - generic_error_layout.addWidget(generic_error_message) - - # Insert USB Device Form - self.insert_usb_form = QWidget() - self.insert_usb_form.setObjectName('insert_usb_form') - usb_form_layout = QVBoxLayout() - self.insert_usb_form.setLayout(usb_form_layout) - self.usb_error_message = SecureQLabel(_( - 'Either the drive is not LUKS-encrypted, or there is something ' - 'else wrong with it.')) - self.usb_error_message.setWordWrap(True) - usb_instructions = SecureQLabel(_( + # Dialog content + self.starting_header = _( + 'Preparing to export:' + '
' + '{}'.format(self.file_name)) + self.insert_usb_header = _('Insert encrypted USB drive') + self.passphrase_header = _('Enter passphrase for USB drive') + self.error_header = _('Unable to export') + self.starting_message = _( + '

Proceed with caution when exporting files

' + 'Malware' + '
' + 'This workstation lets you open documents securely. If you open documents on another ' + 'computer, any embedded malware may spread to your computer or network. If you are ' + 'unsure how to manage this risk, please print the document, or contact your ' + 'administrator.' + '

' + 'Anonymity' + '
' + 'Documents submitted by sources may contain information or hidden metadata that ' + 'identifies who they are. To protect your sources, please consider redacting documents ' + 'before working with them on network-connected computers.') + self.exporting_message = _('Exporting: {}'.format(self.file_name)) + self.insert_usb_message = _( 'Please insert one of the export drives provisioned specifically ' - 'for the SecureDrop Workstation.')) - usb_instructions.setWordWrap(True) - usb_form_layout.addWidget(self.usb_error_message) - usb_form_layout.addWidget(usb_instructions) + 'for the SecureDrop Workstation.') + self.usb_error_message = _( + 'Either the drive is not encrypted or there is something else wrong with it. Please ' + 'try another drive, or see your administrator for help.') + self.passphrase_error_message = _('The passphrase provided did not work. Please try again.') + self.generic_error_message = _('See your administrator for help.') + self.continue_disabled_message = _( + 'The CONTINUE button will be disabled until the Export VM is ready') # Passphrase Form - self.passphrase_form = QWidget() - self.passphrase_form.setObjectName('passphrase_form') - passphrase_form_layout = QVBoxLayout() - self.passphrase_form.setLayout(passphrase_form_layout) - self.passphrase_error_message = SecureQLabel(_( - 'The passphrase provided did not work. Please try again.')) - self.passphrase_error_message.setWordWrap(True) - self.passphrase_instructions = SecureQLabel(_('Enter the passphrase for this drive')) - self.passphrase_instructions.setWordWrap(True) passphrase_label = SecureQLabel(_('Passphrase')) passphrase_label.setObjectName('passphrase_label') self.passphrase_field = QLineEdit() self.passphrase_field.setEchoMode(QLineEdit.Password) - passphrase_form_layout.addWidget(self.passphrase_error_message) - passphrase_form_layout.addWidget(self.passphrase_instructions) + self.passphrase_form = QWidget() + self.passphrase_form.setStyleSheet(self.PASSPHRASE_FORM_CSS) + self.passphrase_form.setObjectName('passphrase_form') + passphrase_form_layout = QVBoxLayout() + self.passphrase_form.setLayout(passphrase_form_layout) passphrase_form_layout.addWidget(passphrase_label) passphrase_form_layout.addWidget(self.passphrase_field) - self.passphrase_error_message.hide() - - # Starting export message - self.exporting_message = SecureQLabel(_( - 'File export in progress:\n' + self.file_name)) - self.exporting_message.setWordWrap(True) - - # Buttons - self.buttons = QWidget() - buttons_layout = QHBoxLayout() - self.buttons.setLayout(buttons_layout) - cancel_button = QPushButton(_('CANCEL')) - cancel_button.clicked.connect(self.close) - self.continue_button = QPushButton(_('CONTINUE')) - self.continue_button.clicked.connect(self._on_continue_clicked) - self.continue_button.setEnabled(False) - buttons_layout.addWidget(cancel_button) - buttons_layout.addWidget(self.continue_button) - - layout.addWidget(self.starting_export_message) - layout.addWidget(self.exporting_message) - layout.addWidget(self.generic_error) - layout.addWidget(self.insert_usb_form) - layout.addWidget(self.passphrase_form) - layout.addWidget(self.buttons) - - self.exporting_message.hide() - self.generic_error.hide() - self.insert_usb_form.hide() + self.body_layout.addWidget(self.passphrase_form) self.passphrase_form.hide() - self.controller.export.start_export_vm_success.connect(self._on_start_export_vm_success) - self.controller.export.start_export_vm_failure.connect(self._on_start_export_vm_failure) - self.controller.export.preflight_check_call_failure.connect( - self._on_preflight_check_call_failure, type=Qt.QueuedConnection) - self.controller.export.export_usb_call_failure.connect( - self._on_export_usb_call_failure, type=Qt.QueuedConnection) - self.controller.export.preflight_check_call_success.connect( - self._request_passphrase, type=Qt.QueuedConnection) - self.controller.export.export_usb_call_success.connect( - self._on_export_success, type=Qt.QueuedConnection) - + self._show_starting_instructions() self.controller.run_start_export_vm() - @pyqtSlot() - def _on_start_export_vm_success(self): - self.continue_button.setEnabled(True) + def _show_starting_instructions(self): + self.header.setText(self.starting_header) + self.body.setText(self.starting_message) + self.button_message.setText('' + self.continue_disabled_message + '') - @pyqtSlot(object) - def _on_start_export_vm_failure(self, error: ExportError): - self._update(error.status) + def _show_passphrase_request_message(self): + self.header.setText(self.passphrase_header) + self.body.setText('') + self.passphrase_form.show() - @pyqtSlot() - def _on_continue_clicked(self): - self.controller.run_export_preflight_checks() + def _show_passphrase_request_message_again(self): + self.header.setText(self.passphrase_header) + self.body.setText(self.passphrase_message) + self.passphrase_form.show() - @pyqtSlot() - def _on_unlock_disk_clicked(self): + def _show_insert_usb_message(self): + self.header.setText(self.insert_usb_header) + self.body.setText(self.insert_usb_message) self.passphrase_form.hide() - self.exporting_message.show() - passphrase = self.passphrase_field.text() - self.controller.export_file_to_usb_drive(self.file_uuid, passphrase) - @pyqtSlot() - def _on_export_success(self): - self.close() + def _show_insert_encrypted_usb_message(self): + self.header.setText(self.error_header) + self.body.setText( + '{}\n\n{}'.format(self.usb_error_message, self.insert_usb_message)) + self.passphrase_form.hide() - def _request_to_insert_usb_device(self, encryption_not_supported: bool = False): - self.starting_export_message.hide() + def _show_generic_error_message(self, error_code: str): + self.header.setText(self.error_header) + self.body.setText('{}\n\n{}'.format(error_code, self.generic_error_message)) self.passphrase_form.hide() - self.insert_usb_form.show() - if encryption_not_supported: - self.usb_error_message.show() + def _update(self, status): + if status == ExportStatus.USB_NOT_CONNECTED.value: + self._show_insert_usb_message() + elif status == ExportStatus.BAD_PASSPHRASE.value: + self._show_passphrase_request_message_again() + elif status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + self._show_insert_encrypted_usb_message() else: - self.usb_error_message.hide() + self._show_generic_error_message(_(status)) @pyqtSlot() - def _request_passphrase(self, bad_passphrase: bool = False): - self.continue_button.clicked.connect(self._on_unlock_disk_clicked) + def _on_continue_clicked(self): + self.controller.run_export_preflight_checks() - logger.debug('requesting passphrase... ') - self.starting_export_message.hide() - self.exporting_message.hide() - self.insert_usb_form.hide() - self.passphrase_form.show() + @pyqtSlot() + def _on_continue_clicked_after_preflight(self, checked: bool = False): + self.controller.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) - if bad_passphrase: - self.passphrase_instructions.hide() - self.passphrase_error_message.show() - else: - self.passphrase_error_message.hide() - self.passphrase_instructions.show() + @pyqtSlot() + def _on_start_export_vm_success(self): + self.button_message.hide() + self.continue_button.setEnabled(True) - def _update(self, status): - logger.debug('updating status... ') - if status == ExportStatus.USB_NOT_CONNECTED.value: - self._request_to_insert_usb_device() - elif status == ExportStatus.BAD_PASSPHRASE.value: - self._request_passphrase(True) - elif status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: - self._request_to_insert_usb_device(True) - else: - self.error_status_code.setText(_(status)) - self.generic_error.show() - self.starting_export_message.hide() - self.passphrase_form.hide() - self.insert_usb_form.hide() + @pyqtSlot(object) + def _on_start_export_vm_failure(self, error: ExportError): + self.button_message.hide() + self._update(error.status) + + @pyqtSlot() + def _on_preflight_success(self): + self.continue_button.clicked.connect(self._on_continue_clicked_after_preflight) + self._show_passphrase_request_message() @pyqtSlot(object) - def _on_preflight_check_call_failure(self, error: ExportError): + def _on_preflight_failure(self, error: ExportError): self._update(error.status) + @pyqtSlot() + def _on_export_success(self): + self.close() + @pyqtSlot(object) - def _on_export_usb_call_failure(self, error: ExportError): + def _on_export_failure(self, error: ExportError): self._update(error.status) diff --git a/securedrop_client/resources/images/delete_close.svg b/securedrop_client/resources/images/delete_close.svg new file mode 100644 index 000000000..cbf8fedf7 --- /dev/null +++ b/securedrop_client/resources/images/delete_close.svg @@ -0,0 +1,11 @@ + + + + Delete-X + Created with Sketch. + + + + + + \ No newline at end of file From 8c12588933acef7684e50c32b8501d33878eb229 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 13 Jan 2020 12:47:53 -0800 Subject: [PATCH 04/31] Update tests for export and print dialogs --- securedrop_client/gui/widgets.py | 23 +- securedrop_client/logic.py | 2 +- tests/gui/test_widgets.py | 589 ++++++++++++++++++++----------- 3 files changed, 382 insertions(+), 232 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 8e201e251..2aea33b38 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -30,7 +30,7 @@ from PyQt5.QtWidgets import QApplication, QListWidget, QLabel, QWidget, QListWidgetItem, \ QHBoxLayout, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect, QPushButton, \ - QDialogButtonBox, QLayout + QDialogButtonBox from securedrop_client.db import DraftReply, Source, Message, File, Reply, User from securedrop_client.storage import source_exists @@ -2275,8 +2275,6 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): 'To protect your sources, please consider redacting documents before printing them.') self.insert_usb_message = _('Please connect your printer to a USB port.') self.generic_error_message = _('See your administrator for help.') - self.usb_error_message = _( - 'Please try reconnecting your printer, or see your administrator for help.') self.continue_disabled_message = _( 'The CONTINUE button will be disabled until the Export VM is ready') @@ -2294,18 +2292,7 @@ def _show_insert_usb_message(self): def _show_generic_error_message(self, error_code: str): self.header.setText(self.error_header) - if not error_code: - self.body.setText(self.generic_error_message) - else: - self.body.setText(error_code + '\n' + self.generic_error_message) - - def _show_usb_error_message(self, error_code: str): - self.header.setText(self.error_header) - if not error_code: - self.body.setText(self.usb_error_message) - else: - message = error_code + '\n' + self.usb_error_message - self.body.setText(message) + self.body.setText('{}: {}'.format(error_code, self.generic_error_message)) def _update(self, status: str): if status == ExportStatus.PRINTER_NOT_FOUND.value: @@ -2434,7 +2421,7 @@ def _show_passphrase_request_message(self): def _show_passphrase_request_message_again(self): self.header.setText(self.passphrase_header) - self.body.setText(self.passphrase_message) + self.body.setText(self.passphrase_error_message) self.passphrase_form.show() def _show_insert_usb_message(self): @@ -2443,14 +2430,14 @@ def _show_insert_usb_message(self): self.passphrase_form.hide() def _show_insert_encrypted_usb_message(self): - self.header.setText(self.error_header) + self.header.setText(self.insert_usb_header) self.body.setText( '{}\n\n{}'.format(self.usb_error_message, self.insert_usb_message)) self.passphrase_form.hide() def _show_generic_error_message(self, error_code: str): self.header.setText(self.error_header) - self.body.setText('{}\n\n{}'.format(error_code, self.generic_error_message)) + self.body.setText('{}: {}'.format(error_code, self.generic_error_message)) self.passphrase_form.hide() def _update(self, status): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 72ab3aaca..0e590d9e6 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -43,7 +43,7 @@ SendReplyJobTimeoutError from securedrop_client.api_jobs.updatestar import UpdateStarJob, UpdateStarJobException from securedrop_client.crypto import GpgHelper -from securedrop_client.export import Export, ExportError +from securedrop_client.export import Export from securedrop_client.queue import ApiJobQueue from securedrop_client.sync import ApiSync from securedrop_client.utils import check_dir_permissions diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index e08bf7687..15931dc2d 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1736,10 +1736,8 @@ def test_FileWidget__on_export_clicked(mocker, session, source): controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - fw._on_export_clicked() - - # Also assert that the dialog is initialized dialog = mocker.patch('securedrop_client.gui.widgets.ExportDialog') + fw._on_export_clicked() dialog.assert_called_once_with(controller, file.uuid, file.filename) @@ -1785,12 +1783,11 @@ def test_FileWidget__on_print_clicked(mocker, session, source): controller.print_file = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - fw._on_print_clicked() - - # Also assert that the dialog is initialized dialog = mocker.patch('securedrop_client.gui.widgets.PrintDialog') + fw._on_print_clicked() - dialog.assert_called_once_with(controller, file.uuid) + + dialog.assert_called_once_with(controller, file.uuid, file.original_filename) def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): @@ -1817,328 +1814,494 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): dialog.assert_not_called() -def test_ExportDialog_pre_flight_updates_dialog_on_CALLED_PROCESS_ERROR(mocker): - """ - Ensure CALLED_PROCESS_ERROR during pre-flight updates the dialog with the status code. - """ - controller = mocker.MagicMock() - called_process_error = ExportError(ExportStatus.CALLED_PROCESS_ERROR.value) - controller.run_export_preflight_checks = mocker.MagicMock(side_effect=called_process_error) - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase = mocker.MagicMock() - export_dialog._request_to_insert_usb_device = mocker.MagicMock() - export_dialog._update = mocker.MagicMock() +def test_ExportDialog_init(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + _show_starting_instructions_fn = mocker.patch( + 'securedrop_client.gui.widgets.ExportDialog._show_starting_instructions') - export_dialog._on_preflight_check_call_failure(called_process_error) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase.assert_not_called() - export_dialog._request_to_insert_usb_device.assert_not_called() - export_dialog._update.assert_called_once_with(called_process_error.status) + _show_starting_instructions_fn.assert_called_once_with() + assert dialog.passphrase_form.isHidden() -def test_ExportDialog_export_updates_dialog_on_CALLED_PROCESS_ERROR(mocker): - """ - Ensure CALLED_PROCESS_ERROR during export updates the dialog with the status code. - """ - controller = mocker.MagicMock() - called_process_error = ExportError(ExportStatus.CALLED_PROCESS_ERROR.value) - controller.run_export_preflight_checks = mocker.MagicMock(side_effect=called_process_error) - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase = mocker.MagicMock() - export_dialog._request_to_insert_usb_device = mocker.MagicMock() - export_dialog._update = mocker.MagicMock() +def test_ExportDialog__show_starting_instructions(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_starting_instructions() + + assert dialog.passphrase_form.isHidden() + assert dialog.button_message.text() == \ + 'The CONTINUE button will be disabled until the Export VM is ready' + assert dialog.header.text() == 'Preparing to export:
mock.jpg' + assert dialog.body.text() == \ + '

Proceed with caution when exporting files

' \ + 'Malware' \ + '
' \ + 'This workstation lets you open documents securely. If you open documents on another ' \ + 'computer, any embedded malware may spread to your computer or network. If you are ' \ + 'unsure how to manage this risk, please print the document, or contact your ' \ + 'administrator.' \ + '

' \ + 'Anonymity' \ + '
' \ + 'Documents submitted by sources may contain information or hidden metadata that ' \ + 'identifies who they are. To protect your sources, please consider redacting documents ' \ + 'before working with them on network-connected computers.' \ + + +def test_ExportDialog___show_passphrase_request_message(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog._on_export_usb_call_failure(called_process_error) + dialog._show_passphrase_request_message() - export_dialog._request_passphrase.assert_not_called() - export_dialog._request_to_insert_usb_device.assert_not_called() - export_dialog._update.assert_called_once_with(called_process_error.status) + assert not dialog.passphrase_form.isHidden() + assert dialog.header.text() == 'Enter passphrase for USB drive' + assert dialog.body.text() == '' -# def test_ExportDialog__on_retry_export_button_clicked(mocker): -# """ -# Ensure happy path runs preflight checks. -# """ -# controller = mocker.MagicMock() -# export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') +def test_ExportDialog__show_passphrase_request_message_again(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_passphrase_request_message_again() + + assert not dialog.passphrase_form.isHidden() + assert dialog.header.text() == 'Enter passphrase for USB drive' + assert dialog.body.text() == 'The passphrase provided did not work. Please try again.' + + +def test_ExportDialog__show_insert_usb_message(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_insert_usb_message() + + assert dialog.header.text() == 'Insert encrypted USB drive' + assert dialog.body.text() == \ + 'Please insert one of the export drives provisioned specifically ' \ + 'for the SecureDrop Workstation.' + assert dialog.passphrase_form.isHidden() + + +def test_ExportDialog__show_insert_encrypted_usb_message(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_insert_encrypted_usb_message() + + assert dialog.header.text() == 'Insert encrypted USB drive' + assert dialog.body.text() == \ + 'Either the drive is not encrypted or there is something else wrong with it. Please ' \ + 'try another drive, or see your administrator for help.' \ + '\n\n' \ + 'Please insert one of the export drives provisioned specifically for the SecureDrop ' \ + 'Workstation.' + assert dialog.passphrase_form.isHidden() -# export_dialog._on_retry_export_button_clicked() -# controller.run_export_preflight_checks.assert_called_with() +def test_ExportDialog__show_generic_error_message(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_generic_error_message('mock_error_status') + assert dialog.passphrase_form.isHidden() + assert dialog.header.text() == 'Unable to export' + assert dialog.body.text() == 'mock_error_status: See your administrator for help.' -def test_ExportDialog__update_export_button_clicked_USB_NOT_CONNECTED(mocker): + +def test_ExportDialog__update_when_status_is_USB_NOT_CONNECTED(mocker): """ Ensure request to insert USB device on USB_NOT_CONNECTED. """ - controller = mocker.MagicMock() - usb_not_connected_error = ExportError(ExportStatus.USB_NOT_CONNECTED.value) - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase = mocker.MagicMock() - export_dialog._request_to_insert_usb_device = mocker.MagicMock() + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_insert_usb_message = mocker.MagicMock() - export_dialog._update(usb_not_connected_error.status) + dialog._update(ExportStatus.USB_NOT_CONNECTED.value) - export_dialog._request_passphrase.assert_not_called() - export_dialog._request_to_insert_usb_device.assert_called_once_with() + dialog._show_insert_usb_message.assert_called_once_with() -def test_ExportDialog__request_to_insert_usb_device(mocker): - """Ensure that the correct widgets are visible or hidden.""" - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') +def test_ExportDialog__update_when_status_is_BAD_PASSPHRASE(mocker): + """ + Ensure request to enter passphrase again on BAD_PASSPHRASE. + """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_passphrase_request_message_again = mocker.MagicMock() - export_dialog._request_to_insert_usb_device() + dialog._update(ExportStatus.BAD_PASSPHRASE.value) - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.insert_usb_form.isHidden() - assert export_dialog.usb_error_message.isHidden() + dialog._show_passphrase_request_message_again.assert_called_once_with() -def test_ExportDialog__request_to_insert_usb_device_after_encryption_error(mocker): - """Ensure that the correct widgets are visible or hidden.""" - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') +def test_ExportDialog__update_when_status_is_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): + """ + Ensure request to insert USB device on USB_NOT_CONNECTED. + """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_insert_encrypted_usb_message = mocker.MagicMock() - export_dialog._request_to_insert_usb_device(encryption_not_supported=True) + dialog._update(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.insert_usb_form.isHidden() - assert not export_dialog.usb_error_message.isHidden() + dialog._show_insert_encrypted_usb_message.assert_called_once_with() -def test_ExportDialog__request_passphrase(mocker): - """Ensure that the correct widgets are visible or hidden.""" - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') +def test_ExportDialog__update_when_status_is_CALLED_PROCESS_ERROR(mocker): + """ + Ensure request to insert USB device on USB_NOT_CONNECTED. + """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_generic_error_message = mocker.MagicMock() - export_dialog._request_passphrase() + dialog._update(ExportStatus.CALLED_PROCESS_ERROR.value) - assert not export_dialog.passphrase_form.isHidden() - assert export_dialog.insert_usb_form.isHidden() - assert export_dialog.passphrase_error_message.isHidden() - assert not export_dialog.passphrase_instructions.isHidden() + dialog._show_generic_error_message.assert_called_once_with('CALLED_PROCESS_ERROR') -def test_ExportDialog__request_passphrase_more_than_once(mocker): - """Ensure that the correct widgets are visible or hidden.""" - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') +def test_ExportDialog__update_when_status_is_unknown(mocker): + """ + Ensure request to insert USB device on USB_NOT_CONNECTED. + """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_generic_error_message = mocker.MagicMock() - export_dialog._request_passphrase(bad_passphrase=True) + dialog._update('Some Unknown Error Status') - assert not export_dialog.passphrase_form.isHidden() - assert export_dialog.insert_usb_form.isHidden() - assert not export_dialog.passphrase_error_message.isHidden() - assert export_dialog.passphrase_instructions.isHidden() + dialog._show_generic_error_message.assert_called_once_with('Some Unknown Error Status') -def test_ExportDialog__on_export_success_closes_window(mocker): +def test_ExportDialog__on_continue_clicked(mocker): """ - Ensure successful export results in the export dialog window closing. + Ensure happy path runs preflight checks. """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) controller = mocker.MagicMock() - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog.close = mocker.MagicMock() + dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._on_export_success() + dialog._on_continue_clicked() - export_dialog.close.assert_called_once_with() + controller.run_export_preflight_checks.assert_called_with() -def test_ExportDialog__on_unlock_disk_clicked(mocker): +def test_ExportDialog__on_continue_clicked_after_preflight(mocker): """ Ensure export of file begins once the passphrase is retrieved from the uesr. """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) controller = mocker.MagicMock() controller.export_file_to_usb_drive = mocker.MagicMock() - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._update = mocker.MagicMock() - export_dialog.passphrase_field.text = mocker.MagicMock(return_value='mock_passphrase') + dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') + dialog.passphrase_field.text = mocker.MagicMock(return_value='mock_passphrase') - export_dialog._on_unlock_disk_clicked() + dialog._on_continue_clicked_after_preflight() controller.export_file_to_usb_drive.assert_called_once_with('mock_uuid', 'mock_passphrase') - export_dialog._update.assert_not_called() -def test_ExportDialog__on_unlock_disk_clicked_asks_for_passphrase_again_on_error(mocker): - """ - Ensure user is asked for passphrase when there is a bad passphrase error. - """ - controller = mocker.MagicMock() - bad_password_export_error = ExportError(ExportStatus.BAD_PASSPHRASE.value) - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase = mocker.MagicMock() - export_dialog.passphrase_field.text = mocker.MagicMock(return_value='mock_passphrase') +def test_ExportDialog__on_start_export_vm_success(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._on_start_export_vm_success() + + assert dialog.button_message.isHidden() + assert dialog.continue_button.isEnabled() + - export_dialog._on_export_usb_call_failure(bad_password_export_error) +def test_ExportDialog__on_start_export_vm_failure(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._update = mocker.MagicMock() + + dialog._on_start_export_vm_failure(ExportError('generic error')) + + assert dialog.button_message.isHidden() + dialog._update.assert_called_once_with('generic error') + + +def test_ExportDialog__on_preflight_success(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_passphrase_request_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + + dialog._on_preflight_success() - export_dialog._request_passphrase.assert_called_with(True) + dialog._show_passphrase_request_message.assert_called_once_with() + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._on_continue_clicked_after_preflight) -def test_ExportDialog__update_preflight_non_called_process_error(mocker): +def test_ExportDialog__on_preflight_failure(mocker): """ Ensure generic errors are passed through to _update """ - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog.generic_error = mocker.MagicMock() - error = ExportError('generic error') - export_dialog._on_preflight_check_call_failure(error) - export_dialog.generic_error.show.assert_called_once_with() + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._update = mocker.MagicMock() + + dialog._on_preflight_failure(ExportError('generic error')) + + dialog._update.assert_called_once_with('generic error') -def test_ExportDialog__update_after_USB_NOT_CONNECTED(mocker): +def test_ExportDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(mocker): """ - Ensure USB_NOT_CONNECTED results in asking the user connect their USB device. + Ensure CALLED_PROCESS_ERROR during pre-flight updates the dialog with the status code. """ - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog._request_to_insert_usb_device = mocker.MagicMock() - export_dialog._update(ExportStatus.USB_NOT_CONNECTED.value) - export_dialog._request_to_insert_usb_device.assert_called_once_with() + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + controller = mocker.MagicMock() + dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') + dialog._show_passphrase_request_message_again = mocker.MagicMock() + dialog._show_insert_usb_message = mocker.MagicMock() + dialog._update = mocker.MagicMock() + dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) -def test_ExportDialog__update_after_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): + dialog._show_passphrase_request_message_again.assert_not_called() + dialog._show_insert_usb_message.assert_not_called() + dialog._update.assert_called_once_with(ExportStatus.CALLED_PROCESS_ERROR.value) + + +def test_ExportDialog__on_export_success(mocker): """ - Ensure USB_NOT_CONNECTED results in asking the user connect their USB device. + Ensure successful export results in the export dialog window closing. """ - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog._request_to_insert_usb_device = mocker.MagicMock() - export_dialog._update(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) - export_dialog._request_to_insert_usb_device.assert_called_once_with(True) + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog.close = mocker.MagicMock() + + dialog._on_export_success() + + dialog.close.assert_called_once_with() -def test_ExportDialog__update_after_CALLED_PROCESS_ERROR(mocker): +def test_ExportDialog__on_export_failure(mocker): """ Ensure CALLED_PROCESS_ERROR shows generic 'contact admin' error with correct error status code. """ - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog._request_to_insert_usb_device = mocker.MagicMock() - export_dialog._request_passphrase = mocker.MagicMock() - error = ExportError(ExportStatus.CALLED_PROCESS_ERROR.value) + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._update = mocker.MagicMock() - export_dialog._on_export_usb_call_failure(error) + dialog._on_export_failure(ExportError('mock_error_status')) - assert export_dialog.starting_export_message.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert export_dialog.insert_usb_form.isHidden() - assert not export_dialog.generic_error.isHidden() - assert export_dialog.error_status_code.text() == 'CALLED_PROCESS_ERROR' - export_dialog._request_to_insert_usb_device.assert_not_called() - export_dialog._request_passphrase.assert_not_called() + assert dialog.passphrase_form.isHidden() + dialog._update.assert_called_with('mock_error_status') -# def test_PrintDialog__on_retry_button_clicked(mocker): -# """ -# Ensure happy path prints the file. -# """ -# controller = mocker.MagicMock() -# dialog = PrintDialog(controller, 'mock_uuid') +def test_PrintDialog_init(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + _show_starting_instructions_fn = mocker.patch( + 'securedrop_client.gui.widgets.PrintDialog._show_starting_instructions') -# dialog._on_retry_button_clicked() + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') -# controller.print_file.assert_called_with('mock_uuid') + _show_starting_instructions_fn.assert_called_once_with() -def test_PrintDialog__update_print_button_clicked_PRINTER_NOT_FOUND(mocker): - """ - Ensure request to insert USB device on PRINTER_NOT_FOUND. - """ - controller = mocker.MagicMock() - error = ExportError(ExportStatus.PRINTER_NOT_FOUND.value) - dialog = PrintDialog(controller, 'mock_uuid') - dialog._request_to_insert_usb_device = mocker.MagicMock() +def test_PrintDialog__show_starting_instructions(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._update(error.status) + dialog._show_starting_instructions() - dialog._request_to_insert_usb_device.assert_called_once_with() + assert dialog.button_message.text() == \ + 'The CONTINUE button will be disabled until the Export VM is ready' + assert dialog.header.text() == 'Preparing to print:
mock.jpg' + assert dialog.body.text() == \ + '

Proceed with caution when exporting files

' \ + 'Documents submitted by sources may contain information that identifies who they are. ' \ + 'To protect your sources, please consider redacting documents ' \ + 'before printing them.' \ -def test_PrintDialog__request_to_insert_usb_device(mocker): +def test_PrintDialog__show_insert_usb_message(mocker): """Ensure that the correct widgets are visible or hidden.""" - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid') + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') - dialog._request_to_insert_usb_device() + dialog._show_insert_usb_message() - assert dialog.starting_message.isHidden() - assert dialog.printing_message.isHidden() - assert dialog.generic_error.isHidden() - assert not dialog.insert_usb_form.isHidden() - assert not dialog.usb_error_message.isHidden() + assert dialog.header.text() == 'Insert USB printer' + assert dialog.body.text() == 'Please connect your printer to a USB port.' -def test_PrintDialog__on_print_success_closes_window(mocker): +def test_PrintDialog__show_generic_error_message(mocker): + """Ensure that the correct widgets are visible or hidden.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_generic_error_message('mock_error_status') + + assert dialog.header.text() == 'Unable to print' + assert dialog.body.text() == 'mock_error_status: See your administrator for help.' + + +def test_PrintDialog__update_when_status_is_PRINTER_NOT_FOUND(mocker): """ - Ensure successful print results in the print dialog window closing. + Ensure PRINTER_NOT_FOUND results in asking the user connect their USB device. """ - controller = mocker.MagicMock() - dialog = PrintDialog(controller, 'mock_uuid') - dialog.close = mocker.MagicMock() + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog._show_insert_usb_message = mocker.MagicMock() + dialog._update(ExportStatus.PRINTER_NOT_FOUND.value) + dialog._show_insert_usb_message.assert_called_once_with() - dialog._on_print_success() - dialog.close.assert_called_once_with() +def test_PrintDialog__update_when_status_is_MISSING_PRINTER_URI(mocker): + """ + Ensure MISSING_PRINTER_URI shows generic 'contact admin' error with correct + error status code. + """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog._show_generic_error_message = mocker.MagicMock() + + dialog._on_print_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) + dialog._show_generic_error_message.assert_called_once_with( + ExportStatus.MISSING_PRINTER_URI.value) -def test_PrintDialog__on_print_call_failure_generic_error(mocker): + +def test_PrintDialog__update_when_status_is_CALLED_PROCESS_ERROR(mocker): """ - Ensure generic errors are passed through to _update + Ensure CALLED_PROCESS_ERROR shows generic 'contact admin' error with correct + error status code. """ - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid') - dialog.generic_error = mocker.MagicMock() - error = ExportError('generic error') + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_generic_error_message = mocker.MagicMock() - dialog._on_print_failure(error) + dialog._update(ExportStatus.CALLED_PROCESS_ERROR.value) - dialog.generic_error.show.assert_called_once_with() + dialog._show_generic_error_message.assert_called_once_with('CALLED_PROCESS_ERROR') -def test_PrintDialog__update_after_PRINTER_NOT_FOUND(mocker): +def test_PrintDialog__update_when_status_is_unknown(mocker): """ - Ensure PRINTER_NOT_FOUND results in asking the user connect their USB device. + Ensure request to insert USB device on PRINTER_NOT_FOUND. """ - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid') - dialog._request_to_insert_usb_device = mocker.MagicMock() - dialog._update(ExportStatus.PRINTER_NOT_FOUND.value) - dialog._request_to_insert_usb_device.assert_called_once_with() + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog._show_generic_error_message = mocker.MagicMock() + + dialog._update('Some Unknown Error Status') + dialog._show_generic_error_message.assert_called_once_with('Some Unknown Error Status') -def test_PrintDialog__update_after_MISSING_PRINTER_URI(mocker): + +def test_PrintDialog__on_continue_clicked(mocker): """ - Ensure MISSING_PRINTER_URI shows generic 'contact admin' error with correct - error status code. + Ensure happy path prints the file. + """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + controller = mocker.MagicMock() + dialog = PrintDialog(controller, 'mock_uuid', 'mock_filename') + + dialog._on_continue_clicked() + + controller.print_file.assert_called_with('mock_uuid') + + +def test_PrintDialog__on_start_export_vm_success(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._on_start_export_vm_success() + + assert dialog.button_message.isHidden() + assert dialog.continue_button.isEnabled() + + +def test_PrintDialog__on_start_export_vm_failure(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._update = mocker.MagicMock() + + dialog._on_start_export_vm_failure(ExportError('generic error')) + + assert dialog.button_message.isHidden() + dialog._update.assert_called_once_with('generic error') + + +def test_PrintDialog__on_print_success(mocker): """ - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid') - dialog._request_to_insert_usb_device = mocker.MagicMock() - dialog._update(ExportStatus.MISSING_PRINTER_URI.value) - dialog._request_to_insert_usb_device = mocker.MagicMock() + Ensure successful print results in the print dialog window closing. + """ + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog.close = mocker.MagicMock() - error = ExportError(ExportStatus.MISSING_PRINTER_URI.value) - dialog._on_print_failure(error) + dialog._on_print_success() - assert dialog.error_status_code.text() == 'ERROR_MISSING_PRINTER_URI' - assert dialog.starting_message.isHidden() - assert dialog.printing_message.isHidden() - assert not dialog.generic_error.isHidden() - assert dialog.insert_usb_form.isHidden() - dialog._request_to_insert_usb_device.assert_not_called() + dialog.close.assert_called_once_with() -def test_PrintDialog__update_after_CALLED_PROCESS_ERROR(mocker): +def test_PrintDialog__on_print_failure(mocker): """ - Ensure CALLED_PROCESS_ERROR shows generic 'contact admin' error with correct - error status code. + Ensure generic errors are passed through to _update """ - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid') - dialog._request_to_insert_usb_device = mocker.MagicMock() - dialog._update(ExportStatus.MISSING_PRINTER_URI.value) - dialog._request_to_insert_usb_device = mocker.MagicMock() + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog._show_generic_error_message = mocker.MagicMock() - error = ExportError(ExportStatus.CALLED_PROCESS_ERROR.value) - dialog._on_print_failure(error) + dialog._on_print_failure(ExportError('generic error')) - assert dialog.error_status_code.text() == 'CALLED_PROCESS_ERROR' - assert dialog.starting_message.isHidden() - assert dialog.insert_usb_form.isHidden() - assert not dialog.generic_error.isHidden() - dialog._request_to_insert_usb_device.assert_not_called() + dialog._show_generic_error_message.assert_called_once_with('generic error') def test_ConversationView_init(mocker, homedir): From e02884d4191bdc0abe8f5fdfca39ecb9d94b5ea7 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 13 Jan 2020 21:06:34 -0800 Subject: [PATCH 05/31] add error details section and resize dialog --- securedrop_client/gui/widgets.py | 158 ++++++++++++++++++++++--------- tests/gui/test_widgets.py | 23 +++-- 2 files changed, 126 insertions(+), 55 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 2aea33b38..7203ced5c 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2104,7 +2104,7 @@ class FramelessModal(QDialog): #frameless_modal { min-width: 800px; max-width: 800px; - min-height: 400px; + min-height: 300px; max-height: 800px; background-color: #fff; border: 1px solid #2a319d; @@ -2119,7 +2119,9 @@ class FramelessModal(QDialog): #header { font-family: 'Montserrat'; font-size: 24px; + font-weight: 600; color: #2a319d; + padding-bottom: 2px; } #header_line { margin: 20px 0px 20px 0px; @@ -2128,10 +2130,17 @@ class FramelessModal(QDialog): background-color: rgba(42, 49, 157, 0.15); border: none; } + #error_details { + font-family: 'Montserrat'; + font-size: 16px; + color: #ff0064; + padding-bottom: 20px; + } #body { font-family: 'Montserrat'; font-size: 16px; color: #302aa3; + padding-bottom: 20px; } #button_box QPushButton { margin: 0px 0px 0px 12px; @@ -2158,6 +2167,7 @@ class FramelessModal(QDialog): ''' CONTENT_MARGIN = 40 + BODY_MARGIN = 0 def __init__(self): parent = QApplication.activeWindow() @@ -2169,13 +2179,6 @@ def __init__(self): self.setWindowModality(Qt.WindowModal) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - # Always display center of the application window - application_window_size = parent.geometry() - dialog_size = self.geometry() - x_center = (application_window_size.width() - dialog_size.width()) / 2 - y_center = (application_window_size.height() - dialog_size.height()) / 2 - self.move(x_center, y_center) - # Set drop shadow effect effect = QGraphicsDropShadowEffect(self) effect.setOffset(0, 1) @@ -2194,27 +2197,8 @@ def __init__(self): close_button.clicked.connect(self.close) titlebar_layout.addWidget(close_button, alignment=Qt.AlignRight) - # Buttons - window_buttons = QWidget() - window_buttons.setObjectName('window_buttons') - button_layout = QVBoxLayout() - window_buttons.setLayout(button_layout) - cancel_button = QPushButton(_('CANCEL')) - cancel_button.setAutoDefault(False) - cancel_button.clicked.connect(self.close) - self.continue_button = QPushButton(_('CONTINUE')) - button_box = QDialogButtonBox(Qt.Horizontal) - button_box.setObjectName('button_box') - button_box.addButton(cancel_button, QDialogButtonBox.ActionRole) - button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) - self.button_message = QLabel() - self.button_message.setObjectName('button_message') - button_layout.addWidget(self.button_message, alignment=Qt.AlignRight) - button_layout.addWidget(button_box, alignment=Qt.AlignRight) - # Content including: header, body, help menu, and buttons content = QWidget() - content.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) content_layout = QVBoxLayout() content.setLayout(content_layout) content_layout.setContentsMargins( @@ -2224,16 +2208,40 @@ def __init__(self): self.header.setWordWrap(True) header_line = QWidget() header_line.setObjectName('header_line') + self.error_details = QLabel() + self.error_details.setObjectName('error_details') + self.error_details.setWordWrap(True) self.body = QLabel() - self.body.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) self.body.setObjectName('body') self.body.setWordWrap(True) self.body.setScaledContents(True) + body_container = QWidget() self.body_layout = QVBoxLayout() - self.body.setLayout(self.body_layout) + self.body_layout.setContentsMargins( + self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN) + body_container.setLayout(self.body_layout) + self.body_layout.addWidget(self.body) + window_buttons = QWidget() + window_buttons.setObjectName('window_buttons') + button_layout = QVBoxLayout() + window_buttons.setLayout(button_layout) + cancel_button = QPushButton(_('CANCEL')) + cancel_button.setAutoDefault(False) + cancel_button.clicked.connect(self.close) + self.continue_button = QPushButton(_('CONTINUE')) + button_box = QDialogButtonBox(Qt.Horizontal) + button_box.setObjectName('button_box') + button_box.addButton(cancel_button, QDialogButtonBox.ActionRole) + button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) + self.button_message = QLabel() + self.button_message.setObjectName('button_message') + button_layout.addWidget(self.button_message, alignment=Qt.AlignRight) + button_layout.addWidget(button_box, alignment=Qt.AlignRight) content_layout.addWidget(self.header) content_layout.addWidget(header_line) - content_layout.addWidget(self.body) + content_layout.addWidget(self.error_details) + content_layout.addWidget(body_container) + content_layout.addStretch() content_layout.addWidget(window_buttons) # Layout @@ -2242,6 +2250,15 @@ def __init__(self): layout.addWidget(titlebar) layout.addWidget(content) + self.center_dialog() + + def center_dialog(self): + application_window_size = QApplication.activeWindow().geometry() + dialog_size = self.geometry() + x_center = (application_window_size.width() - dialog_size.width()) / 2 + y_center = (application_window_size.height() - dialog_size.height()) / 2 + self.move(x_center, y_center) + class PrintDialog(FramelessModal): @@ -2264,9 +2281,9 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Dialog content self.starting_header = _( - 'Preparing to print:' + 'Preparing to print:' '
' - '{}'.format(self.file_name)) + '{}'.format(self.file_name)) self.insert_usb_header = _('Insert USB printer') self.error_header = _('Unable to print') self.starting_message = _( @@ -2283,16 +2300,25 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): def _show_starting_instructions(self): self.header.setText(self.starting_header) + self.error_details.hide() self.body.setText(self.starting_message) self.button_message.setText('' + self.continue_disabled_message + '') + self.adjustSize() + self.center_dialog() def _show_insert_usb_message(self): self.header.setText(self.insert_usb_header) + self.error_details.hide() self.body.setText(self.insert_usb_message) + self.adjustSize() + self.center_dialog() def _show_generic_error_message(self, error_code: str): self.header.setText(self.error_header) + self.error_details.hide() self.body.setText('{}: {}'.format(error_code, self.generic_error_message)) + self.adjustSize() + self.center_dialog() def _update(self, status: str): if status == ExportStatus.PRINTER_NOT_FOUND.value: @@ -2326,18 +2352,24 @@ def _on_print_failure(self, error: ExportError): class ExportDialog(FramelessModal): PASSPHRASE_FORM_CSS = ''' - #passphrase_label { + #passphrase_form QLabel { font-family: 'Montserrat'; font-weight: 500; - font-size: 13px; + font-size: 12px; + color: #2a319d; } #passphrase_form QLineEdit { border-radius: 0px; min-height: 30px; - margin: 0px 0px 10px 0px; + max-height: 30px; + background-color: #f8f8f8; + padding-bottom: 2px; } ''' + PASSPHRASE_LABEL_SPACING = 0.5 + PASSPHRASE_MARGIN = 0 + def __init__(self, controller: Controller, file_uuid: str, file_name: str): super().__init__() @@ -2359,9 +2391,9 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Dialog content self.starting_header = _( - 'Preparing to export:' + 'Preparing to export:' '
' - '{}'.format(self.file_name)) + '{}'.format(self.file_name)) self.insert_usb_header = _('Insert encrypted USB drive') self.passphrase_header = _('Enter passphrase for USB drive') self.error_header = _('Unable to export') @@ -2384,23 +2416,35 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): 'Please insert one of the export drives provisioned specifically ' 'for the SecureDrop Workstation.') self.usb_error_message = _( - 'Either the drive is not encrypted or there is something else wrong with it. Please ' - 'try another drive, or see your administrator for help.') + 'Either the drive is not encrypted or there is something else wrong with it.') self.passphrase_error_message = _('The passphrase provided did not work. Please try again.') self.generic_error_message = _('See your administrator for help.') self.continue_disabled_message = _( 'The CONTINUE button will be disabled until the Export VM is ready') # Passphrase Form - passphrase_label = SecureQLabel(_('Passphrase')) - passphrase_label.setObjectName('passphrase_label') - self.passphrase_field = QLineEdit() - self.passphrase_field.setEchoMode(QLineEdit.Password) self.passphrase_form = QWidget() self.passphrase_form.setStyleSheet(self.PASSPHRASE_FORM_CSS) self.passphrase_form.setObjectName('passphrase_form') passphrase_form_layout = QVBoxLayout() + passphrase_form_layout.setContentsMargins( + self.PASSPHRASE_MARGIN, + self.PASSPHRASE_MARGIN, + self.PASSPHRASE_MARGIN, + self.PASSPHRASE_MARGIN) self.passphrase_form.setLayout(passphrase_form_layout) + passphrase_label = SecureQLabel(_('Passphrase')) + passphrase_label.setObjectName('passphrase_label') + font = QFont() + font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING) + passphrase_label.setFont(font) + self.passphrase_field = QLineEdit() + self.passphrase_field.setEchoMode(QLineEdit.Password) + effect = QGraphicsDropShadowEffect(self) + effect.setOffset(0, -1) + effect.setBlurRadius(4) + effect.setColor(QColor('#aaa')) + self.passphrase_field.setGraphicsEffect(effect) passphrase_form_layout.addWidget(passphrase_label) passphrase_form_layout.addWidget(self.passphrase_field) self.body_layout.addWidget(self.passphrase_form) @@ -2413,32 +2457,52 @@ def _show_starting_instructions(self): self.header.setText(self.starting_header) self.body.setText(self.starting_message) self.button_message.setText('' + self.continue_disabled_message + '') + self.adjustSize() + self.center_dialog() def _show_passphrase_request_message(self): self.header.setText(self.passphrase_header) - self.body.setText('') + self.error_details.hide() + self.body.hide() self.passphrase_form.show() + self.continue_button.setText('SUBMIT') + self.adjustSize() + self.center_dialog() def _show_passphrase_request_message_again(self): self.header.setText(self.passphrase_header) - self.body.setText(self.passphrase_error_message) + self.error_details.setText(self.passphrase_error_message) + self.body.hide() self.passphrase_form.show() + self.continue_button.setText('SUBMIT') + self.adjustSize() + self.center_dialog() def _show_insert_usb_message(self): self.header.setText(self.insert_usb_header) self.body.setText(self.insert_usb_message) + self.error_details.hide() self.passphrase_form.hide() + self.continue_button.setText('CONTINUE') + self.adjustSize() + self.center_dialog() def _show_insert_encrypted_usb_message(self): self.header.setText(self.insert_usb_header) - self.body.setText( - '{}\n\n{}'.format(self.usb_error_message, self.insert_usb_message)) + self.error_details.setText(self.usb_error_message) + self.body.setText(self.insert_usb_message) self.passphrase_form.hide() + self.continue_button.setText('CONTINUE') + self.adjustSize() + self.center_dialog() def _show_generic_error_message(self, error_code: str): self.header.setText(self.error_header) + self.error_details.hide() self.body.setText('{}: {}'.format(error_code, self.generic_error_message)) self.passphrase_form.hide() + self.adjustSize() + self.center_dialog() def _update(self, status): if status == ExportStatus.USB_NOT_CONNECTED.value: diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 15931dc2d..ec40e5edb 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1838,7 +1838,10 @@ def test_ExportDialog__show_starting_instructions(mocker): assert dialog.passphrase_form.isHidden() assert dialog.button_message.text() == \ 'The CONTINUE button will be disabled until the Export VM is ready' - assert dialog.header.text() == 'Preparing to export:
mock.jpg' + assert dialog.header.text() == \ + 'Preparing to export:' \ + '
' \ + 'mock.jpg' assert dialog.body.text() == \ '

Proceed with caution when exporting files

' \ 'Malware' \ @@ -1865,7 +1868,7 @@ def test_ExportDialog___show_passphrase_request_message(mocker): assert not dialog.passphrase_form.isHidden() assert dialog.header.text() == 'Enter passphrase for USB drive' - assert dialog.body.text() == '' + assert dialog.body.isHidden() def test_ExportDialog__show_passphrase_request_message_again(mocker): @@ -1878,7 +1881,8 @@ def test_ExportDialog__show_passphrase_request_message_again(mocker): assert not dialog.passphrase_form.isHidden() assert dialog.header.text() == 'Enter passphrase for USB drive' - assert dialog.body.text() == 'The passphrase provided did not work. Please try again.' + assert dialog.error_details.text() == 'The passphrase provided did not work. Please try again.' + assert dialog.body.isHidden() def test_ExportDialog__show_insert_usb_message(mocker): @@ -1905,10 +1909,9 @@ def test_ExportDialog__show_insert_encrypted_usb_message(mocker): dialog._show_insert_encrypted_usb_message() assert dialog.header.text() == 'Insert encrypted USB drive' + assert dialog.error_details.text() == \ + 'Either the drive is not encrypted or there is something else wrong with it.' assert dialog.body.text() == \ - 'Either the drive is not encrypted or there is something else wrong with it. Please ' \ - 'try another drive, or see your administrator for help.' \ - '\n\n' \ 'Please insert one of the export drives provisioned specifically for the SecureDrop ' \ 'Workstation.' assert dialog.passphrase_form.isHidden() @@ -1924,6 +1927,7 @@ def test_ExportDialog__show_generic_error_message(mocker): assert dialog.passphrase_form.isHidden() assert dialog.header.text() == 'Unable to export' + assert dialog.error_details.isHidden() assert dialog.body.text() == 'mock_error_status: See your administrator for help.' @@ -2135,7 +2139,7 @@ def test_PrintDialog_init(mocker): _show_starting_instructions_fn = mocker.patch( 'securedrop_client.gui.widgets.PrintDialog._show_starting_instructions') - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') _show_starting_instructions_fn.assert_called_once_with() @@ -2150,7 +2154,10 @@ def test_PrintDialog__show_starting_instructions(mocker): assert dialog.button_message.text() == \ 'The CONTINUE button will be disabled until the Export VM is ready' - assert dialog.header.text() == 'Preparing to print:
mock.jpg' + assert dialog.header.text() == \ + 'Preparing to print:' \ + '
' \ + 'mock.jpg' assert dialog.body.text() == \ '

Proceed with caution when exporting files

' \ 'Documents submitted by sources may contain information that identifies who they are. ' \ From d712fd159439f2cd9dd41ced5bfa5a429c7fa9a7 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Tue, 14 Jan 2020 14:03:46 -0800 Subject: [PATCH 06/31] reword print speedbump --- securedrop_client/gui/widgets.py | 16 +++++++++++++--- tests/gui/test_widgets.py | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 7203ced5c..5a8dd074e 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2287,9 +2287,19 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.insert_usb_header = _('Insert USB printer') self.error_header = _('Unable to print') self.starting_message = _( - '

Proceed with caution when exporting files

' - 'Documents submitted by sources may contain information that identifies who they are. ' - 'To protect your sources, please consider redacting documents before printing them.') + '

Security advice for printing documents

' + '
' + 'Malware' + '
' + 'Never open web addresses or scan QR codes contained in printed documents without ' + 'taking security precautions. If you are unsure how to manage this risk, please ' + 'contact your administrator.' + '

' + 'Anonymity' + '
' + 'Before publishing, protect your sources by redacting any information that could ' + 'identify them. Documents may contain identifying information invisible to the naked ' + 'eye (e.g., printer dots).') self.insert_usb_message = _('Please connect your printer to a USB port.') self.generic_error_message = _('See your administrator for help.') self.continue_disabled_message = _( diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index ec40e5edb..43eb6a512 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -2159,10 +2159,19 @@ def test_PrintDialog__show_starting_instructions(mocker): '
' \ 'mock.jpg' assert dialog.body.text() == \ - '

Proceed with caution when exporting files

' \ - 'Documents submitted by sources may contain information that identifies who they are. ' \ - 'To protect your sources, please consider redacting documents ' \ - 'before printing them.' \ + '

Security advice for printing documents

' \ + '
' \ + 'Malware' \ + '
' \ + 'Never open web addresses or scan QR codes contained in printed documents without ' \ + 'taking security precautions. If you are unsure how to manage this risk, please ' \ + 'contact your administrator.' \ + '

' \ + 'Anonymity' \ + '
' \ + 'Before publishing, protect your sources by redacting any information that could ' \ + 'identify them. Documents may contain identifying information invisible to the naked ' \ + 'eye (e.g., printer dots).' def test_PrintDialog__show_insert_usb_message(mocker): From 6102eddadae918b317a035c5c993586c7f72c422 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Tue, 14 Jan 2020 15:00:49 -0800 Subject: [PATCH 07/31] gracefully handle no active window case --- securedrop_client/gui/widgets.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 5a8dd074e..9b9c484f7 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2250,10 +2250,11 @@ def __init__(self): layout.addWidget(titlebar) layout.addWidget(content) - self.center_dialog() - def center_dialog(self): - application_window_size = QApplication.activeWindow().geometry() + active_window = QApplication.activeWindow() + if not active_window: + return + application_window_size = active_window.geometry() dialog_size = self.geometry() x_center = (application_window_size.width() - dialog_size.width()) / 2 y_center = (application_window_size.height() - dialog_size.height()) / 2 From d4839e75ca9f83324fb77e59dec7c8b22434e7a6 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Wed, 15 Jan 2020 18:46:10 -0800 Subject: [PATCH 08/31] qubes workaround: only allow one modal at a time --- securedrop_client/gui/widgets.py | 30 +++++++++++++++++++---- tests/gui/test_widgets.py | 41 +++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 9b9c484f7..4b3aab02e 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1985,6 +1985,10 @@ def __init__( file_ready_signal.connect(self._on_file_downloaded, type=Qt.QueuedConnection) file_missing.connect(self._on_file_missing, type=Qt.QueuedConnection) + # Make sure we only allow one export or print operation at a time (workaround for frameless + # modals not working as expected on QubesOS) + self.modal_in_progress = False + def eventFilter(self, obj, event): t = event.type() if t == QEvent.MouseButtonPress: @@ -2042,8 +2046,11 @@ def _on_export_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - dialog = ExportDialog(self.controller, self.file.uuid, self.file.original_filename) - dialog.exec() + if not self.modal_in_progress: + self.modal_in_progress = True + dialog = ExportDialog(self.controller, self.file.uuid, self.file.original_filename) + dialog.modal_closing.connect(self._unset_modal_in_progress) + dialog.exec() @pyqtSlot() def _on_print_clicked(self): @@ -2053,8 +2060,15 @@ def _on_print_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - dialog = PrintDialog(self.controller, self.file.uuid, self.file.original_filename) - dialog.exec() + if not self.modal_in_progress: + self.modal_in_progress = True + dialog = PrintDialog(self.controller, self.file.uuid, self.file.original_filename) + dialog.modal_closing.connect(self._unset_modal_in_progress) + dialog.exec() + + @pyqtSlot() + def _unset_modal_in_progress(self): + self.modal_in_progress = False def _on_left_click(self): """ @@ -2169,6 +2183,8 @@ class FramelessModal(QDialog): CONTENT_MARGIN = 40 BODY_MARGIN = 0 + modal_closing = pyqtSignal() + def __init__(self): parent = QApplication.activeWindow() super().__init__(parent) @@ -2176,7 +2192,7 @@ def __init__(self): self.setObjectName('frameless_modal') self.setStyleSheet(self.CSS) self.setWindowFlags(Qt.Widget | Qt.FramelessWindowHint) - self.setWindowModality(Qt.WindowModal) + self.setWindowModality(Qt.ApplicationModal) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) # Set drop shadow effect @@ -2250,6 +2266,10 @@ def __init__(self): layout.addWidget(titlebar) layout.addWidget(content) + def close(self): + self.modal_closing.emit() + super().close() + def center_dialog(self): active_window = QApplication.activeWindow() if not active_window: diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 43eb6a512..03ab9134e 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1410,6 +1410,29 @@ def test_ReplyWidget_init(mocker): assert mock_failure_connected.called +def test_FileWidget__unset_modal_in_progress(mocker, source, session): + file = factory.File(source=source['source'], is_downloaded=True) + session.add(file) + session.commit() + + get_file = mocker.MagicMock(return_value=file) + controller = mocker.MagicMock(get_file=get_file) + + fw = FileWidget(file.uuid, controller, mocker.MagicMock()) + fw.update = mocker.MagicMock() + mocker.patch('securedrop_client.gui.widgets.QDialog.exec') + controller.run_export_preflight_checks = mocker.MagicMock() + controller.downloaded_file_exists = mocker.MagicMock(return_value=True) + + assert fw.modal_in_progress is False + fw._unset_modal_in_progress() + assert fw.modal_in_progress is False + fw._on_export_clicked() + assert fw.modal_in_progress is True + fw._unset_modal_in_progress() + assert fw.modal_in_progress is False + + def test_FileWidget_init_file_not_downloaded(mocker, source, session): """ Check the FileWidget is configured correctly when the file is not downloaded. @@ -1815,7 +1838,7 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): def test_ExportDialog_init(mocker): - """Ensure that the correct widgets are visible or hidden.""" + """Ensure that ExportDialog is set up correctly.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) _show_starting_instructions_fn = mocker.patch( @@ -1827,6 +1850,22 @@ def test_ExportDialog_init(mocker): assert dialog.passphrase_form.isHidden() +def test_ExportDialog_close(mocker): + """Ensure that dialog emits closing signal and is hidden after close is called.""" + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog.modal_closing = mocker.MagicMock() + dialog.modal_closing.emit = mocker.MagicMock() + + assert dialog.isHidden() is False + + dialog.close() + + dialog.modal_closing.emit.assert_called_once_with() + assert dialog.isHidden() is True + + def test_ExportDialog__show_starting_instructions(mocker): """Ensure that the correct widgets are visible or hidden.""" mocker.patch( From 51ee7f889044733111ffe88c63b26119f4a75577 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Wed, 15 Jan 2020 20:51:31 -0800 Subject: [PATCH 09/31] run preflight on dialog startup --- securedrop_client/export.py | 38 +++++++- securedrop_client/gui/widgets.py | 145 +++++++++++++++++-------------- securedrop_client/logic.py | 9 +- 3 files changed, 120 insertions(+), 72 deletions(-) diff --git a/securedrop_client/export.py b/securedrop_client/export.py index 94b51ca98..a19007f06 100644 --- a/securedrop_client/export.py +++ b/securedrop_client/export.py @@ -55,6 +55,11 @@ class Export(QObject): 'device': 'usb-test' } + PRINTER_PREFLIGHT_FN = 'printer-preflight.sd-export' + PRINTER_PREFLIGHT_METADATA = { + 'device': 'printer-preflight' + } + DISK_TEST_FN = 'disk-test.sd-export' DISK_TEST_METADATA = { 'device': 'disk-test' @@ -74,16 +79,20 @@ class Export(QObject): DISK_EXPORT_DIR = 'export_data' # Set up signals for communication with the GUI thread + begin_preflight_check = pyqtSignal() preflight_check_call_failure = pyqtSignal(object) preflight_check_call_success = pyqtSignal() begin_usb_export = pyqtSignal(list, str) - begin_preflight_check = pyqtSignal() export_usb_call_failure = pyqtSignal(object) export_usb_call_success = pyqtSignal() + export_completed = pyqtSignal(list) + + begin_printer_preflight = pyqtSignal() + printer_preflight_success = pyqtSignal() + printer_preflight_failure = pyqtSignal(object) begin_print = pyqtSignal(list) print_call_failure = pyqtSignal(object) print_call_success = pyqtSignal() - export_completed = pyqtSignal(list) def __init__(self) -> None: super().__init__() @@ -91,6 +100,7 @@ def __init__(self) -> None: self.begin_preflight_check.connect(self.run_preflight_checks, type=Qt.QueuedConnection) self.begin_usb_export.connect(self.send_file_to_usb_device, type=Qt.QueuedConnection) self.begin_print.connect(self.print, type=Qt.QueuedConnection) + self.begin_printer_preflight.connect(self.run_printer_preflight, type=Qt.QueuedConnection) def _export_archive(cls, archive_path: str) -> str: ''' @@ -183,6 +193,17 @@ def _add_file_to_archive(cls, archive: tarfile.TarFile, filepath: str) -> None: arcname = os.path.join(cls.DISK_EXPORT_DIR, filename) archive.add(filepath, arcname=arcname, recursive=False) + def _run_printer_preflight(self, archive_dir: str) -> None: + ''' + Make sure printer is ready. + ''' + archive_path = self._create_archive( + archive_dir, self.PRINTER_PREFLIGHT_FN, self.PRINTER_PREFLIGHT_METADATA) + + status = self._export_archive(archive_path) + if status: + raise ExportError(status) + def _run_usb_test(self, archive_dir: str) -> None: ''' Run usb-test. @@ -263,6 +284,19 @@ def run_preflight_checks(self) -> None: logger.debug('completed preflight checks: failure') self.preflight_check_call_failure.emit(e) + @pyqtSlot() + def run_printer_preflight(self) -> None: + ''' + Make sure the Export VM is started. + ''' + with TemporaryDirectory() as temp_dir: + try: + self._run_printer_preflight(temp_dir) + self.printer_preflight_success.emit() + except ExportError as e: + logger.error(e) + self.printer_preflight_failure.emit(e) + @pyqtSlot(list, str) def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None: ''' diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 4b3aab02e..8f69a3e2f 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2237,10 +2237,10 @@ def __init__(self): self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN) body_container.setLayout(self.body_layout) self.body_layout.addWidget(self.body) - window_buttons = QWidget() - window_buttons.setObjectName('window_buttons') + self.window_buttons = QWidget() + self.window_buttons.setObjectName('window_buttons') button_layout = QVBoxLayout() - window_buttons.setLayout(button_layout) + self.window_buttons.setLayout(button_layout) cancel_button = QPushButton(_('CANCEL')) cancel_button.setAutoDefault(False) cancel_button.clicked.connect(self.close) @@ -2258,7 +2258,7 @@ def __init__(self): content_layout.addWidget(self.error_details) content_layout.addWidget(body_container) content_layout.addStretch() - content_layout.addWidget(window_buttons) + content_layout.addWidget(self.window_buttons) # Layout layout = QVBoxLayout(self) @@ -2289,15 +2289,14 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name + self.error_status = None # Connect controller signals to slots - self.controller.export.start_export_vm_success.connect(self._on_start_export_vm_success) - self.controller.export.start_export_vm_failure.connect(self._on_start_export_vm_failure) - self.controller.export.print_call_success.connect(self._on_print_success) - self.controller.export.print_call_failure.connect(self._on_print_failure) + self.controller.export.printer_preflight_success.connect(self._on_preflight_success) + self.controller.export.printer_preflight_failure.connect(self._on_preflight_failure) # Connect parent signals to slots - self.continue_button.clicked.connect(self._on_continue_clicked) + self.continue_button.clicked.connect(self._print_file) self.continue_button.setEnabled(False) # Dialog content @@ -2327,7 +2326,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): 'The CONTINUE button will be disabled until the Export VM is ready') self._show_starting_instructions() - self.controller.run_start_export_vm() + self.controller.run_printer_preflight_checks() def _show_starting_instructions(self): self.header.setText(self.starting_header) @@ -2344,40 +2343,46 @@ def _show_insert_usb_message(self): self.adjustSize() self.center_dialog() - def _show_generic_error_message(self, error_code: str): + def _show_generic_error_message(self): + self.window_buttons.hide() self.header.setText(self.error_header) self.error_details.hide() - self.body.setText('{}: {}'.format(error_code, self.generic_error_message)) + self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) self.adjustSize() self.center_dialog() - def _update(self, status: str): - if status == ExportStatus.PRINTER_NOT_FOUND.value: - self._show_insert_usb_message() - else: - self._show_generic_error_message(status) - @pyqtSlot() - def _on_continue_clicked(self): + def _print_file(self): self.controller.print_file(self.file_uuid) + self.close() @pyqtSlot() - def _on_start_export_vm_success(self): - self.button_message.hide() - self.continue_button.setEnabled(True) - - @pyqtSlot(object) - def _on_start_export_vm_failure(self, error: ExportError): - self.button_message.hide() - self._update(error.status) + def _on_preflight_success(self): + # Wire up continue button to the next step + self.continue_button.clicked.connect(self._print_file) - @pyqtSlot() - def _on_print_success(self): - self.close() + # If the continue button is disabled then this is the result of a background preflight check + if not self.continue_button.isEnabled(): + self.button_message.hide() + self.continue_button.setEnabled(True) + else: + self.continue_button.click() @pyqtSlot(object) - def _on_print_failure(self, error: ExportError): - self._update(error.status) + def _on_preflight_failure(self, error: ExportError): + # Wire up continue button to the next step + self.error_status = error.status + if self.error_status == ExportStatus.PRINTER_NOT_FOUND.value: + self.continue_button.clicked.connect(self._show_insert_usb_message) + else: + self.continue_button.clicked.connect(self._show_generic_error_message) + + # If the continue button is disabled then this is the result of a background preflight check + if not self.continue_button.isEnabled(): + self.button_message.hide() + self.continue_button.setEnabled(True) + else: + self.continue_button.click() class ExportDialog(FramelessModal): @@ -2407,17 +2412,16 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name + self.error_status = None # Connect controller signals to slots - self.controller.export.start_export_vm_success.connect(self._on_start_export_vm_success) - self.controller.export.start_export_vm_failure.connect(self._on_start_export_vm_failure) self.controller.export.preflight_check_call_success.connect(self._on_preflight_success) self.controller.export.preflight_check_call_failure.connect(self._on_preflight_failure) self.controller.export.export_usb_call_success.connect(self._on_export_success) self.controller.export.export_usb_call_failure.connect(self._on_export_failure) # Connect parent signals to slots - self.continue_button.clicked.connect(self._on_continue_clicked) + self.continue_button.clicked.connect(self._export_file) self.continue_button.setEnabled(False) # Dialog content @@ -2482,7 +2486,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.passphrase_form.hide() self._show_starting_instructions() - self.controller.run_start_export_vm() + self.controller.run_export_preflight_checks() def _show_starting_instructions(self): self.header.setText(self.starting_header) @@ -2492,6 +2496,7 @@ def _show_starting_instructions(self): self.center_dialog() def _show_passphrase_request_message(self): + self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.hide() self.body.hide() @@ -2501,6 +2506,7 @@ def _show_passphrase_request_message(self): self.center_dialog() def _show_passphrase_request_message_again(self): + self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.setText(self.passphrase_error_message) self.body.hide() @@ -2510,6 +2516,7 @@ def _show_passphrase_request_message_again(self): self.center_dialog() def _show_insert_usb_message(self): + self.continue_button.clicked.connect(self.controller.run_export_preflight_checks) self.header.setText(self.insert_usb_header) self.body.setText(self.insert_usb_message) self.error_details.hide() @@ -2519,6 +2526,7 @@ def _show_insert_usb_message(self): self.center_dialog() def _show_insert_encrypted_usb_message(self): + self.continue_button.clicked.connect(self.controller.run_export_preflight_checks) self.header.setText(self.insert_usb_header) self.error_details.setText(self.usb_error_message) self.body.setText(self.insert_usb_message) @@ -2527,50 +2535,51 @@ def _show_insert_encrypted_usb_message(self): self.adjustSize() self.center_dialog() - def _show_generic_error_message(self, error_code: str): + def _show_generic_error_message(self): + self.window_buttons.hide() self.header.setText(self.error_header) self.error_details.hide() - self.body.setText('{}: {}'.format(error_code, self.generic_error_message)) + self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) self.passphrase_form.hide() + self.window_buttons.hide() self.adjustSize() self.center_dialog() - def _update(self, status): - if status == ExportStatus.USB_NOT_CONNECTED.value: - self._show_insert_usb_message() - elif status == ExportStatus.BAD_PASSPHRASE.value: - self._show_passphrase_request_message_again() - elif status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: - self._show_insert_encrypted_usb_message() - else: - self._show_generic_error_message(_(status)) - @pyqtSlot() - def _on_continue_clicked(self): - self.controller.run_export_preflight_checks() - - @pyqtSlot() - def _on_continue_clicked_after_preflight(self, checked: bool = False): + def _export_file(self, checked: bool = False): self.controller.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) - @pyqtSlot() - def _on_start_export_vm_success(self): - self.button_message.hide() - self.continue_button.setEnabled(True) - - @pyqtSlot(object) - def _on_start_export_vm_failure(self, error: ExportError): - self.button_message.hide() - self._update(error.status) - @pyqtSlot() def _on_preflight_success(self): - self.continue_button.clicked.connect(self._on_continue_clicked_after_preflight) - self._show_passphrase_request_message() + # Wire up continue button to the next step + self.continue_button.clicked.connect(self._show_passphrase_request_message) + + # If the continue button is disabled then this is the result of a background preflight check + if not self.continue_button.isEnabled(): + self.button_message.hide() + self.continue_button.setEnabled(True) + else: + self.continue_button.click() @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): - self._update(error.status) + # Wire up continue button to the next step + self.error_status = error.status + if self.error_status == ExportStatus.USB_NOT_CONNECTED.value: + self.continue_button.clicked.connect(self._show_insert_usb_message) + elif self.error_status == ExportStatus.BAD_PASSPHRASE.value: + self.continue_button.clicked.connect(self._show_passphrase_request_message_again) + elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + self.continue_button.clicked.connect(self._show_insert_encrypted_usb_message) + else: + self.continue_button.clicked.connect(self._show_generic_error_message) + + # If the continue button is disabled then this is the result of a background preflight check + if not self.continue_button.isEnabled(): + self.button_message.hide() + self.continue_button.setEnabled(True) + else: + self.continue_button.click() @pyqtSlot() def _on_export_success(self): @@ -2578,7 +2587,9 @@ def _on_export_success(self): @pyqtSlot(object) def _on_export_failure(self, error: ExportError): - self._update(error.status) + self.error_status = error.status + self.error_status = error.status + self._show_generic_error_message() class ConversationView(QWidget): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 0e590d9e6..3d88a0cde 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -634,14 +634,17 @@ def on_file_open(self, file: db.File) -> None: process = QProcess(self) process.start(command, args) - def run_start_export_vm(self): + def run_printer_preflight_checks(self): + ''' + Run preflight checks to make sure the Export VM is configured correctly. + ''' logger.info('Starting Export VM') if not self.qubes: - self.export.start_export_vm_success.emit() + self.export.printer_preflight_success.emit() return - self.export.start_export_vm.emit() + self.export.begin_printer_preflight.emit() def run_export_preflight_checks(self): ''' From d123b26e2bcbfe87991dfad2ce86ea17a7dfbee3 Mon Sep 17 00:00:00 2001 From: Erik Moeller Date: Thu, 16 Jan 2020 17:37:19 -0800 Subject: [PATCH 10/31] Tweak printer dialog language for clarity The heading "Malware" in particular is potentially connfusing; other tweaks are mainly intended to more immediately recap the content of the warning. --- securedrop_client/gui/widgets.py | 12 ++++++------ tests/gui/test_widgets.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 8f69a3e2f..6e22daebf 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2307,19 +2307,19 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.insert_usb_header = _('Insert USB printer') self.error_header = _('Unable to print') self.starting_message = _( - '

Security advice for printing documents

' + '

Managing printout risks

' '
' - 'Malware' + 'QR-Codes and visible web addresses' '
' 'Never open web addresses or scan QR codes contained in printed documents without ' 'taking security precautions. If you are unsure how to manage this risk, please ' 'contact your administrator.' '

' - 'Anonymity' + 'Printer dots' '
' - 'Before publishing, protect your sources by redacting any information that could ' - 'identify them. Documents may contain identifying information invisible to the naked ' - 'eye (e.g., printer dots).') + 'Any part of a printed page may contain identifying information ' + 'invisible to the naked eye, such as printer dots. Please carefully ' + 'consider this risk when working with or publishing scanned printouts.') self.insert_usb_message = _('Please connect your printer to a USB port.') self.generic_error_message = _('See your administrator for help.') self.continue_disabled_message = _( diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 03ab9134e..c429af08b 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -2198,19 +2198,19 @@ def test_PrintDialog__show_starting_instructions(mocker): '
' \ 'mock.jpg' assert dialog.body.text() == \ - '

Security advice for printing documents

' \ + '

Managing printout risks

' \ '
' \ - 'Malware' \ + 'QR-Codes and visible web addresses' \ '
' \ 'Never open web addresses or scan QR codes contained in printed documents without ' \ 'taking security precautions. If you are unsure how to manage this risk, please ' \ 'contact your administrator.' \ '

' \ - 'Anonymity' \ + 'Printer dots' \ '
' \ - 'Before publishing, protect your sources by redacting any information that could ' \ - 'identify them. Documents may contain identifying information invisible to the naked ' \ - 'eye (e.g., printer dots).' + 'Any part of a printed page may contain identifying information ' \ + 'invisible to the naked eye, such as printer dots. Please carefully ' \ + 'consider this risk when working with or publishing scanned printouts.' def test_PrintDialog__show_insert_usb_message(mocker): From 5cdefa928bb7d317a4ff2e67b1f285c943dee979 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 16 Jan 2020 16:20:14 -0800 Subject: [PATCH 11/31] refactor how we handle different Export VM codes --- securedrop_client/gui/widgets.py | 66 +++++++++++++++----------------- securedrop_client/logic.py | 15 +++++--- 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 6e22daebf..566bffc64 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2289,14 +2289,12 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name - self.error_status = None # Connect controller signals to slots self.controller.export.printer_preflight_success.connect(self._on_preflight_success) self.controller.export.printer_preflight_failure.connect(self._on_preflight_failure) # Connect parent signals to slots - self.continue_button.clicked.connect(self._print_file) self.continue_button.setEnabled(False) # Dialog content @@ -2343,11 +2341,11 @@ def _show_insert_usb_message(self): self.adjustSize() self.center_dialog() - def _show_generic_error_message(self): + def _show_generic_error_message(self, error_status: str): self.window_buttons.hide() self.header.setText(self.error_header) self.error_details.hide() - self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) + self.body.setText('{}: {}'.format(error_status, self.generic_error_message)) self.adjustSize() self.center_dialog() @@ -2365,24 +2363,25 @@ def _on_preflight_success(self): if not self.continue_button.isEnabled(): self.button_message.hide() self.continue_button.setEnabled(True) - else: - self.continue_button.click() + return + + self._print_file() @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): # Wire up continue button to the next step - self.error_status = error.status - if self.error_status == ExportStatus.PRINTER_NOT_FOUND.value: - self.continue_button.clicked.connect(self._show_insert_usb_message) - else: - self.continue_button.clicked.connect(self._show_generic_error_message) + self.continue_button.clicked.connect(self.controller.run_printer_preflight_checks) # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.button_message.hide() self.continue_button.setEnabled(True) + return + + if error.status == ExportStatus.PRINTER_NOT_FOUND.value: + self._show_insert_usb_message() else: - self.continue_button.click() + self._show_generic_error_message(error.status) class ExportDialog(FramelessModal): @@ -2412,7 +2411,6 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name - self.error_status = None # Connect controller signals to slots self.controller.export.preflight_check_call_success.connect(self._on_preflight_success) @@ -2421,7 +2419,6 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller.export.export_usb_call_failure.connect(self._on_export_failure) # Connect parent signals to slots - self.continue_button.clicked.connect(self._export_file) self.continue_button.setEnabled(False) # Dialog content @@ -2496,7 +2493,6 @@ def _show_starting_instructions(self): self.center_dialog() def _show_passphrase_request_message(self): - self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.hide() self.body.hide() @@ -2506,7 +2502,6 @@ def _show_passphrase_request_message(self): self.center_dialog() def _show_passphrase_request_message_again(self): - self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.setText(self.passphrase_error_message) self.body.hide() @@ -2516,7 +2511,6 @@ def _show_passphrase_request_message_again(self): self.center_dialog() def _show_insert_usb_message(self): - self.continue_button.clicked.connect(self.controller.run_export_preflight_checks) self.header.setText(self.insert_usb_header) self.body.setText(self.insert_usb_message) self.error_details.hide() @@ -2526,7 +2520,6 @@ def _show_insert_usb_message(self): self.center_dialog() def _show_insert_encrypted_usb_message(self): - self.continue_button.clicked.connect(self.controller.run_export_preflight_checks) self.header.setText(self.insert_usb_header) self.error_details.setText(self.usb_error_message) self.body.setText(self.insert_usb_message) @@ -2535,11 +2528,11 @@ def _show_insert_encrypted_usb_message(self): self.adjustSize() self.center_dialog() - def _show_generic_error_message(self): + def _show_generic_error_message(self, error_status: str): self.window_buttons.hide() self.header.setText(self.error_header) self.error_details.hide() - self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) + self.body.setText('{}: {}'.format(error_status, self.generic_error_message)) self.passphrase_form.hide() self.window_buttons.hide() self.adjustSize() @@ -2552,34 +2545,39 @@ def _export_file(self, checked: bool = False): @pyqtSlot() def _on_preflight_success(self): # Wire up continue button to the next step - self.continue_button.clicked.connect(self._show_passphrase_request_message) + self.continue_button.clicked.connect(self._export_file) # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.button_message.hide() self.continue_button.setEnabled(True) - else: - self.continue_button.click() + return + + self._show_passphrase_request_message() @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): # Wire up continue button to the next step - self.error_status = error.status - if self.error_status == ExportStatus.USB_NOT_CONNECTED.value: - self.continue_button.clicked.connect(self._show_insert_usb_message) - elif self.error_status == ExportStatus.BAD_PASSPHRASE.value: - self.continue_button.clicked.connect(self._show_passphrase_request_message_again) - elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: - self.continue_button.clicked.connect(self._show_insert_encrypted_usb_message) + if error.status == ExportStatus.BAD_PASSPHRASE.value: + self.continue_button.clicked.connect(self._export_file) else: - self.continue_button.clicked.connect(self._show_generic_error_message) + self.continue_button.clicked.connect(self.controller.run_export_preflight_checks) # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.button_message.hide() self.continue_button.setEnabled(True) + return + + if error.status == ExportStatus.BAD_PASSPHRASE.value: + self._show_passphrase_request_message_again() + elif error.status == ExportStatus.USB_NOT_CONNECTED.value: + self._show_insert_usb_message() + elif error.status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + self._show_insert_encrypted_usb_message() else: - self.continue_button.click() + self._show_generic_error_message(error.status) + @pyqtSlot() def _on_export_success(self): @@ -2587,9 +2585,7 @@ def _on_export_success(self): @pyqtSlot(object) def _on_export_failure(self, error: ExportError): - self.error_status = error.status - self.error_status = error.status - self._show_generic_error_message() + self._show_generic_error_message(error.status) class ConversationView(QWidget): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 3d88a0cde..273f4ece7 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -43,7 +43,7 @@ SendReplyJobTimeoutError from securedrop_client.api_jobs.updatestar import UpdateStarJob, UpdateStarJobException from securedrop_client.crypto import GpgHelper -from securedrop_client.export import Export +from securedrop_client.export import Export, ExportError, ExportStatus from securedrop_client.queue import ApiJobQueue from securedrop_client.sync import ApiSync from securedrop_client.utils import check_dir_permissions @@ -638,10 +638,12 @@ def run_printer_preflight_checks(self): ''' Run preflight checks to make sure the Export VM is configured correctly. ''' - logger.info('Starting Export VM') + logger.info('Running printer preflight check') if not self.qubes: - self.export.printer_preflight_success.emit() + self.export.printer_preflight_failure.emit( + ExportError(ExportStatus.PRINTER_NOT_FOUND.value)) + # self.export.printer_preflight_success.emit() return self.export.begin_printer_preflight.emit() @@ -650,10 +652,12 @@ def run_export_preflight_checks(self): ''' Run preflight checks to make sure the Export VM is configured correctly. ''' - logger.info('Running export preflight checks') + logger.info('Running export preflight check') if not self.qubes: - self.export.preflight_check_call_success.emit() + self.export.preflight_check_call_failure.emit( + ExportError(ExportStatus.USB_NOT_CONNECTED.value)) + # self.export.preflight_check_call_success.emit() return self.export.begin_preflight_check.emit() @@ -690,7 +694,6 @@ def print_file(self, file_uuid: str) -> None: return if not self.qubes: - self.export.print_call_success.emit() return self.export.begin_print.emit([file_location]) From 1e0a2cbf7ca3917f032a6d19dac25e3f2427fc50 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 16 Jan 2020 19:39:05 -0800 Subject: [PATCH 12/31] refactor export status code handling --- securedrop_client/gui/widgets.py | 62 ++++++++++++++++++-------------- securedrop_client/logic.py | 8 ++--- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 566bffc64..3b77ec8fb 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2296,6 +2296,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Connect parent signals to slots self.continue_button.setEnabled(False) + self.continue_button.clicked.connect(self.controller.run_printer_preflight_checks) # Dialog content self.starting_header = _( @@ -2335,6 +2336,7 @@ def _show_starting_instructions(self): self.center_dialog() def _show_insert_usb_message(self): + self.continue_button.clicked.connect(self.controller.run_printer_preflight_checks) self.header.setText(self.insert_usb_header) self.error_details.hide() self.body.setText(self.insert_usb_message) @@ -2369,19 +2371,20 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): - # Wire up continue button to the next step - self.continue_button.clicked.connect(self.controller.run_printer_preflight_checks) - # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + if error.status == ExportStatus.PRINTER_NOT_FOUND.value: + self.continue_button.clicked.connect(self._show_insert_usb_message) + else: + self.continue_button.clicked.connect(self._show_generic_error_message) + self.button_message.hide() self.continue_button.setEnabled(True) - return - - if error.status == ExportStatus.PRINTER_NOT_FOUND.value: - self._show_insert_usb_message() else: - self._show_generic_error_message(error.status) + if error.status == ExportStatus.PRINTER_NOT_FOUND.value: + self._show_insert_usb_message() + else: + self._show_generic_error_message(error.status) class ExportDialog(FramelessModal): @@ -2493,6 +2496,7 @@ def _show_starting_instructions(self): self.center_dialog() def _show_passphrase_request_message(self): + self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.hide() self.body.hide() @@ -2502,6 +2506,7 @@ def _show_passphrase_request_message(self): self.center_dialog() def _show_passphrase_request_message_again(self): + self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.setText(self.passphrase_error_message) self.body.hide() @@ -2511,6 +2516,7 @@ def _show_passphrase_request_message_again(self): self.center_dialog() def _show_insert_usb_message(self): + self.continue_button.clicked.connect(self._show_insert_usb_message) self.header.setText(self.insert_usb_header) self.body.setText(self.insert_usb_message) self.error_details.hide() @@ -2520,6 +2526,7 @@ def _show_insert_usb_message(self): self.center_dialog() def _show_insert_encrypted_usb_message(self): + self.continue_button.clicked.connect(self._show_insert_usb_message) self.header.setText(self.insert_usb_header) self.error_details.setText(self.usb_error_message) self.body.setText(self.insert_usb_message) @@ -2544,11 +2551,9 @@ def _export_file(self, checked: bool = False): @pyqtSlot() def _on_preflight_success(self): - # Wire up continue button to the next step - self.continue_button.clicked.connect(self._export_file) - # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + self.continue_button.clicked.connect(self._show_passphrase_request_message) self.button_message.hide() self.continue_button.setEnabled(True) return @@ -2557,31 +2562,34 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): - # Wire up continue button to the next step - if error.status == ExportStatus.BAD_PASSPHRASE.value: - self.continue_button.clicked.connect(self._export_file) - else: - self.continue_button.clicked.connect(self.controller.run_export_preflight_checks) - # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + if error.status == ExportStatus.BAD_PASSPHRASE.value: + self.continue_button.clicked.connect(self._show_passphrase_request_message_again) + elif error.status == ExportStatus.USB_NOT_CONNECTED.value: + self.continue_button.clicked.connect(self._show_insert_usb_message) + elif error.status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + self.continue_button.clicked.connect(self._show_insert_encrypted_usb_message) + else: + self.continue_button.clicked.connect(self._show_generic_error_message) + self.button_message.hide() self.continue_button.setEnabled(True) - return - - if error.status == ExportStatus.BAD_PASSPHRASE.value: - self._show_passphrase_request_message_again() - elif error.status == ExportStatus.USB_NOT_CONNECTED.value: - self._show_insert_usb_message() - elif error.status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: - self._show_insert_encrypted_usb_message() else: - self._show_generic_error_message(error.status) + if error.status == ExportStatus.BAD_PASSPHRASE.value: + self._show_passphrase_request_message_again() + elif error.status == ExportStatus.USB_NOT_CONNECTED.value: + self._show_insert_usb_message() + elif error.status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + self._show_insert_encrypted_usb_message() + else: + self._show_generic_error_message(error.status) @pyqtSlot() def _on_export_success(self): - self.close() + pass + # self.close() @pyqtSlot(object) def _on_export_failure(self, error: ExportError): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 273f4ece7..c582fdbed 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -641,9 +641,7 @@ def run_printer_preflight_checks(self): logger.info('Running printer preflight check') if not self.qubes: - self.export.printer_preflight_failure.emit( - ExportError(ExportStatus.PRINTER_NOT_FOUND.value)) - # self.export.printer_preflight_success.emit() + self.export.printer_preflight_success.emit() return self.export.begin_printer_preflight.emit() @@ -655,9 +653,7 @@ def run_export_preflight_checks(self): logger.info('Running export preflight check') if not self.qubes: - self.export.preflight_check_call_failure.emit( - ExportError(ExportStatus.USB_NOT_CONNECTED.value)) - # self.export.preflight_check_call_success.emit() + self.export.preflight_check_call_success.emit() return self.export.begin_preflight_check.emit() From dfed9f75a7b3eac00945e3be98543eeb0adc0277 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 16 Jan 2020 19:41:04 -0800 Subject: [PATCH 13/31] hide header line when asking for passphrase --- securedrop_client/gui/widgets.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 3b77ec8fb..11c0c3551 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2222,8 +2222,8 @@ def __init__(self): self.header = QLabel() self.header.setObjectName('header') self.header.setWordWrap(True) - header_line = QWidget() - header_line.setObjectName('header_line') + self.header_line = QWidget() + self.header_line.setObjectName('header_line') self.error_details = QLabel() self.error_details.setObjectName('error_details') self.error_details.setWordWrap(True) @@ -2254,7 +2254,7 @@ def __init__(self): button_layout.addWidget(self.button_message, alignment=Qt.AlignRight) button_layout.addWidget(button_box, alignment=Qt.AlignRight) content_layout.addWidget(self.header) - content_layout.addWidget(header_line) + content_layout.addWidget(self.header_line) content_layout.addWidget(self.error_details) content_layout.addWidget(body_container) content_layout.addStretch() @@ -2498,6 +2498,7 @@ def _show_starting_instructions(self): def _show_passphrase_request_message(self): self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) + self.header_line.hide() self.error_details.hide() self.body.hide() self.passphrase_form.show() @@ -2508,6 +2509,7 @@ def _show_passphrase_request_message(self): def _show_passphrase_request_message_again(self): self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) + self.header_line.hide() self.error_details.setText(self.passphrase_error_message) self.body.hide() self.passphrase_form.show() @@ -2518,6 +2520,7 @@ def _show_passphrase_request_message_again(self): def _show_insert_usb_message(self): self.continue_button.clicked.connect(self._show_insert_usb_message) self.header.setText(self.insert_usb_header) + self.header_line.show() self.body.setText(self.insert_usb_message) self.error_details.hide() self.passphrase_form.hide() @@ -2528,6 +2531,7 @@ def _show_insert_usb_message(self): def _show_insert_encrypted_usb_message(self): self.continue_button.clicked.connect(self._show_insert_usb_message) self.header.setText(self.insert_usb_header) + self.header_line.show() self.error_details.setText(self.usb_error_message) self.body.setText(self.insert_usb_message) self.passphrase_form.hide() @@ -2538,6 +2542,7 @@ def _show_insert_encrypted_usb_message(self): def _show_generic_error_message(self, error_status: str): self.window_buttons.hide() self.header.setText(self.error_header) + self.header_line.show() self.error_details.hide() self.body.setText('{}: {}'.format(error_status, self.generic_error_message)) self.passphrase_form.hide() From 9d2165e5c184a2e3f418c9a8164ea8ea9e8a78d1 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 16 Jan 2020 21:30:09 -0800 Subject: [PATCH 14/31] update tests for export and print --- securedrop_client/gui/widgets.py | 8 +- securedrop_client/logic.py | 2 +- tests/gui/test_widgets.py | 360 +++++++++++++++---------------- tests/test_export.py | 68 ++++++ tests/test_logic.py | 26 +-- 5 files changed, 258 insertions(+), 206 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 11c0c3551..8179cc698 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2358,11 +2358,9 @@ def _print_file(self): @pyqtSlot() def _on_preflight_success(self): - # Wire up continue button to the next step - self.continue_button.clicked.connect(self._print_file) - # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + self.continue_button.clicked.connect(self._print_file) self.button_message.hide() self.continue_button.setEnabled(True) return @@ -2590,11 +2588,9 @@ def _on_preflight_failure(self, error: ExportError): else: self._show_generic_error_message(error.status) - @pyqtSlot() def _on_export_success(self): - pass - # self.close() + self.close() @pyqtSlot(object) def _on_export_failure(self, error: ExportError): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index c582fdbed..98266014b 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -43,7 +43,7 @@ SendReplyJobTimeoutError from securedrop_client.api_jobs.updatestar import UpdateStarJob, UpdateStarJobException from securedrop_client.crypto import GpgHelper -from securedrop_client.export import Export, ExportError, ExportStatus +from securedrop_client.export import Export from securedrop_client.queue import ApiJobQueue from securedrop_client.sync import ApiSync from securedrop_client.utils import check_dir_permissions diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index c429af08b..03d1504e6 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1838,7 +1838,6 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): def test_ExportDialog_init(mocker): - """Ensure that ExportDialog is set up correctly.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) _show_starting_instructions_fn = mocker.patch( @@ -1851,7 +1850,6 @@ def test_ExportDialog_init(mocker): def test_ExportDialog_close(mocker): - """Ensure that dialog emits closing signal and is hidden after close is called.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -1867,7 +1865,6 @@ def test_ExportDialog_close(mocker): def test_ExportDialog__show_starting_instructions(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -1894,11 +1891,10 @@ def test_ExportDialog__show_starting_instructions(mocker): '
' \ 'Documents submitted by sources may contain information or hidden metadata that ' \ 'identifies who they are. To protect your sources, please consider redacting documents ' \ - 'before working with them on network-connected computers.' \ + 'before working with them on network-connected computers.' def test_ExportDialog___show_passphrase_request_message(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -1911,7 +1907,6 @@ def test_ExportDialog___show_passphrase_request_message(mocker): def test_ExportDialog__show_passphrase_request_message_again(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -1925,7 +1920,6 @@ def test_ExportDialog__show_passphrase_request_message_again(mocker): def test_ExportDialog__show_insert_usb_message(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -1940,7 +1934,6 @@ def test_ExportDialog__show_insert_usb_message(mocker): def test_ExportDialog__show_insert_encrypted_usb_message(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -1957,7 +1950,6 @@ def test_ExportDialog__show_insert_encrypted_usb_message(mocker): def test_ExportDialog__show_generic_error_message(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -1970,181 +1962,169 @@ def test_ExportDialog__show_generic_error_message(mocker): assert dialog.body.text() == 'mock_error_status: See your administrator for help.' -def test_ExportDialog__update_when_status_is_USB_NOT_CONNECTED(mocker): - """ - Ensure request to insert USB device on USB_NOT_CONNECTED. - """ +def test_ExportDialog__on_preflight_failure_when_status_is_USB_NOT_CONNECTED(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') dialog._show_insert_usb_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._update(ExportStatus.USB_NOT_CONNECTED.value) + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) + dialog.continue_button.clicked.connect.assert_called_once_with(dialog._show_insert_usb_message) + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) dialog._show_insert_usb_message.assert_called_once_with() -def test_ExportDialog__update_when_status_is_BAD_PASSPHRASE(mocker): - """ - Ensure request to enter passphrase again on BAD_PASSPHRASE. - """ +def test_ExportDialog__on_preflight_failure_when_status_is_BAD_PASSPHRASE(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') dialog._show_passphrase_request_message_again = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._update(ExportStatus.BAD_PASSPHRASE.value) + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError(ExportStatus.BAD_PASSPHRASE.value)) + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_passphrase_request_message_again) + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError(ExportStatus.BAD_PASSPHRASE.value)) dialog._show_passphrase_request_message_again.assert_called_once_with() -def test_ExportDialog__update_when_status_is_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): - """ - Ensure request to insert USB device on USB_NOT_CONNECTED. - """ +def test_ExportDialog__on_preflight_failure_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') dialog._show_insert_encrypted_usb_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._update(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure( + ExportError(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value)) + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_insert_encrypted_usb_message) + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError( + ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value)) dialog._show_insert_encrypted_usb_message.assert_called_once_with() -def test_ExportDialog__update_when_status_is_CALLED_PROCESS_ERROR(mocker): - """ - Ensure request to insert USB device on USB_NOT_CONNECTED. - """ +def test_ExportDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') dialog._show_generic_error_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._update(ExportStatus.CALLED_PROCESS_ERROR.value) + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_generic_error_message) - dialog._show_generic_error_message.assert_called_once_with('CALLED_PROCESS_ERROR') + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog._show_generic_error_message.assert_called_once_with( + ExportStatus.CALLED_PROCESS_ERROR.value) -def test_ExportDialog__update_when_status_is_unknown(mocker): - """ - Ensure request to insert USB device on USB_NOT_CONNECTED. - """ +def test_ExportDialog__on_preflight_failure_when_status_is_unknown(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') dialog._show_generic_error_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._update('Some Unknown Error Status') + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_generic_error_message) + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) dialog._show_generic_error_message.assert_called_once_with('Some Unknown Error Status') -def test_ExportDialog__on_continue_clicked(mocker): - """ - Ensure happy path runs preflight checks. - """ +def test_ExportDialog__export_file(mocker): mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - controller = mocker.MagicMock() - dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - - dialog._on_continue_clicked() - - controller.run_export_preflight_checks.assert_called_with() - - -def test_ExportDialog__on_continue_clicked_after_preflight(mocker): - """ - Ensure export of file begins once the passphrase is retrieved from the uesr. - """ - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) controller = mocker.MagicMock() controller.export_file_to_usb_drive = mocker.MagicMock() dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') dialog.passphrase_field.text = mocker.MagicMock(return_value='mock_passphrase') - dialog._on_continue_clicked_after_preflight() + dialog._export_file() controller.export_file_to_usb_drive.assert_called_once_with('mock_uuid', 'mock_passphrase') -def test_ExportDialog__on_start_export_vm_success(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - - dialog._on_start_export_vm_success() - - assert dialog.button_message.isHidden() - assert dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_start_export_vm_failure(mocker): +def test_ExportDialog__on_preflight_success(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._update = mocker.MagicMock() + dialog._show_passphrase_request_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._on_start_export_vm_failure(ExportError('generic error')) + dialog._on_preflight_success() + dialog._show_passphrase_request_message.assert_not_called() + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_passphrase_request_message) assert dialog.button_message.isHidden() - dialog._update.assert_called_once_with('generic error') -def test_ExportDialog__on_preflight_success(mocker): +def test_ExportDialog__on_preflight_success_when_continue_enabled(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') dialog._show_passphrase_request_message = mocker.MagicMock() - dialog.continue_button = mocker.MagicMock() - dialog.continue_button.clicked = mocker.MagicMock() + dialog.continue_button.setEnabled(True) dialog._on_preflight_success() dialog._show_passphrase_request_message.assert_called_once_with() - dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._on_continue_clicked_after_preflight) -def test_ExportDialog__on_preflight_failure(mocker): - """ - Ensure generic errors are passed through to _update - """ +def test_ExportDialog__on_preflight_success_enabled_after_preflight_success(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._update = mocker.MagicMock() - - dialog._on_preflight_failure(ExportError('generic error')) - - dialog._update.assert_called_once_with('generic error') + assert not dialog.continue_button.isEnabled() + dialog._on_preflight_success() + assert dialog.continue_button.isEnabled() -def test_ExportDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(mocker): - """ - Ensure CALLED_PROCESS_ERROR during pre-flight updates the dialog with the status code. - """ +def test_ExportDialog__on_preflight_success_enabled_after_preflight_failure(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - controller = mocker.MagicMock() - dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - dialog._show_passphrase_request_message_again = mocker.MagicMock() - dialog._show_insert_usb_message = mocker.MagicMock() - dialog._update = mocker.MagicMock() - - dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) - - dialog._show_passphrase_request_message_again.assert_not_called() - dialog._show_insert_usb_message.assert_not_called() - dialog._update.assert_called_once_with(ExportStatus.CALLED_PROCESS_ERROR.value) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + assert not dialog.continue_button.isEnabled() + dialog._on_preflight_failure(mocker.MagicMock()) + assert dialog.continue_button.isEnabled() def test_ExportDialog__on_export_success(mocker): - """ - Ensure successful export results in the export dialog window closing. - """ mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -2156,23 +2136,18 @@ def test_ExportDialog__on_export_success(mocker): def test_ExportDialog__on_export_failure(mocker): - """ - Ensure CALLED_PROCESS_ERROR shows generic 'contact admin' error with correct - error status code. - """ mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._update = mocker.MagicMock() + dialog._show_generic_error_message = mocker.MagicMock() dialog._on_export_failure(ExportError('mock_error_status')) assert dialog.passphrase_form.isHidden() - dialog._update.assert_called_with('mock_error_status') + dialog._show_generic_error_message.assert_called_with('mock_error_status') def test_PrintDialog_init(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) _show_starting_instructions_fn = mocker.patch( @@ -2184,7 +2159,6 @@ def test_PrintDialog_init(mocker): def test_PrintDialog__show_starting_instructions(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -2214,7 +2188,6 @@ def test_PrintDialog__show_starting_instructions(mocker): def test_PrintDialog__show_insert_usb_message(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') @@ -2226,7 +2199,6 @@ def test_PrintDialog__show_insert_usb_message(mocker): def test_PrintDialog__show_generic_error_message(mocker): - """Ensure that the correct widgets are visible or hidden.""" mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') @@ -2237,126 +2209,142 @@ def test_PrintDialog__show_generic_error_message(mocker): assert dialog.body.text() == 'mock_error_status: See your administrator for help.' -def test_PrintDialog__update_when_status_is_PRINTER_NOT_FOUND(mocker): - """ - Ensure PRINTER_NOT_FOUND results in asking the user connect their USB device. - """ +def test_PrintDialog__print_file(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') - dialog._show_insert_usb_message = mocker.MagicMock() - dialog._update(ExportStatus.PRINTER_NOT_FOUND.value) - dialog._show_insert_usb_message.assert_called_once_with() - - -def test_PrintDialog__update_when_status_is_MISSING_PRINTER_URI(mocker): - """ - Ensure MISSING_PRINTER_URI shows generic 'contact admin' error with correct - error status code. - """ - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') - dialog._show_generic_error_message = mocker.MagicMock() + dialog.close = mocker.MagicMock() - dialog._on_print_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) + dialog._print_file() - dialog._show_generic_error_message.assert_called_once_with( - ExportStatus.MISSING_PRINTER_URI.value) + dialog.close.assert_called_once_with() -def test_PrintDialog__update_when_status_is_CALLED_PROCESS_ERROR(mocker): - """ - Ensure CALLED_PROCESS_ERROR shows generic 'contact admin' error with correct - error status code. - """ +def test_PrintDialog__on_preflight_success(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._show_generic_error_message = mocker.MagicMock() + dialog._print_file = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._update(ExportStatus.CALLED_PROCESS_ERROR.value) + dialog._on_preflight_success() - dialog._show_generic_error_message.assert_called_once_with('CALLED_PROCESS_ERROR') + dialog._print_file.assert_not_called() + dialog.continue_button.clicked.connect.assert_called_once_with(dialog._print_file) + assert dialog.button_message.isHidden() -def test_PrintDialog__update_when_status_is_unknown(mocker): - """ - Ensure request to insert USB device on PRINTER_NOT_FOUND. - """ +def test_PrintDialog__on_preflight_success_when_continue_enabled(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') - dialog._show_generic_error_message = mocker.MagicMock() + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._print_file = mocker.MagicMock() + dialog.continue_button.setEnabled(True) - dialog._update('Some Unknown Error Status') + dialog._on_preflight_success() - dialog._show_generic_error_message.assert_called_once_with('Some Unknown Error Status') + dialog._print_file.assert_called_once_with() -def test_PrintDialog__on_continue_clicked(mocker): - """ - Ensure happy path prints the file. - """ +def test_PrintDialog__on_preflight_success_enabled_after_preflight_success(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - controller = mocker.MagicMock() - dialog = PrintDialog(controller, 'mock_uuid', 'mock_filename') + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + assert not dialog.continue_button.isEnabled() + dialog._on_preflight_success() + assert dialog.continue_button.isEnabled() - dialog._on_continue_clicked() - controller.print_file.assert_called_with('mock_uuid') +def test_PrintDialog__on_preflight_success_enabled_after_preflight_failure(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + assert not dialog.continue_button.isEnabled() + dialog._on_preflight_failure(mocker.MagicMock()) + assert dialog.continue_button.isEnabled() -def test_PrintDialog__on_start_export_vm_success(mocker): +def test_PrintDialog__on_preflight_failure_when_status_is_PRINTER_NOT_FOUND(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog._show_insert_usb_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._on_start_export_vm_success() + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError(ExportStatus.PRINTER_NOT_FOUND.value)) + dialog.continue_button.clicked.connect.assert_called_once_with(dialog._show_insert_usb_message) - assert dialog.button_message.isHidden() - assert dialog.continue_button.isEnabled() + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError(ExportStatus.PRINTER_NOT_FOUND.value)) + dialog._show_insert_usb_message.assert_called_once_with() -def test_PrintDialog__on_start_export_vm_failure(mocker): +def test_PrintDialog__on_preflight_failure_when_status_is_MISSING_PRINTER_URI(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._update = mocker.MagicMock() + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + dialog._show_generic_error_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._on_start_export_vm_failure(ExportError('generic error')) + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_generic_error_message) - assert dialog.button_message.isHidden() - dialog._update.assert_called_once_with('generic error') + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) + dialog._show_generic_error_message.assert_called_once_with( + ExportStatus.MISSING_PRINTER_URI.value) -def test_PrintDialog__on_print_success(mocker): - """ - Ensure successful print results in the print dialog window closing. - """ +def test_PrintDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') - dialog.close = mocker.MagicMock() + dialog._show_generic_error_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._on_print_success() + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_generic_error_message) - dialog.close.assert_called_once_with() + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog._show_generic_error_message.assert_called_once_with( + ExportStatus.CALLED_PROCESS_ERROR.value) -def test_PrintDialog__on_print_failure(mocker): - """ - Ensure generic errors are passed through to _update - """ +def test_PrintDialog__on_preflight_failure_when_status_is_unknown(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') dialog._show_generic_error_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - dialog._on_print_failure(ExportError('generic error')) + # When the continue button is enabled, ensure clicking continue will show next instructions + dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_generic_error_message) - dialog._show_generic_error_message.assert_called_once_with('generic error') + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) + dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) + dialog._show_generic_error_message.assert_called_once_with('Some Unknown Error Status') def test_ConversationView_init(mocker, homedir): diff --git a/tests/test_export.py b/tests/test_export.py index 913c769ee..24351ee7a 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -6,6 +6,74 @@ from securedrop_client.export import Export, ExportError +def test_run_printer_preflight(mocker): + ''' + Ensure TemporaryDirectory is used when creating and sending the archives during the preflight + checks and that the success signal is emitted by Export. + ''' + mock_temp_dir = mocker.MagicMock() + mock_temp_dir.__enter__ = mocker.MagicMock(return_value='mock_temp_dir') + mocker.patch('securedrop_client.export.TemporaryDirectory', return_value=mock_temp_dir) + export = Export() + export.printer_preflight_success = mocker.MagicMock() + export.printer_preflight_success.emit = mocker.MagicMock() + _run_printer_preflight = mocker.patch.object(export, '_run_printer_preflight') + + export.run_printer_preflight() + + _run_printer_preflight.assert_called_once_with('mock_temp_dir') + export.printer_preflight_success.emit.assert_called_once_with() + + +def test_run_printer_preflight_error(mocker): + ''' + Ensure TemporaryDirectory is used when creating and sending the archives during the preflight + checks and that the failure signal is emitted by Export. + ''' + mock_temp_dir = mocker.MagicMock() + mock_temp_dir.__enter__ = mocker.MagicMock(return_value='mock_temp_dir') + mocker.patch('securedrop_client.export.TemporaryDirectory', return_value=mock_temp_dir) + export = Export() + export.printer_preflight_failure = mocker.MagicMock() + export.printer_preflight_failure.emit = mocker.MagicMock() + error = ExportError('bang!') + _run_print_preflight = mocker.patch.object(export, '_run_printer_preflight', side_effect=error) + + export.run_printer_preflight() + + _run_print_preflight.assert_called_once_with('mock_temp_dir') + export.printer_preflight_failure.emit.assert_called_once_with(error) + + +def test__run_printer_preflight(mocker): + ''' + Ensure _export_archive and _create_archive are called with the expected parameters, + _export_archive is called with the return value of _create_archive, and + _run_disk_test returns without error if 'USB_CONNECTED' is the return value of _export_archive. + ''' + export = Export() + export._create_archive = mocker.MagicMock(return_value='mock_archive_path') + export._export_archive = mocker.MagicMock(return_value='') + + export._run_printer_preflight('mock_archive_dir') + + export._export_archive.assert_called_once_with('mock_archive_path') + export._create_archive.assert_called_once_with( + 'mock_archive_dir', 'printer-preflight.sd-export', {'device': 'printer-preflight'}) + + +def test__run_printer_preflight_raises_ExportError_if_not_empty_string(mocker): + ''' + Ensure ExportError is raised if _run_disk_test returns anything other than 'USB_CONNECTED'. + ''' + export = Export() + export._create_archive = mocker.MagicMock(return_value='mock_archive_path') + export._export_archive = mocker.MagicMock(return_value='SOMETHING_OTHER_THAN_EMPTY_STRING') + + with pytest.raises(ExportError): + export._run_printer_preflight('mock_archive_dir') + + def test_print(mocker): ''' Ensure TemporaryDirectory is used when creating and sending the archive containing the file to diff --git a/tests/test_logic.py b/tests/test_logic.py index 7add05dd7..6fd073851 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -1505,30 +1505,30 @@ def test_Controller_call_update_star_success(homedir, config, mocker, session_ma co.on_update_star_failure, type=Qt.QueuedConnection) -def test_Controller_run_start_export_vm(homedir, mocker, session, source): +def test_Controller_run_printer_preflight_checks(homedir, mocker, session, source): co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() - co.export.start_export_vm = mocker.MagicMock() - co.export.start_export_vm.emit = mocker.MagicMock() + co.export.begin_printer_preflight = mocker.MagicMock() + co.export.begin_printer_preflight.emit = mocker.MagicMock() - co.run_start_export_vm() + co.run_printer_preflight_checks() - co.export.start_export_vm.emit.call_count == 1 + co.export.begin_printer_preflight.emit.call_count == 1 -def test_Controller_run_start_export_vm_not_qubes(homedir, mocker, session, source): +def test_Controller_run_printer_preflight_checks_not_qubes(homedir, mocker, session, source): co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() - co.export.start_export_vm = mocker.MagicMock() - co.export.start_export_vm.emit = mocker.MagicMock() - co.export.start_export_vm_success = mocker.MagicMock() - co.export.start_export_vm_success.emit = mocker.MagicMock() + co.export.begin_printer_preflight = mocker.MagicMock() + co.export.begin_printer_preflight.emit = mocker.MagicMock() + co.export.printer_preflight_success = mocker.MagicMock() + co.export.printer_preflight_success.emit = mocker.MagicMock() - co.run_start_export_vm() + co.run_printer_preflight_checks() - co.export.start_export_vm.emit.call_count == 0 - co.export.start_export_vm_success.emit.call_count == 1 + co.export.begin_printer_preflight.emit.call_count == 0 + co.export.printer_preflight_success.emit.call_count == 1 def test_Controller_run_print_file(mocker, session, homedir): From 2a0ec1594eb1ccf62aec1252b16004102da9ef5b Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 10:34:46 -0800 Subject: [PATCH 15/31] polish primary dialog button --- securedrop_client/gui/widgets.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 8179cc698..c3fb62e7d 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2171,6 +2171,15 @@ class FramelessModal(QDialog): border: 2px solid rgba(42, 49, 157, 0.4); color: rgba(42, 49, 157, 0.4); } + #button_box QPushButton#primary_button { + background-color: #2a319d; + color: #fff; + } + #button_box QPushButton#primary_button::disabled { + border: 2px solid #8084C5; + background-color: #8084C5; + color: #fff; + } #button_message { font-family: 'Source Sans Pro'; font-weight: 500; @@ -2245,6 +2254,8 @@ def __init__(self): cancel_button.setAutoDefault(False) cancel_button.clicked.connect(self.close) self.continue_button = QPushButton(_('CONTINUE')) + self.continue_button.setObjectName('primary_button') + self.continue_button.setDefault(True) button_box = QDialogButtonBox(Qt.Horizontal) button_box.setObjectName('button_box') button_box.addButton(cancel_button, QDialogButtonBox.ActionRole) From 64ef17d832b7f738ac038e1a5c6ab2e085909efe Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 11:07:42 -0800 Subject: [PATCH 16/31] remove disabled button text --- securedrop_client/gui/widgets.py | 24 +++--------------------- tests/gui/test_widgets.py | 6 ------ 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index c3fb62e7d..e5aee0bd2 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2176,16 +2176,9 @@ class FramelessModal(QDialog): color: #fff; } #button_box QPushButton#primary_button::disabled { - border: 2px solid #8084C5; - background-color: #8084C5; - color: #fff; - } - #button_message { - font-family: 'Source Sans Pro'; - font-weight: 500; - font-size: 16px; - color: #ff3366; - padding-bottom: 6px; + border: 2px solid #C2C4E3; + background-color: #C2C4E3; + color: #E1E2F1; } ''' @@ -2260,9 +2253,6 @@ def __init__(self): button_box.setObjectName('button_box') button_box.addButton(cancel_button, QDialogButtonBox.ActionRole) button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) - self.button_message = QLabel() - self.button_message.setObjectName('button_message') - button_layout.addWidget(self.button_message, alignment=Qt.AlignRight) button_layout.addWidget(button_box, alignment=Qt.AlignRight) content_layout.addWidget(self.header) content_layout.addWidget(self.header_line) @@ -2332,8 +2322,6 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): 'consider this risk when working with or publishing scanned printouts.') self.insert_usb_message = _('Please connect your printer to a USB port.') self.generic_error_message = _('See your administrator for help.') - self.continue_disabled_message = _( - 'The CONTINUE button will be disabled until the Export VM is ready') self._show_starting_instructions() self.controller.run_printer_preflight_checks() @@ -2342,7 +2330,6 @@ def _show_starting_instructions(self): self.header.setText(self.starting_header) self.error_details.hide() self.body.setText(self.starting_message) - self.button_message.setText('' + self.continue_disabled_message + '') self.adjustSize() self.center_dialog() @@ -2372,7 +2359,6 @@ def _on_preflight_success(self): # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.continue_button.clicked.connect(self._print_file) - self.button_message.hide() self.continue_button.setEnabled(True) return @@ -2387,7 +2373,6 @@ def _on_preflight_failure(self, error: ExportError): else: self.continue_button.clicked.connect(self._show_generic_error_message) - self.button_message.hide() self.continue_button.setEnabled(True) else: if error.status == ExportStatus.PRINTER_NOT_FOUND.value: @@ -2500,7 +2485,6 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): def _show_starting_instructions(self): self.header.setText(self.starting_header) self.body.setText(self.starting_message) - self.button_message.setText('' + self.continue_disabled_message + '') self.adjustSize() self.center_dialog() @@ -2568,7 +2552,6 @@ def _on_preflight_success(self): # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.continue_button.clicked.connect(self._show_passphrase_request_message) - self.button_message.hide() self.continue_button.setEnabled(True) return @@ -2587,7 +2570,6 @@ def _on_preflight_failure(self, error: ExportError): else: self.continue_button.clicked.connect(self._show_generic_error_message) - self.button_message.hide() self.continue_button.setEnabled(True) else: if error.status == ExportStatus.BAD_PASSPHRASE.value: diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 03d1504e6..67e32adf4 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1872,8 +1872,6 @@ def test_ExportDialog__show_starting_instructions(mocker): dialog._show_starting_instructions() assert dialog.passphrase_form.isHidden() - assert dialog.button_message.text() == \ - 'The CONTINUE button will be disabled until the Export VM is ready' assert dialog.header.text() == \ 'Preparing to export:' \ '
' \ @@ -2091,7 +2089,6 @@ def test_ExportDialog__on_preflight_success(mocker): dialog._show_passphrase_request_message.assert_not_called() dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_passphrase_request_message) - assert dialog.button_message.isHidden() def test_ExportDialog__on_preflight_success_when_continue_enabled(mocker): @@ -2165,8 +2162,6 @@ def test_PrintDialog__show_starting_instructions(mocker): dialog._show_starting_instructions() - assert dialog.button_message.text() == \ - 'The CONTINUE button will be disabled until the Export VM is ready' assert dialog.header.text() == \ 'Preparing to print:' \ '
' \ @@ -2233,7 +2228,6 @@ def test_PrintDialog__on_preflight_success(mocker): dialog._print_file.assert_not_called() dialog.continue_button.clicked.connect.assert_called_once_with(dialog._print_file) - assert dialog.button_message.isHidden() def test_PrintDialog__on_preflight_success_when_continue_enabled(mocker): From f17a7aa1b97084f1aa691d1a9955ddd7fb669cc1 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 11:21:27 -0800 Subject: [PATCH 17/31] hold onto error status --- securedrop_client/gui/widgets.py | 19 +++++++++++------- tests/gui/test_widgets.py | 34 ++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index e5aee0bd2..3b54ae4f6 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2290,6 +2290,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name + self.error_status = None # Hold onto the error status we receive from the Export VM # Connect controller signals to slots self.controller.export.printer_preflight_success.connect(self._on_preflight_success) @@ -2341,11 +2342,11 @@ def _show_insert_usb_message(self): self.adjustSize() self.center_dialog() - def _show_generic_error_message(self, error_status: str): + def _show_generic_error_message(self): self.window_buttons.hide() self.header.setText(self.error_header) self.error_details.hide() - self.body.setText('{}: {}'.format(error_status, self.generic_error_message)) + self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) self.adjustSize() self.center_dialog() @@ -2366,6 +2367,7 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): + self.error_status = error.status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): if error.status == ExportStatus.PRINTER_NOT_FOUND.value: @@ -2378,7 +2380,7 @@ def _on_preflight_failure(self, error: ExportError): if error.status == ExportStatus.PRINTER_NOT_FOUND.value: self._show_insert_usb_message() else: - self._show_generic_error_message(error.status) + self._show_generic_error_message() class ExportDialog(FramelessModal): @@ -2408,6 +2410,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name + self.error_status = None # Hold onto the error status we receive from the Export VM # Connect controller signals to slots self.controller.export.preflight_check_call_success.connect(self._on_preflight_success) @@ -2532,12 +2535,12 @@ def _show_insert_encrypted_usb_message(self): self.adjustSize() self.center_dialog() - def _show_generic_error_message(self, error_status: str): + def _show_generic_error_message(self): self.window_buttons.hide() self.header.setText(self.error_header) self.header_line.show() self.error_details.hide() - self.body.setText('{}: {}'.format(error_status, self.generic_error_message)) + self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) self.passphrase_form.hide() self.window_buttons.hide() self.adjustSize() @@ -2559,6 +2562,7 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): + self.error_status = error.status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): if error.status == ExportStatus.BAD_PASSPHRASE.value: @@ -2579,7 +2583,7 @@ def _on_preflight_failure(self, error: ExportError): elif error.status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: self._show_insert_encrypted_usb_message() else: - self._show_generic_error_message(error.status) + self._show_generic_error_message() @pyqtSlot() def _on_export_success(self): @@ -2587,7 +2591,8 @@ def _on_export_success(self): @pyqtSlot(object) def _on_export_failure(self, error: ExportError): - self._show_generic_error_message(error.status) + self.error_status = error.status + self._show_generic_error_message() class ConversationView(QWidget): diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 67e32adf4..9b092173a 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1951,8 +1951,9 @@ def test_ExportDialog__show_generic_error_message(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog.error_status = 'mock_error_status' - dialog._show_generic_error_message('mock_error_status') + dialog._show_generic_error_message() assert dialog.passphrase_form.isHidden() assert dialog.header.text() == 'Unable to export' @@ -2034,13 +2035,13 @@ def test_ExportDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR( dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) + assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) - dialog._show_generic_error_message.assert_called_once_with( - ExportStatus.CALLED_PROCESS_ERROR.value) - + dialog._show_generic_error_message.assert_called_once_with() + assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value def test_ExportDialog__on_preflight_failure_when_status_is_unknown(mocker): mocker.patch( @@ -2055,12 +2056,13 @@ def test_ExportDialog__on_preflight_failure_when_status_is_unknown(mocker): dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) + assert dialog.error_status == 'Some Unknown Error Status' # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) - dialog._show_generic_error_message.assert_called_once_with('Some Unknown Error Status') - + dialog._show_generic_error_message.assert_called_once_with() + assert dialog.error_status == 'Some Unknown Error Status' def test_ExportDialog__export_file(mocker): mocker.patch( @@ -2141,7 +2143,8 @@ def test_ExportDialog__on_export_failure(mocker): dialog._on_export_failure(ExportError('mock_error_status')) assert dialog.passphrase_form.isHidden() - dialog._show_generic_error_message.assert_called_with('mock_error_status') + dialog._show_generic_error_message.assert_called_with() + assert dialog.error_status == 'mock_error_status' def test_PrintDialog_init(mocker): @@ -2197,8 +2200,9 @@ def test_PrintDialog__show_generic_error_message(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog.error_status = 'mock_error_status' - dialog._show_generic_error_message('mock_error_status') + dialog._show_generic_error_message() assert dialog.header.text() == 'Unable to print' assert dialog.body.text() == 'mock_error_status: See your administrator for help.' @@ -2292,12 +2296,13 @@ def test_PrintDialog__on_preflight_failure_when_status_is_MISSING_PRINTER_URI(mo dialog._on_preflight_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) + assert dialog.error_status == ExportStatus.MISSING_PRINTER_URI.value # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) dialog._on_preflight_failure(ExportError(ExportStatus.MISSING_PRINTER_URI.value)) - dialog._show_generic_error_message.assert_called_once_with( - ExportStatus.MISSING_PRINTER_URI.value) + dialog._show_generic_error_message.assert_called_once_with() + assert dialog.error_status == ExportStatus.MISSING_PRINTER_URI.value def test_PrintDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(mocker): @@ -2313,12 +2318,13 @@ def test_PrintDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(m dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) + assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) - dialog._show_generic_error_message.assert_called_once_with( - ExportStatus.CALLED_PROCESS_ERROR.value) + dialog._show_generic_error_message.assert_called_once_with() + assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value def test_PrintDialog__on_preflight_failure_when_status_is_unknown(mocker): @@ -2334,11 +2340,13 @@ def test_PrintDialog__on_preflight_failure_when_status_is_unknown(mocker): dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) + assert dialog.error_status == 'Some Unknown Error Status' # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) - dialog._show_generic_error_message.assert_called_once_with('Some Unknown Error Status') + dialog._show_generic_error_message.assert_called_once_with() + assert dialog.error_status == 'Some Unknown Error Status' def test_ConversationView_init(mocker, homedir): From 3dedc999fc4de9073ce4168ee5c95e9b525fd627 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 11:39:52 -0800 Subject: [PATCH 18/31] show cancel and done buttons for generic errors --- securedrop_client/gui/widgets.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 3b54ae4f6..59c67ae54 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2239,10 +2239,10 @@ def __init__(self): self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN) body_container.setLayout(self.body_layout) self.body_layout.addWidget(self.body) - self.window_buttons = QWidget() - self.window_buttons.setObjectName('window_buttons') + window_buttons = QWidget() + window_buttons.setObjectName('window_buttons') button_layout = QVBoxLayout() - self.window_buttons.setLayout(button_layout) + window_buttons.setLayout(button_layout) cancel_button = QPushButton(_('CANCEL')) cancel_button.setAutoDefault(False) cancel_button.clicked.connect(self.close) @@ -2259,7 +2259,7 @@ def __init__(self): content_layout.addWidget(self.error_details) content_layout.addWidget(body_container) content_layout.addStretch() - content_layout.addWidget(self.window_buttons) + content_layout.addWidget(window_buttons) # Layout layout = QVBoxLayout(self) @@ -2343,7 +2343,8 @@ def _show_insert_usb_message(self): self.center_dialog() def _show_generic_error_message(self): - self.window_buttons.hide() + self.continue_button.clicked.connect(self.close) + self.continue_button.setText('DONE') self.header.setText(self.error_header) self.error_details.hide() self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) @@ -2536,13 +2537,13 @@ def _show_insert_encrypted_usb_message(self): self.center_dialog() def _show_generic_error_message(self): - self.window_buttons.hide() + self.continue_button.clicked.connect(self.close) + self.continue_button.setText('DONE') self.header.setText(self.error_header) self.header_line.show() self.error_details.hide() self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) self.passphrase_form.hide() - self.window_buttons.hide() self.adjustSize() self.center_dialog() From 7b3a95d2c9c06cdee2e7ece560be51351ba5a82e Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 13:55:13 -0800 Subject: [PATCH 19/31] wrap controller method --- securedrop_client/gui/widgets.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 59c67ae54..2aede4266 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2298,7 +2298,6 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Connect parent signals to slots self.continue_button.setEnabled(False) - self.continue_button.clicked.connect(self.controller.run_printer_preflight_checks) # Dialog content self.starting_header = _( @@ -2325,7 +2324,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.generic_error_message = _('See your administrator for help.') self._show_starting_instructions() - self.controller.run_printer_preflight_checks() + self._run_preflight() def _show_starting_instructions(self): self.header.setText(self.starting_header) @@ -2335,7 +2334,7 @@ def _show_starting_instructions(self): self.center_dialog() def _show_insert_usb_message(self): - self.continue_button.clicked.connect(self.controller.run_printer_preflight_checks) + self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) self.error_details.hide() self.body.setText(self.insert_usb_message) @@ -2351,6 +2350,10 @@ def _show_generic_error_message(self): self.adjustSize() self.center_dialog() + @pyqtSlot() + def _run_preflight(self): + self.controller.run_printer_preflight_checks() + @pyqtSlot() def _print_file(self): self.controller.print_file(self.file_uuid) @@ -2484,7 +2487,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.passphrase_form.hide() self._show_starting_instructions() - self.controller.run_export_preflight_checks() + self._run_preflight() def _show_starting_instructions(self): self.header.setText(self.starting_header) @@ -2515,7 +2518,7 @@ def _show_passphrase_request_message_again(self): self.center_dialog() def _show_insert_usb_message(self): - self.continue_button.clicked.connect(self._show_insert_usb_message) + self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) self.header_line.show() self.body.setText(self.insert_usb_message) @@ -2526,7 +2529,7 @@ def _show_insert_usb_message(self): self.center_dialog() def _show_insert_encrypted_usb_message(self): - self.continue_button.clicked.connect(self._show_insert_usb_message) + self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) self.header_line.show() self.error_details.setText(self.usb_error_message) @@ -2547,6 +2550,10 @@ def _show_generic_error_message(self): self.adjustSize() self.center_dialog() + @pyqtSlot() + def _run_preflight(self): + self.controller.run_export_preflight_checks() + @pyqtSlot() def _export_file(self, checked: bool = False): self.controller.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) From a374a5797afeef9053284e42ec084e1dbdc7335b Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 14:26:08 -0800 Subject: [PATCH 20/31] wire up dialog after bad passphrase --- securedrop_client/gui/widgets.py | 20 +-- tests/gui/test_widgets.py | 206 ++++++++++++++++--------------- 2 files changed, 120 insertions(+), 106 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 2aede4266..5d0868542 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2570,6 +2570,17 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): + self._update_dialog(error) + + @pyqtSlot() + def _on_export_success(self): + self.close() + + @pyqtSlot(object) + def _on_export_failure(self, error: ExportError): + self._update_dialog(error) + + def _update_dialog(self, error: ExportStatus): self.error_status = error.status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): @@ -2593,15 +2604,6 @@ def _on_preflight_failure(self, error: ExportError): else: self._show_generic_error_message() - @pyqtSlot() - def _on_export_success(self): - self.close() - - @pyqtSlot(object) - def _on_export_failure(self, error: ExportError): - self.error_status = error.status - self._show_generic_error_message() - class ConversationView(QWidget): """ diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 9b092173a..6f23bc6d4 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1961,7 +1961,101 @@ def test_ExportDialog__show_generic_error_message(mocker): assert dialog.body.text() == 'mock_error_status: See your administrator for help.' -def test_ExportDialog__on_preflight_failure_when_status_is_USB_NOT_CONNECTED(mocker): +def test_ExportDialog__export_file(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + controller = mocker.MagicMock() + controller.export_file_to_usb_drive = mocker.MagicMock() + dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') + dialog.passphrase_field.text = mocker.MagicMock(return_value='mock_passphrase') + + dialog._export_file() + + controller.export_file_to_usb_drive.assert_called_once_with('mock_uuid', 'mock_passphrase') + + +def test_ExportDialog__on_preflight_success(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_passphrase_request_message = mocker.MagicMock() + dialog.continue_button = mocker.MagicMock() + dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) + + dialog._on_preflight_success() + + dialog._show_passphrase_request_message.assert_not_called() + dialog.continue_button.clicked.connect.assert_called_once_with( + dialog._show_passphrase_request_message) + + +def test_ExportDialog__on_preflight_success_when_continue_enabled(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._show_passphrase_request_message = mocker.MagicMock() + dialog.continue_button.setEnabled(True) + + dialog._on_preflight_success() + + dialog._show_passphrase_request_message.assert_called_once_with() + + +def test_ExportDialog__on_preflight_success_enabled_after_preflight_success(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + assert not dialog.continue_button.isEnabled() + dialog._on_preflight_success() + assert dialog.continue_button.isEnabled() + + +def test_ExportDialog__on_preflight_success_enabled_after_preflight_failure(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + assert not dialog.continue_button.isEnabled() + dialog._on_preflight_failure(mocker.MagicMock()) + assert dialog.continue_button.isEnabled() + + +def test_ExportDialog__on_preflight_failure(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._update_dialog = mocker.MagicMock() + + error = ExportError('mock_error_status') + dialog._on_preflight_failure(error) + + dialog._update_dialog.assert_called_with(error) + + +def test_ExportDialog__on_export_success(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog.close = mocker.MagicMock() + + dialog._on_export_success() + + dialog.close.assert_called_once_with() + + +def test_ExportDialog__on_export_failure(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + dialog._update_dialog = mocker.MagicMock() + + error = ExportError('mock_error_status') + dialog._on_export_failure(error) + + dialog._update_dialog.assert_called_with(error) + + +def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') @@ -1971,16 +2065,16 @@ def test_ExportDialog__on_preflight_failure_when_status_is_USB_NOT_CONNECTED(moc mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._on_preflight_failure(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) + dialog._update_dialog(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) dialog.continue_button.clicked.connect.assert_called_once_with(dialog._show_insert_usb_message) # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._on_preflight_failure(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) + dialog._update_dialog(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) dialog._show_insert_usb_message.assert_called_once_with() -def test_ExportDialog__on_preflight_failure_when_status_is_BAD_PASSPHRASE(mocker): +def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') @@ -1990,17 +2084,17 @@ def test_ExportDialog__on_preflight_failure_when_status_is_BAD_PASSPHRASE(mocker mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._on_preflight_failure(ExportError(ExportStatus.BAD_PASSPHRASE.value)) + dialog._update_dialog(ExportError(ExportStatus.BAD_PASSPHRASE.value)) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_passphrase_request_message_again) # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._on_preflight_failure(ExportError(ExportStatus.BAD_PASSPHRASE.value)) + dialog._update_dialog(ExportError(ExportStatus.BAD_PASSPHRASE.value)) dialog._show_passphrase_request_message_again.assert_called_once_with() -def test_ExportDialog__on_preflight_failure_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): +def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') @@ -2010,19 +2104,19 @@ def test_ExportDialog__on_preflight_failure_when_status_DISK_ENCRYPTION_NOT_SUPP mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._on_preflight_failure( + dialog._update_dialog( ExportError(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value)) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_insert_encrypted_usb_message) # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._on_preflight_failure(ExportError( + dialog._update_dialog(ExportError( ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value)) dialog._show_insert_encrypted_usb_message.assert_called_once_with() -def test_ExportDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR(mocker): +def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') @@ -2032,18 +2126,18 @@ def test_ExportDialog__on_preflight_failure_when_status_is_CALLED_PROCESS_ERROR( mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog._update_dialog(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._on_preflight_failure(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog._update_dialog(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value -def test_ExportDialog__on_preflight_failure_when_status_is_unknown(mocker): +def test_ExportDialog__update_dialog_when_status_is_unknown(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') @@ -2053,99 +2147,17 @@ def test_ExportDialog__on_preflight_failure_when_status_is_unknown(mocker): mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) + dialog._update_dialog(ExportError('Some Unknown Error Status')) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) assert dialog.error_status == 'Some Unknown Error Status' # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._on_preflight_failure(ExportError('Some Unknown Error Status')) + dialog._update_dialog(ExportError('Some Unknown Error Status')) dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == 'Some Unknown Error Status' -def test_ExportDialog__export_file(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - controller = mocker.MagicMock() - controller.export_file_to_usb_drive = mocker.MagicMock() - dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - dialog.passphrase_field.text = mocker.MagicMock(return_value='mock_passphrase') - - dialog._export_file() - - controller.export_file_to_usb_drive.assert_called_once_with('mock_uuid', 'mock_passphrase') - - -def test_ExportDialog__on_preflight_success(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._show_passphrase_request_message = mocker.MagicMock() - dialog.continue_button = mocker.MagicMock() - dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) - - dialog._on_preflight_success() - - dialog._show_passphrase_request_message.assert_not_called() - dialog.continue_button.clicked.connect.assert_called_once_with( - dialog._show_passphrase_request_message) - - -def test_ExportDialog__on_preflight_success_when_continue_enabled(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._show_passphrase_request_message = mocker.MagicMock() - dialog.continue_button.setEnabled(True) - - dialog._on_preflight_success() - - dialog._show_passphrase_request_message.assert_called_once_with() - - -def test_ExportDialog__on_preflight_success_enabled_after_preflight_success(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - assert not dialog.continue_button.isEnabled() - dialog._on_preflight_success() - assert dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_preflight_success_enabled_after_preflight_failure(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - assert not dialog.continue_button.isEnabled() - dialog._on_preflight_failure(mocker.MagicMock()) - assert dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_export_success(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog.close = mocker.MagicMock() - - dialog._on_export_success() - - dialog.close.assert_called_once_with() - - -def test_ExportDialog__on_export_failure(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog._show_generic_error_message = mocker.MagicMock() - - dialog._on_export_failure(ExportError('mock_error_status')) - - assert dialog.passphrase_form.isHidden() - dialog._show_generic_error_message.assert_called_with() - assert dialog.error_status == 'mock_error_status' - def test_PrintDialog_init(mocker): mocker.patch( From bd71ffea813809e336fb5318ba5507b860e4898d Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 14:37:33 -0800 Subject: [PATCH 21/31] add printer and savetodisk icons --- securedrop_client/gui/__init__.py | 8 +++++ securedrop_client/gui/widgets.py | 21 +++++++++++- securedrop_client/resources/images/blank.svg | 3 ++ .../resources/images/printer.svg | 26 ++++++++++++++ .../resources/images/savetodisk.svg | 34 +++++++++++++++++++ tests/gui/test_init.py | 18 ++++++++++ 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 securedrop_client/resources/images/blank.svg create mode 100644 securedrop_client/resources/images/printer.svg create mode 100644 securedrop_client/resources/images/savetodisk.svg diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index ea029a7da..3c8199d7c 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -150,6 +150,14 @@ def __init__(self, filename: str, svg_size: str = None) -> None: self.svg.setFixedSize(svg_size) if svg_size else self.svg.setFixedSize(QSize()) layout.addWidget(self.svg) + def update_image(self, filename, svg_size: str = None): + self.svg = load_svg(filename) + self.svg.setFixedSize(svg_size) if svg_size else self.svg.setFixedSize(QSize()) + child = self.layout().takeAt(0) + if child and child.widget(): + child.widget().deleteLater() + self.layout().addWidget(self.svg) + class SecureQLabel(QLabel): def __init__( diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 5d0868542..cb294399a 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2130,6 +2130,9 @@ class FramelessModal(QDialog): font-size: 12px; color: #2a319d; } + #header_icon { + padding-left: 10px; + } #header { font-family: 'Montserrat'; font-size: 24px; @@ -2183,6 +2186,7 @@ class FramelessModal(QDialog): ''' CONTENT_MARGIN = 40 + HEADER_MARGIN = 0 BODY_MARGIN = 0 modal_closing = pyqtSignal() @@ -2221,9 +2225,20 @@ def __init__(self): content.setLayout(content_layout) content_layout.setContentsMargins( self.CONTENT_MARGIN, 0, self.CONTENT_MARGIN, self.CONTENT_MARGIN) + header_container = QWidget() + header_container_layout = QHBoxLayout() + header_container_layout.setContentsMargins( + self.HEADER_MARGIN, self.HEADER_MARGIN, self.HEADER_MARGIN, self.HEADER_MARGIN) + header_container.setLayout(header_container_layout) + self.header_icon = SvgLabel('blank.svg', svg_size=QSize(64, 64)) + self.header_icon.setObjectName('header_icon') + self.header_icon.setFixedWidth(80) self.header = QLabel() self.header.setObjectName('header') self.header.setWordWrap(True) + header_container_layout.addWidget(self.header_icon, alignment=Qt.AlignLeft) + header_container_layout.addWidget(self.header, alignment=Qt.AlignLeft) + header_container_layout.addStretch() self.header_line = QWidget() self.header_line.setObjectName('header_line') self.error_details = QLabel() @@ -2254,7 +2269,7 @@ def __init__(self): button_box.addButton(cancel_button, QDialogButtonBox.ActionRole) button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) button_layout.addWidget(button_box, alignment=Qt.AlignRight) - content_layout.addWidget(self.header) + content_layout.addWidget(header_container) content_layout.addWidget(self.header_line) content_layout.addWidget(self.error_details) content_layout.addWidget(body_container) @@ -2299,6 +2314,8 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Connect parent signals to slots self.continue_button.setEnabled(False) + self.header_icon.update_image('printer.svg', svg_size=QSize(64, 64)) + # Dialog content self.starting_header = _( 'Preparing to print:' @@ -2425,6 +2442,8 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): # Connect parent signals to slots self.continue_button.setEnabled(False) + self.header_icon.update_image('savetodisk.svg', QSize(64, 64)) + # Dialog content self.starting_header = _( 'Preparing to export:' diff --git a/securedrop_client/resources/images/blank.svg b/securedrop_client/resources/images/blank.svg new file mode 100644 index 000000000..1b468c601 --- /dev/null +++ b/securedrop_client/resources/images/blank.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/securedrop_client/resources/images/printer.svg b/securedrop_client/resources/images/printer.svg new file mode 100644 index 000000000..571f1bbff --- /dev/null +++ b/securedrop_client/resources/images/printer.svg @@ -0,0 +1,26 @@ + + + + Print icon export + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/securedrop_client/resources/images/savetodisk.svg b/securedrop_client/resources/images/savetodisk.svg new file mode 100644 index 000000000..fd6640a66 --- /dev/null +++ b/securedrop_client/resources/images/savetodisk.svg @@ -0,0 +1,34 @@ + + + + Printer Icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/gui/test_init.py b/tests/gui/test_init.py index b1451fe66..bb29ae289 100644 --- a/tests/gui/test_init.py +++ b/tests/gui/test_init.py @@ -130,6 +130,24 @@ def test_SvgLabel_init(mocker): assert sl.svg == svg +def test_SvgLabel_update(mocker): + """ + Ensure SvgLabel calls the expected methods correctly to set the icon and size. + """ + svg = mocker.MagicMock() + load_svg_fn = mocker.patch('securedrop_client.gui.load_svg', return_value=svg) + mocker.patch('securedrop_client.gui.QHBoxLayout.addWidget') + sl = SvgLabel(filename='mock', svg_size=QSize(1, 1)) + + sl.update_image(filename='mock_two', svg_size=QSize(2, 2)) + + assert sl.svg == svg + assert load_svg_fn.call_args_list[0][0][0] == 'mock' + assert load_svg_fn.call_args_list[1][0][0] == 'mock_two' + assert sl.svg.setFixedSize.call_args_list[0][0][0] == QSize(1, 1) + assert sl.svg.setFixedSize.call_args_list[1][0][0] == QSize(2, 2) + + def test_SecureQLabel_init(): label_text = '' sl = SecureQLabel(label_text) From 958fe5dfdd8de0141592187d578264da9d617fdf Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 16:42:46 -0800 Subject: [PATCH 22/31] rename framelessmodal to framelessdialog --- securedrop_client/gui/widgets.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index cb294399a..579ae327a 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2112,10 +2112,10 @@ def stop_button_animation(self): self.set_button_state() -class FramelessModal(QDialog): +class FramelessDialog(QDialog): CSS = ''' - #frameless_modal { + #frameless_dialog { min-width: 800px; max-width: 800px; min-height: 300px; @@ -2195,11 +2195,9 @@ def __init__(self): parent = QApplication.activeWindow() super().__init__(parent) - self.setObjectName('frameless_modal') + self.setObjectName('frameless_dialog') self.setStyleSheet(self.CSS) - self.setWindowFlags(Qt.Widget | Qt.FramelessWindowHint) - self.setWindowModality(Qt.ApplicationModal) - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + self.setWindowFlags(Qt.Widget) # Set drop shadow effect effect = QGraphicsDropShadowEffect(self) @@ -2297,7 +2295,7 @@ def center_dialog(self): self.move(x_center, y_center) -class PrintDialog(FramelessModal): +class PrintDialog(FramelessDialog): def __init__(self, controller: Controller, file_uuid: str, file_name: str): super().__init__() @@ -2404,7 +2402,7 @@ def _on_preflight_failure(self, error: ExportError): self._show_generic_error_message() -class ExportDialog(FramelessModal): +class ExportDialog(FramelessDialog): PASSPHRASE_FORM_CSS = ''' #passphrase_form QLabel { From 4f9694effac42dd74220ee1df3de4e265212c743 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 17:01:20 -0800 Subject: [PATCH 23/31] show error for bad passphrase --- securedrop_client/gui/widgets.py | 6 +++++- tests/gui/test_widgets.py | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 579ae327a..aefd3afe6 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2151,7 +2151,8 @@ class FramelessDialog(QDialog): font-family: 'Montserrat'; font-size: 16px; color: #ff0064; - padding-bottom: 20px; + padding-top: 20px; + padding-bottom: 10px; } #body { font-family: 'Montserrat'; @@ -2242,6 +2243,7 @@ def __init__(self): self.error_details = QLabel() self.error_details.setObjectName('error_details') self.error_details.setWordWrap(True) + self.error_details.hide() self.body = QLabel() self.body.setObjectName('body') self.body.setWordWrap(True) @@ -2410,6 +2412,7 @@ class ExportDialog(FramelessDialog): font-weight: 500; font-size: 12px; color: #2a319d; + padding-top: 10px; } #passphrase_form QLineEdit { border-radius: 0px; @@ -2528,6 +2531,7 @@ def _show_passphrase_request_message_again(self): self.header.setText(self.passphrase_header) self.header_line.hide() self.error_details.setText(self.passphrase_error_message) + self.error_details.show() self.body.hide() self.passphrase_form.show() self.continue_button.setText('SUBMIT') diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 6f23bc6d4..b7ef22c8a 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1909,8 +1909,11 @@ def test_ExportDialog__show_passphrase_request_message_again(mocker): 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + assert dialog.error_details.isHidden() is True + dialog._show_passphrase_request_message_again() + assert dialog.error_details.isHidden() is False assert not dialog.passphrase_form.isHidden() assert dialog.header.text() == 'Enter passphrase for USB drive' assert dialog.error_details.text() == 'The passphrase provided did not work. Please try again.' From 3ded5f2a7af0a62f4d7965eedc41092ec8569308 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 17 Jan 2020 17:26:51 -0800 Subject: [PATCH 24/31] add show/hide passphrase --- securedrop_client/gui/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index aefd3afe6..db73d0ea9 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2494,7 +2494,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): font = QFont() font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING) passphrase_label.setFont(font) - self.passphrase_field = QLineEdit() + self.passphrase_field = PasswordEdit(self) self.passphrase_field.setEchoMode(QLineEdit.Password) effect = QGraphicsDropShadowEffect(self) effect.setOffset(0, -1) From 54b1268c974dd409055c30c5c1d1c416e108154d Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 20 Jan 2020 23:56:06 -0800 Subject: [PATCH 25/31] show when an export is successful in the client --- securedrop_client/gui/widgets.py | 92 +++++++++++++--------- tests/gui/test_widgets.py | 127 ++++++++++++++++++++++++------- 2 files changed, 154 insertions(+), 65 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index db73d0ea9..b1489f1bd 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1987,7 +1987,7 @@ def __init__( # Make sure we only allow one export or print operation at a time (workaround for frameless # modals not working as expected on QubesOS) - self.modal_in_progress = False + self.dialog_in_progress = False def eventFilter(self, obj, event): t = event.type() @@ -2046,10 +2046,10 @@ def _on_export_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - if not self.modal_in_progress: - self.modal_in_progress = True + if not self.dialog_in_progress: + self.dialog_in_progress = True dialog = ExportDialog(self.controller, self.file.uuid, self.file.original_filename) - dialog.modal_closing.connect(self._unset_modal_in_progress) + dialog.dialog_closing.connect(self._unset_dialog_in_progress) dialog.exec() @pyqtSlot() @@ -2060,15 +2060,15 @@ def _on_print_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - if not self.modal_in_progress: - self.modal_in_progress = True + if not self.dialog_in_progress: + self.dialog_in_progress = True dialog = PrintDialog(self.controller, self.file.uuid, self.file.original_filename) - dialog.modal_closing.connect(self._unset_modal_in_progress) + dialog.dialog_closing.connect(self._unset_dialog_in_progress) dialog.exec() @pyqtSlot() - def _unset_modal_in_progress(self): - self.modal_in_progress = False + def _unset_dialog_in_progress(self): + self.dialog_in_progress = False def _on_left_click(self): """ @@ -2190,7 +2190,7 @@ class FramelessDialog(QDialog): HEADER_MARGIN = 0 BODY_MARGIN = 0 - modal_closing = pyqtSignal() + dialog_closing = pyqtSignal() def __init__(self): parent = QApplication.activeWindow() @@ -2258,15 +2258,15 @@ def __init__(self): window_buttons.setObjectName('window_buttons') button_layout = QVBoxLayout() window_buttons.setLayout(button_layout) - cancel_button = QPushButton(_('CANCEL')) - cancel_button.setAutoDefault(False) - cancel_button.clicked.connect(self.close) + self.cancel_button = QPushButton(_('CANCEL')) + self.cancel_button.setAutoDefault(False) + self.cancel_button.clicked.connect(self.close) self.continue_button = QPushButton(_('CONTINUE')) self.continue_button.setObjectName('primary_button') self.continue_button.setDefault(True) button_box = QDialogButtonBox(Qt.Horizontal) button_box.setObjectName('button_box') - button_box.addButton(cancel_button, QDialogButtonBox.ActionRole) + button_box.addButton(self.cancel_button, QDialogButtonBox.ActionRole) button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) button_layout.addWidget(button_box, alignment=Qt.AlignRight) content_layout.addWidget(header_container) @@ -2283,7 +2283,7 @@ def __init__(self): layout.addWidget(content) def close(self): - self.modal_closing.emit() + self.dialog_closing.emit() super().close() def center_dialog(self): @@ -2345,25 +2345,25 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): def _show_starting_instructions(self): self.header.setText(self.starting_header) - self.error_details.hide() self.body.setText(self.starting_message) + self.error_details.hide() self.adjustSize() self.center_dialog() def _show_insert_usb_message(self): self.continue_button.clicked.connect(self._run_preflight) - self.header.setText(self.insert_usb_header) - self.error_details.hide() + self.header.setText('\n{}'.format(self.insert_usb_header)) self.body.setText(self.insert_usb_message) + self.error_details.hide() self.adjustSize() self.center_dialog() def _show_generic_error_message(self): self.continue_button.clicked.connect(self.close) self.continue_button.setText('DONE') - self.header.setText(self.error_header) - self.error_details.hide() + self.header.setText('\n{}'.format(self.error_header)) self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) + self.error_details.hide() self.adjustSize() self.center_dialog() @@ -2452,6 +2452,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): '{}'.format(self.file_name)) self.insert_usb_header = _('Insert encrypted USB drive') self.passphrase_header = _('Enter passphrase for USB drive') + self.success_header = _('Export successful') self.error_header = _('Unable to export') self.starting_message = _( '

Proceed with caution when exporting files

' @@ -2477,6 +2478,8 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.generic_error_message = _('See your administrator for help.') self.continue_disabled_message = _( 'The CONTINUE button will be disabled until the Export VM is ready') + self.success_message = _( + 'Remember to be careful when working with files outside of your Workstation machine.') # Passphrase Form self.passphrase_form = QWidget() @@ -2517,57 +2520,74 @@ def _show_starting_instructions(self): def _show_passphrase_request_message(self): self.continue_button.clicked.connect(self._export_file) - self.header.setText(self.passphrase_header) + self.header.setText('\n{}'.format(self.passphrase_header)) + self.continue_button.setText('SUBMIT') self.header_line.hide() self.error_details.hide() self.body.hide() self.passphrase_form.show() - self.continue_button.setText('SUBMIT') self.adjustSize() self.center_dialog() def _show_passphrase_request_message_again(self): self.continue_button.clicked.connect(self._export_file) - self.header.setText(self.passphrase_header) - self.header_line.hide() + self.header.setText('\n{}'.format(self.passphrase_header)) self.error_details.setText(self.passphrase_error_message) - self.error_details.show() + self.continue_button.setText('SUBMIT') + self.header_line.hide() self.body.hide() + self.error_details.show() self.passphrase_form.show() - self.continue_button.setText('SUBMIT') + self.adjustSize() + self.center_dialog() + + def _show_success_message(self): + self.continue_button.clicked.connect(self.close) + self.header.setText('\n{}'.format(self.success_header)) + self.continue_button.setText('DONE') + self.body.setText(self.success_message) + self.cancel_button.hide() + self.error_details.hide() + self.passphrase_form.hide() + self.header_line.show() + self.body.show() self.adjustSize() self.center_dialog() def _show_insert_usb_message(self): self.continue_button.clicked.connect(self._run_preflight) - self.header.setText(self.insert_usb_header) - self.header_line.show() + self.header.setText('\n{}'.format(self.insert_usb_header)) + self.continue_button.setText('CONTINUE') self.body.setText(self.insert_usb_message) self.error_details.hide() self.passphrase_form.hide() - self.continue_button.setText('CONTINUE') + self.header_line.show() + self.body.show() self.adjustSize() self.center_dialog() def _show_insert_encrypted_usb_message(self): self.continue_button.clicked.connect(self._run_preflight) - self.header.setText(self.insert_usb_header) - self.header_line.show() + self.header.setText('\n{}'.format(self.insert_usb_header)) self.error_details.setText(self.usb_error_message) + self.continue_button.setText('CONTINUE') self.body.setText(self.insert_usb_message) self.passphrase_form.hide() - self.continue_button.setText('CONTINUE') + self.header_line.show() + self.error_details.show() + self.body.show() self.adjustSize() self.center_dialog() def _show_generic_error_message(self): self.continue_button.clicked.connect(self.close) self.continue_button.setText('DONE') - self.header.setText(self.error_header) - self.header_line.show() - self.error_details.hide() + self.header.setText('\n{}'.format(self.error_header)) self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) + self.error_details.hide() self.passphrase_form.hide() + self.header_line.show() + self.body.show() self.adjustSize() self.center_dialog() @@ -2595,7 +2615,7 @@ def _on_preflight_failure(self, error: ExportError): @pyqtSlot() def _on_export_success(self): - self.close() + self._show_success_message() @pyqtSlot(object) def _on_export_failure(self, error: ExportError): diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index b7ef22c8a..e7e323195 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1410,7 +1410,7 @@ def test_ReplyWidget_init(mocker): assert mock_failure_connected.called -def test_FileWidget__unset_modal_in_progress(mocker, source, session): +def test_FileWidget__unset_dialog_in_progress(mocker, source, session): file = factory.File(source=source['source'], is_downloaded=True) session.add(file) session.commit() @@ -1424,13 +1424,13 @@ def test_FileWidget__unset_modal_in_progress(mocker, source, session): controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - assert fw.modal_in_progress is False - fw._unset_modal_in_progress() - assert fw.modal_in_progress is False + assert fw.dialog_in_progress is False + fw._unset_dialog_in_progress() + assert fw.dialog_in_progress is False fw._on_export_clicked() - assert fw.modal_in_progress is True - fw._unset_modal_in_progress() - assert fw.modal_in_progress is False + assert fw.dialog_in_progress is True + fw._unset_dialog_in_progress() + assert fw.dialog_in_progress is False def test_FileWidget_init_file_not_downloaded(mocker, source, session): @@ -1853,15 +1853,15 @@ def test_ExportDialog_close(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog.modal_closing = mocker.MagicMock() - dialog.modal_closing.emit = mocker.MagicMock() + dialog.dialog_closing = mocker.MagicMock() + dialog.dialog_closing.emit = mocker.MagicMock() - assert dialog.isHidden() is False + assert not dialog.isHidden() dialog.close() - dialog.modal_closing.emit.assert_called_once_with() - assert dialog.isHidden() is True + dialog.dialog_closing.emit.assert_called_once_with() + assert dialog.isHidden() def test_ExportDialog__show_starting_instructions(mocker): @@ -1871,7 +1871,6 @@ def test_ExportDialog__show_starting_instructions(mocker): dialog._show_starting_instructions() - assert dialog.passphrase_form.isHidden() assert dialog.header.text() == \ 'Preparing to export:' \ '
' \ @@ -1890,6 +1889,13 @@ def test_ExportDialog__show_starting_instructions(mocker): 'Documents submitted by sources may contain information or hidden metadata that ' \ 'identifies who they are. To protect your sources, please consider redacting documents ' \ 'before working with them on network-connected computers.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert dialog.error_details.isHidden() + assert not dialog.body.isHidden() + assert dialog.passphrase_form.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_ExportDialog___show_passphrase_request_message(mocker): @@ -1899,9 +1905,14 @@ def test_ExportDialog___show_passphrase_request_message(mocker): dialog._show_passphrase_request_message() - assert not dialog.passphrase_form.isHidden() - assert dialog.header.text() == 'Enter passphrase for USB drive' + assert dialog.header.text() == '\nEnter passphrase for USB drive' + assert not dialog.header.isHidden() + assert dialog.header_line.isHidden() + assert dialog.error_details.isHidden() assert dialog.body.isHidden() + assert not dialog.passphrase_form.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_ExportDialog__show_passphrase_request_message_again(mocker): @@ -1909,15 +1920,37 @@ def test_ExportDialog__show_passphrase_request_message_again(mocker): 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - assert dialog.error_details.isHidden() is True - dialog._show_passphrase_request_message_again() - assert dialog.error_details.isHidden() is False - assert not dialog.passphrase_form.isHidden() - assert dialog.header.text() == 'Enter passphrase for USB drive' + assert dialog.header.text() == '\nEnter passphrase for USB drive' assert dialog.error_details.text() == 'The passphrase provided did not work. Please try again.' assert dialog.body.isHidden() + assert not dialog.header.isHidden() + assert dialog.header_line.isHidden() + assert not dialog.error_details.isHidden() + assert dialog.body.isHidden() + assert not dialog.passphrase_form.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() + + +def test_ExportDialog__show_success_message(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_success_message() + + assert dialog.header.text() == '\nExport successful' + assert dialog.body.text() == \ + 'Remember to be careful when working with files outside of your Workstation machine.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert dialog.error_details.isHidden() + assert not dialog.body.isHidden() + assert dialog.passphrase_form.isHidden() + assert not dialog.continue_button.isHidden() + assert dialog.cancel_button.isHidden() def test_ExportDialog__show_insert_usb_message(mocker): @@ -1927,11 +1960,17 @@ def test_ExportDialog__show_insert_usb_message(mocker): dialog._show_insert_usb_message() - assert dialog.header.text() == 'Insert encrypted USB drive' + assert dialog.header.text() == '\nInsert encrypted USB drive' assert dialog.body.text() == \ 'Please insert one of the export drives provisioned specifically ' \ 'for the SecureDrop Workstation.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert dialog.error_details.isHidden() + assert not dialog.body.isHidden() assert dialog.passphrase_form.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_ExportDialog__show_insert_encrypted_usb_message(mocker): @@ -1941,13 +1980,19 @@ def test_ExportDialog__show_insert_encrypted_usb_message(mocker): dialog._show_insert_encrypted_usb_message() - assert dialog.header.text() == 'Insert encrypted USB drive' + assert dialog.header.text() == '\nInsert encrypted USB drive' assert dialog.error_details.text() == \ 'Either the drive is not encrypted or there is something else wrong with it.' assert dialog.body.text() == \ 'Please insert one of the export drives provisioned specifically for the SecureDrop ' \ 'Workstation.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert not dialog.error_details.isHidden() + assert not dialog.body.isHidden() assert dialog.passphrase_form.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_ExportDialog__show_generic_error_message(mocker): @@ -1958,10 +2003,15 @@ def test_ExportDialog__show_generic_error_message(mocker): dialog._show_generic_error_message() - assert dialog.passphrase_form.isHidden() - assert dialog.header.text() == 'Unable to export' - assert dialog.error_details.isHidden() + assert dialog.header.text() == '\nUnable to export' assert dialog.body.text() == 'mock_error_status: See your administrator for help.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert dialog.error_details.isHidden() + assert not dialog.body.isHidden() + assert dialog.passphrase_form.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_ExportDialog__export_file(mocker): @@ -2039,11 +2089,11 @@ def test_ExportDialog__on_export_success(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog.close = mocker.MagicMock() + dialog._show_success_message = mocker.MagicMock() dialog._on_export_success() - dialog.close.assert_called_once_with() + dialog._show_success_message.assert_called_once_with() def test_ExportDialog__on_export_failure(mocker): @@ -2140,6 +2190,7 @@ def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(mocker) dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value + def test_ExportDialog__update_dialog_when_status_is_unknown(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) @@ -2198,6 +2249,12 @@ def test_PrintDialog__show_starting_instructions(mocker): 'Any part of a printed page may contain identifying information ' \ 'invisible to the naked eye, such as printer dots. Please carefully ' \ 'consider this risk when working with or publishing scanned printouts.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert dialog.error_details.isHidden() + assert not dialog.body.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_PrintDialog__show_insert_usb_message(mocker): @@ -2207,8 +2264,14 @@ def test_PrintDialog__show_insert_usb_message(mocker): dialog._show_insert_usb_message() - assert dialog.header.text() == 'Insert USB printer' + assert dialog.header.text() == '\nInsert USB printer' assert dialog.body.text() == 'Please connect your printer to a USB port.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert dialog.error_details.isHidden() + assert not dialog.body.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_PrintDialog__show_generic_error_message(mocker): @@ -2219,8 +2282,14 @@ def test_PrintDialog__show_generic_error_message(mocker): dialog._show_generic_error_message() - assert dialog.header.text() == 'Unable to print' + assert dialog.header.text() == '\nUnable to print' assert dialog.body.text() == 'mock_error_status: See your administrator for help.' + assert not dialog.header.isHidden() + assert not dialog.header_line.isHidden() + assert dialog.error_details.isHidden() + assert not dialog.body.isHidden() + assert not dialog.continue_button.isHidden() + assert not dialog.cancel_button.isHidden() def test_PrintDialog__print_file(mocker): From a271358139ac955b513791382079adfced03d261 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Tue, 21 Jan 2020 10:55:18 -0800 Subject: [PATCH 26/31] fix mypy error default to empty string --- securedrop_client/gui/__init__.py | 2 +- securedrop_client/gui/widgets.py | 31 ++++++++++++++++--------------- tests/gui/test_widgets.py | 26 ++++++++++++-------------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 3c8199d7c..76a3d2fc4 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -150,7 +150,7 @@ def __init__(self, filename: str, svg_size: str = None) -> None: self.svg.setFixedSize(svg_size) if svg_size else self.svg.setFixedSize(QSize()) layout.addWidget(self.svg) - def update_image(self, filename, svg_size: str = None): + def update_image(self, filename: str, svg_size: str = None) -> None: self.svg = load_svg(filename) self.svg.setFixedSize(svg_size) if svg_size else self.svg.setFixedSize(QSize()) child = self.layout().takeAt(0) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index b1489f1bd..28911e97f 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -26,7 +26,8 @@ from uuid import uuid4 from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer, QSize, pyqtBoundSignal, \ QObject, QPoint -from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient, QKeySequence +from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient,QKeySequence, \ + QCursor from PyQt5.QtWidgets import QApplication, QListWidget, QLabel, QWidget, QListWidgetItem, \ QHBoxLayout, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect, QPushButton, \ @@ -2048,7 +2049,7 @@ def _on_export_clicked(self): if not self.dialog_in_progress: self.dialog_in_progress = True - dialog = ExportDialog(self.controller, self.file.uuid, self.file.original_filename) + dialog = ExportDialog(self.controller, self.file.uuid, self.file.filename) dialog.dialog_closing.connect(self._unset_dialog_in_progress) dialog.exec() @@ -2062,7 +2063,7 @@ def _on_print_clicked(self): if not self.dialog_in_progress: self.dialog_in_progress = True - dialog = PrintDialog(self.controller, self.file.uuid, self.file.original_filename) + dialog = PrintDialog(self.controller, self.file.uuid, self.file.filename) dialog.dialog_closing.connect(self._unset_dialog_in_progress) dialog.exec() @@ -2305,7 +2306,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name - self.error_status = None # Hold onto the error status we receive from the Export VM + self.error_status = '' # Hold onto the error status we receive from the Export VM # Connect controller signals to slots self.controller.export.printer_preflight_success.connect(self._on_preflight_success) @@ -2432,7 +2433,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid self.file_name = file_name - self.error_status = None # Hold onto the error status we receive from the Export VM + self.error_status = '' # Hold onto the error status we receive from the Export VM # Connect controller signals to slots self.controller.export.preflight_check_call_success.connect(self._on_preflight_success) @@ -2611,7 +2612,7 @@ def _on_preflight_success(self): @pyqtSlot(object) def _on_preflight_failure(self, error: ExportError): - self._update_dialog(error) + self._update_dialog(error.status) @pyqtSlot() def _on_export_success(self): @@ -2619,28 +2620,28 @@ def _on_export_success(self): @pyqtSlot(object) def _on_export_failure(self, error: ExportError): - self._update_dialog(error) + self._update_dialog(error.status) - def _update_dialog(self, error: ExportStatus): - self.error_status = error.status + def _update_dialog(self, error_status: str): + self.error_status = error_status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): - if error.status == ExportStatus.BAD_PASSPHRASE.value: + if self.error_status == ExportStatus.BAD_PASSPHRASE.value: self.continue_button.clicked.connect(self._show_passphrase_request_message_again) - elif error.status == ExportStatus.USB_NOT_CONNECTED.value: + elif self.error_status == ExportStatus.USB_NOT_CONNECTED.value: self.continue_button.clicked.connect(self._show_insert_usb_message) - elif error.status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: self.continue_button.clicked.connect(self._show_insert_encrypted_usb_message) else: self.continue_button.clicked.connect(self._show_generic_error_message) self.continue_button.setEnabled(True) else: - if error.status == ExportStatus.BAD_PASSPHRASE.value: + if self.error_status == ExportStatus.BAD_PASSPHRASE.value: self._show_passphrase_request_message_again() - elif error.status == ExportStatus.USB_NOT_CONNECTED.value: + elif self.error_status == ExportStatus.USB_NOT_CONNECTED.value: self._show_insert_usb_message() - elif error.status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: self._show_insert_encrypted_usb_message() else: self._show_generic_error_message() diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index e7e323195..67b13d052 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -2082,7 +2082,7 @@ def test_ExportDialog__on_preflight_failure(mocker): error = ExportError('mock_error_status') dialog._on_preflight_failure(error) - dialog._update_dialog.assert_called_with(error) + dialog._update_dialog.assert_called_with('mock_error_status') def test_ExportDialog__on_export_success(mocker): @@ -2105,7 +2105,7 @@ def test_ExportDialog__on_export_failure(mocker): error = ExportError('mock_error_status') dialog._on_export_failure(error) - dialog._update_dialog.assert_called_with(error) + dialog._update_dialog.assert_called_with('mock_error_status') def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker): @@ -2118,12 +2118,12 @@ def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker): mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._update_dialog(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) + dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED.value) dialog.continue_button.clicked.connect.assert_called_once_with(dialog._show_insert_usb_message) # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._update_dialog(ExportError(ExportStatus.USB_NOT_CONNECTED.value)) + dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED.value) dialog._show_insert_usb_message.assert_called_once_with() @@ -2137,13 +2137,13 @@ def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker): mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._update_dialog(ExportError(ExportStatus.BAD_PASSPHRASE.value)) + dialog._update_dialog(ExportStatus.BAD_PASSPHRASE.value) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_passphrase_request_message_again) # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._update_dialog(ExportError(ExportStatus.BAD_PASSPHRASE.value)) + dialog._update_dialog(ExportStatus.BAD_PASSPHRASE.value) dialog._show_passphrase_request_message_again.assert_called_once_with() @@ -2157,15 +2157,13 @@ def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_E mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._update_dialog( - ExportError(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value)) + dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_insert_encrypted_usb_message) # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._update_dialog(ExportError( - ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value)) + dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) dialog._show_insert_encrypted_usb_message.assert_called_once_with() @@ -2179,14 +2177,14 @@ def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(mocker) mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._update_dialog(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR.value) dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._update_dialog(ExportError(ExportStatus.CALLED_PROCESS_ERROR.value)) + dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR.value) dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value @@ -2201,14 +2199,14 @@ def test_ExportDialog__update_dialog_when_status_is_unknown(mocker): mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - dialog._update_dialog(ExportError('Some Unknown Error Status')) + dialog._update_dialog('Some Unknown Error Status') dialog.continue_button.clicked.connect.assert_called_once_with( dialog._show_generic_error_message) assert dialog.error_status == 'Some Unknown Error Status' # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(dialog.continue_button, 'isEnabled', return_value=True) - dialog._update_dialog(ExportError('Some Unknown Error Status')) + dialog._update_dialog('Some Unknown Error Status') dialog._show_generic_error_message.assert_called_once_with() assert dialog.error_status == 'Some Unknown Error Status' From dd0e72ac512ba8bf05a990c0eb30e75bc20833d2 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Wed, 19 Feb 2020 08:41:47 -0800 Subject: [PATCH 27/31] fix resize issue --- securedrop_client/gui/widgets.py | 92 +++++++++++++++----------------- tests/gui/test_widgets.py | 21 ++++---- 2 files changed, 54 insertions(+), 59 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 28911e97f..0fb88e122 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -26,7 +26,7 @@ from uuid import uuid4 from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer, QSize, pyqtBoundSignal, \ QObject, QPoint -from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient,QKeySequence, \ +from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient, QKeySequence, \ QCursor from PyQt5.QtWidgets import QApplication, QListWidget, QLabel, QWidget, QListWidgetItem, \ QHBoxLayout, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ @@ -2132,34 +2132,38 @@ class FramelessDialog(QDialog): color: #2a319d; } #header_icon { - padding-left: 10px; + min-width: 80px; + max-width: 80px; + min-height: 64px; + max-height: 64px; + margin: 0px 0px 0px 30px; } #header { + min-height: 68px; + max-height: 68px; + margin: 0px 0px 0px 4px; font-family: 'Montserrat'; font-size: 24px; font-weight: 600; color: #2a319d; - padding-bottom: 2px; } #header_line { - margin: 20px 0px 20px 0px; + margin: 0px 40px 20px 40px; min-height: 2px; max-height: 2px; background-color: rgba(42, 49, 157, 0.15); border: none; } #error_details { + margin: 0px 40px 0px 36px; font-family: 'Montserrat'; font-size: 16px; color: #ff0064; - padding-top: 20px; - padding-bottom: 10px; } #body { font-family: 'Montserrat'; font-size: 16px; color: #302aa3; - padding-bottom: 20px; } #button_box QPushButton { margin: 0px 0px 0px 12px; @@ -2187,9 +2191,8 @@ class FramelessDialog(QDialog): } ''' - CONTENT_MARGIN = 40 - HEADER_MARGIN = 0 - BODY_MARGIN = 0 + MARGIN = 40 + NO_MARGIN = 0 dialog_closing = pyqtSignal() @@ -2219,42 +2222,39 @@ def __init__(self): close_button.clicked.connect(self.close) titlebar_layout.addWidget(close_button, alignment=Qt.AlignRight) - # Content including: header, body, help menu, and buttons - content = QWidget() - content_layout = QVBoxLayout() - content.setLayout(content_layout) - content_layout.setContentsMargins( - self.CONTENT_MARGIN, 0, self.CONTENT_MARGIN, self.CONTENT_MARGIN) + # Header for icon and task title header_container = QWidget() header_container_layout = QHBoxLayout() - header_container_layout.setContentsMargins( - self.HEADER_MARGIN, self.HEADER_MARGIN, self.HEADER_MARGIN, self.HEADER_MARGIN) header_container.setLayout(header_container_layout) self.header_icon = SvgLabel('blank.svg', svg_size=QSize(64, 64)) self.header_icon.setObjectName('header_icon') - self.header_icon.setFixedWidth(80) self.header = QLabel() self.header.setObjectName('header') - self.header.setWordWrap(True) - header_container_layout.addWidget(self.header_icon, alignment=Qt.AlignLeft) - header_container_layout.addWidget(self.header, alignment=Qt.AlignLeft) + header_container_layout.addWidget(self.header_icon) + header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter) header_container_layout.addStretch() + self.header_line = QWidget() self.header_line.setObjectName('header_line') + + # Widget for displaying error messages self.error_details = QLabel() self.error_details.setObjectName('error_details') self.error_details.setWordWrap(True) self.error_details.hide() + + # Body to display instructions and forms self.body = QLabel() self.body.setObjectName('body') self.body.setWordWrap(True) self.body.setScaledContents(True) body_container = QWidget() self.body_layout = QVBoxLayout() - self.body_layout.setContentsMargins( - self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN, self.BODY_MARGIN) + self.body_layout.setContentsMargins(self.MARGIN, self.NO_MARGIN, self.MARGIN, self.MARGIN) body_container.setLayout(self.body_layout) self.body_layout.addWidget(self.body) + + # Buttons to continue and cancel window_buttons = QWidget() window_buttons.setObjectName('window_buttons') button_layout = QVBoxLayout() @@ -2270,18 +2270,18 @@ def __init__(self): button_box.addButton(self.cancel_button, QDialogButtonBox.ActionRole) button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) button_layout.addWidget(button_box, alignment=Qt.AlignRight) - content_layout.addWidget(header_container) - content_layout.addWidget(self.header_line) - content_layout.addWidget(self.error_details) - content_layout.addWidget(body_container) - content_layout.addStretch() - content_layout.addWidget(window_buttons) - - # Layout + button_layout.setContentsMargins(self.NO_MARGIN, self.NO_MARGIN, self.MARGIN, self.MARGIN) + + # Main widget layout layout = QVBoxLayout(self) self.setLayout(layout) layout.addWidget(titlebar) - layout.addWidget(content) + layout.addWidget(header_container) + layout.addWidget(self.header_line) + layout.addWidget(self.error_details) + layout.addWidget(body_container) + layout.addStretch() + layout.addWidget(window_buttons) def close(self): self.dialog_closing.emit() @@ -2326,7 +2326,6 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.error_header = _('Unable to print') self.starting_message = _( '

Managing printout risks

' - '
' 'QR-Codes and visible web addresses' '
' 'Never open web addresses or scan QR codes contained in printed documents without ' @@ -2353,7 +2352,7 @@ def _show_starting_instructions(self): def _show_insert_usb_message(self): self.continue_button.clicked.connect(self._run_preflight) - self.header.setText('\n{}'.format(self.insert_usb_header)) + self.header.setText(self.insert_usb_header) self.body.setText(self.insert_usb_message) self.error_details.hide() self.adjustSize() @@ -2362,7 +2361,7 @@ def _show_insert_usb_message(self): def _show_generic_error_message(self): self.continue_button.clicked.connect(self.close) self.continue_button.setText('DONE') - self.header.setText('\n{}'.format(self.error_header)) + self.header.setText(self.error_header) self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) self.error_details.hide() self.adjustSize() @@ -2413,7 +2412,7 @@ class ExportDialog(FramelessDialog): font-weight: 500; font-size: 12px; color: #2a319d; - padding-top: 10px; + padding-top: 6px; } #passphrase_form QLineEdit { border-radius: 0px; @@ -2425,7 +2424,7 @@ class ExportDialog(FramelessDialog): ''' PASSPHRASE_LABEL_SPACING = 0.5 - PASSPHRASE_MARGIN = 0 + NO_MARGIN = 0 def __init__(self, controller: Controller, file_uuid: str, file_name: str): super().__init__() @@ -2488,10 +2487,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.passphrase_form.setObjectName('passphrase_form') passphrase_form_layout = QVBoxLayout() passphrase_form_layout.setContentsMargins( - self.PASSPHRASE_MARGIN, - self.PASSPHRASE_MARGIN, - self.PASSPHRASE_MARGIN, - self.PASSPHRASE_MARGIN) + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN) self.passphrase_form.setLayout(passphrase_form_layout) passphrase_label = SecureQLabel(_('Passphrase')) passphrase_label.setObjectName('passphrase_label') @@ -2521,7 +2517,7 @@ def _show_starting_instructions(self): def _show_passphrase_request_message(self): self.continue_button.clicked.connect(self._export_file) - self.header.setText('\n{}'.format(self.passphrase_header)) + self.header.setText(self.passphrase_header) self.continue_button.setText('SUBMIT') self.header_line.hide() self.error_details.hide() @@ -2532,7 +2528,7 @@ def _show_passphrase_request_message(self): def _show_passphrase_request_message_again(self): self.continue_button.clicked.connect(self._export_file) - self.header.setText('\n{}'.format(self.passphrase_header)) + self.header.setText(self.passphrase_header) self.error_details.setText(self.passphrase_error_message) self.continue_button.setText('SUBMIT') self.header_line.hide() @@ -2544,7 +2540,7 @@ def _show_passphrase_request_message_again(self): def _show_success_message(self): self.continue_button.clicked.connect(self.close) - self.header.setText('\n{}'.format(self.success_header)) + self.header.setText(self.success_header) self.continue_button.setText('DONE') self.body.setText(self.success_message) self.cancel_button.hide() @@ -2557,7 +2553,7 @@ def _show_success_message(self): def _show_insert_usb_message(self): self.continue_button.clicked.connect(self._run_preflight) - self.header.setText('\n{}'.format(self.insert_usb_header)) + self.header.setText(self.insert_usb_header) self.continue_button.setText('CONTINUE') self.body.setText(self.insert_usb_message) self.error_details.hide() @@ -2569,7 +2565,7 @@ def _show_insert_usb_message(self): def _show_insert_encrypted_usb_message(self): self.continue_button.clicked.connect(self._run_preflight) - self.header.setText('\n{}'.format(self.insert_usb_header)) + self.header.setText(self.insert_usb_header) self.error_details.setText(self.usb_error_message) self.continue_button.setText('CONTINUE') self.body.setText(self.insert_usb_message) @@ -2583,7 +2579,7 @@ def _show_insert_encrypted_usb_message(self): def _show_generic_error_message(self): self.continue_button.clicked.connect(self.close) self.continue_button.setText('DONE') - self.header.setText('\n{}'.format(self.error_header)) + self.header.setText(self.error_header) self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) self.error_details.hide() self.passphrase_form.hide() diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 67b13d052..b444d0da1 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1418,7 +1418,7 @@ def test_FileWidget__unset_dialog_in_progress(mocker, source, session): get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) - fw = FileWidget(file.uuid, controller, mocker.MagicMock()) + fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) fw.update = mocker.MagicMock() mocker.patch('securedrop_client.gui.widgets.QDialog.exec') controller.run_export_preflight_checks = mocker.MagicMock() @@ -1810,7 +1810,7 @@ def test_FileWidget__on_print_clicked(mocker, session, source): fw._on_print_clicked() - dialog.assert_called_once_with(controller, file.uuid, file.original_filename) + dialog.assert_called_once_with(controller, file.uuid, file.filename) def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): @@ -1905,7 +1905,7 @@ def test_ExportDialog___show_passphrase_request_message(mocker): dialog._show_passphrase_request_message() - assert dialog.header.text() == '\nEnter passphrase for USB drive' + assert dialog.header.text() == 'Enter passphrase for USB drive' assert not dialog.header.isHidden() assert dialog.header_line.isHidden() assert dialog.error_details.isHidden() @@ -1922,7 +1922,7 @@ def test_ExportDialog__show_passphrase_request_message_again(mocker): dialog._show_passphrase_request_message_again() - assert dialog.header.text() == '\nEnter passphrase for USB drive' + assert dialog.header.text() == 'Enter passphrase for USB drive' assert dialog.error_details.text() == 'The passphrase provided did not work. Please try again.' assert dialog.body.isHidden() assert not dialog.header.isHidden() @@ -1941,7 +1941,7 @@ def test_ExportDialog__show_success_message(mocker): dialog._show_success_message() - assert dialog.header.text() == '\nExport successful' + assert dialog.header.text() == 'Export successful' assert dialog.body.text() == \ 'Remember to be careful when working with files outside of your Workstation machine.' assert not dialog.header.isHidden() @@ -1960,7 +1960,7 @@ def test_ExportDialog__show_insert_usb_message(mocker): dialog._show_insert_usb_message() - assert dialog.header.text() == '\nInsert encrypted USB drive' + assert dialog.header.text() == 'Insert encrypted USB drive' assert dialog.body.text() == \ 'Please insert one of the export drives provisioned specifically ' \ 'for the SecureDrop Workstation.' @@ -1980,7 +1980,7 @@ def test_ExportDialog__show_insert_encrypted_usb_message(mocker): dialog._show_insert_encrypted_usb_message() - assert dialog.header.text() == '\nInsert encrypted USB drive' + assert dialog.header.text() == 'Insert encrypted USB drive' assert dialog.error_details.text() == \ 'Either the drive is not encrypted or there is something else wrong with it.' assert dialog.body.text() == \ @@ -2003,7 +2003,7 @@ def test_ExportDialog__show_generic_error_message(mocker): dialog._show_generic_error_message() - assert dialog.header.text() == '\nUnable to export' + assert dialog.header.text() == 'Unable to export' assert dialog.body.text() == 'mock_error_status: See your administrator for help.' assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() @@ -2235,7 +2235,6 @@ def test_PrintDialog__show_starting_instructions(mocker): 'mock.jpg' assert dialog.body.text() == \ '

Managing printout risks

' \ - '
' \ 'QR-Codes and visible web addresses' \ '
' \ 'Never open web addresses or scan QR codes contained in printed documents without ' \ @@ -2262,7 +2261,7 @@ def test_PrintDialog__show_insert_usb_message(mocker): dialog._show_insert_usb_message() - assert dialog.header.text() == '\nInsert USB printer' + assert dialog.header.text() == 'Insert USB printer' assert dialog.body.text() == 'Please connect your printer to a USB port.' assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() @@ -2280,7 +2279,7 @@ def test_PrintDialog__show_generic_error_message(mocker): dialog._show_generic_error_message() - assert dialog.header.text() == '\nUnable to print' + assert dialog.header.text() == 'Unable to print' assert dialog.body.text() == 'mock_error_status: See your administrator for help.' assert not dialog.header.isHidden() assert not dialog.header_line.isHidden() From 43b2ed39e323db2f989a1aed7863af09182ad2d2 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Wed, 19 Feb 2020 16:12:16 -0800 Subject: [PATCH 28/31] sanitize filename on dialogs --- securedrop_client/gui/widgets.py | 4 ++-- tests/gui/test_widgets.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 0fb88e122..3f4dc3e99 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2305,7 +2305,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid - self.file_name = file_name + self.file_name = SecureQLabel(file_name).text() self.error_status = '' # Hold onto the error status we receive from the Export VM # Connect controller signals to slots @@ -2431,7 +2431,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str): self.controller = controller self.file_uuid = file_uuid - self.file_name = file_name + self.file_name = SecureQLabel(file_name).text() self.error_status = '' # Hold onto the error status we receive from the Export VM # Connect controller signals to slots diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index b444d0da1..474fda682 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1849,6 +1849,18 @@ def test_ExportDialog_init(mocker): assert dialog.passphrase_form.isHidden() +def test_ExportDialog_init_sanitizes_filename(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + secure_qlabel = mocker.patch('securedrop_client.gui.widgets.SecureQLabel') + mocker.patch('securedrop_client.gui.widgets.QVBoxLayout.addWidget') + filename = '' + + ExportDialog(mocker.MagicMock(), 'mock_uuid', filename) + + secure_qlabel.call_args_list[1].assert_called_with(filename) + + def test_ExportDialog_close(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) @@ -2222,6 +2234,17 @@ def test_PrintDialog_init(mocker): _show_starting_instructions_fn.assert_called_once_with() +def test_PrintDialog_init_sanitizes_filename(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + secure_qlabel = mocker.patch('securedrop_client.gui.widgets.SecureQLabel') + filename = '' + + PrintDialog(mocker.MagicMock(), 'mock_uuid', filename) + + secure_qlabel.assert_called_with(filename) + + def test_PrintDialog__show_starting_instructions(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) From 2684b3bed5290625cc96fa375bb53a85aee3d852 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Fri, 21 Feb 2020 10:27:01 -0800 Subject: [PATCH 29/31] break previous connection on continue button --- securedrop_client/gui/widgets.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 3f4dc3e99..e6622f1e3 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2351,6 +2351,7 @@ def _show_starting_instructions(self): self.center_dialog() def _show_insert_usb_message(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) self.body.setText(self.insert_usb_message) @@ -2359,6 +2360,7 @@ def _show_insert_usb_message(self): self.center_dialog() def _show_generic_error_message(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self.close) self.continue_button.setText('DONE') self.header.setText(self.error_header) @@ -2380,6 +2382,7 @@ def _print_file(self): def _on_preflight_success(self): # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._print_file) self.continue_button.setEnabled(True) return @@ -2391,6 +2394,7 @@ def _on_preflight_failure(self, error: ExportError): self.error_status = error.status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + self.continue_button.clicked.disconnect() if error.status == ExportStatus.PRINTER_NOT_FOUND.value: self.continue_button.clicked.connect(self._show_insert_usb_message) else: @@ -2516,6 +2520,7 @@ def _show_starting_instructions(self): self.center_dialog() def _show_passphrase_request_message(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.continue_button.setText('SUBMIT') @@ -2527,6 +2532,7 @@ def _show_passphrase_request_message(self): self.center_dialog() def _show_passphrase_request_message_again(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._export_file) self.header.setText(self.passphrase_header) self.error_details.setText(self.passphrase_error_message) @@ -2539,6 +2545,7 @@ def _show_passphrase_request_message_again(self): self.center_dialog() def _show_success_message(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self.close) self.header.setText(self.success_header) self.continue_button.setText('DONE') @@ -2552,6 +2559,7 @@ def _show_success_message(self): self.center_dialog() def _show_insert_usb_message(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) self.continue_button.setText('CONTINUE') @@ -2564,6 +2572,7 @@ def _show_insert_usb_message(self): self.center_dialog() def _show_insert_encrypted_usb_message(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._run_preflight) self.header.setText(self.insert_usb_header) self.error_details.setText(self.usb_error_message) @@ -2577,6 +2586,7 @@ def _show_insert_encrypted_usb_message(self): self.center_dialog() def _show_generic_error_message(self): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self.close) self.continue_button.setText('DONE') self.header.setText(self.error_header) @@ -2600,6 +2610,7 @@ def _export_file(self, checked: bool = False): def _on_preflight_success(self): # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._show_passphrase_request_message) self.continue_button.setEnabled(True) return @@ -2622,6 +2633,7 @@ def _update_dialog(self, error_status: str): self.error_status = error_status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): + self.continue_button.clicked.disconnect() if self.error_status == ExportStatus.BAD_PASSPHRASE.value: self.continue_button.clicked.connect(self._show_passphrase_request_message_again) elif self.error_status == ExportStatus.USB_NOT_CONNECTED.value: From 0386dda1f016ec7517753f4e0ac2e4dc9accd462 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 24 Feb 2020 12:11:33 -0800 Subject: [PATCH 30/31] prevent more than one dialog at a time --- securedrop_client/gui/widgets.py | 42 +++++++------ tests/gui/test_widgets.py | 100 ++++++++++++++++++------------- 2 files changed, 80 insertions(+), 62 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index e6622f1e3..d85b18c74 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1986,10 +1986,6 @@ def __init__( file_ready_signal.connect(self._on_file_downloaded, type=Qt.QueuedConnection) file_missing.connect(self._on_file_missing, type=Qt.QueuedConnection) - # Make sure we only allow one export or print operation at a time (workaround for frameless - # modals not working as expected on QubesOS) - self.dialog_in_progress = False - def eventFilter(self, obj, event): t = event.type() if t == QEvent.MouseButtonPress: @@ -2047,11 +2043,8 @@ def _on_export_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - if not self.dialog_in_progress: - self.dialog_in_progress = True - dialog = ExportDialog(self.controller, self.file.uuid, self.file.filename) - dialog.dialog_closing.connect(self._unset_dialog_in_progress) - dialog.exec() + dialog = ExportDialog(self.controller, self.file.uuid, self.file.filename) + dialog.exec() @pyqtSlot() def _on_print_clicked(self): @@ -2061,15 +2054,8 @@ def _on_print_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - if not self.dialog_in_progress: - self.dialog_in_progress = True - dialog = PrintDialog(self.controller, self.file.uuid, self.file.filename) - dialog.dialog_closing.connect(self._unset_dialog_in_progress) - dialog.exec() - - @pyqtSlot() - def _unset_dialog_in_progress(self): - self.dialog_in_progress = False + dialog = PrintDialog(self.controller, self.file.uuid, self.file.filename) + dialog.exec() def _on_left_click(self): """ @@ -2194,15 +2180,22 @@ class FramelessDialog(QDialog): MARGIN = 40 NO_MARGIN = 0 - dialog_closing = pyqtSignal() - def __init__(self): parent = QApplication.activeWindow() super().__init__(parent) + # Used to determine whether or not the popup is being closed from our own internal close + # method or from clicking outside of the popup. + # + # This is a workaround for for frameless + # modals not working as expected on QubesOS, i.e. the following flags are ignored in Qubes: + # self.setWindowFlags(Qt.FramelessWindowHint) + # self.setWindowFlags(Qt.CustomizeWindowHint) + self.internal_close_event_emitted = False + self.setObjectName('frameless_dialog') self.setStyleSheet(self.CSS) - self.setWindowFlags(Qt.Widget) + self.setWindowFlags(Qt.Popup) # Set drop shadow effect effect = QGraphicsDropShadowEffect(self) @@ -2283,8 +2276,13 @@ def __init__(self): layout.addStretch() layout.addWidget(window_buttons) + def closeEvent(self, e): + # ignore any close event that doesn't come from our custom close method + if not self.internal_close_event_emitted: + e.ignore() + def close(self): - self.dialog_closing.emit() + self.internal_close_event_emitted = True super().close() def center_dialog(self): diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 474fda682..e64e97e98 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -19,8 +19,8 @@ DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, LeftPane, SyncIcon, \ ErrorStatusBar, ActivityStatusBar, UserProfile, UserButton, UserMenu, LoginButton, \ ReplyBoxWidget, ReplyTextEdit, SourceConversationWrapper, StarToggleButton, LoginOfflineLink, \ - LoginErrorBar, EmptyConversationView, ExportDialog, PrintDialog, PasswordEdit, SecureQLabel, \ - SourceProfileShortWidget + LoginErrorBar, EmptyConversationView, FramelessDialog, ExportDialog, PrintDialog, \ + PasswordEdit, SecureQLabel, SourceProfileShortWidget from tests import factory @@ -1410,29 +1410,6 @@ def test_ReplyWidget_init(mocker): assert mock_failure_connected.called -def test_FileWidget__unset_dialog_in_progress(mocker, source, session): - file = factory.File(source=source['source'], is_downloaded=True) - session.add(file) - session.commit() - - get_file = mocker.MagicMock(return_value=file) - controller = mocker.MagicMock(get_file=get_file) - - fw = FileWidget(file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), 0) - fw.update = mocker.MagicMock() - mocker.patch('securedrop_client.gui.widgets.QDialog.exec') - controller.run_export_preflight_checks = mocker.MagicMock() - controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - - assert fw.dialog_in_progress is False - fw._unset_dialog_in_progress() - assert fw.dialog_in_progress is False - fw._on_export_clicked() - assert fw.dialog_in_progress is True - fw._unset_dialog_in_progress() - assert fw.dialog_in_progress is False - - def test_FileWidget_init_file_not_downloaded(mocker, source, session): """ Check the FileWidget is configured correctly when the file is not downloaded. @@ -1837,6 +1814,64 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): dialog.assert_not_called() +def test_FramelessDialog_closeEvent(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = FramelessDialog() + dialog.internal_close_event_emitted = True + close_event = QEvent(QEvent.Close) + close_event.ignore = mocker.MagicMock() + + dialog.closeEvent(close_event) + + close_event.ignore.assert_not_called() + + +def test_FramelessDialog_closeEvent_ignored_if_not_a_close_event_from_custom_close_buttons(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = FramelessDialog() + dialog.internal_close_event_emitted = False + close_event = QEvent(QEvent.Close) + close_event.ignore = mocker.MagicMock() + + dialog.closeEvent(close_event) + + close_event.ignore.assert_called_once_with() + + +def test_FramelessDialog_close(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = FramelessDialog() + + dialog.internal_close_event_emitted = False + + dialog.close() + + dialog.internal_close_event_emitted = True + + +def test_FramelessDialog_center_dialog(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = FramelessDialog() + dialog.move = mocker.MagicMock() + + dialog.center_dialog() + + dialog.move.call_count == 1 + + +def test_FramelessDialog_center_dialog_with_no_active_window(mocker): + dialog = FramelessDialog() + dialog.move = mocker.MagicMock() + + dialog.center_dialog() + + dialog.move.assert_not_called() + + def test_ExportDialog_init(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) @@ -1861,21 +1896,6 @@ def test_ExportDialog_init_sanitizes_filename(mocker): secure_qlabel.call_args_list[1].assert_called_with(filename) -def test_ExportDialog_close(mocker): - mocker.patch( - 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) - dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - dialog.dialog_closing = mocker.MagicMock() - dialog.dialog_closing.emit = mocker.MagicMock() - - assert not dialog.isHidden() - - dialog.close() - - dialog.dialog_closing.emit.assert_called_once_with() - assert dialog.isHidden() - - def test_ExportDialog__show_starting_instructions(mocker): mocker.patch( 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) From 640b3d94b82221519f2b966f8980f8a9cbc6c83a Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 24 Feb 2020 13:05:02 -0800 Subject: [PATCH 31/31] center when the application window is off-center --- securedrop_client/gui/widgets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index d85b18c74..705c2a42b 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -2291,9 +2291,11 @@ def center_dialog(self): return application_window_size = active_window.geometry() dialog_size = self.geometry() + x = application_window_size.x() + y = application_window_size.y() x_center = (application_window_size.width() - dialog_size.width()) / 2 y_center = (application_window_size.height() - dialog_size.height()) / 2 - self.move(x_center, y_center) + self.move(x + x_center, y + y_center) class PrintDialog(FramelessDialog):