diff --git a/securedrop_client/export.py b/securedrop_client/export.py index 104c8fa8f..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(str) + 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. @@ -258,11 +279,24 @@ 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) + @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/__init__.py b/securedrop_client/gui/__init__.py index ea029a7da..76a3d2fc4 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: 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) + 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 c0455da84..705c2a42b 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -26,11 +26,12 @@ 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, \ + QCursor +from PyQt5.QtWidgets import QApplication, QListWidget, QLabel, QWidget, QListWidgetItem, \ + QHBoxLayout, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ + QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect, QPushButton, \ + QDialogButtonBox from securedrop_client.db import DraftReply, Source, Message, File, Reply, User from securedrop_client.storage import source_exists @@ -2042,10 +2043,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.filename) dialog.exec() @pyqtSlot() @@ -2056,9 +2054,7 @@ def _on_print_clicked(self): if not self.controller.downloaded_file_exists(self.file): return - dialog = PrintDialog(self.controller, self.file.uuid) - dialog.show() - dialog.print() + dialog = PrintDialog(self.controller, self.file.uuid, self.file.filename) dialog.exec() def _on_left_click(self): @@ -2103,351 +2099,560 @@ def stop_button_animation(self): self.set_button_state() -class PrintDialog(QDialog): +class FramelessDialog(QDialog): - CSS_FOR_DIALOG_WITH_ERROR = ''' - #print_dialog { - min-width: 830; - min-height: 430; + CSS = ''' + #frameless_dialog { + min-width: 800px; + max-width: 800px; + min-height: 300px; + 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_icon { + 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; + } + #header_line { + 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; + } + #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_box QPushButton#primary_button { + background-color: #2a319d; + color: #fff; + } + #button_box QPushButton#primary_button::disabled { + border: 2px solid #C2C4E3; + background-color: #C2C4E3; + color: #E1E2F1; } ''' - def __init__(self, controller, file_uuid): - super().__init__() + MARGIN = 40 + NO_MARGIN = 0 - self.controller = controller - self.file_uuid = file_uuid + def __init__(self): + parent = QApplication.activeWindow() + super().__init__(parent) - self.setObjectName('print_dialog') + # 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.Popup) - self.setWindowModality(Qt.WindowModal) + # Set drop shadow effect + effect = QGraphicsDropShadowEffect(self) + effect.setOffset(0, 1) + effect.setBlurRadius(8) + effect.setColor(QColor('#aa000000')) + self.setGraphicsEffect(effect) + self.update() + + # 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) + + # Header for icon and task title + header_container = QWidget() + header_container_layout = QHBoxLayout() + 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 = QLabel() + self.header.setObjectName('header') + 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.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() + window_buttons.setLayout(button_layout) + 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(self.cancel_button, QDialogButtonBox.ActionRole) + button_box.addButton(self.continue_button, QDialogButtonBox.ActionRole) + button_layout.addWidget(button_box, alignment=Qt.AlignRight) + 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(header_container) + layout.addWidget(self.header_line) + layout.addWidget(self.error_details) + layout.addWidget(body_container) + layout.addStretch() + layout.addWidget(window_buttons) - # 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) - buttons = QWidget() - buttons_layout = QHBoxLayout() - buttons.setLayout(buttons_layout) - cancel_button = QPushButton(_('CANCEL')) - retry_button = QPushButton(_('CONTINUE')) - 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) - - # 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) - - 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) - - 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) - - 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 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.internal_close_event_emitted = True + super().close() + + def center_dialog(self): + active_window = QApplication.activeWindow() + if not active_window: + 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 + x_center, y + y_center) + + +class PrintDialog(FramelessDialog): + + def __init__(self, controller: Controller, file_uuid: str, file_name: str): + super().__init__() + + self.controller = controller + self.file_uuid = file_uuid + 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 + 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.setEnabled(False) + + self.header_icon.update_image('printer.svg', svg_size=QSize(64, 64)) + + # 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 = _( + '

Managing printout risks

' + '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.' + '

' + '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._show_starting_instructions() + self._run_preflight() + + def _show_starting_instructions(self): + self.header.setText(self.starting_header) + 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.disconnect() + self.continue_button.clicked.connect(self._run_preflight) + self.header.setText(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.disconnect() + self.continue_button.clicked.connect(self.close) + self.continue_button.setText('DONE') + self.header.setText(self.error_header) + self.body.setText('{}: {}'.format(self.error_status, self.generic_error_message)) + self.error_details.hide() + self.adjustSize() + self.center_dialog() @pyqtSlot() - def _on_retry_button_clicked(self): - self.print() + def _run_preflight(self): + self.controller.run_printer_preflight_checks() @pyqtSlot() - def _on_print_success(self): + def _print_file(self): + self.controller.print_file(self.file_uuid) self.close() - @pyqtSlot(object) - 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() + @pyqtSlot() + 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 - def _request_to_insert_usb_device(self): - self.starting_message.hide() - self.printing_message.hide() - self.generic_error.hide() - self.insert_usb_form.show() + self._print_file() + @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(): + self.continue_button.clicked.disconnect() + 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) -class ExportDialog(QDialog): + self.continue_button.setEnabled(True) + else: + if error.status == ExportStatus.PRINTER_NOT_FOUND.value: + self._show_insert_usb_message() + else: + self._show_generic_error_message() - 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(FramelessDialog): - CSS = ''' - #export_dialog { - min-width: 400; - max-width: 400; - min-height: 200; - max-height: 200; - } - #passphrase_label { + PASSPHRASE_FORM_CSS = ''' + #passphrase_form QLabel { font-family: 'Montserrat'; font-weight: 500; - font-size: 13px; + font-size: 12px; + color: #2a319d; + padding-top: 6px; } #passphrase_form QLineEdit { border-radius: 0px; min-height: 30px; - margin: 0px 0px 10px 0px; + max-height: 30px; + background-color: #f8f8f8; + padding-bottom: 2px; } ''' - def __init__(self, controller, file_uuid, file_name): + PASSPHRASE_LABEL_SPACING = 0.5 + NO_MARGIN = 0 + + 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) - - layout = QVBoxLayout(self) - self.setLayout(layout) - - # 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(_( + 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 + 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.setEnabled(False) + + self.header_icon.update_image('savetodisk.svg', QSize(64, 64)) + + # 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.success_header = _('Export successful') + 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) - 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) + 'for the SecureDrop Workstation.') + self.usb_error_message = _( + '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') + self.success_message = _( + 'Remember to be careful when working with files outside of your Workstation machine.') # Passphrase Form 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.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN) 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() + font = QFont() + font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING) + passphrase_label.setFont(font) + self.passphrase_field = PasswordEdit(self) 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) + 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) - passphrase_form_layout.addWidget(buttons, alignment=Qt.AlignRight) - 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) - - 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) - - self.starting_export_message.show() - self.exporting_message.hide() - self.generic_error.hide() - self.insert_usb_form.hide() + self.body_layout.addWidget(self.passphrase_form) 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.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) - - def export(self): - self.controller.run_export_preflight_checks() + self._show_starting_instructions() + self._run_preflight() + + def _show_starting_instructions(self): + self.header.setText(self.starting_header) + self.body.setText(self.starting_message) + self.adjustSize() + 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') + self.header_line.hide() + self.error_details.hide() + self.body.hide() + self.passphrase_form.show() + self.adjustSize() + 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) + self.continue_button.setText('SUBMIT') + self.header_line.hide() + self.body.hide() + self.error_details.show() + self.passphrase_form.show() + self.adjustSize() + 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') + 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.disconnect() + self.continue_button.clicked.connect(self._run_preflight) + self.header.setText(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.header_line.show() + self.body.show() + self.adjustSize() + 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) + self.continue_button.setText('CONTINUE') + self.body.setText(self.insert_usb_message) + self.passphrase_form.hide() + 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.disconnect() + self.continue_button.clicked.connect(self.close) + self.continue_button.setText('DONE') + 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() + self.header_line.show() + self.body.show() + self.adjustSize() + self.center_dialog() @pyqtSlot() - def _on_retry_export_button_clicked(self): - self.starting_export_message.hide() + def _run_preflight(self): self.controller.run_export_preflight_checks() @pyqtSlot() - def _on_unlock_disk_clicked(self): - self.passphrase_form.hide() - self.exporting_message.show() - passphrase = self.passphrase_field.text() - self.controller.export_file_to_usb_drive(self.file_uuid, passphrase) + def _export_file(self, checked: bool = False): + self.controller.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) @pyqtSlot() - def _on_export_success(self): - self.close() + 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 - def _request_to_insert_usb_device(self, encryption_not_supported: bool = False): - self.starting_export_message.hide() - self.passphrase_form.hide() - self.insert_usb_form.show() + self._show_passphrase_request_message() - if encryption_not_supported: - self.usb_error_message.show() - else: - self.usb_error_message.hide() + @pyqtSlot(object) + def _on_preflight_failure(self, error: ExportError): + self._update_dialog(error.status) @pyqtSlot() - def _request_passphrase(self, bad_passphrase: bool = False): - logger.debug('requesting passphrase... ') - self.starting_export_message.hide() - self.exporting_message.hide() - self.insert_usb_form.hide() - self.passphrase_form.show() - - if bad_passphrase: - self.passphrase_instructions.hide() - self.passphrase_error_message.show() - else: - self.passphrase_error_message.hide() - self.passphrase_instructions.show() - - 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() + def _on_export_success(self): + self._show_success_message() @pyqtSlot(object) - def _on_preflight_check_call_failure(self, error: ExportError): - self._update(error.status) + def _on_export_failure(self, error: ExportError): + self._update_dialog(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(): + 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: + self.continue_button.clicked.connect(self._show_insert_usb_message) + 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) - @pyqtSlot(object) - def _on_export_usb_call_failure(self, error: ExportError): - self._update(error.status) + self.continue_button.setEnabled(True) + else: + if self.error_status == ExportStatus.BAD_PASSPHRASE.value: + self._show_passphrase_request_message_again() + elif self.error_status == ExportStatus.USB_NOT_CONNECTED.value: + self._show_insert_usb_message() + elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value: + self._show_insert_encrypted_usb_message() + else: + self._show_generic_error_message() class ConversationView(QWidget): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index e590f692b..f610bcc74 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -640,13 +640,26 @@ def on_file_open(self, file: db.File) -> None: process = QProcess(self) process.start(command, args) + def run_printer_preflight_checks(self): + ''' + Run preflight checks to make sure the Export VM is configured correctly. + ''' + logger.info('Running printer preflight check') + + if not self.qubes: + self.export.printer_preflight_success.emit() + return + + self.export.begin_printer_preflight.emit() + 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() return self.export.begin_preflight_check.emit() @@ -665,6 +678,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/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/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 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) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 55dd489cd..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 @@ -1736,12 +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() - - 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() dialog.assert_called_once_with(controller, file.uuid, file.filename) @@ -1787,14 +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() - - 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() - dialog.assert_called_once_with(controller, file.uuid) + + dialog.assert_called_once_with(controller, file.uuid, file.filename) def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): @@ -1821,341 +1814,663 @@ 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() +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() - export_dialog.export() + dialog.closeEvent(close_event) - controller.run_export_preflight_checks.assert_called_with() + close_event.ignore.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_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() - export_dialog._on_preflight_check_call_failure(called_process_error) + dialog.closeEvent(close_event) - 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) + close_event.ignore.assert_called_once_with() -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_FramelessDialog_close(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = FramelessDialog() - export_dialog._on_export_usb_call_failure(called_process_error) + dialog.internal_close_event_emitted = False - 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) + dialog.close() + dialog.internal_close_event_emitted = True -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() +def test_FramelessDialog_center_dialog(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = FramelessDialog() + dialog.move = mocker.MagicMock() - controller.run_export_preflight_checks.assert_called_with() + dialog.center_dialog() + dialog.move.call_count == 1 -def test_ExportDialog__update_export_button_clicked_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() - export_dialog._update(usb_not_connected_error.status) +def test_FramelessDialog_center_dialog_with_no_active_window(mocker): + dialog = FramelessDialog() + dialog.move = mocker.MagicMock() - export_dialog._request_passphrase.assert_not_called() - export_dialog._request_to_insert_usb_device.assert_called_once_with() + dialog.center_dialog() + dialog.move.assert_not_called() -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') - export_dialog._request_to_insert_usb_device() +def test_ExportDialog_init(mocker): + 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') - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.insert_usb_form.isHidden() - assert export_dialog.usb_error_message.isHidden() + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + _show_starting_instructions_fn.assert_called_once_with() + assert dialog.passphrase_form.isHidden() -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') - export_dialog._request_to_insert_usb_device(encryption_not_supported=True) +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 = '' - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.insert_usb_form.isHidden() - assert not export_dialog.usb_error_message.isHidden() + ExportDialog(mocker.MagicMock(), 'mock_uuid', filename) + secure_qlabel.call_args_list[1].assert_called_with(filename) -def test_ExportDialog__request_passphrase(mocker): - """Ensure that the correct widgets are visible or hidden.""" - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase() +def test_ExportDialog__show_starting_instructions(mocker): + 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.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.' + 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): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - 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_passphrase_request_message() + assert dialog.header.text() == 'Enter 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__request_passphrase_more_than_once(mocker): - """Ensure that the correct widgets are visible or hidden.""" - export_dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - export_dialog._request_passphrase(bad_passphrase=True) +def test_ExportDialog__show_passphrase_request_message_again(mocker): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = ExportDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') - 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_passphrase_request_message_again() + 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() + 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__on_export_success_closes_window(mocker): - """ - Ensure successful export results in the export dialog window closing. - """ - controller = mocker.MagicMock() - export_dialog = ExportDialog(controller, 'mock_uuid', 'mock.jpg') - export_dialog.close = mocker.MagicMock() - export_dialog._on_export_success() +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') - export_dialog.close.assert_called_once_with() + dialog._show_success_message() + 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() + 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__on_unlock_disk_clicked(mocker): - """ - Ensure export of file begins once the passphrase is retrieved from the uesr. - """ + +def test_ExportDialog__show_insert_usb_message(mocker): + 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 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): + 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.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): + 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() + + 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() + 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): + 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._export_file() 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_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) - export_dialog._on_export_usb_call_failure(bad_password_export_error) + dialog._on_preflight_success() - export_dialog._request_passphrase.assert_called_with(True) + 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__update_preflight_non_called_process_error(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() +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() -def test_ExportDialog__update_after_USB_NOT_CONNECTED(mocker): - """ - Ensure USB_NOT_CONNECTED results in asking the user connect their USB device. - """ - 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() + dialog._show_passphrase_request_message.assert_called_once_with() -def test_ExportDialog__update_after_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(mocker): - """ - Ensure USB_NOT_CONNECTED results in asking the user connect their USB device. - """ - 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) +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__update_after_CALLED_PROCESS_ERROR(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) +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() - export_dialog._on_export_usb_call_failure(error) - 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() +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) -def test_PrintDialog__on_retry_button_clicked(mocker): - """ - Ensure happy path prints the file. - """ - controller = mocker.MagicMock() - dialog = PrintDialog(controller, 'mock_uuid') + dialog._update_dialog.assert_called_with('mock_error_status') - dialog._on_retry_button_clicked() - controller.print_file.assert_called_with('mock_uuid') +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._show_success_message = mocker.MagicMock() + dialog._on_export_success() + + dialog._show_success_message.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() - dialog._update(error.status) +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() - dialog._request_to_insert_usb_device.assert_called_once_with() + error = ExportError('mock_error_status') + dialog._on_export_failure(error) + dialog._update_dialog.assert_called_with('mock_error_status') -def test_PrintDialog__request_to_insert_usb_device(mocker): - """Ensure that the correct widgets are visible or hidden.""" - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid') - dialog._request_to_insert_usb_device() +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') + 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) - 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() + # When the continue button is enabled, ensure clicking continue will show next instructions + 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(ExportStatus.USB_NOT_CONNECTED.value) + dialog._show_insert_usb_message.assert_called_once_with() -def test_PrintDialog__on_print_success_closes_window(mocker): - """ - Ensure successful print results in the print dialog window closing. - """ - controller = mocker.MagicMock() - dialog = PrintDialog(controller, 'mock_uuid') + +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') + 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) + + # When the continue button is enabled, ensure clicking continue will show next instructions + 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(ExportStatus.BAD_PASSPHRASE.value) + dialog._show_passphrase_request_message_again.assert_called_once_with() + + +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') + 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) + + # When the continue button is enabled, ensure clicking continue will show next instructions + 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(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR.value) + dialog._show_insert_encrypted_usb_message.assert_called_once_with() + + +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') + 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) + + # When the continue button is enabled, ensure clicking continue will show next instructions + 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(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__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') + 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) + + # When the continue button is enabled, ensure clicking continue will show next instructions + 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('Some Unknown Error Status') + dialog._show_generic_error_message.assert_called_once_with() + assert dialog.error_status == 'Some Unknown Error Status' + + +def test_PrintDialog_init(mocker): + 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') + + PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + _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()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock.jpg') + + dialog._show_starting_instructions() + + assert dialog.header.text() == \ + 'Preparing to print:' \ + '
' \ + '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 ' \ + 'taking security precautions. If you are unsure how to manage this risk, please ' \ + 'contact your administrator.' \ + '

' \ + '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.' + 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): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') + + dialog._show_insert_usb_message() + + 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() + 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): + 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() + + 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() + 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): + mocker.patch( + 'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow()) + dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid', 'mock_filename') dialog.close = mocker.MagicMock() - dialog._on_print_success() + dialog._print_file() dialog.close.assert_called_once_with() -def test_PrintDialog__on_print_call_failure_generic_error(mocker): - """ - Ensure generic errors are passed through to _update - """ - dialog = PrintDialog(mocker.MagicMock(), 'mock_uuid') - dialog.generic_error = mocker.MagicMock() - error = ExportError('generic error') +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._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._on_print_failure(error) + dialog._on_preflight_success() - dialog.generic_error.show.assert_called_once_with() + dialog._print_file.assert_not_called() + dialog.continue_button.clicked.connect.assert_called_once_with(dialog._print_file) -def test_PrintDialog__update_after_PRINTER_NOT_FOUND(mocker): - """ - Ensure PRINTER_NOT_FOUND results in asking the user connect their USB device. - """ - 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() +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.jpg') + dialog._print_file = mocker.MagicMock() + dialog.continue_button.setEnabled(True) + dialog._on_preflight_success() -def test_PrintDialog__update_after_MISSING_PRINTER_URI(mocker): - """ - Ensure MISSING_PRINTER_URI shows generic 'contact admin' error with correct - error status code. - """ - 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() + dialog._print_file.assert_called_once_with() - error = ExportError(ExportStatus.MISSING_PRINTER_URI.value) - dialog._on_print_failure(error) - 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() +def test_PrintDialog__on_preflight_success_enabled_after_preflight_success(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_success() + assert dialog.continue_button.isEnabled() -def test_PrintDialog__update_after_CALLED_PROCESS_ERROR(mocker): - """ - Ensure CALLED_PROCESS_ERROR shows generic 'contact admin' error with correct - error status code. - """ - 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() +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() - error = ExportError(ExportStatus.CALLED_PROCESS_ERROR.value) - dialog._on_print_failure(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() +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_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) + + # 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) + + # 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_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_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) + + # 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.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() + assert dialog.error_status == ExportStatus.MISSING_PRINTER_URI.value + + +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._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) + + # 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) + 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() + assert dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR.value + + +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) + + # 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) + 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() + assert dialog.error_status == 'Some Unknown Error Status' def test_ConversationView_init(mocker, homedir): diff --git a/tests/test_export.py b/tests/test_export.py index fd9267cae..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 @@ -151,7 +219,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): diff --git a/tests/test_logic.py b/tests/test_logic.py index 438a82e06..68bdcdb8f 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -1516,6 +1516,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_printer_preflight_checks(homedir, mocker, session, source): + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co.export = mocker.MagicMock() + co.export.begin_printer_preflight = mocker.MagicMock() + co.export.begin_printer_preflight.emit = mocker.MagicMock() + + co.run_printer_preflight_checks() + + co.export.begin_printer_preflight.emit.call_count == 1 + + +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.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_printer_preflight_checks() + + 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): co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock()