From 396fec1406807fe2a35a5efcb6c08e4bc35a4fb5 Mon Sep 17 00:00:00 2001 From: Ro Date: Wed, 24 Jan 2024 19:55:43 -0500 Subject: [PATCH 01/22] Combine export.py and device.py and avoid long-running thread/singleton pattern for export service --- client/securedrop_client/app.py | 9 +- client/securedrop_client/export.py | 341 ------------- client/securedrop_client/export_status.py | 9 +- client/securedrop_client/gui/actions.py | 8 +- .../gui/conversation/export/device.py | 286 +++++++++-- .../gui/conversation/export/file_dialog.py | 3 +- .../gui/conversation/export/print_dialog.py | 3 +- client/securedrop_client/gui/widgets.py | 4 +- client/tests/conftest.py | 6 +- .../functional/test_export_file_dialog.py | 4 +- .../gui/conversation/export/test_device.py | 364 +++++++++++--- .../gui/conversation/export/test_dialog.py | 2 +- .../conversation/export/test_file_dialog.py | 2 +- .../conversation/export/test_print_dialog.py | 2 +- .../export/test_print_transcript_dialog.py | 2 +- .../export/test_transcript_dialog.py | 2 +- client/tests/integration/conftest.py | 37 +- client/tests/test_export.py | 453 ------------------ 18 files changed, 569 insertions(+), 968 deletions(-) delete mode 100644 client/securedrop_client/export.py delete mode 100644 client/tests/test_export.py diff --git a/client/securedrop_client/app.py b/client/securedrop_client/app.py index 8e9fe9e9ca..8a6a990c42 100644 --- a/client/securedrop_client/app.py +++ b/client/securedrop_client/app.py @@ -34,7 +34,7 @@ from PyQt5.QtCore import Qt, QThread, QTimer from PyQt5.QtWidgets import QApplication, QMessageBox -from securedrop_client import __version__, export, state +from securedrop_client import __version__, state from securedrop_client.database import Database from securedrop_client.db import make_session_maker from securedrop_client.gui.main import Window @@ -240,16 +240,11 @@ def start_app(args, qt_args) -> NoReturn: # type: ignore[no-untyped-def] database = Database(session) app_state = state.State(database) - with threads(4) as [ - export_service_thread, + with threads(3) as [ sync_thread, main_queue_thread, file_download_queue_thread, ]: - export_service = export.getService() - export_service.moveToThread(export_service_thread) - export_service_thread.start() - gui = Window(app_state) controller = Controller( diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py deleted file mode 100644 index 366f68eab6..0000000000 --- a/client/securedrop_client/export.py +++ /dev/null @@ -1,341 +0,0 @@ -import json -import logging -import os -import subprocess -import tarfile -import threading -from io import BytesIO -from shlex import quote -from tempfile import TemporaryDirectory -from typing import List, Optional - -from PyQt5.QtCore import QObject, pyqtBoundSignal, pyqtSignal, pyqtSlot - -from securedrop_client.export_status import ExportStatus - -logger = logging.getLogger(__name__) - - -class ExportError(Exception): - def __init__(self, status: "ExportStatus"): - self.status: "ExportStatus" = status - - -class Export(QObject): - """ - This class sends files over to the Export VM so that they can be copied to a luks-encrypted USB - disk drive or printed by a USB-connected printer. - - Files are archived in a specified format, which you can learn more about in the README for the - securedrop-export repository. - """ - - METADATA_FN = "metadata.json" - - USB_TEST_FN = "usb-test.sd-export" - USB_TEST_METADATA = {"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"} - - PRINT_FN = "print_archive.sd-export" - PRINT_METADATA = {"device": "printer"} - - DISK_FN = "archive.sd-export" - DISK_METADATA = {"device": "disk", "encryption_method": "luks"} - DISK_ENCRYPTION_KEY_NAME = "encryption_key" - DISK_EXPORT_DIR = "export_data" - - # Set up signals for communication with the controller # - # Emit ExportStatus - preflight_check_call_success = pyqtSignal(object) - export_usb_call_success = pyqtSignal(object) - printer_preflight_success = pyqtSignal(object) - print_call_success = pyqtSignal(object) - - # Emit ExportError(status=ExportStatus) - export_usb_call_failure = pyqtSignal(object) - preflight_check_call_failure = pyqtSignal(object) - printer_preflight_failure = pyqtSignal(object) - print_call_failure = pyqtSignal(object) - - # Emit List[str] of filepaths - export_completed = pyqtSignal(list) - - def __init__( - self, - export_preflight_check_requested: Optional[pyqtBoundSignal] = None, - export_requested: Optional[pyqtBoundSignal] = None, - print_preflight_check_requested: Optional[pyqtBoundSignal] = None, - print_requested: Optional[pyqtBoundSignal] = None, - ) -> None: - super().__init__() - - self.connect_signals( - export_preflight_check_requested, - export_requested, - print_preflight_check_requested, - print_requested, - ) - - def connect_signals( - self, - export_preflight_check_requested: Optional[pyqtBoundSignal] = None, - export_requested: Optional[pyqtBoundSignal] = None, - print_preflight_check_requested: Optional[pyqtBoundSignal] = None, - print_requested: Optional[pyqtBoundSignal] = None, - ) -> None: - # This instance can optionally react to events to prevent - # coupling it to dependent code. - if export_preflight_check_requested is not None: - export_preflight_check_requested.connect(self.run_preflight_checks) - if export_requested is not None: - export_requested.connect(self.send_file_to_usb_device) - if print_requested is not None: - print_requested.connect(self.print) - if print_preflight_check_requested is not None: - print_preflight_check_requested.connect(self.run_printer_preflight) - - def _run_qrexec_export(cls, archive_path: str) -> ExportStatus: - """ - Make the subprocess call to send the archive to the Export VM, where the archive will be - processed. - - Args: - archive_path (str): The path to the archive to be processed. - - Returns: - str: The export status returned from the Export VM processing script. - - Raises: - ExportError: Raised if (1) CalledProcessError is encountered, which can occur when - trying to start the Export VM when the USB device is not attached, or (2) when - the return code from `check_output` is not 0. - """ - try: - # There are already talks of switching to a QVM-RPC implementation for unlocking devices - # and exporting files, so it's important to remember to shell-escape what we pass to the - # shell, even if for the time being we're already protected against shell injection via - # Python's implementation of subprocess, see - # https://docs.python.org/3/library/subprocess.html#security-considerations - output = subprocess.check_output( - [ - quote("qrexec-client-vm"), - quote("--"), - quote("sd-devices"), - quote("qubes.OpenInVM"), - quote("/usr/lib/qubes/qopen-in-vm"), - quote("--view-only"), - quote("--"), - quote(archive_path), - ], - stderr=subprocess.STDOUT, - ) - result = output.decode("utf-8").strip() - - return ExportStatus(result) - - except ValueError as e: - logger.debug(f"Export subprocess returned unexpected value: {e}") - raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) - except subprocess.CalledProcessError as e: - logger.error("Subprocess failed") - logger.debug(f"Subprocess failed: {e}") - raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) - - def _create_archive( - cls, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] - ) -> str: - """ - Create the archive to be sent to the Export VM. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - archive_fn (str): The name of the archive file. - metadata (dict): The dictionary containing metadata to add to the archive. - filepaths (List[str]): The list of files to add to the archive. - - Returns: - str: The path to newly-created archive file. - """ - archive_path = os.path.join(archive_dir, archive_fn) - - with tarfile.open(archive_path, "w:gz") as archive: - cls._add_virtual_file_to_archive(archive, cls.METADATA_FN, metadata) - - # When more than one file is added to the archive, - # extra care must be taken to prevent name collisions. - is_one_of_multiple_files = len(filepaths) > 1 - for filepath in filepaths: - cls._add_file_to_archive( - archive, filepath, prevent_name_collisions=is_one_of_multiple_files - ) - - return archive_path - - def _add_virtual_file_to_archive( - cls, archive: tarfile.TarFile, filename: str, filedata: dict - ) -> None: - """ - Add filedata to a stream of in-memory bytes and add these bytes to the archive. - - Args: - archive (TarFile): The archive object to add the virtual file to. - filename (str): The name of the virtual file. - filedata (dict): The data to add to the bytes stream. - - """ - filedata_string = json.dumps(filedata) - filedata_bytes = BytesIO(filedata_string.encode("utf-8")) - tarinfo = tarfile.TarInfo(filename) - tarinfo.size = len(filedata_string) - archive.addfile(tarinfo, filedata_bytes) - - def _add_file_to_archive( - cls, archive: tarfile.TarFile, filepath: str, prevent_name_collisions: bool = False - ) -> None: - """ - Add the file to the archive. When the archive is extracted, the file should exist in a - directory called "export_data". - - Args: - archive: The archive object ot add the file to. - filepath: The path to the file that will be added to the supplied archive. - """ - filename = os.path.basename(filepath) - arcname = os.path.join(cls.DISK_EXPORT_DIR, filename) - if prevent_name_collisions: - (parent_path, _) = os.path.split(filepath) - grand_parent_path, parent_name = os.path.split(parent_path) - grand_parent_name = os.path.split(grand_parent_path)[1] - arcname = os.path.join("export_data", grand_parent_name, parent_name, filename) - if filename == "transcript.txt": - arcname = os.path.join("export_data", parent_name, filename) - - archive.add(filepath, arcname=arcname, recursive=False) - - def _build_archive_and_export( - self, metadata: dict, filename: str, filepaths: List[str] = [] - ) -> ExportStatus: - """ - Build archive, run qrexec command and return resulting ExportStatus. - - ExportError may be raised during underlying _run_qrexec_export call, - and is handled by the calling method. - """ - with TemporaryDirectory() as tmp_dir: - archive_path = self._create_archive( - archive_dir=tmp_dir, archive_fn=filename, metadata=metadata, filepaths=filepaths - ) - return self._run_qrexec_export(archive_path) - - @pyqtSlot() - def run_preflight_checks(self) -> None: - """ - Run preflight checks to verify that a valid USB device is connected. - """ - try: - logger.debug( - "beginning preflight checks in thread {}".format(threading.current_thread().ident) - ) - - status = self._build_archive_and_export( - metadata=self.USB_TEST_METADATA, filename=self.USB_TEST_FN - ) - - logger.debug("completed preflight checks: success") - self.preflight_check_call_success.emit(status) - 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. - """ - try: - status = self._build_archive_and_export( - metadata=self.PRINTER_PREFLIGHT_METADATA, filename=self.PRINTER_PREFLIGHT_FN - ) - self.printer_preflight_success.emit(status) - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.printer_preflight_failure.emit(e) - - @pyqtSlot(list, str) - def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None: - """ - Export the file to the luks-encrypted usb disk drive attached to the Export VM. - - Args: - filepath: The path of file to export. - passphrase: The passphrase to unlock the luks-encrypted usb disk drive. - """ - try: - logger.debug("beginning export from thread {}".format(threading.current_thread().ident)) - # Edit metadata template to include passphrase - metadata = self.DISK_METADATA.copy() - metadata[self.DISK_ENCRYPTION_KEY_NAME] = passphrase - status = self._build_archive_and_export( - metadata=metadata, filename=self.DISK_FN, filepaths=filepaths - ) - - self.export_usb_call_success.emit(status) - logger.debug(f"Status {status}") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.export_usb_call_failure.emit(e) - - self.export_completed.emit(filepaths) - - @pyqtSlot(list) - def print(self, filepaths: List[str]) -> None: - """ - Print the file to the printer attached to the Export VM. - - Args: - filepath: The path of file to export. - """ - try: - logger.debug( - "beginning printer from thread {}".format(threading.current_thread().ident) - ) - status = self._build_archive_and_export( - metadata=self.PRINT_METADATA, filename=self.PRINT_FN, filepaths=filepaths - ) - self.print_call_success.emit(status) - logger.debug(f"Status {status}") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.print_call_failure.emit(e) - - self.export_completed.emit(filepaths) - - -Service = Export - -# Store a singleton service instance. -_service = Service() - - -def resetService() -> None: - """Replaces the existing sngleton service instance by a new one. - - Get the instance by using getService(). - """ - global _service - _service = Service() - - -def getService() -> Service: - """All calls to this function return the same singleton service instance. - - Use resetService() to replace it by a new one.""" - return _service diff --git a/client/securedrop_client/export_status.py b/client/securedrop_client/export_status.py index 2c2a199246..8950c66d6e 100644 --- a/client/securedrop_client/export_status.py +++ b/client/securedrop_client/export_status.py @@ -1,11 +1,16 @@ from enum import Enum +class ExportError(Exception): + def __init__(self, status: "ExportStatus"): + self.status: "ExportStatus" = status + + class ExportStatus(Enum): """ All possible strings returned by the qrexec calls to sd-devices. These values come from - `print/status.py` and `disk/status.py` in `https://github.com/freedomofpress/securedrop-export` - and must only be changed in coordination with changes released in that repo. + `print/status.py` and `disk/status.py` in `securedrop-export` + and must only be changed in coordination with changes released in that component. """ # Export diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index c4dfd6a704..5d8420b866 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -12,7 +12,7 @@ from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import QAction, QDialog, QMenu -from securedrop_client import export, state +from securedrop_client import state from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source from securedrop_client.gui.base import ModalDialog @@ -160,7 +160,7 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller, export.getService()) + self._export_device = ConversationExportDevice(controller) self.triggered.connect(self._on_triggered) @@ -212,7 +212,7 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller, export.getService()) + self._export_device = ConversationExportDevice(controller) self.triggered.connect(self._on_triggered) @@ -267,7 +267,7 @@ def __init__( self._source = source self._state = app_state - self._export_device = ConversationExportDevice(controller, export.getService()) + self._export_device = ConversationExportDevice(controller) self.triggered.connect(self._on_triggered) diff --git a/client/securedrop_client/gui/conversation/export/device.py b/client/securedrop_client/gui/conversation/export/device.py index 9cf61dd06b..bd7dd6604b 100644 --- a/client/securedrop_client/gui/conversation/export/device.py +++ b/client/securedrop_client/gui/conversation/export/device.py @@ -1,25 +1,45 @@ +from io import BytesIO import logging +import json import os +import subprocess +from shlex import quote +import tarfile +from tempfile import TemporaryDirectory from typing import List from PyQt5.QtCore import QObject, pyqtSignal -from securedrop_client.export import Export from securedrop_client.logic import Controller +from securedrop_client.export_status import ExportStatus, ExportError + logger = logging.getLogger(__name__) class Device(QObject): - """Abstracts an export service for use in GUI components. + """ + Send files to Export VM so that they can be copied to a + disk drive or printed by a USB-connected printer. - This class defines an interface for GUI components to have access - to the status of an export device without needed to interact directly - with the underlying export service. + Files are archived in a specified format, (see `export` README). """ - export_preflight_check_requested = pyqtSignal() - print_preflight_check_requested = pyqtSignal() + _METADATA_FN = "metadata.json" + + _USB_TEST_FN = "usb-test.sd-export" + _USB_TEST_METADATA = {"device": "usb-test"} + + _PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" + _PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} + + _PRINT_FN = "print_archive.sd-export" + _PRINT_METADATA = {"device": "printer"} + + _DISK_FN = "archive.sd-export" + _DISK_METADATA = {"device": "disk", "encryption_method": "luks"} + _DISK_ENCRYPTION_KEY_NAME = "encryption_key" + _DISK_EXPORT_DIR = "export_data" # Emit ExportStatus export_preflight_check_succeeded = pyqtSignal(object) @@ -35,67 +55,53 @@ class Device(QObject): print_preflight_check_failed = pyqtSignal(object) print_failed = pyqtSignal(object) - # Emit List[str] filepaths - export_requested = pyqtSignal(list, str) - export_completed = pyqtSignal(list) - print_requested = pyqtSignal(list) - - def __init__(self, controller: Controller, export_service: Export) -> None: + def __init__(self, controller: Controller) -> None: super().__init__() self._controller = controller - self._export_service = export_service - - self._export_service.connect_signals( - self.export_preflight_check_requested, - self.export_requested, - self.print_preflight_check_requested, - self.print_requested, - ) - - # Abstract the Export instance away from the GUI - self._export_service.preflight_check_call_success.connect( - self.export_preflight_check_succeeded - ) - self._export_service.preflight_check_call_failure.connect( - self.export_preflight_check_failed - ) - - self._export_service.export_usb_call_success.connect(self.export_succeeded) - self._export_service.export_usb_call_failure.connect(self.export_failed) - self._export_service.export_completed.connect(self.export_completed) - - self._export_service.printer_preflight_success.connect(self.print_preflight_check_succeeded) - self._export_service.printer_preflight_failure.connect(self.print_preflight_check_failed) - - self._export_service.print_call_failure.connect(self.print_failed) - self._export_service.print_call_success.connect(self.print_succeeded) def run_printer_preflight_checks(self) -> None: """ - Run preflight checks to make sure the Export VM is configured correctly. + Make sure the Export VM is started. """ logger.info("Running printer preflight check") - self.print_preflight_check_requested.emit() + try: + status = self._build_archive_and_export( + metadata=self._PRINTER_PREFLIGHT_METADATA, filename=self._PRINTER_PREFLIGHT_FN + ) + self.print_preflight_check_succeeded.emit(status) + except ExportError as e: + logger.error("Print preflight failed") + logger.debug(f"Print preflight failed: {e}") + self.print_preflight_check_failed.emit(e) def run_export_preflight_checks(self) -> None: """ - Run preflight checks to make sure the Export VM is configured correctly. + Run preflight check to verify that a valid USB device is connected. """ - logger.info("Running export preflight check") - self.export_preflight_check_requested.emit() + try: + logger.debug("Beginning export preflight check") + status = self._build_archive_and_export( + metadata=self._USB_TEST_METADATA, filename=self._USB_TEST_FN + ) + self.export_preflight_check_succeeded.emit(status) + except ExportError as e: + logger.error("Export preflight failed") + self.export_preflight_check_failed.emit(e) def export_transcript(self, file_location: str, passphrase: str) -> None: """ Send the transcript specified by file_location to the Export VM. """ - self.export_requested.emit([file_location], passphrase) + logger.debug("Export transcript") + self._send_file_to_usb_device([file_location], passphrase) def export_files(self, file_locations: List[str], passphrase: str) -> None: """ Send the files specified by file_locations to the Export VM. """ - self.export_requested.emit(file_locations, passphrase) + logger.debug(f"Export {len(file_locations)} files") + self._send_file_to_usb_device(file_locations, passphrase) def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: """ @@ -105,19 +111,19 @@ def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: """ file = self._controller.get_file(file_uuid) file_location = file.location(self._controller.data_dir) - logger.info("Exporting file in: {}".format(os.path.dirname(file_location))) + logger.debug("Exporting file in: {}".format(os.path.dirname(file_location))) if not self._controller.downloaded_file_exists(file): logger.warning(f"Cannot find file in {file_location}") return - self.export_requested.emit([file_location], passphrase) + self._send_file_to_usb_device([file_location], passphrase) def print_transcript(self, file_location: str) -> None: """ Send the transcript specified by file_location to the Export VM. """ - self.print_requested.emit([file_location]) + self._print([file_location]) def print_file(self, file_uuid: str) -> None: """ @@ -126,10 +132,188 @@ def print_file(self, file_uuid: str) -> None: """ file = self._controller.get_file(file_uuid) file_location = file.location(self._controller.data_dir) - logger.info("Printing file in: {}".format(os.path.dirname(file_location))) + logger.debug("Printing file in: {}".format(os.path.dirname(file_location))) if not self._controller.downloaded_file_exists(file): logger.warning(f"Cannot find file in {file_location}") return - self.print_requested.emit([file_location]) + self._print([file_location]) + + def _run_qrexec_export(self, archive_path: str) -> ExportStatus: + """ + Make the subprocess call to send the archive to the Export VM, where the archive will be + processed. + + Args: + archive_path (str): The path to the archive to be processed. + + Returns: + str: The export status returned from the Export VM processing script. + + Raises: + ExportError: Raised if (1) CalledProcessError is encountered, which can occur when + trying to start the Export VM when the USB device is not attached, or (2) when + the return code from `check_output` is not 0. + """ + try: + # There are already talks of switching to a QVM-RPC implementation for unlocking devices + # and exporting files, so it's important to remember to shell-escape what we pass to the + # shell, even if for the time being we're already protected against shell injection via + # Python's implementation of subprocess, see + # https://docs.python.org/3/library/subprocess.html#security-considerations + output = subprocess.check_output( + [ + quote("qrexec-client-vm"), + quote("--"), + quote("sd-devices"), + quote("qubes.OpenInVM"), + quote("/usr/lib/qubes/qopen-in-vm"), + quote("--view-only"), + quote("--"), + quote(archive_path), + ], + stderr=subprocess.STDOUT, + ) + result = output.decode("utf-8").strip() + + return ExportStatus(result) + + except ValueError as e: + logger.debug(f"Export subprocess returned unexpected value: {e}") + raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) + except subprocess.CalledProcessError as e: + logger.error("Subprocess failed") + logger.debug(f"Subprocess failed: {e}") + raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) + + def _create_archive( + self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] + ) -> str: + """ + Create the archive to be sent to the Export VM. + + Args: + archive_dir (str): The path to the directory in which to create the archive. + archive_fn (str): The name of the archive file. + metadata (dict): The dictionary containing metadata to add to the archive. + filepaths (List[str]): The list of files to add to the archive. + + Returns: + str: The path to newly-created archive file. + """ + archive_path = os.path.join(archive_dir, archive_fn) + + with tarfile.open(archive_path, "w:gz") as archive: + self._add_virtual_file_to_archive(archive, self._METADATA_FN, metadata) + + # When more than one file is added to the archive, + # extra care must be taken to prevent name collisions. + is_one_of_multiple_files = len(filepaths) > 1 + for filepath in filepaths: + self._add_file_to_archive( + archive, filepath, prevent_name_collisions=is_one_of_multiple_files + ) + + return archive_path + + def _add_virtual_file_to_archive( + self, archive: tarfile.TarFile, filename: str, filedata: dict + ) -> None: + """ + Add filedata to a stream of in-memory bytes and add these bytes to the archive. + + Args: + archive (TarFile): The archive object to add the virtual file to. + filename (str): The name of the virtual file. + filedata (dict): The data to add to the bytes stream. + + """ + filedata_string = json.dumps(filedata) + filedata_bytes = BytesIO(filedata_string.encode("utf-8")) + tarinfo = tarfile.TarInfo(filename) + tarinfo.size = len(filedata_string) + archive.addfile(tarinfo, filedata_bytes) + + def _add_file_to_archive( + self, archive: tarfile.TarFile, filepath: str, prevent_name_collisions: bool = False + ) -> None: + """ + Add the file to the archive. When the archive is extracted, the file should exist in a + directory called "export_data". + + Args: + archive: The archive object ot add the file to. + filepath: The path to the file that will be added to the supplied archive. + """ + filename = os.path.basename(filepath) + arcname = os.path.join(self._DISK_EXPORT_DIR, filename) + if prevent_name_collisions: + (parent_path, _) = os.path.split(filepath) + grand_parent_path, parent_name = os.path.split(parent_path) + grand_parent_name = os.path.split(grand_parent_path)[1] + arcname = os.path.join("export_data", grand_parent_name, parent_name, filename) + if filename == "transcript.txt": + arcname = os.path.join("export_data", parent_name, filename) + + archive.add(filepath, arcname=arcname, recursive=False) + + def _build_archive_and_export( + self, metadata: dict, filename: str, filepaths: List[str] = [] + ) -> ExportStatus: + """ + Build archive, run qrexec command and return resulting ExportStatus. + + ExportError may be raised during underlying _run_qrexec_export call, + and is handled by the calling method. + """ + with TemporaryDirectory() as tmp_dir: + archive_path = self._create_archive( + archive_dir=tmp_dir, archive_fn=filename, metadata=metadata, filepaths=filepaths + ) + return self._run_qrexec_export(archive_path) + + def _send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None: + """ + Export the file to the luks-encrypted usb disk drive attached to the Export VM. + + Args: + filepath: The path of file to export. + passphrase: The passphrase to unlock the luks-encrypted usb disk drive. + """ + try: + logger.debug("beginning export") + # Edit metadata template to include passphrase + metadata = self._DISK_METADATA.copy() + metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase + status = self._build_archive_and_export( + metadata=metadata, filename=self._DISK_FN, filepaths=filepaths + ) + + self.export_succeeded.emit(status) + logger.debug(f"Status {status}") + except ExportError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.export_failed.emit(e) + + def _print(self, filepaths: List[str]) -> None: + """ + Print the file to the printer attached to the Export VM. + + Args: + filepath: The path of file to export. + """ + try: + logger.debug("beginning print") + status = self._build_archive_and_export( + metadata=self._PRINT_METADATA, filename=self._PRINT_FN, filepaths=filepaths + ) + self.print_succeeded.emit(status) + logger.debug(f"Status {status}") + except ExportError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.print_failed.emit(e) + + self.export_succeeded.emit(filepaths) diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py index 414d2c8b15..44b042c8bc 100644 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ b/client/securedrop_client/gui/conversation/export/file_dialog.py @@ -9,8 +9,7 @@ from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QLineEdit, QVBoxLayout, QWidget -from securedrop_client.export import ExportError -from securedrop_client.export_status import ExportStatus +from securedrop_client.export_status import ExportStatus, ExportError from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel from securedrop_client.gui.base.checkbox import SDCheckBox diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index 32e160bd1c..c869565573 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -3,8 +3,7 @@ from PyQt5.QtCore import QSize, pyqtSlot -from securedrop_client.export import ExportError -from securedrop_client.export_status import ExportStatus +from securedrop_client.export_status import ExportStatus, ExportError from securedrop_client.gui.base import ModalDialog, SecureQLabel from .device import Device diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index a03f5b905d..85364a5941 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -60,7 +60,7 @@ QWidget, ) -from securedrop_client import export, state +from securedrop_client import state from securedrop_client.db import ( DraftReply, File, @@ -2255,7 +2255,7 @@ def __init__( self.controller = controller - self._export_device = conversation.ExportDevice(controller, export.getService()) + self._export_device = conversation.ExportDevice(controller) self.file = self.controller.get_file(file_uuid) self.uuid = file_uuid diff --git a/client/tests/conftest.py b/client/tests/conftest.py index a7918bbc1a..2471a36fca 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -11,7 +11,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMainWindow -from securedrop_client import export, state +from securedrop_client import state from securedrop_client.app import configure_locale_and_language from securedrop_client.config import Config from securedrop_client.db import ( @@ -23,7 +23,7 @@ Source, make_session_maker, ) -from securedrop_client.export import ExportStatus +from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -169,7 +169,7 @@ def homedir(i18n): yield tmpdir -class MockExportService(export.Service): +class MockExportService: # todo """An export service that assumes the Qubes RPC calls are successful and skips them.""" def __init__(self, unlocked: bool): diff --git a/client/tests/functional/test_export_file_dialog.py b/client/tests/functional/test_export_file_dialog.py index 1fd160266f..1436e1e32f 100644 --- a/client/tests/functional/test_export_file_dialog.py +++ b/client/tests/functional/test_export_file_dialog.py @@ -17,12 +17,10 @@ ) -def _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export_service): +def _setup_export(functional_test_logged_in_context, qtbot, mocker): """ Helper. Set up export test context and return reference to export dialog. """ - mocker.patch("securedrop_client.export.getService", return_value=mock_export_service) - gui, controller = functional_test_logged_in_context def check_for_sources(): diff --git a/client/tests/gui/conversation/export/test_device.py b/client/tests/gui/conversation/export/test_device.py index 88a599162b..ba1569b4af 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/gui/conversation/export/test_device.py @@ -1,22 +1,38 @@ -from unittest import mock - -from PyQt5.QtTest import QSignalSpy +import os +import pytest +from securedrop_client.export_status import ExportError, ExportStatus -from securedrop_client.export import Export from securedrop_client.gui.conversation.export import Device from securedrop_client.logic import Controller +import subprocess +import tarfile from tests import factory +from tempfile import NamedTemporaryFile, TemporaryDirectory +from unittest import mock - +_PATH_TO_PRETEND_ARCHIVE = "/tmp/archive-pretend" +_QREXEC_EXPORT_COMMAND = [ + "qrexec-client-vm", + "--", + "sd-devices", + "qubes.OpenInVM", + "/usr/lib/qubes/qopen-in-vm", + "--view-only", + "--", + f"{_PATH_TO_PRETEND_ARCHIVE}", +] +_MOCK_TMPDIR = "/tmp/mock_tmpdir" + + +@mock.patch("subprocess.check_output") class TestDevice: @classmethod def setup_class(cls): - mock_export_service = mock.MagicMock(spec=Export) mock_get_file = mock.MagicMock() cls.mock_controller = mock.MagicMock(spec=Controller) cls.mock_controller.data_dir = "pretend-data-dir" cls.mock_controller.get_file = mock_get_file - cls.device = Device(cls.mock_controller, mock_export_service) + cls.device = Device(cls.mock_controller) # Reset any manually-changed mock controller values before next test @classmethod @@ -24,41 +40,83 @@ def setup_method(cls): cls.mock_file = factory.File(source=factory.Source()) cls.mock_controller.get_file.return_value = cls.mock_file cls.mock_controller.downloaded_file_exists.return_value = True + cls.device._create_archive = mock.MagicMock() + cls.device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE + cls.mock_tmpdir = mock.MagicMock() + cls.mock_tmpdir.__enter__ = mock.MagicMock(return_value=_MOCK_TMPDIR) @classmethod def teardown_method(cls): cls.mock_file = None cls.mock_controller.get_file.return_value = None - - def test_Device_run_printer_preflight_checks(self): - print_preflight_check_requested_emissions = QSignalSpy( - self.device.print_preflight_check_requested - ) - - self.device.run_printer_preflight_checks() - - assert len(print_preflight_check_requested_emissions) == 1 - - def test_Device_run_print_file(self): - # file = factory.File(source=factory.Source()) + cls.device._create_archive = None + + def test_Device_run_printer_preflight_checks(self, mock_subprocess): + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.run_printer_preflight_checks() + + mock_subprocess.assert_called_once() + assert ( + _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] + ), f"Actual: {mock_subprocess.call_args[0]}" + + def test_Device_run_print_preflight_checks_with_error(self, mock_sp): + with mock.patch.object( + self.device, + "_build_archive_and_export", + side_effect=ExportError(ExportStatus.ERROR_PRINTER_NOT_SUPPORTED), + ), mock.patch( + "securedrop_client.gui.conversation.export.device.logger.error" + ) as err, pytest.raises( + ExportError + ) as e: + self.device.run_export_preflight_checks() + + err.assert_called_once_with("Print preflight failed") + + def test_Device_run_print_file(self, mock_subprocess): file = self.mock_file - print_requested_emissions = QSignalSpy(self.device.print_requested) - - self.device.print_file(file.uuid) - - assert len(print_requested_emissions) == 1 - - def test_Device_print_transcript(self): - print_requested_emissions = QSignalSpy(self.device.print_requested) + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.print_file(file.uuid) + + filepath = file.location(self.mock_controller.data_dir) + + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._PRINT_FN, + metadata=self.device._PRINT_METADATA, + filepaths=[filepath], + ) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] + def test_Device_print_transcript(self, mock_subprocess): filepath = "some/file/path" - self.device.print_transcript(filepath) + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.print_transcript(filepath) - assert len(print_requested_emissions) == 1 - assert print_requested_emissions[0] == [["some/file/path"]] + mock_subprocess.assert_called_once() - def test_Device_print_file_file_missing(self, mocker): + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._PRINT_FN, + metadata=self.device._PRINT_METADATA, + filepaths=[filepath], + ) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] + + def test_Device_print_file_file_missing(self, mock_subprocess, mocker): file = self.mock_file self.mock_controller.downloaded_file_exists.return_value = False @@ -67,78 +125,252 @@ def test_Device_print_file_file_missing(self, mocker): ) self.device.print_file(file.uuid) + mock_subprocess.assert_not_called() path = str(file.location(self.mock_controller.data_dir)) log_msg = f"Cannot find file in {path}" warning_logger.assert_called_once_with(log_msg) - def test_Device_print_file_when_orig_file_already_exists(self): + def test_Device_print_file_when_orig_file_already_exists(self, mock_subprocess): file = self.mock_file - print_requested_emissions = QSignalSpy(self.device.print_requested) - self.device.print_file(file.uuid) + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.print_file(file.uuid) - assert len(print_requested_emissions) == 1 self.mock_controller.get_file.assert_called_with(file.uuid) + mock_subprocess.assert_called_once() - def test_Device_run_export_preflight_checks(self): - export_preflight_check_requested_emissions = QSignalSpy( - self.device.export_preflight_check_requested + filepath = file.location(self.mock_controller.data_dir) + + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._PRINT_FN, + metadata=self.device._PRINT_METADATA, + filepaths=[filepath], + ) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] + + def test_Device_run_export_preflight_checks(self, mock_subprocess): + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.run_export_preflight_checks() + mock_subprocess.assert_called_once() + + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._USB_TEST_FN, + metadata=self.device._USB_TEST_METADATA, + filepaths=[], ) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] + + def test_Device_run_export_preflight_checks_with_error(self, mock_sp): + with mock.patch.object( + self.device, + "_build_archive_and_export", + side_effect=ExportError(ExportStatus.DEVICE_ERROR), + ), mock.patch( + "securedrop_client.gui.conversation.export.device.logger.error" + ) as err, pytest.raises( + ExportError + ) as e: + self.device.run_export_preflight_checks() + + err.assert_called_once_with("Export preflight failed") + + def test_Device_export_file_to_usb_drive(self, mock_subprocess): + file = self.mock_file + passphrase = "mock passphrase" - self.device.run_export_preflight_checks() + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.export_file_to_usb_drive(file.uuid, passphrase) + mock_subprocess.assert_called_once() - assert len(export_preflight_check_requested_emissions) == 1 + filepath = file.location(self.mock_controller.data_dir) - def test_Device_export_file_to_usb_drive(self): - file = self.mock_file - export_requested_emissions = QSignalSpy(self.device.export_requested) - self.device.export_file_to_usb_drive(file.uuid, "mock passphrase") + expected_md = self.device._DISK_METADATA.copy() + expected_md[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase - assert len(export_requested_emissions) == 1 + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._DISK_FN, + metadata=expected_md, + filepaths=[filepath], + ) - def test_Device_export_file_to_usb_drive_file_missing(self, mocker): + def test_Device_export_file_to_usb_drive_file_missing(self, mock_subprocess, mocker): file = self.mock_file self.mock_controller.downloaded_file_exists.return_value = False warning_logger = mocker.patch( "securedrop_client.gui.conversation.export.device.logger.warning" ) - - self.device.export_file_to_usb_drive(file.uuid, "mock passphrase") + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.export_file_to_usb_drive(file.uuid, "mock passphrase") path = str(file.location(self.mock_controller.data_dir)) log_msg = f"Cannot find file in {path}" warning_logger.assert_called_once_with(log_msg) - def test_Device_export_file_to_usb_drive_when_orig_file_already_exists(self): - export_requested_emissions = QSignalSpy(self.device.export_requested) + mock_subprocess.assert_not_called() + + def test_Device_export_file_to_usb_drive_when_orig_file_already_exists(self, mock_subprocess): file = self.mock_file + passphrase = "mock passphrase" - self.device.export_file_to_usb_drive(file.uuid, "mock passphrase") + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.export_file_to_usb_drive(file.uuid, passphrase) + + expected_metadata = self.device._DISK_METADATA.copy() + expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase + expected_filepath = file.location(self.mock_controller.data_dir) - assert len(export_requested_emissions) == 1 self.mock_controller.get_file.assert_called_with(file.uuid) + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._DISK_FN, + metadata=expected_metadata, + filepaths=[expected_filepath], + ) - def test_Device_export_transcript(self): - export_requested_emissions = QSignalSpy(self.device.export_requested) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] + + def test_Device_export_transcript(self, mock_subprocess): filepath = "some/file/path" + passphrase = "passphrase" + + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.export_transcript(filepath, passphrase) + + expected_metadata = self.device._DISK_METADATA.copy() + expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase + + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._DISK_FN, + metadata=expected_metadata, + filepaths=[filepath], + ) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - self.device.export_transcript(filepath, "passphrase") + def test_Device_export_files(self, mock_subprocess): + filepaths = ["some/file/path", "some/other/file/path"] + passphrase = "Correct-horse-battery-staple!" + + expected_metadata = self.device._DISK_METADATA.copy() + expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase + + with mock.patch( + "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.export_files(filepaths, passphrase) + + self.device._create_archive.assert_called_once_with( + archive_dir=_MOCK_TMPDIR, + archive_fn=self.device._DISK_FN, + metadata=expected_metadata, + filepaths=filepaths, + ) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - assert len(export_requested_emissions) == 1 - assert export_requested_emissions[0] == [["some/file/path"], "passphrase"] + @pytest.mark.parametrize("status", [i.value for i in ExportStatus]) + def test__run_qrexec_success(self, mocked_subprocess, status): + mocked_subprocess.return_value = f"{status}\n".encode("utf-8") + enum = ExportStatus(status) - def test_Device_export_files(self): - export_requested_emissions = QSignalSpy(self.device.export_requested) + assert self.device._run_qrexec_export(_PATH_TO_PRETEND_ARCHIVE) == enum - filepaths = ["some/file/path", "some/other/file/path"] + def test__run_qrexec_calledprocess_raises_exportstatus(self, mocked_subprocess): + mocked_subprocess.side_effect = ValueError( + "These are not the ExportStatuses you're looking for..." + ) + with pytest.raises(ExportError) as e: + self.device._run_qrexec_export(_PATH_TO_PRETEND_ARCHIVE) + + assert e.value.status == ExportStatus.UNEXPECTED_RETURN_STATUS - self.device.export_files(filepaths, "passphrase") + def test__run_qrexec_valuerror_raises_exportstatus(self, mocked_subprocess): + mocked_subprocess.side_effect = subprocess.CalledProcessError(1, "check_output") + with pytest.raises(ExportError) as e: + self.device._run_qrexec_export(_PATH_TO_PRETEND_ARCHIVE) + + assert e.value.status == ExportStatus.CALLED_PROCESS_ERROR + + @mock.patch("securedrop_client.gui.conversation.export.device.tarfile") + def test__add_virtual_file_to_archive(self, mock_tarfile, mock_sp): + mock_tarinfo = mock.MagicMock(spec=tarfile.TarInfo) + mock_tarfile.TarInfo.return_value = mock_tarinfo + + self.device._add_virtual_file_to_archive( + mock_tarfile, "mock_file", {"test_filedata": "lgtm"} + ) - assert len(export_requested_emissions) == 1 - assert export_requested_emissions[0] == [ - ["some/file/path", "some/other/file/path"], - "passphrase", - ] + mock_tarfile.TarInfo.assert_called_once() + + def test__create_archive(self, mocker): + """ + Ensure _create_archive creates an archive in the supplied directory. + """ + # A Device where we don't mock out the tempfile + device = Device(self.mock_controller) + archive_path = None + filepaths = [_PATH_TO_PRETEND_ARCHIVE] + with TemporaryDirectory() as temp_dir: + archive_path = device._create_archive(temp_dir, "mock.sd-export", {}, filepaths) + assert archive_path == os.path.join(temp_dir, "mock.sd-export") + assert os.path.exists(archive_path) # sanity check + + assert not os.path.exists(archive_path) + + def test__create_archive_with_an_export_file(self, mocker): + device = Device(self.mock_controller) + archive_path = None + with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: + archive_path = device._create_archive( + temp_dir, "mock.sd-export", {}, [export_file.name] + ) + assert archive_path == os.path.join(temp_dir, "mock.sd-export") + assert os.path.exists(archive_path) # sanity check + + assert not os.path.exists(archive_path) + + def test__create_archive_with_multiple_export_files(self, mocker): + device = Device(self.mock_controller) + archive_path = None + with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file_one, NamedTemporaryFile() as export_file_two: + transcript_path = os.path.join(temp_dir, "transcript.txt") + with open(transcript_path, "a+") as transcript: + archive_path = device._create_archive( + temp_dir, + "mock.sd-export", + {}, + [export_file_one.name, export_file_two.name, transcript.name], + ) + assert archive_path == os.path.join(temp_dir, "mock.sd-export") + assert os.path.exists(archive_path) # sanity check + + assert not os.path.exists(archive_path) diff --git a/client/tests/gui/conversation/export/test_dialog.py b/client/tests/gui/conversation/export/test_dialog.py index 335176ed3f..adceb44c57 100644 --- a/client/tests/gui/conversation/export/test_dialog.py +++ b/client/tests/gui/conversation/export/test_dialog.py @@ -1,4 +1,4 @@ -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation import ExportDialog from tests.helper import app # noqa: F401 diff --git a/client/tests/gui/conversation/export/test_file_dialog.py b/client/tests/gui/conversation/export/test_file_dialog.py index e0b6101550..36c927fdd0 100644 --- a/client/tests/gui/conversation/export/test_file_dialog.py +++ b/client/tests/gui/conversation/export/test_file_dialog.py @@ -1,4 +1,4 @@ -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation import ExportFileDialog from tests.helper import app # noqa: F401 diff --git a/client/tests/gui/conversation/export/test_print_dialog.py b/client/tests/gui/conversation/export/test_print_dialog.py index d21765fdbc..fdb37ff8b4 100644 --- a/client/tests/gui/conversation/export/test_print_dialog.py +++ b/client/tests/gui/conversation/export/test_print_dialog.py @@ -1,4 +1,4 @@ -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation import PrintFileDialog from tests.helper import app # noqa: F401 diff --git a/client/tests/gui/conversation/export/test_print_transcript_dialog.py b/client/tests/gui/conversation/export/test_print_transcript_dialog.py index a59a8e4410..af86797842 100644 --- a/client/tests/gui/conversation/export/test_print_transcript_dialog.py +++ b/client/tests/gui/conversation/export/test_print_transcript_dialog.py @@ -1,4 +1,4 @@ -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation import PrintTranscriptDialog from tests.helper import app # noqa: F401 diff --git a/client/tests/gui/conversation/export/test_transcript_dialog.py b/client/tests/gui/conversation/export/test_transcript_dialog.py index f0abfa859d..380806ec56 100644 --- a/client/tests/gui/conversation/export/test_transcript_dialog.py +++ b/client/tests/gui/conversation/export/test_transcript_dialog.py @@ -1,4 +1,4 @@ -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation import ExportTranscriptDialog from tests.helper import app # noqa: F401 diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index 80d3a4911d..428fdc193a 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -1,9 +1,8 @@ import pytest from PyQt5.QtWidgets import QApplication -from securedrop_client import export from securedrop_client.app import threads -from securedrop_client.export import ExportStatus +from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.base import ModalDialog from securedrop_client.gui.main import Window @@ -12,11 +11,7 @@ @pytest.fixture(scope="function") -def main_window(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.export.getService", - return_value=mock_export_service, - ) +def main_window(mocker, homedir): # Setup app = QApplication([]) gui = Window() @@ -68,11 +63,7 @@ def main_window(mocker, homedir, mock_export_service): @pytest.fixture(scope="function") -def main_window_no_key(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.export.getService", - return_value=mock_export_service, - ) +def main_window_no_key(mocker, homedir): # Setup app = QApplication([]) gui = Window() @@ -155,9 +146,9 @@ def modal_dialog(mocker, homedir): @pytest.fixture(scope="function") -def mock_export_service(): - """An export service that assumes the Qubes RPC calls are successful and skips them.""" - export_service = export.Service() +def mock_device(): + """A export that assumes the Qubes RPC calls are successful and skips them.""" + # todo: This will be done without export.Service(), i.e just with Device # Ensure the export_service doesn't rely on Qubes OS: export_service.run_preflight_checks = lambda: ExportStatus.DEVICE_LOCKED export_service.send_file_to_usb_device = lambda paths, passphrase: ExportStatus.SUCCESS_EXPORT @@ -167,11 +158,7 @@ def mock_export_service(): @pytest.fixture(scope="function") -def print_dialog(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.export.getService", - return_value=mock_export_service, - ) +def print_dialog(mocker, homedir): app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -193,7 +180,7 @@ def print_dialog(mocker, homedir, mock_export_service): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller, mock_export_service) + export_device = conversation.ExportDevice(controller) gui.setup(controller) gui.login_dialog.close() dialog = conversation.PrintFileDialog(export_device, "file_uuid", "file_name") @@ -206,11 +193,7 @@ def print_dialog(mocker, homedir, mock_export_service): @pytest.fixture(scope="function") -def export_file_dialog(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.export.getService", - return_value=mock_export_service, - ) +def export_file_dialog(mocker, homedir): app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -229,7 +212,7 @@ def export_file_dialog(mocker, homedir, mock_export_service): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller, mock_export_service) + export_device = conversation.ExportDevice(controller) gui.setup(controller) gui.login_dialog.close() dialog = conversation.ExportFileDialog(export_device, "file_uuid", "file_name") diff --git a/client/tests/test_export.py b/client/tests/test_export.py deleted file mode 100644 index d5e43b4f3e..0000000000 --- a/client/tests/test_export.py +++ /dev/null @@ -1,453 +0,0 @@ -import os -import subprocess -import unittest -from tempfile import NamedTemporaryFile, TemporaryDirectory - -import pytest - -from securedrop_client import export -from securedrop_client.export import Export, ExportError, ExportStatus - - -class TestService(unittest.TestCase): - def tearDown(self): - # ensure any changes to the export.Service instance are reset - # export.resetService() - pass - - def test_service_is_unique(self): - service = export.getService() - same_service = export.getService() # Act. - - self.assertTrue( - service is same_service, - "expected successive calls to getService to return the same service, got different services", # noqa: E501 - ) - - def test_service_can_be_reset(self): - service = export.getService() - export.resetService() # Act. - different_service = export.getService() - - self.assertTrue( - different_service is not service, - "expected resetService to reset the service, got same service after reset", - ) - - -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. - """ - - export = Export() - mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.PRINT_PREFLIGHT_SUCCESS - ) - export.printer_preflight_success = mocker.MagicMock() - export.printer_preflight_success.emit = mocker.MagicMock() - - export.run_printer_preflight() - export.printer_preflight_success.emit.assert_called_once_with( - ExportStatus.PRINT_PREFLIGHT_SUCCESS - ) - - -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. - """ - - export = Export() - error = ExportError("bang!") - mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - - export.printer_preflight_failure = mocker.MagicMock() - export.printer_preflight_failure.emit = mocker.MagicMock() - - export.run_printer_preflight() - - export.printer_preflight_failure.emit.assert_called_once_with(error) - - -def test_print(mocker): - export = Export() - - mock_qrexec_call = mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.PRINT_SUCCESS - ) - - export.print_call_success = mocker.MagicMock() - export.print_call_success.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - - export.print(["path1", "path2"]) - - mock_qrexec_call.assert_called_once_with( - metadata=export.PRINT_METADATA, filename=export.PRINT_FN, filepaths=["path1", "path2"] - ) - export.print_call_success.emit.assert_called_once_with(ExportStatus.PRINT_SUCCESS) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_print_error(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archive containing the file to - print and that the failure signal is emitted. - """ - 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.print_call_failure = mocker.MagicMock() - export.print_call_failure.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - error = ExportError("oh no!") - _run_print = mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - mocker.patch("os.path.exists", return_value=True) - - export.print(["path1", "path2"]) - - _run_print.assert_called_once_with( - metadata=export.PRINT_METADATA, filename=export.PRINT_FN, filepaths=["path1", "path2"] - ) - export.print_call_failure.emit.assert_called_once_with(error) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_send_file_to_usb_device(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archive containing the export - file and that the success signal is emitted. - """ - 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.export_usb_call_success = mocker.MagicMock() - export.export_usb_call_success.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - _run_disk_export = mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.SUCCESS_EXPORT - ) - mocker.patch("os.path.exists", return_value=True) - - metadata = export.DISK_METADATA - metadata[export.DISK_ENCRYPTION_KEY_NAME] = "mock passphrase" - - export.send_file_to_usb_device(["path1", "path2"], "mock passphrase") - - _run_disk_export.assert_called_once_with( - metadata=metadata, filename=export.DISK_FN, filepaths=["path1", "path2"] - ) - export.export_usb_call_success.emit.assert_called_once_with(ExportStatus.SUCCESS_EXPORT) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_send_file_to_usb_device_error(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archive containing the export - file and that the failure signal is emitted. - """ - export = 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_usb_call_failure = mocker.MagicMock() - export.export_usb_call_failure.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - error = ExportError("ohno") - _run_disk_export = mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - - metadata = export.DISK_METADATA - metadata[export.DISK_ENCRYPTION_KEY_NAME] = "mock passphrase" - - export.send_file_to_usb_device(["path1", "path2"], "mock passphrase") - - _run_disk_export.assert_called_once_with( - metadata=metadata, filename=export.DISK_FN, filepaths=["path1", "path2"] - ) - export.export_usb_call_failure.emit.assert_called_once_with(error) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_run_usb_preflight_checks(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.preflight_check_call_success = mocker.MagicMock() - export.preflight_check_call_success.emit = mocker.MagicMock() - _run_export = mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.DEVICE_LOCKED - ) - - export.run_preflight_checks() - - _run_export.assert_called_once_with( - metadata=export.USB_TEST_METADATA, filename=export.USB_TEST_FN - ) - export.preflight_check_call_success.emit.assert_called_once_with(ExportStatus.DEVICE_LOCKED) - - -def test_run_usb_preflight_checks_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.preflight_check_call_failure = mocker.MagicMock() - export.preflight_check_call_failure.emit = mocker.MagicMock() - error = ExportError("bang!") - _run_export = mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - - export.run_preflight_checks() - - _run_export.assert_called_once_with( - metadata=export.USB_TEST_METADATA, filename=export.USB_TEST_FN - ) - export.preflight_check_call_failure.emit.assert_called_once_with(error) - - -@pytest.mark.parametrize("success_qrexec", [e.value for e in ExportStatus]) -def test__build_archive_and_export_success(mocker, success_qrexec): - """ - Test the command that calls out to underlying qrexec service. - """ - export = 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) - - mock_qrexec_call = mocker.patch.object( - export, "_run_qrexec_export", return_value=bytes(success_qrexec, "utf-8") - ) - mocker.patch.object(export, "_create_archive", return_value="mock_archive_path") - - metadata = {"device": "pretend", "encryption_method": "transparent"} - - result = export._build_archive_and_export( - metadata=metadata, filename="mock_filename", filepaths=["mock_filepath"] - ) - mock_qrexec_call.assert_called_once() - - assert result == bytes(success_qrexec, "utf-8") - - -def test__build_archive_and_export_error(mocker): - """ - Test the command that calls out to underlying qrexec service. - """ - export = 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) - - mocker.patch.object(export, "_create_archive", return_value="mock_archive_path") - - mock_qrexec_call = mocker.patch.object( - export, "_run_qrexec_export", side_effect=ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) - ) - - metadata = {"device": "pretend", "encryption_method": "transparent"} - - with pytest.raises(ExportError): - result = export._build_archive_and_export( - metadata=metadata, filename="mock_filename", filepaths=["mock_filepath"] - ) - assert result == ExportStatus.UNEXPECTED_RETURN_STATUS - - mock_qrexec_call.assert_called_once() - - -def test__create_archive(mocker): - """ - Ensure _create_archive creates an archive in the supplied directory. - """ - export = Export() - archive_path = None - with TemporaryDirectory() as temp_dir: - archive_path = export._create_archive( - archive_dir=temp_dir, archive_fn="mock.sd-export", metadata={}, filepaths=[] - ) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") - assert os.path.exists(archive_path) # sanity check - - assert not os.path.exists(archive_path) - - -def test__create_archive_with_an_export_file(mocker): - export = Export() - archive_path = None - with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: - archive_path = export._create_archive( - archive_dir=temp_dir, - archive_fn="mock.sd-export", - metadata={}, - filepaths=[export_file.name], - ) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") - assert os.path.exists(archive_path) # sanity check - - assert not os.path.exists(archive_path) - - -def test__create_archive_with_multiple_export_files(mocker): - """ - Ensure an archive - """ - export = Export() - archive_path = None - with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file_one, NamedTemporaryFile() as export_file_two: # noqa - transcript_path = os.path.join(temp_dir, "transcript.txt") - with open(transcript_path, "a+") as transcript: - archive_path = export._create_archive( - temp_dir, - "mock.sd-export", - {}, - [export_file_one.name, export_file_two.name, transcript.name], - ) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") - assert os.path.exists(archive_path) # sanity check - - assert not os.path.exists(archive_path) - - -@pytest.mark.parametrize("qrexec_return_value_success", [e.value for e in ExportStatus]) -def test__run_qrexec_export(mocker, qrexec_return_value_success): - """ - Ensure the subprocess call returns the expected output. - """ - export = Export() - qrexec_mocker = mocker.patch( - "subprocess.check_output", return_value=bytes(qrexec_return_value_success, "utf-8") - ) - result = export._run_qrexec_export("mock.sd-export") - - qrexec_mocker.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "mock.sd-export", - ], - stderr=-2, - ) - - assert ExportStatus(result) - - -@pytest.mark.parametrize( - "qrexec_return_value_error", [b"", b"qrexec not connected", b"DEVICE_UNLOCKED\nERROR_WRITE"] -) -def test__run_qrexec_export_returns_bad_data(mocker, qrexec_return_value_error): - """ - Ensure the subprocess call returns the expected output. - """ - export = Export() - qrexec_mocker = mocker.patch("subprocess.check_output", return_value=qrexec_return_value_error) - - with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"): - export._run_qrexec_export("mock.sd-export") - - qrexec_mocker.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "mock.sd-export", - ], - stderr=-2, - ) - - -def test__run_qrexec_export_does_not_raise_ExportError_when_CalledProcessError(mocker): - """ - Ensure ExportError is raised if a CalledProcessError is encountered. - """ - mock_error = subprocess.CalledProcessError(cmd=["mock_cmd"], returncode=123) - mocker.patch("subprocess.check_output", side_effect=mock_error) - - export = Export() - - with pytest.raises(ExportError, match="CALLED_PROCESS_ERROR"): - export._run_qrexec_export("mock.sd-export") - - -def test__run_qrexec_export_with_evil_command(mocker): - """ - Ensure shell command is shell-escaped. - """ - export = Export() - check_output = mocker.patch("subprocess.check_output", return_value=b"ERROR_FILE_NOT_FOUND") - - with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"): - export._run_qrexec_export("somefile; ls -la ~") - - check_output.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "'somefile; ls -la ~'", - ], - stderr=-2, - ) - - -def test__run_qrexec_export_error_on_empty_return_value(mocker): - """ - Ensure an error is raised when qrexec call returns empty string, - """ - export = Export() - check_output = mocker.patch("subprocess.check_output", return_value=b"") - - with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"): - export._run_qrexec_export("somefile.sd-export") - - check_output.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "somefile.sd-export", - ], - stderr=-2, - ) From 03124e3440f93fab289284eb4e8b6229b85cfd9a Mon Sep 17 00:00:00 2001 From: Ro Date: Thu, 25 Jan 2024 10:49:24 -0500 Subject: [PATCH 02/22] Don't depend on controller in Device, just pass list of filepaths for export. Checks to ensure files are present are conducted before dialog launch. --- client/securedrop_client/export_status.py | 3 + client/securedrop_client/gui/actions.py | 19 +-- .../gui/conversation/export/device.py | 49 +++--- client/securedrop_client/gui/widgets.py | 15 +- .../functional/test_export_file_dialog.py | 16 +- .../gui/conversation/export/test_device.py | 157 ++++++++---------- client/tests/gui/test_actions.py | 39 +++-- 7 files changed, 148 insertions(+), 150 deletions(-) diff --git a/client/securedrop_client/export_status.py b/client/securedrop_client/export_status.py index 8950c66d6e..da475c3fa3 100644 --- a/client/securedrop_client/export_status.py +++ b/client/securedrop_client/export_status.py @@ -58,3 +58,6 @@ class ExportStatus(Enum): CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR" ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION" UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS" + + # Client-side error only + ERROR_MISSING_FILES = "ERROR_MISSING_FILES" # All files meant for export are missing diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 5d8420b866..dc822ff02d 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -16,7 +16,7 @@ from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source from securedrop_client.gui.base import ModalDialog -from securedrop_client.gui.conversation import ExportDevice as ConversationExportDevice +from securedrop_client.gui.conversation import ExportDevice from securedrop_client.gui.conversation import ExportDialog as ExportConversationDialog from securedrop_client.gui.conversation import ( ExportTranscriptDialog as ExportConversationTranscriptDialog, @@ -160,8 +160,6 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller) - self.triggered.connect(self._on_triggered) @pyqtSlot() @@ -189,9 +187,8 @@ def _on_triggered(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with open(file_path, "r") as f: - dialog = PrintConversationTranscriptDialog( - self._export_device, TRANSCRIPT_FILENAME, str(file_path) - ) + export = ExportDevice([file_path]) + dialog = PrintConversationTranscriptDialog(export, TRANSCRIPT_FILENAME, str(file_path)) dialog.exec() @@ -212,8 +209,6 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller) - self.triggered.connect(self._on_triggered) @pyqtSlot() @@ -241,8 +236,9 @@ def _on_triggered(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with open(file_path, "r") as f: + export_device = ExportDevice([file_path]) dialog = ExportConversationTranscriptDialog( - self._export_device, TRANSCRIPT_FILENAME, str(file_path) + export_device, TRANSCRIPT_FILENAME, str(file_path) ) dialog.exec() @@ -267,8 +263,6 @@ def __init__( self._source = source self._state = app_state - self._export_device = ConversationExportDevice(controller) - self.triggered.connect(self._on_triggered) @pyqtSlot() @@ -331,6 +325,7 @@ def _prepare_to_export(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with ExitStack() as stack: + export_device = ExportDevice(file_locations) files = [ stack.enter_context(open(file_location, "r")) for file_location in file_locations ] @@ -342,7 +337,7 @@ def _prepare_to_export(self) -> None: summary = _("all files and transcript") dialog = ExportConversationDialog( - self._export_device, + export_device, summary, [str(file_location) for file_location in file_locations], ) diff --git a/client/securedrop_client/gui/conversation/export/device.py b/client/securedrop_client/gui/conversation/export/device.py index bd7dd6604b..aa37f1af90 100644 --- a/client/securedrop_client/gui/conversation/export/device.py +++ b/client/securedrop_client/gui/conversation/export/device.py @@ -10,8 +10,6 @@ from PyQt5.QtCore import QObject, pyqtSignal -from securedrop_client.logic import Controller - from securedrop_client.export_status import ExportStatus, ExportError logger = logging.getLogger(__name__) @@ -19,10 +17,12 @@ class Device(QObject): """ - Send files to Export VM so that they can be copied to a + Interface for sending files to Export VM for transfer to a disk drive or printed by a USB-connected printer. Files are archived in a specified format, (see `export` README). + + A list of valid filepaths must be supplied. """ _METADATA_FN = "metadata.json" @@ -55,10 +55,10 @@ class Device(QObject): print_preflight_check_failed = pyqtSignal(object) print_failed = pyqtSignal(object) - def __init__(self, controller: Controller) -> None: + def __init__(self, filepaths: [str]) -> None: super().__init__() - self._controller = controller + self._filepaths_list = filepaths def run_printer_preflight_checks(self) -> None: """ @@ -109,15 +109,8 @@ def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: unlocking the attached transfer device. If the file is missing, update the db so that is_downloaded is set to False. """ - file = self._controller.get_file(file_uuid) - file_location = file.location(self._controller.data_dir) - logger.debug("Exporting file in: {}".format(os.path.dirname(file_location))) - - if not self._controller.downloaded_file_exists(file): - logger.warning(f"Cannot find file in {file_location}") - return - self._send_file_to_usb_device([file_location], passphrase) + self._send_file_to_usb_device(self._filepaths_list, passphrase) def print_transcript(self, file_location: str) -> None: """ @@ -130,15 +123,8 @@ def print_file(self, file_uuid: str) -> None: Send the file specified by file_uuid to the Export VM. If the file is missing, update the db so that is_downloaded is set to False. """ - file = self._controller.get_file(file_uuid) - file_location = file.location(self._controller.data_dir) - logger.debug("Printing file in: {}".format(os.path.dirname(file_location))) - - if not self._controller.downloaded_file_exists(file): - logger.warning(f"Cannot find file in {file_location}") - return - self._print([file_location]) + self._print(self._filepaths_list) def _run_qrexec_export(self, archive_path: str) -> ExportStatus: """ @@ -210,10 +196,25 @@ def _create_archive( # When more than one file is added to the archive, # extra care must be taken to prevent name collisions. is_one_of_multiple_files = len(filepaths) > 1 + missing_count = 0 for filepath in filepaths: - self._add_file_to_archive( - archive, filepath, prevent_name_collisions=is_one_of_multiple_files - ) + if not (os.path.exists(filepath)): + missing_count += 1 + logger.debug( + f"'{filepath}' does not exist, and will not be included in archive" + ) + # Controller checks files and keeps a reference open during export, + # so this shouldn't be reachable + logger.warning("File not found at specified filepath, skipping") + else: + self._add_file_to_archive( + archive, filepath, prevent_name_collisions=is_one_of_multiple_files + ) + if missing_count == len(filepaths): + # Context manager will delete archive even if an exception occurs + # since the archive is in a TemporaryDirectory + logger.error("Files were moved or missing") + raise ExportError(ExportStatus.ERROR_MISSING_FILES) return archive_path diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 85364a5941..a4930ac7ff 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -2255,8 +2255,6 @@ def __init__( self.controller = controller - self._export_device = conversation.ExportDevice(controller) - self.file = self.controller.get_file(file_uuid) self.uuid = file_uuid self.index = index @@ -2455,11 +2453,16 @@ def _on_export_clicked(self) -> None: """ Called when the export button is clicked. """ + file_location = self.file.location(self.controller.data_dir) + if not self.controller.downloaded_file_exists(self.file): + logger.debug("Clicked export but file not downloaded") return + export_device = conversation.ExportDevice([file_location]) + self.export_dialog = conversation.ExportFileDialog( - self._export_device, self.uuid, self.file.filename + export_device, self.uuid, self.file.filename ) self.export_dialog.show() @@ -2469,9 +2472,13 @@ def _on_print_clicked(self) -> None: Called when the print button is clicked. """ if not self.controller.downloaded_file_exists(self.file): + logger.debug("Clicked print but file not downloaded") return - dialog = conversation.PrintFileDialog(self._export_device, self.uuid, self.file.filename) + filepath = self.file.location(self.controller.data_dir) + export_device = conversation.ExportDevice([filepath]) + + dialog = conversation.PrintFileDialog(export_device, self.uuid, self.file.filename) dialog.exec() def _on_left_click(self) -> None: diff --git a/client/tests/functional/test_export_file_dialog.py b/client/tests/functional/test_export_file_dialog.py index 1436e1e32f..2b078af7cf 100644 --- a/client/tests/functional/test_export_file_dialog.py +++ b/client/tests/functional/test_export_file_dialog.py @@ -66,16 +66,12 @@ def check_for_export_dialog(): @pytest.mark.vcr() -def test_export_file_dialog_locked( - functional_test_logged_in_context, qtbot, mocker, mock_export_service -): +def test_export_file_dialog_locked(functional_test_logged_in_context, qtbot, mocker): """ Download a file, export it, and verify that the export is complete by checking that the label of the export dialog's continue button is "DONE". """ - export_dialog = _setup_export( - functional_test_logged_in_context, qtbot, mocker, mock_export_service - ) + export_dialog = _setup_export(functional_test_logged_in_context, qtbot, mocker) assert export_dialog.passphrase_form.isHidden() is True @@ -100,14 +96,18 @@ def check_password_form(): @pytest.mark.vcr() def test_export_file_dialog_device_already_unlocked( - functional_test_logged_in_context, qtbot, mocker, mock_export_service_unlocked_device + functional_test_logged_in_context, + qtbot, + mocker, ): """ Download a file, export it, and verify that the export is complete by checking that the label of the export dialog's continue button is "DONE". """ export_dialog = _setup_export( - functional_test_logged_in_context, qtbot, mocker, mock_export_service_unlocked_device + functional_test_logged_in_context, + qtbot, + mocker, ) def check_skip_password_prompt_for_unlocked_device(): diff --git a/client/tests/gui/conversation/export/test_device.py b/client/tests/gui/conversation/export/test_device.py index ba1569b4af..05cb429a52 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/gui/conversation/export/test_device.py @@ -3,7 +3,6 @@ from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation.export import Device -from securedrop_client.logic import Controller import subprocess import tarfile from tests import factory @@ -21,42 +20,41 @@ "--", f"{_PATH_TO_PRETEND_ARCHIVE}", ] -_MOCK_TMPDIR = "/tmp/mock_tmpdir" +_MOCK_FILEDIR = "/tmp/mock_tmpdir/" @mock.patch("subprocess.check_output") class TestDevice: @classmethod def setup_class(cls): - mock_get_file = mock.MagicMock() - cls.mock_controller = mock.MagicMock(spec=Controller) - cls.mock_controller.data_dir = "pretend-data-dir" - cls.mock_controller.get_file = mock_get_file - cls.device = Device(cls.mock_controller) + cls.device = None - # Reset any manually-changed mock controller values before next test + # Reset any manually-changed mock values before next test @classmethod def setup_method(cls): cls.mock_file = factory.File(source=factory.Source()) - cls.mock_controller.get_file.return_value = cls.mock_file - cls.mock_controller.downloaded_file_exists.return_value = True + cls.mock_file_location = f"{_MOCK_FILEDIR}{cls.mock_file.filename}" + cls.device = Device([cls.mock_file_location]) cls.device._create_archive = mock.MagicMock() cls.device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE cls.mock_tmpdir = mock.MagicMock() - cls.mock_tmpdir.__enter__ = mock.MagicMock(return_value=_MOCK_TMPDIR) + cls.mock_tmpdir.__enter__ = mock.MagicMock(return_value=_MOCK_FILEDIR) @classmethod def teardown_method(cls): cls.mock_file = None - cls.mock_controller.get_file.return_value = None cls.device._create_archive = None def test_Device_run_printer_preflight_checks(self, mock_subprocess): + device = Device(["/fake/file/name"]) + device._create_archive = mock.MagicMock() + device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE + with mock.patch( "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - self.device.run_printer_preflight_checks() + device.run_printer_preflight_checks() mock_subprocess.assert_called_once() assert ( @@ -64,95 +62,74 @@ def test_Device_run_printer_preflight_checks(self, mock_subprocess): ), f"Actual: {mock_subprocess.call_args[0]}" def test_Device_run_print_preflight_checks_with_error(self, mock_sp): - with mock.patch.object( - self.device, - "_build_archive_and_export", - side_effect=ExportError(ExportStatus.ERROR_PRINTER_NOT_SUPPORTED), - ), mock.patch( - "securedrop_client.gui.conversation.export.device.logger.error" - ) as err, pytest.raises( - ExportError - ) as e: - self.device.run_export_preflight_checks() + mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") - err.assert_called_once_with("Print preflight failed") + with mock.patch("securedrop_client.gui.conversation.export.device.logger.error") as err: + self.device.run_printer_preflight_checks() + + assert "Print preflight failed" in err.call_args[0] def test_Device_run_print_file(self, mock_subprocess): - file = self.mock_file with mock.patch( "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - self.device.print_file(file.uuid) - - filepath = file.location(self.mock_controller.data_dir) + self.device.print_file(self.mock_file.uuid) self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._PRINT_FN, metadata=self.device._PRINT_METADATA, - filepaths=[filepath], + filepaths=[self.mock_file_location], ) mock_subprocess.assert_called_once() assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] def test_Device_print_transcript(self, mock_subprocess): - filepath = "some/file/path" - with mock.patch( "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - self.device.print_transcript(filepath) + self.device.print_transcript(self.mock_file_location) mock_subprocess.assert_called_once() self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._PRINT_FN, metadata=self.device._PRINT_METADATA, - filepaths=[filepath], + filepaths=[self.mock_file_location], ) mock_subprocess.assert_called_once() assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] def test_Device_print_file_file_missing(self, mock_subprocess, mocker): - file = self.mock_file - self.mock_controller.downloaded_file_exists.return_value = False - + device = Device(["/no/such/file"]) warning_logger = mocker.patch( "securedrop_client.gui.conversation.export.device.logger.warning" ) - self.device.print_file(file.uuid) - mock_subprocess.assert_not_called() + log_msg = f"File not found at specified filepath, skipping" - path = str(file.location(self.mock_controller.data_dir)) - log_msg = f"Cannot find file in {path}" + device.print_file("some-missing-file-uuid") - warning_logger.assert_called_once_with(log_msg) + assert log_msg in warning_logger.call_args[0] + mock_subprocess.assert_not_called() def test_Device_print_file_when_orig_file_already_exists(self, mock_subprocess): - file = self.mock_file - with mock.patch( "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - self.device.print_file(file.uuid) + self.device.print_file(self.mock_file.uuid) - self.mock_controller.get_file.assert_called_with(file.uuid) mock_subprocess.assert_called_once() - - filepath = file.location(self.mock_controller.data_dir) - self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._PRINT_FN, metadata=self.device._PRINT_METADATA, - filepaths=[filepath], + filepaths=[self.mock_file_location], ) - mock_subprocess.assert_called_once() assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] def test_Device_run_export_preflight_checks(self, mock_subprocess): @@ -164,7 +141,7 @@ def test_Device_run_export_preflight_checks(self, mock_subprocess): mock_subprocess.assert_called_once() self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._USB_TEST_FN, metadata=self.device._USB_TEST_METADATA, filepaths=[], @@ -173,18 +150,14 @@ def test_Device_run_export_preflight_checks(self, mock_subprocess): assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] def test_Device_run_export_preflight_checks_with_error(self, mock_sp): - with mock.patch.object( - self.device, - "_build_archive_and_export", - side_effect=ExportError(ExportStatus.DEVICE_ERROR), - ), mock.patch( + mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") + + with mock.patch( "securedrop_client.gui.conversation.export.device.logger.error" - ) as err, pytest.raises( - ExportError - ) as e: + ) as err, mock.patch.object(self.device, "export_preflight_check_failed") as mock_signal: self.device.run_export_preflight_checks() - err.assert_called_once_with("Export preflight failed") + assert "Export preflight failed" in err.call_args[0] def test_Device_export_file_to_usb_drive(self, mock_subprocess): file = self.mock_file @@ -197,36 +170,35 @@ def test_Device_export_file_to_usb_drive(self, mock_subprocess): self.device.export_file_to_usb_drive(file.uuid, passphrase) mock_subprocess.assert_called_once() - filepath = file.location(self.mock_controller.data_dir) - expected_md = self.device._DISK_METADATA.copy() expected_md[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._DISK_FN, metadata=expected_md, - filepaths=[filepath], + filepaths=[self.mock_file_location], ) def test_Device_export_file_to_usb_drive_file_missing(self, mock_subprocess, mocker): - file = self.mock_file - self.mock_controller.downloaded_file_exists.return_value = False + file = factory.File() # Not a real file, so not anywhere + device = Device(["/fake/file/location"]) warning_logger = mocker.patch( "securedrop_client.gui.conversation.export.device.logger.warning" ) with mock.patch( + "securedrop_client.gui.conversation.export.device.tarfile.open", + return_value=mock.MagicMock(), + ), mock.patch( "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - self.device.export_file_to_usb_drive(file.uuid, "mock passphrase") - - path = str(file.location(self.mock_controller.data_dir)) - log_msg = f"Cannot find file in {path}" - warning_logger.assert_called_once_with(log_msg) + device.export_file_to_usb_drive(file.uuid, "mock passphrase") + warning_logger.assert_called_once() mock_subprocess.assert_not_called() + # Todo: could get more specific about looking for the emitted failure signal def test_Device_export_file_to_usb_drive_when_orig_file_already_exists(self, mock_subprocess): file = self.mock_file @@ -240,11 +212,10 @@ def test_Device_export_file_to_usb_drive_when_orig_file_already_exists(self, moc expected_metadata = self.device._DISK_METADATA.copy() expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase - expected_filepath = file.location(self.mock_controller.data_dir) + expected_filepath = self.mock_file_location - self.mock_controller.get_file.assert_called_with(file.uuid) self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._DISK_FN, metadata=expected_metadata, filepaths=[expected_filepath], @@ -267,7 +238,7 @@ def test_Device_export_transcript(self, mock_subprocess): expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._DISK_FN, metadata=expected_metadata, filepaths=[filepath], @@ -289,7 +260,7 @@ def test_Device_export_files(self, mock_subprocess): self.device.export_files(filepaths, passphrase) self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_TMPDIR, + archive_dir=_MOCK_FILEDIR, archive_fn=self.device._DISK_FN, metadata=expected_metadata, filepaths=filepaths, @@ -335,19 +306,25 @@ def test__create_archive(self, mocker): """ Ensure _create_archive creates an archive in the supplied directory. """ - # A Device where we don't mock out the tempfile - device = Device(self.mock_controller) archive_path = None - filepaths = [_PATH_TO_PRETEND_ARCHIVE] with TemporaryDirectory() as temp_dir: + # We'll do this in the tmpdir for ease of cleanup + open(os.path.join(temp_dir, "temp_1"), "w+").close() + open(os.path.join(temp_dir, "temp_2"), "w+").close() + filepaths = [os.path.join(temp_dir, "temp_1"), os.path.join(temp_dir, "temp_2")] + device = Device(filepaths) + archive_path = device._create_archive(temp_dir, "mock.sd-export", {}, filepaths) + assert archive_path == os.path.join(temp_dir, "mock.sd-export") assert os.path.exists(archive_path) # sanity check assert not os.path.exists(archive_path) def test__create_archive_with_an_export_file(self, mocker): - device = Device(self.mock_controller) + device = Device( + self.mock_file_location + ) # TODO might not work - might want the tmpdir below archive_path = None with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: archive_path = device._create_archive( @@ -359,7 +336,7 @@ def test__create_archive_with_an_export_file(self, mocker): assert not os.path.exists(archive_path) def test__create_archive_with_multiple_export_files(self, mocker): - device = Device(self.mock_controller) + device = Device(self.mock_file_location) archive_path = None with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file_one, NamedTemporaryFile() as export_file_two: transcript_path = os.path.join(temp_dir, "transcript.txt") @@ -374,3 +351,15 @@ def test__create_archive_with_multiple_export_files(self, mocker): assert os.path.exists(archive_path) # sanity check assert not os.path.exists(archive_path) + + def test__tmpdir_cleaned_up_on_exception(self, mock_sp): + """ + Sanity check. If we encounter an error after archive has been built, + ensure the tmpdir directory cleanup happens. + """ + with TemporaryDirectory() as tmpdir, pytest.raises(ExportError): + print(f"{tmpdir} created") + + raise ExportError("Something bad happened!") + + assert not os.path.exists(tmpdir) diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py index 8f3bde2cb0..e4a97f4848 100644 --- a/client/tests/gui/test_actions.py +++ b/client/tests/gui/test_actions.py @@ -275,12 +275,13 @@ def test_trigger(self, _): return_value="☠ A string with unicode characters." ) - action._export_device.run_printer_preflight_checks = ( - lambda: action._export_device.print_preflight_check_succeeded.emit() - ) - action._export_device.print_transcript = ( - lambda transcript: action._export_device.print_succeeded.emit() - ) + # TODO: these are now accessible through the Device or the Dialog. + # action._export_device.run_printer_preflight_checks = ( + # lambda: action._export_device.print_preflight_check_succeeded.emit() + # ) + # action._export_device.print_transcript = ( + # lambda transcript: action._export_device.print_succeeded.emit() + # ) action.trigger() @@ -303,12 +304,13 @@ def test_trigger(self, _): return_value="☠ A string with unicode characters." ) - action._export_device.run_printer_preflight_checks = ( - lambda: action._export_device.print_preflight_check_succeeded.emit() - ) - action._export_device.print_transcript = ( - lambda transcript: action._export_device.print_succeeded.emit() - ) + # TOdo: these are now accessible through the Device or the Dialog. + # action._export_device.run_printer_preflight_checks = ( + # lambda: action._export_device.print_preflight_check_succeeded.emit() + # ) + # action._export_device.print_transcript = ( + # lambda transcript: action._export_device.print_succeeded.emit() + # ) action.trigger() @@ -336,12 +338,13 @@ def test_trigger(self, _): return_value="☠ A string with unicode characters." ) - action._export_device.run_printer_preflight_checks = ( - lambda: action._export_device.print_preflight_check_succeeded.emit() - ) - action._export_device.print_transcript = ( - lambda transcript: action._export_device.print_succeeded.emit() - ) + # TODO: preflight checks now belong to Device + # action._export_device.run_printer_preflight_checks = ( + # lambda: action._export_device.print_preflight_check_succeeded.emit() + # ) + # action._export_device.print_transcript = ( + # lambda transcript: action._export_device.print_succeeded.emit() + # ) action.trigger() From 90763944c75ad7807d61f236c85953d24824fb88 Mon Sep 17 00:00:00 2001 From: Ro Date: Thu, 25 Jan 2024 19:04:02 -0500 Subject: [PATCH 03/22] (WIP) Update integration test fixture to use Device --- client/tests/integration/conftest.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index 428fdc193a..8520ec8ceb 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -7,6 +7,7 @@ from securedrop_client.gui.base import ModalDialog from securedrop_client.gui.main import Window from securedrop_client.logic import Controller +from securedrop_client.gui.conversation.export import Device from tests import factory @@ -146,15 +147,15 @@ def modal_dialog(mocker, homedir): @pytest.fixture(scope="function") -def mock_device(): +def mock_export(mocker): + device = Device(["/tmp/imaginary-export/imaginary-file-1.tar.gz.gpg"]) + """A export that assumes the Qubes RPC calls are successful and skips them.""" - # todo: This will be done without export.Service(), i.e just with Device - # Ensure the export_service doesn't rely on Qubes OS: - export_service.run_preflight_checks = lambda: ExportStatus.DEVICE_LOCKED - export_service.send_file_to_usb_device = lambda paths, passphrase: ExportStatus.SUCCESS_EXPORT - export_service.run_printer_preflight = lambda: ExportStatus.PRINT_PREFLIGHT_SUCCESS - export_service.run_print = lambda paths: ExportStatus.PRINT_SUCCESS - return export_service + device.run_preflight_checks = lambda: ExportStatus.DEVICE_LOCKED + device.send_file_to_usb_device = lambda paths, passphrase: ExportStatus.SUCCESS_EXPORT + device.run_printer_preflight = lambda: ExportStatus.PRINT_PREFLIGHT_SUCCESS + device.run_print = lambda paths: ExportStatus.PRINT_SUCCESS + return device @pytest.fixture(scope="function") From 882c7452b9ec4a0b257337316550fc07558dc1ea Mon Sep 17 00:00:00 2001 From: Ro Date: Thu, 25 Jan 2024 21:31:32 -0500 Subject: [PATCH 04/22] WIP: update export_service fixtures for functional tests --- client/tests/conftest.py | 42 +++++-------------- .../functional/test_export_file_dialog.py | 20 +++++---- client/tests/integration/conftest.py | 6 ++- 3 files changed, 26 insertions(+), 42 deletions(-) diff --git a/client/tests/conftest.py b/client/tests/conftest.py index 2471a36fca..8ddf3cffcc 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -168,41 +168,21 @@ def homedir(i18n): yield tmpdir - -class MockExportService: # todo - """An export service that assumes the Qubes RPC calls are successful and skips them.""" - - def __init__(self, unlocked: bool): - super().__init__() - if unlocked: - self.preflight_response = ExportStatus.DEVICE_WRITABLE - else: - self.preflight_response = ExportStatus.DEVICE_LOCKED - - def run_preflight_checks(self) -> None: - self.preflight_check_call_success.emit(self.preflight_response) - - def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None: - self.export_usb_call_success.emit(ExportStatus.SUCCESS_EXPORT) - self.export_completed.emit(filepaths) - - def run_printer_preflight(self) -> None: - self.printer_preflight_success.emit(ExportStatus.PRINT_PREFLIGHT_SUCCESS) - - def print(self, filepaths: List[str]) -> None: - self.print_call_success.emit(ExportStatus.PRINT_SUCCESS) - self.export_completed.emit(filepaths) - - @pytest.fixture(scope="function") -def mock_export_service(): - return MockExportService(unlocked=False) +def mock_export(): + device = conversation.ExportDevice(["/mock/file/path"]) -@pytest.fixture(scope="function") -def mock_export_service_unlocked_device(): - return MockExportService(unlocked=True) + device.run_export_preflight_checks = lambda dir: None + device.run_printer_preflight_checks = lambda dir: None + device.export_files = lambda dir, paths, passphrase: None + device.export_transcript = lambda dir, paths, passphrase: None + device.print_file = lambda uuid: None + device.print_transcript = lambda file: None + device.export_file_to_usb_drive = lambda uuid, passphrase: None + device.export_files = lambda filepaths, passphrase: None + return device @pytest.fixture(scope="function") def functional_test_app_started_context(homedir, reply_status_codes, session, config, qtbot): diff --git a/client/tests/functional/test_export_file_dialog.py b/client/tests/functional/test_export_file_dialog.py index 2b078af7cf..60c8601a38 100644 --- a/client/tests/functional/test_export_file_dialog.py +++ b/client/tests/functional/test_export_file_dialog.py @@ -17,10 +17,12 @@ ) -def _setup_export(functional_test_logged_in_context, qtbot, mocker): +def _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export): """ Helper. Set up export test context and return reference to export dialog. """ + mocker.patch("securedrop_client.gui.conversation.export.Device", return_value=mock_export) + gui, controller = functional_test_logged_in_context def check_for_sources(): @@ -66,12 +68,16 @@ def check_for_export_dialog(): @pytest.mark.vcr() -def test_export_file_dialog_locked(functional_test_logged_in_context, qtbot, mocker): +def test_export_file_dialog_locked( + functional_test_logged_in_context, qtbot, mocker, mock_export +): """ Download a file, export it, and verify that the export is complete by checking that the label of the export dialog's continue button is "DONE". """ - export_dialog = _setup_export(functional_test_logged_in_context, qtbot, mocker) + export_dialog = _setup_export( + functional_test_logged_in_context, qtbot, mocker, mock_export + ) assert export_dialog.passphrase_form.isHidden() is True @@ -96,18 +102,14 @@ def check_password_form(): @pytest.mark.vcr() def test_export_file_dialog_device_already_unlocked( - functional_test_logged_in_context, - qtbot, - mocker, + functional_test_logged_in_context, qtbot, mocker, mock_export ): """ Download a file, export it, and verify that the export is complete by checking that the label of the export dialog's continue button is "DONE". """ export_dialog = _setup_export( - functional_test_logged_in_context, - qtbot, - mocker, + functional_test_logged_in_context, qtbot, mocker, mock_export ) def check_skip_password_prompt_for_unlocked_device(): diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index 8520ec8ceb..07206a9ef5 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -160,6 +160,7 @@ def mock_export(mocker): @pytest.fixture(scope="function") def print_dialog(mocker, homedir): + mocker.patch("securedrop_client.gui.conversation.export.Device", return_value=mock_export) app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -181,9 +182,9 @@ def print_dialog(mocker, homedir): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller) gui.setup(controller) gui.login_dialog.close() + export_device = conversation.ExportDevice(["/mock/export/file"]) dialog = conversation.PrintFileDialog(export_device, "file_uuid", "file_name") yield dialog @@ -195,6 +196,7 @@ def print_dialog(mocker, homedir): @pytest.fixture(scope="function") def export_file_dialog(mocker, homedir): + mocker.patch("securedrop_client.gui.conversation.export.Device", return_value=mock_export) app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -213,9 +215,9 @@ def export_file_dialog(mocker, homedir): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller) gui.setup(controller) gui.login_dialog.close() + export_device = conversation.ExportDevice(["/mock/export/filepath"]) dialog = conversation.ExportFileDialog(export_device, "file_uuid", "file_name") dialog.show() From e45b2f183a3bf35687bdc7c0a9555417d9a72cc9 Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 26 Jan 2024 09:30:32 -0500 Subject: [PATCH 05/22] fixup: isort, lint --- .../gui/conversation/export/device.py | 8 +++--- .../gui/conversation/export/file_dialog.py | 2 +- .../gui/conversation/export/print_dialog.py | 2 +- client/tests/conftest.py | 5 ++-- .../functional/test_export_file_dialog.py | 12 +++------ .../gui/conversation/export/test_device.py | 27 +++++++++---------- client/tests/integration/conftest.py | 2 +- 7 files changed, 25 insertions(+), 33 deletions(-) diff --git a/client/securedrop_client/gui/conversation/export/device.py b/client/securedrop_client/gui/conversation/export/device.py index aa37f1af90..bae29311ee 100644 --- a/client/securedrop_client/gui/conversation/export/device.py +++ b/client/securedrop_client/gui/conversation/export/device.py @@ -1,16 +1,16 @@ -from io import BytesIO -import logging import json +import logging import os import subprocess -from shlex import quote import tarfile +from io import BytesIO +from shlex import quote from tempfile import TemporaryDirectory from typing import List from PyQt5.QtCore import QObject, pyqtSignal -from securedrop_client.export_status import ExportStatus, ExportError +from securedrop_client.export_status import ExportError, ExportStatus logger = logging.getLogger(__name__) diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py index 44b042c8bc..931ef2fa77 100644 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ b/client/securedrop_client/gui/conversation/export/file_dialog.py @@ -9,7 +9,7 @@ from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QLineEdit, QVBoxLayout, QWidget -from securedrop_client.export_status import ExportStatus, ExportError +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel from securedrop_client.gui.base.checkbox import SDCheckBox diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index c869565573..a4d74ed423 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import QSize, pyqtSlot -from securedrop_client.export_status import ExportStatus, ExportError +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.base import ModalDialog, SecureQLabel from .device import Device diff --git a/client/tests/conftest.py b/client/tests/conftest.py index 8ddf3cffcc..d3c45c6e46 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -4,7 +4,6 @@ import tempfile from configparser import ConfigParser from datetime import datetime -from typing import List from uuid import uuid4 import pytest @@ -23,7 +22,6 @@ Source, make_session_maker, ) -from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -168,9 +166,9 @@ def homedir(i18n): yield tmpdir + @pytest.fixture(scope="function") def mock_export(): - device = conversation.ExportDevice(["/mock/file/path"]) device.run_export_preflight_checks = lambda dir: None @@ -184,6 +182,7 @@ def mock_export(): return device + @pytest.fixture(scope="function") def functional_test_app_started_context(homedir, reply_status_codes, session, config, qtbot): """ diff --git a/client/tests/functional/test_export_file_dialog.py b/client/tests/functional/test_export_file_dialog.py index 60c8601a38..4872b5fbba 100644 --- a/client/tests/functional/test_export_file_dialog.py +++ b/client/tests/functional/test_export_file_dialog.py @@ -68,16 +68,12 @@ def check_for_export_dialog(): @pytest.mark.vcr() -def test_export_file_dialog_locked( - functional_test_logged_in_context, qtbot, mocker, mock_export -): +def test_export_file_dialog_locked(functional_test_logged_in_context, qtbot, mocker, mock_export): """ Download a file, export it, and verify that the export is complete by checking that the label of the export dialog's continue button is "DONE". """ - export_dialog = _setup_export( - functional_test_logged_in_context, qtbot, mocker, mock_export - ) + export_dialog = _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export) assert export_dialog.passphrase_form.isHidden() is True @@ -108,9 +104,7 @@ def test_export_file_dialog_device_already_unlocked( Download a file, export it, and verify that the export is complete by checking that the label of the export dialog's continue button is "DONE". """ - export_dialog = _setup_export( - functional_test_logged_in_context, qtbot, mocker, mock_export - ) + export_dialog = _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export) def check_skip_password_prompt_for_unlocked_device(): assert export_dialog.passphrase_form.isHidden() is True diff --git a/client/tests/gui/conversation/export/test_device.py b/client/tests/gui/conversation/export/test_device.py index 05cb429a52..e3c01e9eab 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/gui/conversation/export/test_device.py @@ -1,14 +1,15 @@ import os -import pytest -from securedrop_client.export_status import ExportError, ExportStatus - -from securedrop_client.gui.conversation.export import Device import subprocess import tarfile -from tests import factory from tempfile import NamedTemporaryFile, TemporaryDirectory from unittest import mock +import pytest + +from securedrop_client.export_status import ExportError, ExportStatus +from securedrop_client.gui.conversation.export import Device +from tests import factory + _PATH_TO_PRETEND_ARCHIVE = "/tmp/archive-pretend" _QREXEC_EXPORT_COMMAND = [ "qrexec-client-vm", @@ -109,7 +110,7 @@ def test_Device_print_file_file_missing(self, mock_subprocess, mocker): "securedrop_client.gui.conversation.export.device.logger.warning" ) - log_msg = f"File not found at specified filepath, skipping" + log_msg = "File not found at specified filepath, skipping" device.print_file("some-missing-file-uuid") @@ -152,9 +153,7 @@ def test_Device_run_export_preflight_checks(self, mock_subprocess): def test_Device_run_export_preflight_checks_with_error(self, mock_sp): mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") - with mock.patch( - "securedrop_client.gui.conversation.export.device.logger.error" - ) as err, mock.patch.object(self.device, "export_preflight_check_failed") as mock_signal: + with mock.patch("securedrop_client.gui.conversation.export.device.logger.error") as err: self.device.run_export_preflight_checks() assert "Export preflight failed" in err.call_args[0] @@ -338,16 +337,16 @@ def test__create_archive_with_an_export_file(self, mocker): def test__create_archive_with_multiple_export_files(self, mocker): device = Device(self.mock_file_location) archive_path = None - with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file_one, NamedTemporaryFile() as export_file_two: - transcript_path = os.path.join(temp_dir, "transcript.txt") + with TemporaryDirectory() as tmpdir, NamedTemporaryFile() as f1, NamedTemporaryFile() as f2: + transcript_path = os.path.join(tmpdir, "transcript.txt") with open(transcript_path, "a+") as transcript: archive_path = device._create_archive( - temp_dir, + tmpdir, "mock.sd-export", {}, - [export_file_one.name, export_file_two.name, transcript.name], + [f1.name, f2.name, transcript.name], ) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") + assert archive_path == os.path.join(tmpdir, "mock.sd-export") assert os.path.exists(archive_path) # sanity check assert not os.path.exists(archive_path) diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index 07206a9ef5..8fc665843e 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -5,9 +5,9 @@ from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.base import ModalDialog +from securedrop_client.gui.conversation.export import Device from securedrop_client.gui.main import Window from securedrop_client.logic import Controller -from securedrop_client.gui.conversation.export import Device from tests import factory From 392ae690ca22dbdb072d69c6f181c35579ef8c0b Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 26 Jan 2024 12:25:47 -0500 Subject: [PATCH 06/22] Export and print use single method signature across components. Device does not depend on filepaths. --- client/securedrop_client/gui/actions.py | 12 +- .../gui/conversation/export/device.py | 175 +++++++----------- .../gui/conversation/export/dialog.py | 8 +- .../gui/conversation/export/file_dialog.py | 8 +- .../gui/conversation/export/print_dialog.py | 8 +- .../export/print_transcript_dialog.py | 12 +- .../conversation/export/transcript_dialog.py | 10 +- client/securedrop_client/gui/widgets.py | 9 +- client/tests/conftest.py | 18 +- .../gui/conversation/export/test_device.py | 133 ++----------- .../gui/conversation/export/test_dialog.py | 2 +- .../conversation/export/test_file_dialog.py | 8 +- .../conversation/export/test_print_dialog.py | 4 +- .../export/test_transcript_dialog.py | 4 +- client/tests/gui/test_actions.py | 4 +- client/tests/gui/test_widgets.py | 8 +- client/tests/integration/conftest.py | 12 +- 17 files changed, 153 insertions(+), 282 deletions(-) diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index dc822ff02d..4cebcc4d11 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -187,8 +187,10 @@ def _on_triggered(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with open(file_path, "r") as f: - export = ExportDevice([file_path]) - dialog = PrintConversationTranscriptDialog(export, TRANSCRIPT_FILENAME, str(file_path)) + export = ExportDevice() + dialog = PrintConversationTranscriptDialog( + export, TRANSCRIPT_FILENAME, [str(file_path)] + ) dialog.exec() @@ -236,9 +238,9 @@ def _on_triggered(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with open(file_path, "r") as f: - export_device = ExportDevice([file_path]) + export_device = ExportDevice() dialog = ExportConversationTranscriptDialog( - export_device, TRANSCRIPT_FILENAME, str(file_path) + export_device, TRANSCRIPT_FILENAME, [str(file_path)] ) dialog.exec() @@ -325,7 +327,7 @@ def _prepare_to_export(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with ExitStack() as stack: - export_device = ExportDevice(file_locations) + export_device = ExportDevice() files = [ stack.enter_context(open(file_location, "r")) for file_location in file_locations ] diff --git a/client/securedrop_client/gui/conversation/export/device.py b/client/securedrop_client/gui/conversation/export/device.py index bae29311ee..0879ad0ca6 100644 --- a/client/securedrop_client/gui/conversation/export/device.py +++ b/client/securedrop_client/gui/conversation/export/device.py @@ -6,7 +6,7 @@ from io import BytesIO from shlex import quote from tempfile import TemporaryDirectory -from typing import List +from typing import List, Optional from PyQt5.QtCore import QObject, pyqtSignal @@ -48,6 +48,9 @@ class Device(QObject): print_preflight_check_succeeded = pyqtSignal(object) print_succeeded = pyqtSignal(object) + # Used for both print and export + export_completed = pyqtSignal(object) + # Emit ExportError(status=ExportStatus) export_preflight_check_failed = pyqtSignal(object) export_failed = pyqtSignal(object) @@ -55,21 +58,20 @@ class Device(QObject): print_preflight_check_failed = pyqtSignal(object) print_failed = pyqtSignal(object) - def __init__(self, filepaths: [str]) -> None: - super().__init__() - - self._filepaths_list = filepaths - def run_printer_preflight_checks(self) -> None: """ Make sure the Export VM is started. """ - logger.info("Running printer preflight check") + logger.info("Beginning printer preflight check") try: - status = self._build_archive_and_export( - metadata=self._PRINTER_PREFLIGHT_METADATA, filename=self._PRINTER_PREFLIGHT_FN - ) - self.print_preflight_check_succeeded.emit(status) + with TemporaryDirectory() as tmp_dir: + archive_path = self._create_archive( + archive_dir=tmp_dir, + archive_fn=self._PRINTER_PREFLIGHT_FN, + metadata=self._PRINTER_PREFLIGHT_METADATA, + ) + status = self._run_qrexec_export(archive_path) + self.print_preflight_check_succeeded.emit(status) except ExportError as e: logger.error("Print preflight failed") logger.debug(f"Print preflight failed: {e}") @@ -81,50 +83,77 @@ def run_export_preflight_checks(self) -> None: """ try: logger.debug("Beginning export preflight check") - status = self._build_archive_and_export( - metadata=self._USB_TEST_METADATA, filename=self._USB_TEST_FN - ) - self.export_preflight_check_succeeded.emit(status) + + with TemporaryDirectory() as tmp_dir: + archive_path = self._create_archive( + archive_dir=tmp_dir, + archive_fn=self._USB_TEST_FN, + metadata=self._USB_TEST_METADATA, + ) + status = self._run_qrexec_export(archive_path) + self.export_preflight_check_succeeded.emit(status) + except ExportError as e: logger.error("Export preflight failed") self.export_preflight_check_failed.emit(e) - def export_transcript(self, file_location: str, passphrase: str) -> None: + def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: """ - Send the transcript specified by file_location to the Export VM. + Bundle filepaths into a tarball and send to encrypted USB via qrexec, + optionally supplying a passphrase to unlock encrypted drives. """ - logger.debug("Export transcript") - self._send_file_to_usb_device([file_location], passphrase) + try: + logger.debug(f"Begin exporting {len(filepaths)} item(s)") - def export_files(self, file_locations: List[str], passphrase: str) -> None: - """ - Send the files specified by file_locations to the Export VM. - """ - logger.debug(f"Export {len(file_locations)} files") - self._send_file_to_usb_device(file_locations, passphrase) + # Edit metadata template to include passphrase + metadata = self._DISK_METADATA.copy() + if passphrase: + metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase - def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: - """ - Send the file specified by file_uuid to the Export VM with the user-provided passphrase for - unlocking the attached transfer device. If the file is missing, update the db so that - is_downloaded is set to False. - """ + with TemporaryDirectory() as tmp_dir: + archive_path = self._create_archive( + archive_dir=tmp_dir, + archive_fn=self._DISK_FN, + metadata=metadata, + filepaths=filepaths, + ) + status = self._run_qrexec_export(archive_path) - self._send_file_to_usb_device(self._filepaths_list, passphrase) + self.export_succeeded.emit(status) + logger.debug(f"Status {status}") - def print_transcript(self, file_location: str) -> None: - """ - Send the transcript specified by file_location to the Export VM. - """ - self._print([file_location]) + except ExportError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.export_failed.emit(e) + + self.export_completed.emit(filepaths) - def print_file(self, file_uuid: str) -> None: + def print(self, filepaths: List[str]) -> None: """ - Send the file specified by file_uuid to the Export VM. If the file is missing, update the db - so that is_downloaded is set to False. + Bundle files at self._filepaths_list into tarball and send for + printing via qrexec. """ + try: + logger.debug("Beginning print") + + with TemporaryDirectory() as tmp_dir: + archive_path = self._create_archive( + archive_dir=tmp_dir, + archive_fn=self._PRINT_FN, + metadata=self._PRINT_METADATA, + filepaths=filepaths, + ) + status = self._run_qrexec_export(archive_path) + self.print_succeeded.emit(status) + logger.debug(f"Status {status}") - self._print(self._filepaths_list) + except ExportError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.print_failed.emit(e) + + self.export_completed.emit(filepaths) def _run_qrexec_export(self, archive_path: str) -> ExportStatus: """ @@ -174,7 +203,7 @@ def _run_qrexec_export(self, archive_path: str) -> ExportStatus: raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) def _create_archive( - self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] + self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] ) -> str: """ Create the archive to be sent to the Export VM. @@ -210,7 +239,7 @@ def _create_archive( self._add_file_to_archive( archive, filepath, prevent_name_collisions=is_one_of_multiple_files ) - if missing_count == len(filepaths): + if missing_count == len(filepaths) and missing_count > 0: # Context manager will delete archive even if an exception occurs # since the archive is in a TemporaryDirectory logger.error("Files were moved or missing") @@ -258,63 +287,3 @@ def _add_file_to_archive( arcname = os.path.join("export_data", parent_name, filename) archive.add(filepath, arcname=arcname, recursive=False) - - def _build_archive_and_export( - self, metadata: dict, filename: str, filepaths: List[str] = [] - ) -> ExportStatus: - """ - Build archive, run qrexec command and return resulting ExportStatus. - - ExportError may be raised during underlying _run_qrexec_export call, - and is handled by the calling method. - """ - with TemporaryDirectory() as tmp_dir: - archive_path = self._create_archive( - archive_dir=tmp_dir, archive_fn=filename, metadata=metadata, filepaths=filepaths - ) - return self._run_qrexec_export(archive_path) - - def _send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None: - """ - Export the file to the luks-encrypted usb disk drive attached to the Export VM. - - Args: - filepath: The path of file to export. - passphrase: The passphrase to unlock the luks-encrypted usb disk drive. - """ - try: - logger.debug("beginning export") - # Edit metadata template to include passphrase - metadata = self._DISK_METADATA.copy() - metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase - status = self._build_archive_and_export( - metadata=metadata, filename=self._DISK_FN, filepaths=filepaths - ) - - self.export_succeeded.emit(status) - logger.debug(f"Status {status}") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.export_failed.emit(e) - - def _print(self, filepaths: List[str]) -> None: - """ - Print the file to the printer attached to the Export VM. - - Args: - filepath: The path of file to export. - """ - try: - logger.debug("beginning print") - status = self._build_archive_and_export( - metadata=self._PRINT_METADATA, filename=self._PRINT_FN, filepaths=filepaths - ) - self.print_succeeded.emit(status) - logger.debug(f"Status {status}") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.print_failed.emit(e) - - self.export_succeeded.emit(filepaths) diff --git a/client/securedrop_client/gui/conversation/export/dialog.py b/client/securedrop_client/gui/conversation/export/dialog.py index c71ebe2d84..6b184bf994 100644 --- a/client/securedrop_client/gui/conversation/export/dialog.py +++ b/client/securedrop_client/gui/conversation/export/dialog.py @@ -15,17 +15,17 @@ class Dialog(FileDialog): - Overrides the two slots that handles the export action to call said method. """ - def __init__(self, device: Device, summary: str, file_locations: List[str]) -> None: - super().__init__(device, "", summary) + def __init__(self, device: Device, summary: str, filepaths: List[str]) -> None: + super().__init__(device, summary, filepaths) - self.file_locations = file_locations + self.filepaths = filepaths @pyqtSlot(bool) def _export_files(self, checked: bool = False) -> None: self.start_animate_activestate() self.cancel_button.setEnabled(False) self.passphrase_field.setDisabled(True) - self._device.export_files(self.file_locations, self.passphrase_field.text()) + self._device.export(self.filepaths, self.passphrase_field.text()) @pyqtSlot() def _show_passphrase_request_message(self) -> None: diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py index 931ef2fa77..bc9c2f43aa 100644 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ b/client/securedrop_client/gui/conversation/export/file_dialog.py @@ -2,7 +2,7 @@ A dialog that allows journalists to export sensitive files to a USB drive. """ from gettext import gettext as _ -from typing import Optional +from typing import List, Optional from pkg_resources import resource_string from PyQt5.QtCore import QSize, Qt, pyqtSlot @@ -23,12 +23,12 @@ class FileDialog(ModalDialog): NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: + def __init__(self, device: Device, file_name: str, filepaths: List[str]) -> None: super().__init__() self.setStyleSheet(self.DIALOG_CSS) self._device = device - self.file_uuid = file_uuid + self.filepaths = filepaths self.file_name = SecureQLabel( file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() @@ -215,7 +215,7 @@ def _export_file(self, checked: bool = False) -> None: # TODO: If the drive is already unlocked, the passphrase field will be empty. # This is ok, but could violate expectations. The password should be passed # via qrexec in future, to avoid writing it to even a temporary file at all. - self._device.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) + self._device.export(self.filepaths, self.passphrase_field.text()) @pyqtSlot(object) def _on_export_preflight_check_succeeded(self, result: ExportStatus) -> None: diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index a4d74ed423..889a19c0d7 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -1,5 +1,5 @@ from gettext import gettext as _ -from typing import Optional +from typing import List, Optional from PyQt5.QtCore import QSize, pyqtSlot @@ -12,11 +12,11 @@ class PrintDialog(ModalDialog): FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: + def __init__(self, device: Device, file_name: str, filepaths: List[str]) -> None: super().__init__() self._device = device - self.file_uuid = file_uuid + self.filepaths = filepaths self.file_name = SecureQLabel( file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() @@ -94,7 +94,7 @@ def _run_preflight(self) -> None: @pyqtSlot() def _print_file(self) -> None: - self._device.print_file(self.file_uuid) + self._device.print(self.filepaths) self.close() @pyqtSlot() diff --git a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py index 9f47735ce3..4eeb4d52cd 100644 --- a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py @@ -1,3 +1,5 @@ +from typing import List + from PyQt5.QtCore import QSize, pyqtSlot from securedrop_client.gui.conversation.export import PrintDialog @@ -13,13 +15,15 @@ class PrintTranscriptDialog(PrintDialog): - Overrides the slot that handles the printing action to call said method. """ - def __init__(self, device: Device, file_name: str, transcript_location: str) -> None: - super().__init__(device, "", file_name) + def __init__(self, device: Device, file_name: str, filepath: List[str]) -> None: + super().__init__(device, file_name, filepath) - self.transcript_location = transcript_location + # List might seem like an odd choice for this, but this is on the + # way to standardizing one export/print dialog that can send multiple items + self.transcript_location = filepath def _print_transcript(self) -> None: - self._device.print_transcript(self.transcript_location) + self._device.print(self.transcript_location) self.close() @pyqtSlot() diff --git a/client/securedrop_client/gui/conversation/export/transcript_dialog.py b/client/securedrop_client/gui/conversation/export/transcript_dialog.py index 3318197076..e1f573dabe 100644 --- a/client/securedrop_client/gui/conversation/export/transcript_dialog.py +++ b/client/securedrop_client/gui/conversation/export/transcript_dialog.py @@ -2,6 +2,7 @@ A dialog that allows journalists to export sensitive files to a USB drive. """ from gettext import gettext as _ +from typing import List from PyQt5.QtCore import pyqtSlot @@ -17,16 +18,17 @@ class TranscriptDialog(FileDialog): - Overrides the two slots that handles the export action to call said method. """ - def __init__(self, device: Device, file_name: str, transcript_location: str) -> None: - super().__init__(device, "", file_name) + def __init__(self, device: Device, file_name: str, filepath: List[str]) -> None: + super().__init__(device, file_name, filepath) - self.transcript_location = transcript_location + # List[str] to foreshadow multifile export and combining all export dialogs + self.transcript_location = filepath def _export_transcript(self, checked: bool = False) -> None: self.start_animate_activestate() self.cancel_button.setEnabled(False) self.passphrase_field.setDisabled(True) - self._device.export_transcript(self.transcript_location, self.passphrase_field.text()) + self._device.export(self.transcript_location, self.passphrase_field.text()) @pyqtSlot() def _show_passphrase_request_message(self) -> None: diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index a4930ac7ff..7330b2ffe1 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -2459,10 +2459,10 @@ def _on_export_clicked(self) -> None: logger.debug("Clicked export but file not downloaded") return - export_device = conversation.ExportDevice([file_location]) + export_device = conversation.ExportDevice() self.export_dialog = conversation.ExportFileDialog( - export_device, self.uuid, self.file.filename + export_device, self.file.filename, [file_location] ) self.export_dialog.show() @@ -2476,9 +2476,10 @@ def _on_print_clicked(self) -> None: return filepath = self.file.location(self.controller.data_dir) - export_device = conversation.ExportDevice([filepath]) - dialog = conversation.PrintFileDialog(export_device, self.uuid, self.file.filename) + export_device = conversation.ExportDevice() + + dialog = conversation.PrintFileDialog(export_device, self.file.filename, [filepath]) dialog.exec() def _on_left_click(self) -> None: diff --git a/client/tests/conftest.py b/client/tests/conftest.py index d3c45c6e46..a5935b943f 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -78,7 +78,7 @@ def print_dialog(mocker, homedir): export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.PrintFileDialog(export_device, "file_UUID", "file123.jpg") + dialog = conversation.PrintFileDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @@ -90,7 +90,7 @@ def print_transcript_dialog(mocker, homedir): export_device = mocker.MagicMock(spec=conversation.ExportDevice) dialog = conversation.PrintTranscriptDialog( - export_device, "transcript.txt", "some/path/transcript.txt" + export_device, "transcript.txt", ["some/path/transcript.txt"] ) yield dialog @@ -117,7 +117,7 @@ def export_file_dialog(mocker, homedir): export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.ExportFileDialog(export_device, "file_UUID", "file123.jpg") + dialog = conversation.ExportFileDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @@ -129,7 +129,7 @@ def export_transcript_dialog(mocker, homedir): export_device = mocker.MagicMock(spec=conversation.ExportDevice) dialog = conversation.ExportTranscriptDialog( - export_device, "transcript.txt", "/some/path/transcript.txt" + export_device, "transcript.txt", ["/some/path/transcript.txt"] ) yield dialog @@ -169,16 +169,12 @@ def homedir(i18n): @pytest.fixture(scope="function") def mock_export(): - device = conversation.ExportDevice(["/mock/file/path"]) + device = conversation.ExportDevice() device.run_export_preflight_checks = lambda dir: None device.run_printer_preflight_checks = lambda dir: None - device.export_files = lambda dir, paths, passphrase: None - device.export_transcript = lambda dir, paths, passphrase: None - device.print_file = lambda uuid: None - device.print_transcript = lambda file: None - device.export_file_to_usb_drive = lambda uuid, passphrase: None - device.export_files = lambda filepaths, passphrase: None + device.print = lambda filepaths: None + device.export = lambda filepaths, passphrase: None return device diff --git a/client/tests/gui/conversation/export/test_device.py b/client/tests/gui/conversation/export/test_device.py index e3c01e9eab..a3f97cbadf 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/gui/conversation/export/test_device.py @@ -35,7 +35,7 @@ def setup_class(cls): def setup_method(cls): cls.mock_file = factory.File(source=factory.Source()) cls.mock_file_location = f"{_MOCK_FILEDIR}{cls.mock_file.filename}" - cls.device = Device([cls.mock_file_location]) + cls.device = Device() cls.device._create_archive = mock.MagicMock() cls.device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE cls.mock_tmpdir = mock.MagicMock() @@ -47,7 +47,7 @@ def teardown_method(cls): cls.device._create_archive = None def test_Device_run_printer_preflight_checks(self, mock_subprocess): - device = Device(["/fake/file/name"]) + device = Device() device._create_archive = mock.MagicMock() device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE @@ -70,30 +70,12 @@ def test_Device_run_print_preflight_checks_with_error(self, mock_sp): assert "Print preflight failed" in err.call_args[0] - def test_Device_run_print_file(self, mock_subprocess): + def test_Device_print(self, mock_subprocess): with mock.patch( "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - self.device.print_file(self.mock_file.uuid) - - self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_FILEDIR, - archive_fn=self.device._PRINT_FN, - metadata=self.device._PRINT_METADATA, - filepaths=[self.mock_file_location], - ) - mock_subprocess.assert_called_once() - assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - - def test_Device_print_transcript(self, mock_subprocess): - with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", - return_value=self.mock_tmpdir, - ): - self.device.print_transcript(self.mock_file_location) - - mock_subprocess.assert_called_once() + self.device.print([self.mock_file_location]) self.device._create_archive.assert_called_once_with( archive_dir=_MOCK_FILEDIR, @@ -105,34 +87,18 @@ def test_Device_print_transcript(self, mock_subprocess): assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] def test_Device_print_file_file_missing(self, mock_subprocess, mocker): - device = Device(["/no/such/file"]) + device = Device() warning_logger = mocker.patch( "securedrop_client.gui.conversation.export.device.logger.warning" ) log_msg = "File not found at specified filepath, skipping" - device.print_file("some-missing-file-uuid") + device.print("some-missing-file-uuid") assert log_msg in warning_logger.call_args[0] mock_subprocess.assert_not_called() - def test_Device_print_file_when_orig_file_already_exists(self, mock_subprocess): - with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", - return_value=self.mock_tmpdir, - ): - self.device.print_file(self.mock_file.uuid) - - mock_subprocess.assert_called_once() - self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_FILEDIR, - archive_fn=self.device._PRINT_FN, - metadata=self.device._PRINT_METADATA, - filepaths=[self.mock_file_location], - ) - assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - def test_Device_run_export_preflight_checks(self, mock_subprocess): with mock.patch( "securedrop_client.gui.conversation.export.device.TemporaryDirectory", @@ -145,7 +111,6 @@ def test_Device_run_export_preflight_checks(self, mock_subprocess): archive_dir=_MOCK_FILEDIR, archive_fn=self.device._USB_TEST_FN, metadata=self.device._USB_TEST_METADATA, - filepaths=[], ) mock_subprocess.assert_called_once() assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] @@ -158,30 +123,8 @@ def test_Device_run_export_preflight_checks_with_error(self, mock_sp): assert "Export preflight failed" in err.call_args[0] - def test_Device_export_file_to_usb_drive(self, mock_subprocess): - file = self.mock_file - passphrase = "mock passphrase" - - with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", - return_value=self.mock_tmpdir, - ): - self.device.export_file_to_usb_drive(file.uuid, passphrase) - mock_subprocess.assert_called_once() - - expected_md = self.device._DISK_METADATA.copy() - expected_md[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase - - self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_FILEDIR, - archive_fn=self.device._DISK_FN, - metadata=expected_md, - filepaths=[self.mock_file_location], - ) - - def test_Device_export_file_to_usb_drive_file_missing(self, mock_subprocess, mocker): - file = factory.File() # Not a real file, so not anywhere - device = Device(["/fake/file/location"]) + def test_Device_export_file_missing(self, mock_subprocess, mocker): + device = Device() warning_logger = mocker.patch( "securedrop_client.gui.conversation.export.device.logger.warning" @@ -193,37 +136,13 @@ def test_Device_export_file_to_usb_drive_file_missing(self, mock_subprocess, moc "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - device.export_file_to_usb_drive(file.uuid, "mock passphrase") + device.export(["/not/a/real/location"], "mock passphrase") warning_logger.assert_called_once() mock_subprocess.assert_not_called() # Todo: could get more specific about looking for the emitted failure signal - def test_Device_export_file_to_usb_drive_when_orig_file_already_exists(self, mock_subprocess): - file = self.mock_file - passphrase = "mock passphrase" - - with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", - return_value=self.mock_tmpdir, - ): - self.device.export_file_to_usb_drive(file.uuid, passphrase) - - expected_metadata = self.device._DISK_METADATA.copy() - expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase - expected_filepath = self.mock_file_location - - self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_FILEDIR, - archive_fn=self.device._DISK_FN, - metadata=expected_metadata, - filepaths=[expected_filepath], - ) - - mock_subprocess.assert_called_once() - assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - - def test_Device_export_transcript(self, mock_subprocess): + def test_Device_export(self, mock_subprocess): filepath = "some/file/path" passphrase = "passphrase" @@ -231,7 +150,7 @@ def test_Device_export_transcript(self, mock_subprocess): "securedrop_client.gui.conversation.export.device.TemporaryDirectory", return_value=self.mock_tmpdir, ): - self.device.export_transcript(filepath, passphrase) + self.device.export([filepath], passphrase) expected_metadata = self.device._DISK_METADATA.copy() expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase @@ -245,28 +164,6 @@ def test_Device_export_transcript(self, mock_subprocess): mock_subprocess.assert_called_once() assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - def test_Device_export_files(self, mock_subprocess): - filepaths = ["some/file/path", "some/other/file/path"] - passphrase = "Correct-horse-battery-staple!" - - expected_metadata = self.device._DISK_METADATA.copy() - expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase - - with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", - return_value=self.mock_tmpdir, - ): - self.device.export_files(filepaths, passphrase) - - self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_FILEDIR, - archive_fn=self.device._DISK_FN, - metadata=expected_metadata, - filepaths=filepaths, - ) - mock_subprocess.assert_called_once() - assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - @pytest.mark.parametrize("status", [i.value for i in ExportStatus]) def test__run_qrexec_success(self, mocked_subprocess, status): mocked_subprocess.return_value = f"{status}\n".encode("utf-8") @@ -311,7 +208,7 @@ def test__create_archive(self, mocker): open(os.path.join(temp_dir, "temp_1"), "w+").close() open(os.path.join(temp_dir, "temp_2"), "w+").close() filepaths = [os.path.join(temp_dir, "temp_1"), os.path.join(temp_dir, "temp_2")] - device = Device(filepaths) + device = Device() archive_path = device._create_archive(temp_dir, "mock.sd-export", {}, filepaths) @@ -321,9 +218,7 @@ def test__create_archive(self, mocker): assert not os.path.exists(archive_path) def test__create_archive_with_an_export_file(self, mocker): - device = Device( - self.mock_file_location - ) # TODO might not work - might want the tmpdir below + device = Device() archive_path = None with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: archive_path = device._create_archive( @@ -335,7 +230,7 @@ def test__create_archive_with_an_export_file(self, mocker): assert not os.path.exists(archive_path) def test__create_archive_with_multiple_export_files(self, mocker): - device = Device(self.mock_file_location) + device = Device() archive_path = None with TemporaryDirectory() as tmpdir, NamedTemporaryFile() as f1, NamedTemporaryFile() as f2: transcript_path = os.path.join(tmpdir, "transcript.txt") diff --git a/client/tests/gui/conversation/export/test_dialog.py b/client/tests/gui/conversation/export/test_dialog.py index adceb44c57..35a9d19a3a 100644 --- a/client/tests/gui/conversation/export/test_dialog.py +++ b/client/tests/gui/conversation/export/test_dialog.py @@ -161,7 +161,7 @@ def test_ExportDialog__export_files(mocker, export_dialog): export_dialog._export_files() - device.export_files.assert_called_once_with( + device.export.assert_called_once_with( ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"], "mock_passphrase", ) diff --git a/client/tests/gui/conversation/export/test_file_dialog.py b/client/tests/gui/conversation/export/test_file_dialog.py index 36c927fdd0..4608476f67 100644 --- a/client/tests/gui/conversation/export/test_file_dialog.py +++ b/client/tests/gui/conversation/export/test_file_dialog.py @@ -8,7 +8,7 @@ def test_ExportDialog_init(mocker): "securedrop_client.gui.conversation.ExportFileDialog._show_starting_instructions" ) - export_file_dialog = ExportFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") + export_file_dialog = ExportFileDialog(mocker.MagicMock(), "mock.jpg", ["/mock/path/to/file"]) _show_starting_instructions_fn.assert_called_once_with() assert export_file_dialog.passphrase_form.isHidden() @@ -21,7 +21,7 @@ def test_ExportDialog_init_sanitizes_filename(mocker): mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") filename = '' - ExportFileDialog(mocker.MagicMock(), "mock_uuid", filename) + ExportFileDialog(mocker.MagicMock(), filename, ["/mock/path/to/file"]) secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) @@ -171,9 +171,7 @@ def test_ExportDialog__export_file(mocker, export_file_dialog): export_file_dialog._export_file() - device.export_file_to_usb_drive.assert_called_once_with( - export_file_dialog.file_uuid, "mock_passphrase" - ) + device.export.assert_called_once_with(export_file_dialog.filepaths, "mock_passphrase") def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_file_dialog): diff --git a/client/tests/gui/conversation/export/test_print_dialog.py b/client/tests/gui/conversation/export/test_print_dialog.py index fdb37ff8b4..0bd4836f8e 100644 --- a/client/tests/gui/conversation/export/test_print_dialog.py +++ b/client/tests/gui/conversation/export/test_print_dialog.py @@ -8,7 +8,7 @@ def test_PrintFileDialog_init(mocker): "securedrop_client.gui.conversation.PrintFileDialog._show_starting_instructions" ) - PrintFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") + PrintFileDialog(mocker.MagicMock(), "mock.jpg", ["/mock/path/to/file"]) _show_starting_instructions_fn.assert_called_once_with() @@ -19,7 +19,7 @@ def test_PrintFileDialog_init_sanitizes_filename(mocker): ) filename = '' - PrintFileDialog(mocker.MagicMock(), "mock_uuid", filename) + PrintFileDialog(mocker.MagicMock(), filename, ["/mock/path/to/file"]) secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) diff --git a/client/tests/gui/conversation/export/test_transcript_dialog.py b/client/tests/gui/conversation/export/test_transcript_dialog.py index 380806ec56..27e77053b5 100644 --- a/client/tests/gui/conversation/export/test_transcript_dialog.py +++ b/client/tests/gui/conversation/export/test_transcript_dialog.py @@ -171,7 +171,7 @@ def test_TranscriptDialog__show_generic_error_message(mocker, export_transcript_ def test_TranscriptDialog__export_transcript(mocker, export_transcript_dialog): device = mocker.MagicMock() - device.export_transcript = mocker.MagicMock() + device.export = mocker.MagicMock() export_transcript_dialog._device = device export_transcript_dialog.passphrase_field.text = mocker.MagicMock( return_value="mock_passphrase" @@ -179,7 +179,7 @@ def test_TranscriptDialog__export_transcript(mocker, export_transcript_dialog): export_transcript_dialog._export_transcript() - device.export_transcript.assert_called_once_with("/some/path/transcript.txt", "mock_passphrase") + device.export.assert_called_once_with(["/some/path/transcript.txt"], "mock_passphrase") def test_TranscriptDialog__on_export_preflight_check_succeeded(mocker, export_transcript_dialog): diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py index e4a97f4848..1354595607 100644 --- a/client/tests/gui/test_actions.py +++ b/client/tests/gui/test_actions.py @@ -279,7 +279,7 @@ def test_trigger(self, _): # action._export_device.run_printer_preflight_checks = ( # lambda: action._export_device.print_preflight_check_succeeded.emit() # ) - # action._export_device.print_transcript = ( + # action._export_device.print = ( # lambda transcript: action._export_device.print_succeeded.emit() # ) @@ -342,7 +342,7 @@ def test_trigger(self, _): # action._export_device.run_printer_preflight_checks = ( # lambda: action._export_device.print_preflight_check_succeeded.emit() # ) - # action._export_device.print_transcript = ( + # action._export_device.print = ( # lambda transcript: action._export_device.print_succeeded.emit() # ) diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index 11b4234bce..c0495d4b72 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3587,6 +3587,7 @@ def test_FileWidget__on_export_clicked(mocker, session, source): get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) + file_location = file.location(controller.data_dir) export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice") fw = FileWidget( @@ -3600,7 +3601,7 @@ def test_FileWidget__on_export_clicked(mocker, session, source): dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog") fw._on_export_clicked() - dialog.assert_called_once_with(export_device(), file.uuid, file.filename) + dialog.assert_called_once_with(export_device(), file.filename, [file_location]) def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): @@ -3637,7 +3638,7 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): def test_FileWidget__on_print_clicked(mocker, session, source): """ - Ensure print_file is called when the PRINT button is clicked + Ensure print() is called when the PRINT button is clicked """ file = factory.File(source=source["source"], is_downloaded=True) session.add(file) @@ -3646,6 +3647,7 @@ def test_FileWidget__on_print_clicked(mocker, session, source): get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice") + file_location = file.location(controller.data_dir) fw = FileWidget( file.uuid, @@ -3665,7 +3667,7 @@ def test_FileWidget__on_print_clicked(mocker, session, source): fw._on_print_clicked() - dialog.assert_called_once_with(export_device(), file.uuid, file.filename) + dialog.assert_called_once_with(export_device(), file.filename, [file_location]) def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index 8fc665843e..76796c2a6b 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -148,7 +148,7 @@ def modal_dialog(mocker, homedir): @pytest.fixture(scope="function") def mock_export(mocker): - device = Device(["/tmp/imaginary-export/imaginary-file-1.tar.gz.gpg"]) + device = Device() """A export that assumes the Qubes RPC calls are successful and skips them.""" device.run_preflight_checks = lambda: ExportStatus.DEVICE_LOCKED @@ -184,8 +184,8 @@ def print_dialog(mocker, homedir): controller.qubes = False gui.setup(controller) gui.login_dialog.close() - export_device = conversation.ExportDevice(["/mock/export/file"]) - dialog = conversation.PrintFileDialog(export_device, "file_uuid", "file_name") + export_device = conversation.ExportDevice() + dialog = conversation.PrintFileDialog(export_device, "file_name", ["/mock/export/file"]) yield dialog @@ -217,8 +217,10 @@ def export_file_dialog(mocker, homedir): controller.qubes = False gui.setup(controller) gui.login_dialog.close() - export_device = conversation.ExportDevice(["/mock/export/filepath"]) - dialog = conversation.ExportFileDialog(export_device, "file_uuid", "file_name") + export_device = conversation.ExportDevice() + dialog = conversation.ExportFileDialog( + export_device, "file_name", ["/mock/export/filepath"] + ) dialog.show() yield dialog From 1f9bd891f2e2bb1d7e972baa569eba62dba9f549 Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 26 Jan 2024 15:20:32 -0500 Subject: [PATCH 07/22] move device.py -> export.py --- .../export/device.py => export.py} | 2 +- .../gui/conversation/__init__.py | 2 +- .../gui/conversation/export/__init__.py | 2 +- .../gui/conversation/export/dialog.py | 4 +- .../gui/conversation/export/file_dialog.py | 4 +- .../gui/conversation/export/print_dialog.py | 4 +- .../export/print_transcript_dialog.py | 4 +- .../conversation/export/transcript_dialog.py | 4 +- .../gui/conversation/export/test_device.py | 38 +++++++++---------- client/tests/integration/conftest.py | 4 +- 10 files changed, 34 insertions(+), 34 deletions(-) rename client/securedrop_client/{gui/conversation/export/device.py => export.py} (99%) diff --git a/client/securedrop_client/gui/conversation/export/device.py b/client/securedrop_client/export.py similarity index 99% rename from client/securedrop_client/gui/conversation/export/device.py rename to client/securedrop_client/export.py index 0879ad0ca6..59ae3f2f89 100644 --- a/client/securedrop_client/gui/conversation/export/device.py +++ b/client/securedrop_client/export.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) -class Device(QObject): +class Export(QObject): """ Interface for sending files to Export VM for transfer to a disk drive or printed by a USB-connected printer. diff --git a/client/securedrop_client/gui/conversation/__init__.py b/client/securedrop_client/gui/conversation/__init__.py index 29142e98dc..9f649e2425 100644 --- a/client/securedrop_client/gui/conversation/__init__.py +++ b/client/securedrop_client/gui/conversation/__init__.py @@ -3,7 +3,7 @@ """ # Import classes here to make possible to import them from securedrop_client.gui.conversation from .delete import DeleteConversationDialog # noqa: F401 -from .export import Device as ExportDevice # noqa: F401 +from .export import Export as ExportDevice # noqa: F401 from .export import Dialog as ExportDialog # noqa: F401 from .export import FileDialog as ExportFileDialog # noqa: F401 from .export import PrintDialog as PrintFileDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/__init__.py b/client/securedrop_client/gui/conversation/export/__init__.py index 7da54e94cc..00830c85e7 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,4 +1,4 @@ -from .device import Device # noqa: F401 +from ....export import Export # noqa: F401 from .dialog import Dialog # noqa: F401 from .file_dialog import FileDialog # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/dialog.py b/client/securedrop_client/gui/conversation/export/dialog.py index 6b184bf994..a67512415d 100644 --- a/client/securedrop_client/gui/conversation/export/dialog.py +++ b/client/securedrop_client/gui/conversation/export/dialog.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import pyqtSlot -from .device import Device +from ....export import Export from .file_dialog import FileDialog @@ -15,7 +15,7 @@ class Dialog(FileDialog): - Overrides the two slots that handles the export action to call said method. """ - def __init__(self, device: Device, summary: str, filepaths: List[str]) -> None: + def __init__(self, device: Export, summary: str, filepaths: List[str]) -> None: super().__init__(device, summary, filepaths) self.filepaths = filepaths diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py index bc9c2f43aa..fc491d00f3 100644 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ b/client/securedrop_client/gui/conversation/export/file_dialog.py @@ -13,7 +13,7 @@ from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel from securedrop_client.gui.base.checkbox import SDCheckBox -from .device import Device +from ....export import Export class FileDialog(ModalDialog): @@ -23,7 +23,7 @@ class FileDialog(ModalDialog): NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, file_name: str, filepaths: List[str]) -> None: + def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None: super().__init__() self.setStyleSheet(self.DIALOG_CSS) diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index 889a19c0d7..40eaa7c887 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -6,13 +6,13 @@ from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.base import ModalDialog, SecureQLabel -from .device import Device +from ....export import Export class PrintDialog(ModalDialog): FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, file_name: str, filepaths: List[str]) -> None: + def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None: super().__init__() self._device = device diff --git a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py index 4eeb4d52cd..b6508fa06f 100644 --- a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py @@ -4,7 +4,7 @@ from securedrop_client.gui.conversation.export import PrintDialog -from .device import Device +from ....export import Export class PrintTranscriptDialog(PrintDialog): @@ -15,7 +15,7 @@ class PrintTranscriptDialog(PrintDialog): - Overrides the slot that handles the printing action to call said method. """ - def __init__(self, device: Device, file_name: str, filepath: List[str]) -> None: + def __init__(self, device: Export, file_name: str, filepath: List[str]) -> None: super().__init__(device, file_name, filepath) # List might seem like an odd choice for this, but this is on the diff --git a/client/securedrop_client/gui/conversation/export/transcript_dialog.py b/client/securedrop_client/gui/conversation/export/transcript_dialog.py index e1f573dabe..4939e67119 100644 --- a/client/securedrop_client/gui/conversation/export/transcript_dialog.py +++ b/client/securedrop_client/gui/conversation/export/transcript_dialog.py @@ -6,7 +6,7 @@ from PyQt5.QtCore import pyqtSlot -from .device import Device +from ....export import Export from .file_dialog import FileDialog @@ -18,7 +18,7 @@ class TranscriptDialog(FileDialog): - Overrides the two slots that handles the export action to call said method. """ - def __init__(self, device: Device, file_name: str, filepath: List[str]) -> None: + def __init__(self, device: Export, file_name: str, filepath: List[str]) -> None: super().__init__(device, file_name, filepath) # List[str] to foreshadow multifile export and combining all export dialogs diff --git a/client/tests/gui/conversation/export/test_device.py b/client/tests/gui/conversation/export/test_device.py index a3f97cbadf..1509fa3ad7 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/gui/conversation/export/test_device.py @@ -7,7 +7,7 @@ import pytest from securedrop_client.export_status import ExportError, ExportStatus -from securedrop_client.gui.conversation.export import Device +from securedrop_client.gui.conversation.export import Export from tests import factory _PATH_TO_PRETEND_ARCHIVE = "/tmp/archive-pretend" @@ -35,7 +35,7 @@ def setup_class(cls): def setup_method(cls): cls.mock_file = factory.File(source=factory.Source()) cls.mock_file_location = f"{_MOCK_FILEDIR}{cls.mock_file.filename}" - cls.device = Device() + cls.device = Export() cls.device._create_archive = mock.MagicMock() cls.device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE cls.mock_tmpdir = mock.MagicMock() @@ -47,12 +47,12 @@ def teardown_method(cls): cls.device._create_archive = None def test_Device_run_printer_preflight_checks(self, mock_subprocess): - device = Device() + device = Export() device._create_archive = mock.MagicMock() device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + "securedrop_client.export.TemporaryDirectory", return_value=self.mock_tmpdir, ): device.run_printer_preflight_checks() @@ -65,14 +65,14 @@ def test_Device_run_printer_preflight_checks(self, mock_subprocess): def test_Device_run_print_preflight_checks_with_error(self, mock_sp): mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") - with mock.patch("securedrop_client.gui.conversation.export.device.logger.error") as err: + with mock.patch("securedrop_client.export.logger.error") as err: self.device.run_printer_preflight_checks() assert "Print preflight failed" in err.call_args[0] def test_Device_print(self, mock_subprocess): with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + "securedrop_client.export.TemporaryDirectory", return_value=self.mock_tmpdir, ): self.device.print([self.mock_file_location]) @@ -87,9 +87,9 @@ def test_Device_print(self, mock_subprocess): assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] def test_Device_print_file_file_missing(self, mock_subprocess, mocker): - device = Device() + device = Export() warning_logger = mocker.patch( - "securedrop_client.gui.conversation.export.device.logger.warning" + "securedrop_client.export.logger.warning" ) log_msg = "File not found at specified filepath, skipping" @@ -101,7 +101,7 @@ def test_Device_print_file_file_missing(self, mock_subprocess, mocker): def test_Device_run_export_preflight_checks(self, mock_subprocess): with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + "securedrop_client.export.TemporaryDirectory", return_value=self.mock_tmpdir, ): self.device.run_export_preflight_checks() @@ -118,22 +118,22 @@ def test_Device_run_export_preflight_checks(self, mock_subprocess): def test_Device_run_export_preflight_checks_with_error(self, mock_sp): mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") - with mock.patch("securedrop_client.gui.conversation.export.device.logger.error") as err: + with mock.patch("securedrop_client.export.logger.error") as err: self.device.run_export_preflight_checks() assert "Export preflight failed" in err.call_args[0] def test_Device_export_file_missing(self, mock_subprocess, mocker): - device = Device() + device = Export() warning_logger = mocker.patch( - "securedrop_client.gui.conversation.export.device.logger.warning" + "securedrop_client.export.logger.warning" ) with mock.patch( - "securedrop_client.gui.conversation.export.device.tarfile.open", + "securedrop_client.export.tarfile.open", return_value=mock.MagicMock(), ), mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + "securedrop_client.export.TemporaryDirectory", return_value=self.mock_tmpdir, ): device.export(["/not/a/real/location"], "mock passphrase") @@ -147,7 +147,7 @@ def test_Device_export(self, mock_subprocess): passphrase = "passphrase" with mock.patch( - "securedrop_client.gui.conversation.export.device.TemporaryDirectory", + "securedrop_client.export.TemporaryDirectory", return_value=self.mock_tmpdir, ): self.device.export([filepath], passphrase) @@ -187,7 +187,7 @@ def test__run_qrexec_valuerror_raises_exportstatus(self, mocked_subprocess): assert e.value.status == ExportStatus.CALLED_PROCESS_ERROR - @mock.patch("securedrop_client.gui.conversation.export.device.tarfile") + @mock.patch("securedrop_client.export.tarfile") def test__add_virtual_file_to_archive(self, mock_tarfile, mock_sp): mock_tarinfo = mock.MagicMock(spec=tarfile.TarInfo) mock_tarfile.TarInfo.return_value = mock_tarinfo @@ -208,7 +208,7 @@ def test__create_archive(self, mocker): open(os.path.join(temp_dir, "temp_1"), "w+").close() open(os.path.join(temp_dir, "temp_2"), "w+").close() filepaths = [os.path.join(temp_dir, "temp_1"), os.path.join(temp_dir, "temp_2")] - device = Device() + device = Export() archive_path = device._create_archive(temp_dir, "mock.sd-export", {}, filepaths) @@ -218,7 +218,7 @@ def test__create_archive(self, mocker): assert not os.path.exists(archive_path) def test__create_archive_with_an_export_file(self, mocker): - device = Device() + device = Export() archive_path = None with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: archive_path = device._create_archive( @@ -230,7 +230,7 @@ def test__create_archive_with_an_export_file(self, mocker): assert not os.path.exists(archive_path) def test__create_archive_with_multiple_export_files(self, mocker): - device = Device() + device = Export() archive_path = None with TemporaryDirectory() as tmpdir, NamedTemporaryFile() as f1, NamedTemporaryFile() as f2: transcript_path = os.path.join(tmpdir, "transcript.txt") diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index 76796c2a6b..fcdbcdc16d 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -5,7 +5,7 @@ from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.base import ModalDialog -from securedrop_client.gui.conversation.export import Device +from securedrop_client.gui.conversation.export import Export from securedrop_client.gui.main import Window from securedrop_client.logic import Controller from tests import factory @@ -148,7 +148,7 @@ def modal_dialog(mocker, homedir): @pytest.fixture(scope="function") def mock_export(mocker): - device = Device() + device = Export() """A export that assumes the Qubes RPC calls are successful and skips them.""" device.run_preflight_checks = lambda: ExportStatus.DEVICE_LOCKED From a802bb390a7b41d14ead50a497dd4c1eafecf48d Mon Sep 17 00:00:00 2001 From: Ro Date: Sat, 27 Jan 2024 13:47:06 -0500 Subject: [PATCH 08/22] (fixup) fix patch namespace for export mock --- client/tests/functional/test_export_file_dialog.py | 2 +- client/tests/integration/conftest.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/tests/functional/test_export_file_dialog.py b/client/tests/functional/test_export_file_dialog.py index 4872b5fbba..700d89763f 100644 --- a/client/tests/functional/test_export_file_dialog.py +++ b/client/tests/functional/test_export_file_dialog.py @@ -21,7 +21,7 @@ def _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export) """ Helper. Set up export test context and return reference to export dialog. """ - mocker.patch("securedrop_client.gui.conversation.export.Device", return_value=mock_export) + mocker.patch("securedrop_client.export.Export", return_value=mock_export) gui, controller = functional_test_logged_in_context diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index fcdbcdc16d..feb28b10b7 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -160,7 +160,7 @@ def mock_export(mocker): @pytest.fixture(scope="function") def print_dialog(mocker, homedir): - mocker.patch("securedrop_client.gui.conversation.export.Device", return_value=mock_export) + mocker.patch("securedrop_client.export.Export", return_value=mock_export) app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -196,7 +196,7 @@ def print_dialog(mocker, homedir): @pytest.fixture(scope="function") def export_file_dialog(mocker, homedir): - mocker.patch("securedrop_client.gui.conversation.export.Device", return_value=mock_export) + mocker.patch("securedrop_client.export.Export", return_value=mock_export) app = QApplication([]) gui = Window() app.setActiveWindow(gui) From dadebfcb651ffed4a0edf944b0213f4064e4c228 Mon Sep 17 00:00:00 2001 From: Ro Date: Sat, 27 Jan 2024 15:18:43 -0500 Subject: [PATCH 09/22] Use one dialog for all exports. --- client/securedrop_client/gui/actions.py | 18 +- .../gui/conversation/__init__.py | 4 +- .../gui/conversation/export/__init__.py | 4 +- .../gui/conversation/export/dialog.py | 55 --- .../{file_dialog.py => export_dialog.py} | 26 +- .../conversation/export/transcript_dialog.py | 58 --- client/securedrop_client/gui/widgets.py | 2 +- client/tests/conftest.py | 8 +- .../gui/conversation/export/test_device.py | 8 +- .../gui/conversation/export/test_dialog.py | 375 +++++++-------- .../conversation/export/test_file_dialog.py | 433 ++++++++++-------- .../export/test_transcript_dialog.py | 351 -------------- client/tests/gui/test_actions.py | 6 +- client/tests/gui/test_widgets.py | 6 +- client/tests/integration/conftest.py | 4 +- .../tests/integration/test_styles_sdclient.py | 4 +- 16 files changed, 467 insertions(+), 895 deletions(-) delete mode 100644 client/securedrop_client/gui/conversation/export/dialog.py rename client/securedrop_client/gui/conversation/export/{file_dialog.py => export_dialog.py} (95%) delete mode 100644 client/securedrop_client/gui/conversation/export/transcript_dialog.py delete mode 100644 client/tests/gui/conversation/export/test_transcript_dialog.py diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 4cebcc4d11..ecca684662 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -16,11 +16,7 @@ from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source from securedrop_client.gui.base import ModalDialog -from securedrop_client.gui.conversation import ExportDevice -from securedrop_client.gui.conversation import ExportDialog as ExportConversationDialog -from securedrop_client.gui.conversation import ( - ExportTranscriptDialog as ExportConversationTranscriptDialog, -) +from securedrop_client.gui.conversation import ExportDevice, ExportDialog from securedrop_client.gui.conversation import ( PrintTranscriptDialog as PrintConversationTranscriptDialog, ) @@ -217,7 +213,7 @@ def __init__( def _on_triggered(self) -> None: """ (Re-)generates the conversation transcript and opens a confirmation dialog to export it, - in the manner of the existing ExportFileDialog. + in the manner of the existing ExportDialog. """ file_path = ( Path(self.controller.data_dir) @@ -239,9 +235,7 @@ def _on_triggered(self) -> None: # by the operating system. with open(file_path, "r") as f: export_device = ExportDevice() - dialog = ExportConversationTranscriptDialog( - export_device, TRANSCRIPT_FILENAME, [str(file_path)] - ) + dialog = ExportDialog(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) dialog.exec() @@ -272,7 +266,7 @@ def _on_triggered(self) -> None: """ (Re-)generates the conversation transcript and opens a confirmation dialog to export it alongside all the (attached) files that are downloaded, in the manner - of the existing ExportFileDialog. + of the existing ExportDialog. """ if self._state is not None: id = self._state.selected_conversation @@ -298,7 +292,7 @@ def _prepare_to_export(self) -> None: """ (Re-)generates the conversation transcript and opens a confirmation dialog to export it alongside all the (attached) files that are downloaded, in the manner - of the existing ExportFileDialog. + of the existing ExportDialog. """ transcript_location = ( Path(self.controller.data_dir) @@ -338,7 +332,7 @@ def _prepare_to_export(self) -> None: else: summary = _("all files and transcript") - dialog = ExportConversationDialog( + dialog = ExportDialog( export_device, summary, [str(file_location) for file_location in file_locations], diff --git a/client/securedrop_client/gui/conversation/__init__.py b/client/securedrop_client/gui/conversation/__init__.py index 9f649e2425..11729f8017 100644 --- a/client/securedrop_client/gui/conversation/__init__.py +++ b/client/securedrop_client/gui/conversation/__init__.py @@ -4,8 +4,6 @@ # Import classes here to make possible to import them from securedrop_client.gui.conversation from .delete import DeleteConversationDialog # noqa: F401 from .export import Export as ExportDevice # noqa: F401 -from .export import Dialog as ExportDialog # noqa: F401 -from .export import FileDialog as ExportFileDialog # noqa: F401 +from .export import ExportDialog as ExportDialog # noqa: F401 from .export import PrintDialog as PrintFileDialog # noqa: F401 from .export import PrintTranscriptDialog # noqa: F401 -from .export import TranscriptDialog as ExportTranscriptDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/__init__.py b/client/securedrop_client/gui/conversation/export/__init__.py index 00830c85e7..8de6acbe77 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,6 +1,4 @@ from ....export import Export # noqa: F401 -from .dialog import Dialog # noqa: F401 -from .file_dialog import FileDialog # noqa: F401 +from .export_dialog import ExportDialog # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 from .print_transcript_dialog import PrintTranscriptDialog # noqa: F401 -from .transcript_dialog import TranscriptDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/dialog.py b/client/securedrop_client/gui/conversation/export/dialog.py deleted file mode 100644 index a67512415d..0000000000 --- a/client/securedrop_client/gui/conversation/export/dialog.py +++ /dev/null @@ -1,55 +0,0 @@ -from gettext import gettext as _ -from typing import List - -from PyQt5.QtCore import pyqtSlot - -from ....export import Export -from .file_dialog import FileDialog - - -class Dialog(FileDialog): - """Adapts the dialog used to export files to allow exporting a conversation. - - - Adjust the init arguments to export multiple files. - - Adds a method to allow all those files to be exported. - - Overrides the two slots that handles the export action to call said method. - """ - - def __init__(self, device: Export, summary: str, filepaths: List[str]) -> None: - super().__init__(device, summary, filepaths) - - self.filepaths = filepaths - - @pyqtSlot(bool) - def _export_files(self, checked: bool = False) -> None: - self.start_animate_activestate() - self.cancel_button.setEnabled(False) - self.passphrase_field.setDisabled(True) - self._device.export(self.filepaths, self.passphrase_field.text()) - - @pyqtSlot() - def _show_passphrase_request_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_files) - self.header.setText(self.passphrase_header) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.error_details.hide() - self.body.hide() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - @pyqtSlot() - def _show_passphrase_request_message_again(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_files) - 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_field.setFocus() - self.passphrase_form.show() - self.adjustSize() diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/export_dialog.py similarity index 95% rename from client/securedrop_client/gui/conversation/export/file_dialog.py rename to client/securedrop_client/gui/conversation/export/export_dialog.py index fc491d00f3..b1bdbde7e8 100644 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ b/client/securedrop_client/gui/conversation/export/export_dialog.py @@ -16,21 +16,23 @@ from ....export import Export -class FileDialog(ModalDialog): +class ExportDialog(ModalDialog): DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8") PASSPHRASE_LABEL_SPACING = 0.5 NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 - def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None: + def __init__(self, device: Export, summary_text: str, filepaths: List[str]) -> None: super().__init__() self.setStyleSheet(self.DIALOG_CSS) self._device = device self.filepaths = filepaths - self.file_name = SecureQLabel( - file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX + + # This could be the filename, if a single file, or "{n} files" + self.summary_text = SecureQLabel( + summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() # Hold onto the error status we receive from the Export VM self.error_status: Optional[ExportStatus] = None @@ -50,10 +52,10 @@ def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None # Dialog content self.starting_header = _( "Preparing to export:
" '{}' - ).format(self.file_name) + ).format(self.summary_text) self.ready_header = _( "Ready to export:
" '{}' - ).format(self.file_name) + ).format(self.summary_text) self.insert_usb_header = _("Insert encrypted USB drive") self.passphrase_header = _("Enter passphrase for USB drive") self.success_header = _("Export successful") @@ -73,7 +75,7 @@ def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None "identifies who they are. To protect your sources, please consider redacting files " "before working with them on network-connected computers." ) - self.exporting_message = _("Exporting: {}").format(self.file_name) + self.exporting_message = _("Exporting: {}").format(self.summary_text) self.insert_usb_message = _( "Please insert one of the export drives provisioned specifically " "for the SecureDrop Workstation." @@ -127,7 +129,7 @@ def _show_starting_instructions(self) -> None: def _show_passphrase_request_message(self) -> None: self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_file) + self.continue_button.clicked.connect(self._export) self.header.setText(self.passphrase_header) self.continue_button.setText(_("SUBMIT")) self.header_line.hide() @@ -139,7 +141,7 @@ def _show_passphrase_request_message(self) -> None: def _show_passphrase_request_message_again(self) -> None: self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_file) + self.continue_button.clicked.connect(self._export) self.header.setText(self.passphrase_header) self.error_details.setText(self.passphrase_error_message) self.continue_button.setText(_("SUBMIT")) @@ -207,7 +209,7 @@ def _run_preflight(self) -> None: self._device.run_export_preflight_checks() @pyqtSlot() - def _export_file(self, checked: bool = False) -> None: + def _export(self, checked: bool = False) -> None: self.start_animate_activestate() self.cancel_button.setEnabled(False) self.passphrase_field.setDisabled(True) @@ -227,7 +229,7 @@ def _on_export_preflight_check_succeeded(self, result: ExportStatus) -> None: self.continue_button.clicked.disconnect() if result == ExportStatus.DEVICE_WRITABLE: # Skip password prompt, we're there - self.continue_button.clicked.connect(self._export_file) + self.continue_button.clicked.connect(self._export) else: # result == ExportStatus.DEVICE_LOCKED self.continue_button.clicked.connect(self._show_passphrase_request_message) self.continue_button.setEnabled(True) @@ -236,7 +238,7 @@ def _on_export_preflight_check_succeeded(self, result: ExportStatus) -> None: # Skip passphrase prompt if device is unlocked if result == ExportStatus.DEVICE_WRITABLE: - self._export_file() + self._export() else: self._show_passphrase_request_message() diff --git a/client/securedrop_client/gui/conversation/export/transcript_dialog.py b/client/securedrop_client/gui/conversation/export/transcript_dialog.py deleted file mode 100644 index 4939e67119..0000000000 --- a/client/securedrop_client/gui/conversation/export/transcript_dialog.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -A dialog that allows journalists to export sensitive files to a USB drive. -""" -from gettext import gettext as _ -from typing import List - -from PyQt5.QtCore import pyqtSlot - -from ....export import Export -from .file_dialog import FileDialog - - -class TranscriptDialog(FileDialog): - """Adapts the dialog used to export files to allow exporting a conversation transcript. - - - Adjust the init arguments to the needs of conversation transcript export. - - Adds a method to allow a transcript to be exported. - - Overrides the two slots that handles the export action to call said method. - """ - - def __init__(self, device: Export, file_name: str, filepath: List[str]) -> None: - super().__init__(device, file_name, filepath) - - # List[str] to foreshadow multifile export and combining all export dialogs - self.transcript_location = filepath - - def _export_transcript(self, checked: bool = False) -> None: - self.start_animate_activestate() - self.cancel_button.setEnabled(False) - self.passphrase_field.setDisabled(True) - self._device.export(self.transcript_location, self.passphrase_field.text()) - - @pyqtSlot() - def _show_passphrase_request_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_transcript) - self.header.setText(self.passphrase_header) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.error_details.hide() - self.body.hide() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - @pyqtSlot() - def _show_passphrase_request_message_again(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_transcript) - 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_field.setFocus() - self.passphrase_form.show() - self.adjustSize() diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 7330b2ffe1..38c15e0633 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -2461,7 +2461,7 @@ def _on_export_clicked(self) -> None: export_device = conversation.ExportDevice() - self.export_dialog = conversation.ExportFileDialog( + self.export_dialog = conversation.ExportDialog( export_device, self.file.filename, [file_location] ) self.export_dialog.show() diff --git a/client/tests/conftest.py b/client/tests/conftest.py index a5935b943f..eea47c5587 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -97,7 +97,7 @@ def print_transcript_dialog(mocker, homedir): @pytest.fixture(scope="function") -def export_dialog(mocker, homedir): +def export_dialog_multifile(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) export_device = mocker.MagicMock(spec=conversation.ExportDevice) @@ -112,12 +112,12 @@ def export_dialog(mocker, homedir): @pytest.fixture(scope="function") -def export_file_dialog(mocker, homedir): +def export_dialog(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.ExportFileDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) + dialog = conversation.ExportDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @@ -128,7 +128,7 @@ def export_transcript_dialog(mocker, homedir): export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.ExportTranscriptDialog( + dialog = conversation.ExportDialog( export_device, "transcript.txt", ["/some/path/transcript.txt"] ) diff --git a/client/tests/gui/conversation/export/test_device.py b/client/tests/gui/conversation/export/test_device.py index 1509fa3ad7..6b44fd3380 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/gui/conversation/export/test_device.py @@ -88,9 +88,7 @@ def test_Device_print(self, mock_subprocess): def test_Device_print_file_file_missing(self, mock_subprocess, mocker): device = Export() - warning_logger = mocker.patch( - "securedrop_client.export.logger.warning" - ) + warning_logger = mocker.patch("securedrop_client.export.logger.warning") log_msg = "File not found at specified filepath, skipping" @@ -126,9 +124,7 @@ def test_Device_run_export_preflight_checks_with_error(self, mock_sp): def test_Device_export_file_missing(self, mock_subprocess, mocker): device = Export() - warning_logger = mocker.patch( - "securedrop_client.export.logger.warning" - ) + warning_logger = mocker.patch("securedrop_client.export.logger.warning") with mock.patch( "securedrop_client.export.tarfile.open", return_value=mock.MagicMock(), diff --git a/client/tests/gui/conversation/export/test_dialog.py b/client/tests/gui/conversation/export/test_dialog.py index 35a9d19a3a..0d6903441d 100644 --- a/client/tests/gui/conversation/export/test_dialog.py +++ b/client/tests/gui/conversation/export/test_dialog.py @@ -16,17 +16,18 @@ def test_ExportDialog_init(mocker): assert export_dialog.passphrase_form.isHidden() -def test_ExportDialog__show_starting_instructions(mocker, export_dialog): - export_dialog._show_starting_instructions() +def test_ExportDialog__show_starting_instructions(mocker, export_dialog_multifile): + export_dialog_multifile._show_starting_instructions() # "3 files" comes from the export_dialog fixture assert ( - export_dialog.header.text() == "Preparing to export:" + export_dialog_multifile.header.text() == "Preparing to export:" "
" '3 files' ) assert ( - export_dialog.body.text() == "

Understand the risks before exporting files

" + export_dialog_multifile.body.text() + == "

Understand the risks before exporting files

" "Malware" "
" "This workstation lets you open files securely. If you open files on another " @@ -40,304 +41,312 @@ def test_ExportDialog__show_starting_instructions(mocker, export_dialog): "identifies who they are. To protect your sources, please consider redacting files " "before working with them on network-connected computers." ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog___show_passphrase_request_message(mocker, export_dialog): - export_dialog._show_passphrase_request_message() +def test_ExportDialog___show_passphrase_request_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message() - assert export_dialog.header.text() == "Enter passphrase for USB drive" - assert not export_dialog.header.isHidden() - assert export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert export_dialog.body.isHidden() - assert not export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() + assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" + assert not export_dialog_multifile.header.isHidden() + assert export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert export_dialog_multifile.body.isHidden() + assert not export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog): - export_dialog._show_passphrase_request_message_again() +def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message_again() - assert export_dialog.header.text() == "Enter passphrase for USB drive" + assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" assert ( - export_dialog.error_details.text() + export_dialog_multifile.error_details.text() == "The passphrase provided did not work. Please try again." ) - assert export_dialog.body.isHidden() - assert not export_dialog.header.isHidden() - assert export_dialog.header_line.isHidden() - assert not export_dialog.error_details.isHidden() - assert export_dialog.body.isHidden() - assert not export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() + assert export_dialog_multifile.body.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert export_dialog_multifile.header_line.isHidden() + assert not export_dialog_multifile.error_details.isHidden() + assert export_dialog_multifile.body.isHidden() + assert not export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_success_message(mocker, export_dialog): - export_dialog._show_success_message() +def test_ExportDialog__show_success_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_success_message() - assert export_dialog.header.text() == "Export successful" + assert export_dialog_multifile.header.text() == "Export successful" assert ( - export_dialog.body.text() + export_dialog_multifile.body.text() == "Remember to be careful when working with files outside of your Workstation machine." ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert export_dialog.cancel_button.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_insert_usb_message(mocker, export_dialog): - export_dialog._show_insert_usb_message() +def test_ExportDialog__show_insert_usb_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_insert_usb_message() - assert export_dialog.header.text() == "Insert encrypted USB drive" + assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" assert ( - export_dialog.body.text() + export_dialog_multifile.body.text() == "Please insert one of the export drives provisioned specifically " "for the SecureDrop Workstation." ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog): - export_dialog._show_insert_encrypted_usb_message() +def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_insert_encrypted_usb_message() - assert export_dialog.header.text() == "Insert encrypted USB drive" + assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" assert ( - export_dialog.error_details.text() + export_dialog_multifile.error_details.text() == "Either the drive is not encrypted or there is something else wrong with it." ) assert ( - export_dialog.body.text() + export_dialog_multifile.body.text() == "Please insert one of the export drives provisioned specifically for the SecureDrop " "Workstation." ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert not export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert not export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_generic_error_message(mocker, export_dialog): - export_dialog.error_status = "mock_error_status" +def test_ExportDialog__show_generic_error_message(mocker, export_dialog_multifile): + export_dialog_multifile.error_status = "mock_error_status" - export_dialog._show_generic_error_message() + export_dialog_multifile._show_generic_error_message() - assert export_dialog.header.text() == "Export failed" - assert export_dialog.body.text() == "mock_error_status: See your administrator for help." - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() + assert export_dialog_multifile.header.text() == "Export failed" + assert ( + export_dialog_multifile.body.text() == "mock_error_status: See your administrator for help." + ) + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__export_files(mocker, export_dialog): +def test_ExportDialog__export(mocker, export_dialog_multifile): device = mocker.MagicMock() - device.export_file_to_usb_drive = mocker.MagicMock() - export_dialog._device = device - export_dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") + device.export = mocker.MagicMock() + export_dialog_multifile._device = device + export_dialog_multifile.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") - export_dialog._export_files() + export_dialog_multifile._export() device.export.assert_called_once_with( - ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"], + export_dialog_multifile.filepaths, "mock_passphrase", ) -def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog): - export_dialog._show_passphrase_request_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - export_dialog._on_export_preflight_check_succeeded(ExportStatus.PRINT_PREFLIGHT_SUCCESS) + export_dialog_multifile._on_export_preflight_check_succeeded( + ExportStatus.PRINT_PREFLIGHT_SUCCESS + ) - export_dialog._show_passphrase_request_message.assert_not_called() - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_passphrase_request_message + export_dialog_multifile._show_passphrase_request_message.assert_not_called() + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_passphrase_request_message ) def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_dialog + mocker, export_dialog_multifile ): - export_dialog._show_passphrase_request_message = mocker.MagicMock() - export_dialog.continue_button.setEnabled(True) + export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() + export_dialog_multifile.continue_button.setEnabled(True) - export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - export_dialog._show_passphrase_request_message.assert_called_once_with() + export_dialog_multifile._show_passphrase_request_message.assert_called_once_with() def test_ExportDialog__on_export_preflight_check_succeeded_continue_enabled_and_device_unlocked( - mocker, export_dialog + mocker, export_dialog_multifile ): - export_dialog._export_file = mocker.MagicMock() - export_dialog.continue_button.setEnabled(True) + export_dialog_multifile._export = mocker.MagicMock() + export_dialog_multifile.continue_button.setEnabled(True) - export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - export_dialog._export_file.assert_called_once_with() + export_dialog_multifile._export.assert_called_once_with() def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_dialog + mocker, export_dialog_multifile ): - assert not export_dialog.continue_button.isEnabled() - export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - assert export_dialog.continue_button.isEnabled() + assert not export_dialog_multifile.continue_button.isEnabled() + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) + assert export_dialog_multifile.continue_button.isEnabled() def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_dialog + mocker, export_dialog_multifile ): - assert not export_dialog.continue_button.isEnabled() - export_dialog._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_dialog.continue_button.isEnabled() + assert not export_dialog_multifile.continue_button.isEnabled() + export_dialog_multifile._on_export_preflight_check_failed(mocker.MagicMock()) + assert export_dialog_multifile.continue_button.isEnabled() -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog): - export_dialog._update_dialog = mocker.MagicMock() +def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog_multifile): + export_dialog_multifile._update_dialog = mocker.MagicMock() error = ExportError("mock_error_status") - export_dialog._on_export_preflight_check_failed(error) + export_dialog_multifile._on_export_preflight_check_failed(error) - export_dialog._update_dialog.assert_called_with("mock_error_status") + export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") -def test_ExportDialog__on_export_succeeded(mocker, export_dialog): - export_dialog._show_success_message = mocker.MagicMock() +def test_ExportDialog__on_export_succeeded(mocker, export_dialog_multifile): + export_dialog_multifile._show_success_message = mocker.MagicMock() - export_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) + export_dialog_multifile._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) - export_dialog._show_success_message.assert_called_once_with() + export_dialog_multifile._show_success_message.assert_called_once_with() -def test_ExportDialog__on_export_failed(mocker, export_dialog): - export_dialog._update_dialog = mocker.MagicMock() +def test_ExportDialog__on_export_failed(mocker, export_dialog_multifile): + export_dialog_multifile._update_dialog = mocker.MagicMock() error = ExportError("mock_error_status") - export_dialog._on_export_failed(error) + export_dialog_multifile._on_export_failed(error) - export_dialog._update_dialog.assert_called_with("mock_error_status") + export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") -def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker, export_dialog): - export_dialog._show_insert_usb_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED( + mocker, export_dialog_multifile +): + export_dialog_multifile._show_insert_usb_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_insert_usb_message + export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_insert_usb_message ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_dialog._show_insert_usb_message.assert_called_once_with() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) + export_dialog_multifile._show_insert_usb_message.assert_called_once_with() -def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_dialog): - export_dialog._show_passphrase_request_message_again = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message_again = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_passphrase_request_message_again + export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_passphrase_request_message_again ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) # fka BAD_PASSPHRASE - export_dialog._show_passphrase_request_message_again.assert_called_once_with() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) # fka BAD_PASSPHRASE + export_dialog_multifile._show_passphrase_request_message_again.assert_called_once_with() def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_dialog + mocker, export_dialog_multifile ): - export_dialog._show_insert_encrypted_usb_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) + export_dialog_multifile._show_insert_encrypted_usb_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog( + export_dialog_multifile._update_dialog( ExportStatus.INVALID_DEVICE_DETECTED ) # DISK_ENCRYPTION_NOT_SUPPORTED_ERROR - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_insert_encrypted_usb_message + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_insert_encrypted_usb_message ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_dialog._show_insert_encrypted_usb_message.assert_called_once_with() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) + export_dialog_multifile._show_insert_encrypted_usb_message.assert_called_once_with() -def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(mocker, export_dialog): - export_dialog._show_generic_error_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( + mocker, export_dialog_multifile +): + export_dialog_multifile._show_generic_error_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_generic_error_message + export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_generic_error_message ) - assert export_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR + assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog._show_generic_error_message.assert_called_once_with() - assert export_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) + export_dialog_multifile._show_generic_error_message.assert_called_once_with() + assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR -def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_dialog): - export_dialog._show_generic_error_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_dialog_multifile): + export_dialog_multifile._show_generic_error_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog("Some Unknown Error Status") - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_generic_error_message + export_dialog_multifile._update_dialog("Some Unknown Error Status") + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_generic_error_message ) - assert export_dialog.error_status == "Some Unknown Error Status" + assert export_dialog_multifile.error_status == "Some Unknown Error Status" # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog("Some Unknown Error Status") - export_dialog._show_generic_error_message.assert_called_once_with() - assert export_dialog.error_status == "Some Unknown Error Status" + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog("Some Unknown Error Status") + export_dialog_multifile._show_generic_error_message.assert_called_once_with() + assert export_dialog_multifile.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/conversation/export/test_file_dialog.py b/client/tests/gui/conversation/export/test_file_dialog.py index 4608476f67..a9ee020be8 100644 --- a/client/tests/gui/conversation/export/test_file_dialog.py +++ b/client/tests/gui/conversation/export/test_file_dialog.py @@ -1,14 +1,14 @@ from securedrop_client.export_status import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportFileDialog +from securedrop_client.gui.conversation import ExportDialog from tests.helper import app # noqa: F401 def test_ExportDialog_init(mocker): _show_starting_instructions_fn = mocker.patch( - "securedrop_client.gui.conversation.ExportFileDialog._show_starting_instructions" + "securedrop_client.gui.conversation.ExportDialog._show_starting_instructions" ) - export_file_dialog = ExportFileDialog(mocker.MagicMock(), "mock.jpg", ["/mock/path/to/file"]) + export_file_dialog = ExportDialog(mocker.MagicMock(), "mock.jpg", ["/mock/path/to/file"]) _show_starting_instructions_fn.assert_called_once_with() assert export_file_dialog.passphrase_form.isHidden() @@ -16,27 +16,62 @@ def test_ExportDialog_init(mocker): def test_ExportDialog_init_sanitizes_filename(mocker): secure_qlabel = mocker.patch( - "securedrop_client.gui.conversation.export.file_dialog.SecureQLabel" + "securedrop_client.gui.conversation.export.export_dialog.SecureQLabel" ) mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") filename = '' - ExportFileDialog(mocker.MagicMock(), filename, ["/mock/path/to/file"]) + ExportDialog(mocker.MagicMock(), filename, ["/mock/path/to/file"]) secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) -def test_ExportDialog__show_starting_instructions(mocker, export_file_dialog): - export_file_dialog._show_starting_instructions() +def test_ExportDialog__show_starting_instructions(mocker, export_dialog_multifile): + export_dialog_multifile._show_starting_instructions() # file123.jpg comes from the export_file_dialog fixture assert ( - export_file_dialog.header.text() == "Preparing to export:" + export_dialog_multifile.header.text() == "Preparing to export:" + "
" + '3 files' + ) + assert ( + export_dialog_multifile.body.text() + == "

Understand the risks before exporting files

" + "Malware" + "
" + "This workstation lets you open files securely. If you open files 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 file, or contact your " + "administrator." + "

" + "Anonymity" + "
" + "Files submitted by sources may contain information or hidden metadata that " + "identifies who they are. To protect your sources, please consider redacting files " + "before working with them on network-connected computers." + ) + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() + + +# The summary text is different for a dialog with one file vs 3 files +def test_ExportDialog__show_starting_instructions_single_file(mocker, export_dialog): + export_dialog._show_starting_instructions() + + # file123.jpg comes from the export_file_dialog fixture + assert ( + export_dialog.header.text() == "Preparing to export:" "
" 'file123.jpg' ) assert ( - export_file_dialog.body.text() == "

Understand the risks before exporting files

" + export_dialog.body.text() == "

Understand the risks before exporting files

" "Malware" "
" "This workstation lets you open files securely. If you open files on another " @@ -50,317 +85,321 @@ def test_ExportDialog__show_starting_instructions(mocker, export_file_dialog): "identifies who they are. To protect your sources, please consider redacting files " "before working with them on network-connected computers." ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() + assert not export_dialog.header.isHidden() + assert not export_dialog.header_line.isHidden() + assert export_dialog.error_details.isHidden() + assert not export_dialog.body.isHidden() + assert export_dialog.passphrase_form.isHidden() + assert not export_dialog.continue_button.isHidden() + assert not export_dialog.cancel_button.isHidden() -def test_ExportDialog___show_passphrase_request_message(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message() +def test_ExportDialog___show_passphrase_request_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message() - assert export_file_dialog.header.text() == "Enter passphrase for USB drive" - assert not export_file_dialog.header.isHidden() - assert export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert export_file_dialog.body.isHidden() - assert not export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() + assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" + assert not export_dialog_multifile.header.isHidden() + assert export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert export_dialog_multifile.body.isHidden() + assert not export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message_again() +def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message_again() - assert export_file_dialog.header.text() == "Enter passphrase for USB drive" + assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" assert ( - export_file_dialog.error_details.text() + export_dialog_multifile.error_details.text() == "The passphrase provided did not work. Please try again." ) - assert export_file_dialog.body.isHidden() - assert not export_file_dialog.header.isHidden() - assert export_file_dialog.header_line.isHidden() - assert not export_file_dialog.error_details.isHidden() - assert export_file_dialog.body.isHidden() - assert not export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() + assert export_dialog_multifile.body.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert export_dialog_multifile.header_line.isHidden() + assert not export_dialog_multifile.error_details.isHidden() + assert export_dialog_multifile.body.isHidden() + assert not export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_success_message(mocker, export_file_dialog): - export_file_dialog._show_success_message() +def test_ExportDialog__show_success_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_success_message() - assert export_file_dialog.header.text() == "Export successful" + assert export_dialog_multifile.header.text() == "Export successful" assert ( - export_file_dialog.body.text() + export_dialog_multifile.body.text() == "Remember to be careful when working with files outside of your Workstation machine." ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert export_file_dialog.cancel_button.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_insert_usb_message(mocker, export_file_dialog): - export_file_dialog._show_insert_usb_message() +def test_ExportDialog__show_insert_usb_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_insert_usb_message() - assert export_file_dialog.header.text() == "Insert encrypted USB drive" + assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" assert ( - export_file_dialog.body.text() + export_dialog_multifile.body.text() == "Please insert one of the export drives provisioned specifically " "for the SecureDrop Workstation." ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_file_dialog): - export_file_dialog._show_insert_encrypted_usb_message() +def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog_multifile): + export_dialog_multifile._show_insert_encrypted_usb_message() - assert export_file_dialog.header.text() == "Insert encrypted USB drive" + assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" assert ( - export_file_dialog.error_details.text() + export_dialog_multifile.error_details.text() == "Either the drive is not encrypted or there is something else wrong with it." ) assert ( - export_file_dialog.body.text() + export_dialog_multifile.body.text() == "Please insert one of the export drives provisioned specifically for the SecureDrop " "Workstation." ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert not export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert not export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__show_generic_error_message(mocker, export_file_dialog): - export_file_dialog.error_status = "mock_error_status" +def test_ExportDialog__show_generic_error_message(mocker, export_dialog_multifile): + export_dialog_multifile.error_status = "mock_error_status" - export_file_dialog._show_generic_error_message() + export_dialog_multifile._show_generic_error_message() - assert export_file_dialog.header.text() == "Export failed" - assert export_file_dialog.body.text() == "mock_error_status: See your administrator for help." - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() + assert export_dialog_multifile.header.text() == "Export failed" + assert ( + export_dialog_multifile.body.text() == "mock_error_status: See your administrator for help." + ) + assert not export_dialog_multifile.header.isHidden() + assert not export_dialog_multifile.header_line.isHidden() + assert export_dialog_multifile.error_details.isHidden() + assert not export_dialog_multifile.body.isHidden() + assert export_dialog_multifile.passphrase_form.isHidden() + assert not export_dialog_multifile.continue_button.isHidden() + assert not export_dialog_multifile.cancel_button.isHidden() -def test_ExportDialog__export_file(mocker, export_file_dialog): +def test_ExportDialog__export_file(mocker, export_dialog_multifile): device = mocker.MagicMock() - device.export_file_to_usb_drive = mocker.MagicMock() - export_file_dialog._device = device - export_file_dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") + device.export = mocker.MagicMock() + export_dialog_multifile._device = device + export_dialog_multifile.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") - export_file_dialog._export_file() + export_dialog_multifile._export() - device.export.assert_called_once_with(export_file_dialog.filepaths, "mock_passphrase") + device.export.assert_called_once_with(export_dialog_multifile.filepaths, "mock_passphrase") -def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - export_file_dialog._show_passphrase_request_message.assert_not_called() - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_passphrase_request_message + export_dialog_multifile._show_passphrase_request_message.assert_not_called() + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_passphrase_request_message ) def test_ExportDialog__on_export_preflight_check_succeeded_device_unlocked( - mocker, export_file_dialog + mocker, export_dialog_multifile ): - export_file_dialog._export_file = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) + export_dialog_multifile._export = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - export_file_dialog._export_file.assert_not_called() - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._export_file + export_dialog_multifile._export.assert_not_called() + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._export ) def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_file_dialog + mocker, export_dialog_multifile ): - export_file_dialog._show_passphrase_request_message = mocker.MagicMock() - export_file_dialog.continue_button.setEnabled(True) + export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() + export_dialog_multifile.continue_button.setEnabled(True) - export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - export_file_dialog._show_passphrase_request_message.assert_called_once_with() + export_dialog_multifile._show_passphrase_request_message.assert_called_once_with() def test_ExportDialog__on_export_preflight_check_succeeded_unlocked_device_when_continue_enabled( - mocker, export_file_dialog + mocker, export_dialog_multifile ): - export_file_dialog._export_file = mocker.MagicMock() - export_file_dialog.continue_button.setEnabled(True) + export_dialog_multifile._export = mocker.MagicMock() + export_dialog_multifile.continue_button.setEnabled(True) - export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - export_file_dialog._export_file.assert_called_once_with() + export_dialog_multifile._export.assert_called_once_with() def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_file_dialog + mocker, export_dialog_multifile ): - assert not export_file_dialog.continue_button.isEnabled() - export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - assert export_file_dialog.continue_button.isEnabled() + assert not export_dialog_multifile.continue_button.isEnabled() + export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) + assert export_dialog_multifile.continue_button.isEnabled() def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_file_dialog + mocker, export_dialog_multifile ): - assert not export_file_dialog.continue_button.isEnabled() - export_file_dialog._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_file_dialog.continue_button.isEnabled() + assert not export_dialog_multifile.continue_button.isEnabled() + export_dialog_multifile._on_export_preflight_check_failed(mocker.MagicMock()) + assert export_dialog_multifile.continue_button.isEnabled() -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_file_dialog): - export_file_dialog._update_dialog = mocker.MagicMock() +def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog_multifile): + export_dialog_multifile._update_dialog = mocker.MagicMock() error = ExportError("mock_error_status") - export_file_dialog._on_export_preflight_check_failed(error) + export_dialog_multifile._on_export_preflight_check_failed(error) - export_file_dialog._update_dialog.assert_called_with("mock_error_status") + export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") -def test_ExportDialog__on_export_succeeded(mocker, export_file_dialog): - export_file_dialog._show_success_message = mocker.MagicMock() +def test_ExportDialog__on_export_succeeded(mocker, export_dialog_multifile): + export_dialog_multifile._show_success_message = mocker.MagicMock() - export_file_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) + export_dialog_multifile._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) - export_file_dialog._show_success_message.assert_called_once_with() + export_dialog_multifile._show_success_message.assert_called_once_with() -def test_ExportDialog__on_export_failed(mocker, export_file_dialog): - export_file_dialog._update_dialog = mocker.MagicMock() +def test_ExportDialog__on_export_failed(mocker, export_dialog_multifile): + export_dialog_multifile._update_dialog = mocker.MagicMock() error = ExportError("mock_error_status") - export_file_dialog._on_export_failed(error) + export_dialog_multifile._on_export_failed(error) - export_file_dialog._update_dialog.assert_called_with("mock_error_status") + export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") -def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker, export_file_dialog): - export_file_dialog._show_insert_usb_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED( + mocker, export_dialog_multifile +): + export_dialog_multifile._show_insert_usb_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_insert_usb_message + export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_insert_usb_message ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_file_dialog._show_insert_usb_message.assert_called_once_with() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) + export_dialog_multifile._show_insert_usb_message.assert_called_once_with() -def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message_again = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_dialog_multifile): + export_dialog_multifile._show_passphrase_request_message_again = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_passphrase_request_message_again + export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_passphrase_request_message_again ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_file_dialog._show_passphrase_request_message_again.assert_called_once_with() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) + export_dialog_multifile._show_passphrase_request_message_again.assert_called_once_with() def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_file_dialog + mocker, export_dialog_multifile ): - export_file_dialog._show_insert_encrypted_usb_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) + export_dialog_multifile._show_insert_encrypted_usb_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_insert_encrypted_usb_message + export_dialog_multifile._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_insert_encrypted_usb_message ) # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_file_dialog._show_insert_encrypted_usb_message.assert_called_once_with() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) + export_dialog_multifile._show_insert_encrypted_usb_message.assert_called_once_with() def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( - mocker, export_file_dialog + mocker, export_dialog_multifile ): - export_file_dialog._show_generic_error_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) + export_dialog_multifile._show_generic_error_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_generic_error_message + export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_generic_error_message ) - assert export_file_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR + assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_file_dialog._show_generic_error_message.assert_called_once_with() - assert export_file_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) + export_dialog_multifile._show_generic_error_message.assert_called_once_with() + assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR -def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_file_dialog): - export_file_dialog._show_generic_error_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) +def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_dialog_multifile): + export_dialog_multifile._show_generic_error_message = mocker.MagicMock() + export_dialog_multifile.continue_button = mocker.MagicMock() + export_dialog_multifile.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog("Some Unknown Error Status") - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_generic_error_message + export_dialog_multifile._update_dialog("Some Unknown Error Status") + export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( + export_dialog_multifile._show_generic_error_message ) - assert export_file_dialog.error_status == "Some Unknown Error Status" + assert export_dialog_multifile.error_status == "Some Unknown Error Status" # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog("Some Unknown Error Status") - export_file_dialog._show_generic_error_message.assert_called_once_with() - assert export_file_dialog.error_status == "Some Unknown Error Status" + mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) + export_dialog_multifile._update_dialog("Some Unknown Error Status") + export_dialog_multifile._show_generic_error_message.assert_called_once_with() + assert export_dialog_multifile.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/conversation/export/test_transcript_dialog.py b/client/tests/gui/conversation/export/test_transcript_dialog.py deleted file mode 100644 index 27e77053b5..0000000000 --- a/client/tests/gui/conversation/export/test_transcript_dialog.py +++ /dev/null @@ -1,351 +0,0 @@ -from securedrop_client.export_status import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportTranscriptDialog -from tests.helper import app # noqa: F401 - - -def test_TranscriptDialog_init(mocker): - _show_starting_instructions_fn = mocker.patch( - "securedrop_client.gui.conversation.ExportTranscriptDialog._show_starting_instructions" - ) - - export_transcript_dialog = ExportTranscriptDialog( - mocker.MagicMock(), "transcript.txt", "/some/path/transcript.txt" - ) - - _show_starting_instructions_fn.assert_called_once_with() - assert export_transcript_dialog.passphrase_form.isHidden() - - -def test_TranscriptDialog_init_sanitizes_filename(mocker): - secure_qlabel = mocker.patch( - "securedrop_client.gui.conversation.export.file_dialog.SecureQLabel" - ) - mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") - filename = '' - - ExportTranscriptDialog(mocker.MagicMock(), filename, "/some/path/transcript.txt") - - secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) - - -def test_TranscriptDialog__show_starting_instructions(mocker, export_transcript_dialog): - export_transcript_dialog._show_starting_instructions() - - # transcript.txt comes from the export_transcript_dialog fixture - assert ( - export_transcript_dialog.header.text() == "Preparing to export:" - "
" - 'transcript.txt' - ) - assert ( - export_transcript_dialog.body.text() - == "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files 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 file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog___show_passphrase_request_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_passphrase_request_message() - - assert export_transcript_dialog.header.text() == "Enter passphrase for USB drive" - assert not export_transcript_dialog.header.isHidden() - assert export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert export_transcript_dialog.body.isHidden() - assert not export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_passphrase_request_message_again(mocker, export_transcript_dialog): - export_transcript_dialog._show_passphrase_request_message_again() - - assert export_transcript_dialog.header.text() == "Enter passphrase for USB drive" - assert ( - export_transcript_dialog.error_details.text() - == "The passphrase provided did not work. Please try again." - ) - assert export_transcript_dialog.body.isHidden() - assert not export_transcript_dialog.header.isHidden() - assert export_transcript_dialog.header_line.isHidden() - assert not export_transcript_dialog.error_details.isHidden() - assert export_transcript_dialog.body.isHidden() - assert not export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_success_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_success_message() - - assert export_transcript_dialog.header.text() == "Export successful" - assert ( - export_transcript_dialog.body.text() - == "Remember to be careful when working with files outside of your Workstation machine." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_insert_usb_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_insert_usb_message() - - assert export_transcript_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_transcript_dialog.body.text() - == "Please insert one of the export drives provisioned specifically " - "for the SecureDrop Workstation." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_insert_encrypted_usb_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_insert_encrypted_usb_message() - - assert export_transcript_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_transcript_dialog.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_transcript_dialog.body.text() - == "Please insert one of the export drives provisioned specifically for the SecureDrop " - "Workstation." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert not export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_generic_error_message(mocker, export_transcript_dialog): - export_transcript_dialog.error_status = "mock_error_status" - - export_transcript_dialog._show_generic_error_message() - - assert export_transcript_dialog.header.text() == "Export failed" - assert ( - export_transcript_dialog.body.text() - == "mock_error_status: See your administrator for help." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__export_transcript(mocker, export_transcript_dialog): - device = mocker.MagicMock() - device.export = mocker.MagicMock() - export_transcript_dialog._device = device - export_transcript_dialog.passphrase_field.text = mocker.MagicMock( - return_value="mock_passphrase" - ) - - export_transcript_dialog._export_transcript() - - device.export.assert_called_once_with(["/some/path/transcript.txt"], "mock_passphrase") - - -def test_TranscriptDialog__on_export_preflight_check_succeeded(mocker, export_transcript_dialog): - export_transcript_dialog._show_passphrase_request_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - export_transcript_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - - export_transcript_dialog._show_passphrase_request_message.assert_not_called() - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_passphrase_request_message - ) - - -def test_TranscriptDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_passphrase_request_message = mocker.MagicMock() - export_transcript_dialog.continue_button.setEnabled(True) - - export_transcript_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - - export_transcript_dialog._show_passphrase_request_message.assert_called_once_with() - - -def test_TranscriptDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_transcript_dialog -): - assert not export_transcript_dialog.continue_button.isEnabled() - export_transcript_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - assert export_transcript_dialog.continue_button.isEnabled() - - -def test_TranscriptDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_transcript_dialog -): - assert not export_transcript_dialog.continue_button.isEnabled() - export_transcript_dialog._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_transcript_dialog.continue_button.isEnabled() - - -def test_TranscriptDialog__on_export_preflight_check_failed(mocker, export_transcript_dialog): - export_transcript_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_transcript_dialog._on_export_preflight_check_failed(error) - - export_transcript_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_TranscriptDialog__on_export_succeeded(mocker, export_transcript_dialog): - export_transcript_dialog._show_success_message = mocker.MagicMock() - - export_transcript_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) - - export_transcript_dialog._show_success_message.assert_called_once_with() - - -def test_TranscriptDialog__on_export_failed(mocker, export_transcript_dialog): - export_transcript_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_transcript_dialog._on_export_failed(error) - - export_transcript_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_TranscriptDialog__update_dialog_when_status_is_USB_NOT_CONNECTED( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_insert_usb_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_insert_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_transcript_dialog._show_insert_usb_message.assert_called_once_with() - - -def test_TranscriptDialog__update_dialog_when_status_is_BAD_PASSPHRASE( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_passphrase_request_message_again = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_passphrase_request_message_again - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_transcript_dialog._show_passphrase_request_message_again.assert_called_once_with() - - -def test_TranscriptDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_insert_encrypted_usb_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_insert_encrypted_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_transcript_dialog._show_insert_encrypted_usb_message.assert_called_once_with() - - -def test_TranscriptDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_generic_error_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_generic_error_message - ) - assert export_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_transcript_dialog._show_generic_error_message.assert_called_once_with() - assert export_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - -def test_TranscriptDialog__update_dialog_when_status_is_unknown(mocker, export_transcript_dialog): - export_transcript_dialog._show_generic_error_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog("Some Unknown Error Status") - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_generic_error_message - ) - assert export_transcript_dialog.error_status == "Some Unknown Error Status" - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog("Some Unknown Error Status") - export_transcript_dialog._show_generic_error_message.assert_called_once_with() - assert export_transcript_dialog.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py index 1354595607..c52a2b7210 100644 --- a/client/tests/gui/test_actions.py +++ b/client/tests/gui/test_actions.py @@ -289,7 +289,7 @@ def test_trigger(self, _): class TestExportConversationTranscriptAction(unittest.TestCase): - @patch("securedrop_client.gui.actions.ExportConversationTranscriptDialog") + @patch("securedrop_client.gui.actions.ExportDialog") def test_trigger(self, _): with managed_locale(): locale.setlocale(locale.LC_ALL, ("en_US", "latin-1")) @@ -304,7 +304,7 @@ def test_trigger(self, _): return_value="☠ A string with unicode characters." ) - # TOdo: these are now accessible through the Device or the Dialog. + # TODO: these are now accessible through the Device or the Dialog. # action._export_device.run_printer_preflight_checks = ( # lambda: action._export_device.print_preflight_check_succeeded.emit() # ) @@ -318,7 +318,7 @@ def test_trigger(self, _): class TestExportConversationAction(unittest.TestCase): - @patch("securedrop_client.gui.actions.ExportConversationDialog") + @patch("securedrop_client.gui.actions.ExportDialog") def test_trigger(self, _): with managed_locale(): locale.setlocale(locale.LC_ALL, ("en_US", "latin-1")) diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index c0495d4b72..4ef404f718 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3588,6 +3588,8 @@ def test_FileWidget__on_export_clicked(mocker, session, source): get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) file_location = file.location(controller.data_dir) + + # It doesn't live here, but see __init__.py export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice") fw = FileWidget( @@ -3598,7 +3600,7 @@ 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) - dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog") + dialog = mocker.patch("securedrop_client.gui.conversation.ExportDialog") fw._on_export_clicked() dialog.assert_called_once_with(export_device(), file.filename, [file_location]) @@ -3628,7 +3630,7 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): mocker.patch("PyQt5.QtWidgets.QDialog.exec") controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=False) - dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog") + dialog = mocker.patch("securedrop_client.gui.conversation.ExportDialog") fw._on_export_clicked() diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index feb28b10b7..c00880041f 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -218,9 +218,7 @@ def export_file_dialog(mocker, homedir): gui.setup(controller) gui.login_dialog.close() export_device = conversation.ExportDevice() - dialog = conversation.ExportFileDialog( - export_device, "file_name", ["/mock/export/filepath"] - ) + dialog = conversation.ExportDialog(export_device, "file_name", ["/mock/export/filepath"]) dialog.show() yield dialog diff --git a/client/tests/integration/test_styles_sdclient.py b/client/tests/integration/test_styles_sdclient.py index 3fab23e9eb..fa5d0484ab 100644 --- a/client/tests/integration/test_styles_sdclient.py +++ b/client/tests/integration/test_styles_sdclient.py @@ -130,8 +130,8 @@ def test_class_name_matches_css_object_name_for_print_dialog(print_dialog): def test_class_name_matches_css_object_name_for_export_file_dialog(export_file_dialog): - assert "FileDialog" == export_file_dialog.__class__.__name__ - assert "FileDialog" in export_file_dialog.passphrase_form.objectName() + assert "ExportDialog" == export_file_dialog.__class__.__name__ + assert "ExportDialog" in export_file_dialog.passphrase_form.objectName() def test_class_name_matches_css_object_name_for_modal_dialog(modal_dialog): From 7d343f05cb12fc19a6186c47b70f0760dcada262 Mon Sep 17 00:00:00 2001 From: Ro Date: Mon, 29 Jan 2024 14:58:22 -0500 Subject: [PATCH 10/22] WIP: export wizard --- client/securedrop_client/export.py | 29 +- client/securedrop_client/gui/actions.py | 7 +- .../gui/conversation/export/__init__.py | 1 + .../gui/conversation/export/dialog_button.css | 29 ++ .../conversation/export/dialog_message.css | 13 + .../gui/conversation/export/export_dialog.py | 69 +++- .../gui/conversation/export/export_wizard.py | 235 +++++++++++ .../export/export_wizard_constants.py | 41 ++ .../conversation/export/export_wizard_page.py | 371 ++++++++++++++++++ client/securedrop_client/gui/widgets.py | 6 +- .../conversation/export/test_export_wizard.py | 141 +++++++ 11 files changed, 931 insertions(+), 11 deletions(-) create mode 100644 client/securedrop_client/gui/conversation/export/dialog_button.css create mode 100644 client/securedrop_client/gui/conversation/export/dialog_message.css create mode 100644 client/securedrop_client/gui/conversation/export/export_wizard.py create mode 100644 client/securedrop_client/gui/conversation/export/export_wizard_constants.py create mode 100644 client/securedrop_client/gui/conversation/export/export_wizard_page.py create mode 100644 client/tests/gui/conversation/export/test_export_wizard.py diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 59ae3f2f89..b18be2a68e 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -41,6 +41,9 @@ class Export(QObject): _DISK_ENCRYPTION_KEY_NAME = "encryption_key" _DISK_EXPORT_DIR = "export_data" + # New, replacement for export success and error statuses + export_state_changed = pyqtSignal(object) + # Emit ExportStatus export_preflight_check_succeeded = pyqtSignal(object) export_succeeded = pyqtSignal(object) @@ -97,6 +100,13 @@ def run_export_preflight_checks(self) -> None: logger.error("Export preflight failed") self.export_preflight_check_failed.emit(e) + if e.status: + self.export_state_changed.emit(e.status) + else: + logger.error("ExportError, no status supplied") + # Emit a generic error + self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) + def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: """ Bundle filepaths into a tarball and send to encrypted USB via qrexec, @@ -118,6 +128,7 @@ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: filepaths=filepaths, ) status = self._run_qrexec_export(archive_path) + self.export_state_changed.emit(status) self.export_succeeded.emit(status) logger.debug(f"Status {status}") @@ -127,6 +138,13 @@ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: logger.debug(f"Export failed: {e}") self.export_failed.emit(e) + if e.status and isinstance(e.status, ExportStatus): + self.export_state_changed.emit(e.status) + else: + logger.error("ExportError, no status supplied") + # Emit a generic error + self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) + self.export_completed.emit(filepaths) def print(self, filepaths: List[str]) -> None: @@ -191,8 +209,15 @@ def _run_qrexec_export(self, archive_path: str) -> ExportStatus: stderr=subprocess.STDOUT, ) result = output.decode("utf-8").strip() - - return ExportStatus(result) + if result: + + # This is a bit messy, but make sure we are just taking the last line + # (no-op if no newline) + status_string = result.split("\n")[-1] + return ExportStatus(status_string) + else: + logger.error("Export subprocess did not return a value we could parse") + raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) except ValueError as e: logger.debug(f"Export subprocess returned unexpected value: {e}") diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index ecca684662..2e231f949a 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -16,10 +16,11 @@ from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source from securedrop_client.gui.base import ModalDialog -from securedrop_client.gui.conversation import ExportDevice, ExportDialog +from securedrop_client.gui.conversation import ExportDevice from securedrop_client.gui.conversation import ( PrintTranscriptDialog as PrintConversationTranscriptDialog, ) +from securedrop_client.gui.conversation.export import ExportWizard from securedrop_client.logic import Controller from securedrop_client.utils import safe_mkdir @@ -235,7 +236,7 @@ def _on_triggered(self) -> None: # by the operating system. with open(file_path, "r") as f: export_device = ExportDevice() - dialog = ExportDialog(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) + dialog = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) dialog.exec() @@ -332,7 +333,7 @@ def _prepare_to_export(self) -> None: else: summary = _("all files and transcript") - dialog = ExportDialog( + dialog = ExportWizard( export_device, summary, [str(file_location) for file_location in file_locations], diff --git a/client/securedrop_client/gui/conversation/export/__init__.py b/client/securedrop_client/gui/conversation/export/__init__.py index 8de6acbe77..c929b33ab4 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,4 +1,5 @@ from ....export import Export # noqa: F401 from .export_dialog import ExportDialog # noqa: F401 +from .export_wizard import ExportWizard # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 from .print_transcript_dialog import PrintTranscriptDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/dialog_button.css b/client/securedrop_client/gui/conversation/export/dialog_button.css new file mode 100644 index 0000000000..132952a4bd --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/dialog_button.css @@ -0,0 +1,29 @@ +#ModalDialog_button_box QPushButton#ModalDialog_primary_button { + background-color: #2a319d; + color: #fff; +} + +#ModalDialog.dangerous #ModalDialog_button_box QPushButton { + border-color: #ff3366; + color: #ff3366; +} + +#ModalDialog.dangerous #ModalDialog_button_box QPushButton#ModalDialog_primary_button { + background-color: #ff3366; + border-color: #ff3366; + color: #ffffff; +} + +#ModalDialog_button_box QPushButton#ModalDialog_primary_button::disabled { + border: 2px solid #c2c4e3; + background-color: #c2c4e3; + color: #e1e2f1; +} + +#ModalDialog_button_box QPushButton#ModalDialog_primary_button_active { + background-color: #f1f1f6; + color: #fff; + border: 2px solid #f1f1f6; + margin: 0; + height: 40px; +} diff --git a/client/securedrop_client/gui/conversation/export/dialog_message.css b/client/securedrop_client/gui/conversation/export/dialog_message.css new file mode 100644 index 0000000000..20415fe9b9 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/dialog_message.css @@ -0,0 +1,13 @@ +#ModalDialog_error_details { + margin: 0px 40px 0px 36px; + font-family: 'Montserrat'; + font-size: 16px; + color: #ff0064; +} + +#ModalDialog_error_details_active { + margin: 0px 40px 0px 36px; + font-family: 'Montserrat'; + font-size: 16px; + color: #ff66c4; +} diff --git a/client/securedrop_client/gui/conversation/export/export_dialog.py b/client/securedrop_client/gui/conversation/export/export_dialog.py index b1bdbde7e8..11cf9d82e0 100644 --- a/client/securedrop_client/gui/conversation/export/export_dialog.py +++ b/client/securedrop_client/gui/conversation/export/export_dialog.py @@ -23,14 +23,28 @@ class ExportDialog(ModalDialog): NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 - def __init__(self, device: Export, summary_text: str, filepaths: List[str]) -> None: + def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> None: + """ + Args: + export (Export): manages the export and returns status information to the + dialog + + summary_text (str): String descriptor of what is being exported (will be + displayed to user). For single-item exports, the filename can be used; for + multifile exports, a summary (such as f"{len(filepaths) files"}) can be used. + + filepaths (List[str]): list of complete non-relative paths to items targeted for + export. Filepaths should be checked by the controller before this dialog launches, + although the dialog performs basic before attempting to export to ensure the file + is present. (In the current implementation, are held open by a context manager to + prevent the scenario where the file is deleted while the dialog is open). + """ super().__init__() self.setStyleSheet(self.DIALOG_CSS) - self._device = device + self._device = export self.filepaths = filepaths - # This could be the filename, if a single file, or "{n} files" self.summary_text = SecureQLabel( summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() @@ -139,6 +153,7 @@ def _show_passphrase_request_message(self) -> None: self.passphrase_form.show() self.adjustSize() + # Retrying an incorrect passphrase def _show_passphrase_request_message_again(self) -> None: self.continue_button.clicked.disconnect() self.continue_button.clicked.connect(self._export) @@ -287,3 +302,51 @@ def _update_dialog(self, error_status: ExportStatus) -> None: self._show_insert_encrypted_usb_message() else: self._show_generic_error_message() + + +# def _ui_preparing_to_export(self): +# pass + + +# def _ui_ready_export_no_passphrase_prompt(self): +# pass + + +# def _ui_ready_export_passphrase_prompt(self): +# pass + + +# def _ui_ready_export_retry_passphrase(self): +# pass + + +# def _ui_no_devices(self): +# pass + + +# def _ui_too_many_devices(self): +# pass + + +def _ui_invalid_device(self): + pass + + +def _ui_error_export_did_not_complete(self): + pass + + +def _ui_error_after_export_complete(self): + pass + + +# Dialog states: +# Preparing to Export.... (preflight) +# Ready to export (no passphrase just "export") + +# please enter your passphrase (->"export") + +# Please insert a USB (no_device) +# Please re-enter your passphrase (-> "export") +# Error, time to close (invalid device) +# Too many options (multi-device) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py new file mode 100644 index 0000000000..1ac1952074 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -0,0 +1,235 @@ +import logging +from gettext import gettext as _ +from typing import List + +from pkg_resources import resource_string +from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtGui import QIcon, QKeyEvent +from PyQt5.QtWidgets import QApplication, QWizard, QWizardPage + +from securedrop_client.export import Export +from securedrop_client.export_status import ExportStatus +from securedrop_client.gui.base import SecureQLabel +from securedrop_client.gui.conversation.export.export_wizard_constants import Pages +from securedrop_client.gui.conversation.export.export_wizard_page import ( + FinalPage, + InsertUSBPage, + PassphraseWizardPage, + PreflightPage, +) + +logger = logging.getLogger(__name__) + + +class ExportWizard(QWizard): + """ + Guide user through the steps of exporting to a USB. + """ + + PASSPHRASE_LABEL_SPACING = 0.5 + NO_MARGIN = 0 + FILENAME_WIDTH_PX = 260 + BUTTON_CSS = resource_string(__name__, "dialog_button.css").decode("utf-8") + + # TODO: If the drive is unlocked, we don't need a passphrase; + # if we do, it's populated later + PASS_PLACEHOLDER_FIELD = "" + + def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> None: + parent = QApplication.activeWindow() + super().__init__(parent) + self.export = export + self.summary_text = SecureQLabel( + summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX + ).text() + self.filepaths = filepaths + self.current_status = None # Optional[ExportStatus] + + # Signal from qrexec command runner + self.export.export_state_changed.connect(self.on_status_received) + + self._set_layout() + self._set_pages() + self._style_buttons() + + def keyPressEvent(self, event: QKeyEvent) -> None: + if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return: + if self.cancel_button.hasFocus(): + self.cancel_button.click() + else: + self.next_button.click() + else: + super().keyPressEvent(event) + + def text(self) -> str: + """A text-only representation of the dialog.""" + return self.body.text() + + def _style_buttons(self) -> None: + # When the dialog launches, the export preflight call is executed + # on the first screen, as well as for validation on the USB insert page. + # Otherwise, all calls are to export + self.next_button = self.button(QWizard.WizardButton.NextButton) + self.next_button.clicked.connect(self.request_export) + self.next_button.setStyleSheet(self.BUTTON_CSS) + self.cancel_button = self.button(QWizard.WizardButton.CancelButton) + self.cancel_button.setStyleSheet(self.BUTTON_CSS) + + # Activestate animation + # TODO: we may not use this + # self.button_animation = load_movie("activestate-wide.gif") + # self.button_animation.setScaledSize(QSize(32, 32)) + # self.button_animation.frameChanged.connect(self.animate_activestate) + + # TODO: may not style buttons like this + def animate_activestate(self) -> None: + self.next_button.setIcon(QIcon(self.button_animation.currentPixmap())) + + # TODO + def start_animate_activestate(self) -> None: + self.button_animation.start() + self.next_button.setText("") + self.next_button.setMinimumSize(QSize(142, 43)) + # Reset widget stylesheets + self.next_button.setStyleSheet("") + self.next_button.setObjectName("ModalDialog_primary_button_active") + self.next_button.setStyleSheet(self.BUTTON_CSS) + + # TODO- move to parent class + def stop_animate_activestate(self) -> None: + self.next_button.setIcon(QIcon()) + self.button_animation.stop() + self.next_button.setText(_("CONTINUE")) + # Reset widget stylesheets + self.next_button.setStyleSheet("") + self.next_button.setObjectName("ModalDialog_primary_button") + self.next_button.setStyleSheet(self.BUTTON_CSS) + + def _set_layout(self) -> None: + self.setWindowTitle(f"Export {self.summary_text}") + self.setModal(False) + self.setOptions( + QWizard.NoBackButtonOnLastPage + | QWizard.NoCancelButtonOnLastPage + | QWizard.NoBackButtonOnStartPage + ) + + def _set_pages(self) -> None: + for page in [ + self._create_preflight(), + self._create_insert_usb(), + self._create_passphrase_prompt(), + self._create_done(), + ]: + self.addPage(page) + + # Nice to have, but steals the focus from the password field after 1 character is typed. + # Probably another way to have it be based on validating the status + # page.completeChanged.connect(lambda: self._set_focus(QWizard.WizardButton.NextButton)) + + @pyqtSlot(int) + def _set_focus(self, which: QWizard.WizardButton) -> None: + self.button(which).setFocus() + + def request_export(self) -> None: + passphrase_untrusted = self.field("passphrase") + if str(passphrase_untrusted) is not None: + # Export is shell-escaped + self.export.export(self.filepaths, str(passphrase_untrusted)) + else: + self.export.export(self.filepaths, self.PASS_PLACEHOLDER_FIELD) + + def request_export_preflight(self) -> None: + self.export.run_export_preflight_checks() + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + """ + Update the wizard position based on incoming ExportStatus. + If a status is shown that represents a removed device, + rewind the wizard to the appropriate pane. + + To update the text on an individual page, the page listens + for this signal and can call `update_content` in the listener. + """ + logger.debug(f"Wizard received {status.value}") + + # Unrecoverable - end the wizard + if status in [ + ExportStatus.ERROR_MOUNT, + ExportStatus.ERROR_EXPORT, + ExportStatus.ERROR_MISSING_FILES, + ExportStatus.DEVICE_ERROR, + ExportStatus.CALLED_PROCESS_ERROR, + ExportStatus.UNEXPECTED_RETURN_STATUS, + ]: + logger.error(f"Encountered {status.value}, cannot export") + self.end_wizard_with_error(status) + return + + target = None # Optional[PageEnum] + if status in [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ]: + target = Pages.INSERT_USB + elif status in [ExportStatus.DEVICE_LOCKED, ExportStatus.ERROR_UNLOCK_LUKS]: + target = Pages.UNLOCK_USB + + # Someone may have yanked out or unmounted a USB + if target: + # If the user is stuck on the same page, show them a hint + should_show_hint = (target == self.currentId()) + self.rewind(target) + self.currentPage().update_content(status, should_show_hint) + + # Update status + self.current_status = status + + def rewind(self, target: Pages) -> None: + """ + Navigate back to target page. + """ + while self.currentId() > target: + self.back() + + def end_wizard_with_error(self, error: ExportStatus) -> None: + """ + If and end state is reached, display message and let user + end the wizard. + """ + page = self.currentPage() + + # There's no way to advance a wizard to an arbitrary page + # in PyQt5 + if page: + # Disable "next" on terminal error pages + page.set_complete(False) + if isinstance(page, PassphraseWizardPage): + # Advance one page and display the error + self.next() + self.page(self.currentId()).update_content(error) + elif isinstance(page, FinalPage): + page.update_content(error) + else: + # Should be unreachable + logger.error("Tried to end early, but user should cancel") + + # readywhen: valid usb inserted + def _create_preflight(self) -> QWizardPage: + return PreflightPage(self.export, self.summary_text) + + # readywhen: usb inserted + def _create_insert_usb(self) -> QWizardPage: + return InsertUSBPage(self.export, self.summary_text) + + def _create_passphrase_prompt(self) -> QWizardPage: + return PassphraseWizardPage(self.export) + + # def _create_export(self) -> QWizardPage: + # return ExportPage(self.export) + + # readywhen: all done + def _create_done(self) -> QWizardPage: + return FinalPage(self.export) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py new file mode 100644 index 0000000000..7236350ec2 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -0,0 +1,41 @@ +from enum import IntEnum +from gettext import gettext as _ + +from securedrop_client.export_status import ExportStatus + +""" +Export wizard page ordering, human-readable status messages +""" + + +# Sequential list of pages (the order matters) +class Pages(IntEnum): + PREFLIGHT = 0 + INSERT_USB = 1 + UNLOCK_USB = 2 + EXPORT_DONE = 3 + + +# Human-readable status info +STATUS_MESSAGES = { + ExportStatus.NO_DEVICE_DETECTED: _("No device detected"), + ExportStatus.MULTI_DEVICE_DETECTED: _("Too many USBs; please insert one supported device."), + ExportStatus.INVALID_DEVICE_DETECTED: _( + "Either the drive is not encrypted or there is something else wrong with it." + ), + ExportStatus.DEVICE_WRITABLE: _("The device is ready for export."), + ExportStatus.DEVICE_LOCKED: _("The device is locked."), + ExportStatus.ERROR_UNLOCK_LUKS: _("The passphrase provided did not work. Please try again."), + ExportStatus.ERROR_MOUNT: _("Error mounting drive"), + ExportStatus.ERROR_EXPORT: _("Error during export"), + ExportStatus.ERROR_EXPORT_CLEANUP: _( + "Files were exported succesfully, but the drive could not be unmounted" + ), + ExportStatus.SUCCESS_EXPORT: _("Export successful"), + ExportStatus.DEVICE_ERROR: _( + "Error encountered with this device. See your administrator for help." + ), + ExportStatus.ERROR_MISSING_FILES: _("Files were moved or missing and could not be exported."), + ExportStatus.CALLED_PROCESS_ERROR: _("Error encountered. Please contact support."), + ExportStatus.UNEXPECTED_RETURN_STATUS: _("Error encountered. Please contact support."), +} diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py new file mode 100644 index 0000000000..6a1a35a9ac --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -0,0 +1,371 @@ +import logging +from gettext import gettext as _ + +from pkg_resources import resource_string +from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtGui import QColor, QFont, QPixmap +from PyQt5.QtWidgets import ( + QApplication, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, + QWizardPage, +) + +from securedrop_client.export import Export +from securedrop_client.export_status import ExportStatus +from securedrop_client.gui.base import PasswordEdit, SecureQLabel +from securedrop_client.gui.base.checkbox import SDCheckBox +from securedrop_client.gui.base.misc import SvgLabel +from securedrop_client.gui.conversation.export.export_wizard_constants import STATUS_MESSAGES, Pages +from securedrop_client.resources import load_movie + +logger = logging.getLogger(__name__) + + +class ExportWizardPage(QWizardPage): + """ + Base class for all export wizard pages. Individual pages should inherit + from this class to: + * include additional layout items + * implement dynamic ordering (i.e., if the next window varies + depending on the result of the previous action, in which case the + `nextId()` method must be overwritten) + * implement custom validation (logic that prevents a user + from skipping to the next page until conditions are met) + + Every wizard page has: + * A header (page title) + * Body (instructions) + * Optional error_instructions (Additional text that is hidden but + appears on recoverable error to help the user advance to the next stage) + * Directional buttons (continue/done, cancel) + """ + + DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8") + ERROR_DETAILS_CSS = resource_string(__name__, "dialog_message.css").decode("utf-8") + + MARGIN = 40 + PASSPHRASE_LABEL_SPACING = 0.5 + NO_MARGIN = 0 + FILENAME_WIDTH_PX = 260 + + def __init__(self, export: Export, header: str, body: str) -> None: + parent = QApplication.activeWindow() + super().__init__(parent) + self.export = export + self.header_text = header + self.body_text = body + self.status = None # Optional[ExportStatus] + self._is_complete = True # Won't override parent method unless explicitly set to False + + self.setLayout(self._build_layout()) + + # Listen for export updates from export + self.export.export_state_changed.connect(self.on_status_received) + + def set_complete(self, is_complete: bool) -> None: + """ + Flag a page as being incomplete. (Disables Next button) + """ + self._is_complete = is_complete + + def isComplete(self) -> bool: + return self._is_complete and super().isComplete() + + def _build_layout(self) -> QVBoxLayout: + """ + Create parent layout, draw elements, return parent layout + """ + self.setStyleSheet(self.DIALOG_CSS) + parent_layout = QVBoxLayout() + parent_layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) + + # 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("ModalDialog_header_icon") + self.header_spinner = QPixmap() + self.header_spinner_label = QLabel() + self.header_spinner_label.setObjectName("ModalDialog_header_spinner") + self.header_spinner_label.setMinimumSize(64, 64) + self.header_spinner_label.setVisible(False) + self.header_spinner_label.setPixmap(self.header_spinner) + self.header = QLabel() + self.header.setObjectName("ModalDialog_header") + header_container_layout.addWidget(self.header_icon) + header_container_layout.addWidget(self.header_spinner_label) + header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter) + header_container_layout.addStretch() + self.header_line = QWidget() + self.header_line.setObjectName("ModalDialog_header_line") + + # Body to display instructions and forms + self.body = QLabel() + self.body.setObjectName("ModalDialog_body") + self.body.setWordWrap(True) + self.body.setScaledContents(True) + body_container = QWidget() + self.body_layout = QVBoxLayout() + self.body_layout.setContentsMargins( + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) + body_container.setLayout(self.body_layout) + self.body_layout.addWidget(self.body) + + # Widget for displaying error messages (hidden by default) + self.error_details = QLabel() + self.error_details.setObjectName("ModalDialog_error_details") + self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) + self.error_details.setWordWrap(True) + self.error_details.hide() + + # Header animation + self.header_animation = load_movie("header_animation.gif") + self.header_animation.setScaledSize(QSize(64, 64)) + self.header_animation.frameChanged.connect(self.animate_header) + + # Populate text content + self.header.setText(self.header_text) + self.body.setText(self.body_text) + + # Add all the layout elements + parent_layout.addWidget(header_container) + parent_layout.addWidget(self.header_line) + parent_layout.addWidget(body_container) + # parent_layout.addStretch(1) + parent_layout.addWidget(self.error_details) + # parent_layout.setSizeConstraint(QLayout.SetFixedSize) + + return parent_layout + + def animate_header(self) -> None: + self.header_spinner_label.setPixmap(self.header_animation.currentPixmap()) + + def animate_activestate(self) -> None: + pass # Animation handled in parent + + def start_animate_activestate(self) -> None: + self.error_details.setStyleSheet("") + self.error_details.setObjectName("ModalDialog_error_details_active") + self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) + + def start_animate_header(self) -> None: + self.header_icon.setVisible(False) + self.header_spinner_label.setVisible(True) + self.header_animation.start() + + def stop_animate_activestate(self) -> None: + self.error_details.setStyleSheet("") + self.error_details.setObjectName("ModalDialog_error_details") + self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) + + def stop_animate_header(self) -> None: + self.header_icon.setVisible(True) + self.header_spinner_label.setVisible(False) + self.header_animation.stop() + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + self.status = status + self.completeChanged.emit() + # Some children (not the Prefight Page) may wish to call update_content here + + def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None: + """ + Update page's content based on new status. + Children may re-implement this method. + """ + if not status: + logger.error("Empty status value given to update_content") + status = ExportStatus.UNEXPECTED_RETURN_STATUS + + if should_show_hint: + self.error_details.setText(STATUS_MESSAGES.get(status)) + self.error_details.show() + else: + self.error_details.hide() + + +class PreflightPage(ExportWizardPage): + def __init__(self, export, summary): + header = _( + "Preparing to export:
" '{}' + ).format(summary) + body = _( + "

Understand the risks before exporting files

" + "Malware" + "
" + "This workstation lets you open files securely. If you open files 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 file, or contact your " + "administrator." + "

" + "Anonymity" + "
" + "Files submitted by sources may contain information or hidden metadata that " + "identifies who they are. To protect your sources, please consider redacting files " + "before working with them on network-connected computers." + ) + + super().__init__(export, header=header, body=body) + self.export.run_export_preflight_checks() + + def nextId(self): + """ + Override builtin to allow bypassing the password page if device is unlocked. + """ + if self.status == ExportStatus.DEVICE_WRITABLE: + logger.debug("Skip password prompt") + return Pages.EXPORT_DONE + elif self.status == ExportStatus.DEVICE_LOCKED: + logger.debug("Device locked - prompt for passphrase") + return Pages.UNLOCK_USB + else: + return super().nextId() + + +class InsertUSBPage(ExportWizardPage): + def __init__(self, export, summary): + header = _("Ready to export:
" '{}').format( + summary + ) + body = _( + "Please insert one of the export drives provisioned specifically " + "for the SecureDrop Workstation." + ) + super().__init__(export, header=header, body=body) + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + super().on_status_received(status) + self.update_content(status) + + def validatePage(self) -> bool: + """ + Override method to implement custom validation logic, which prevents the + wizard from advancing past this stage unless preconditions are met, and + shows an error-specific hint to the user. + """ + if self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.DEVICE_LOCKED): + self.error_details.hide() + return True + else: + logger.debug(f"Status is {self.status}, rechecking") + + # Show the user a hint + if self.status in ( + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ): + self.update_content(self.status) + else: + # Shouldn't reach + # Status may be None here + logger.warning("InsertUSBPage encountered unexpected status") + + # will return DEVICE_WRITABLE, DEVICE_LOCKED, or an error status + self.export.run_export_preflight_checks() + return False + + def nextId(self): + """ + Override builtin to allow bypassing the password page if device unlocked + """ + if self.status == ExportStatus.DEVICE_WRITABLE: + logger.debug("Skip password prompt") + return Pages.EXPORT_DONE + else: + return super().nextId() + + +class FinalPage(ExportWizardPage): + def __init__(self, export: Export) -> None: + header = _("Export successful") + body = _( + "Remember to be careful when working with files outside of your Workstation machine." + ) + super().__init__(export, header, body) + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + super().on_status_received(status) + self.update_content(status) + + def update_content(self, status: ExportStatus, should_show_hint: bool = False): + if self.status == ExportStatus.SUCCESS_EXPORT: + header = _("Export successful") + body = _( + "Remember to be careful when working with files " + "outside of your Workstation machine." + ) + elif self.status == ExportStatus.ERROR_EXPORT_CLEANUP: + header = header = _("Export sucessful, but drive was not locked") + body = STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP) + + else: + header = _("Export failed") + if not self.status: + self.status = ExportStatus.UNEXPECTED_RETURN_STATUS + body = STATUS_MESSAGES.get(self.status) + + self.header.setText(header) + self.body.setText(body) + + +class PassphraseWizardPage(ExportWizardPage): + """ + Wizard page that includes a passphrase prompt field + """ + + def __init__(self, export): + header = _("Enter passphrase for USB drive") + super().__init__(export, header, body=None) + + def _build_layout(self) -> QVBoxLayout: + layout = super()._build_layout() + + # Passphrase Form + self.passphrase_form = QWidget() + self.passphrase_form.setObjectName("ModalDialog_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) + passphrase_label = SecureQLabel(_("Passphrase")) + 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) + effect = QGraphicsDropShadowEffect(self) + effect.setOffset(0, -1) + effect.setBlurRadius(4) + effect.setColor(QColor("#aaa")) + self.passphrase_field.setGraphicsEffect(effect) + + # Makes the password text accessible outside of this panel + self.registerField("passphrase*", self.passphrase_field) + + check = SDCheckBox() + check.checkbox.stateChanged.connect(self.passphrase_field.on_toggle_password_Action) + + passphrase_form_layout.addWidget(passphrase_label) + passphrase_form_layout.addWidget(self.passphrase_field) + passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight) + + # Add it + layout.addWidget(self.passphrase_form) + return layout + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + super().on_status_received(status) + self.update_content(status) diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 38c15e0633..4c8155dab1 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -81,6 +81,7 @@ ) from securedrop_client.gui.base import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton from securedrop_client.gui.conversation import DeleteConversationDialog +from securedrop_client.gui.conversation.export import ExportWizard from securedrop_client.gui.datetime_helpers import format_datetime_local from securedrop_client.gui.source import DeleteSourceDialog from securedrop_client.logic import Controller @@ -2461,9 +2462,8 @@ def _on_export_clicked(self) -> None: export_device = conversation.ExportDevice() - self.export_dialog = conversation.ExportDialog( - export_device, self.file.filename, [file_location] - ) + self.export_dialog = ExportWizard(export_device, self.file.filename, [file_location]) + # fka conversation.ExportDialog self.export_dialog.show() @pyqtSlot() diff --git a/client/tests/gui/conversation/export/test_export_wizard.py b/client/tests/gui/conversation/export/test_export_wizard.py new file mode 100644 index 0000000000..cc916a19e3 --- /dev/null +++ b/client/tests/gui/conversation/export/test_export_wizard.py @@ -0,0 +1,141 @@ +from unittest import mock + +from securedrop_client.export_status import ExportStatus +from securedrop_client.gui.conversation.export import Export, ExportWizard +from securedrop_client.gui.conversation.export.export_wizard_constants import STATUS_MESSAGES, Pages +from securedrop_client.gui.conversation.export.export_wizard_page import ( + FinalPage, + InsertUSBPage, + PassphraseWizardPage, + PreflightPage, +) +from tests import factory + + +class TestExportWizard: + @classmethod + def _mock_export_preflight_success(cls) -> Export: + export = Export() + export.run_export_preflight_checks = lambda: export.export_state_changed.emit( + ExportStatus.DEVICE_LOCKED + ) + export.export = ( + mock.MagicMock() + ) # We will choose different signals and emit them during testing + return export + + @classmethod + def setup_class(cls): + cls.mock_controller = mock.MagicMock() + cls.mock_controller.data_dir = "/pretend/data-dir/" + cls.mock_source = factory.Source() + cls.mock_export = cls._mock_export_preflight_success() + cls.mock_file = factory.File(source=cls.mock_source) + cls.filepath = cls.mock_file.location(cls.mock_controller.data_dir) + + @classmethod + def setup_method(cls): + cls.wizard = ExportWizard(cls.mock_export, cls.mock_file.filename, [cls.filepath]) + + @classmethod + def teardown_method(cls): + cls.wizard.destroy() + cls.wizard = None + + def test_wizard_setup(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + assert len(self.wizard.pageIds()) == len(Pages._member_names_), self.wizard.pageIds() + assert isinstance(self.wizard.currentPage(), PreflightPage) + + def test_wizard_skips_insert_page_when_device_found_preflight(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.wizard.next() + + assert isinstance(self.wizard.currentPage(), PassphraseWizardPage) + + def test_wizard_exports_directly_to_unlocked_device(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + # Simulate an unlocked device + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_WRITABLE) + self.wizard.next() + + assert isinstance( + self.wizard.currentPage(), FinalPage + ), f"Actually, f{type(self.wizard.currentPage())}" + + def test_wizard_rewinds_if_device_removed(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.wizard.next() + assert isinstance(self.wizard.currentPage(), PassphraseWizardPage) + + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + + def test_wizard_all_steps(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + + self.mock_export.export_state_changed.emit(ExportStatus.MULTI_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + assert self.wizard.currentPage().error_details.isVisible() + + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_LOCKED) + self.wizard.next() + page = self.wizard.currentPage() + assert isinstance(page, PassphraseWizardPage) + + # No password entered, we shouldn't be able to advance + self.wizard.next() + assert isinstance(page, PassphraseWizardPage) + + # Type a passphrase. According to pytest-qt's own documentation, using + # qtbot.keyClicks and other interactions can lead to flaky tests, + # so using the setText method is fine, esp for unit testing. + page.passphrase_field.setText("correct horse battery staple!") + + # How dare you try a commonly-used password like that + self.mock_export.export_state_changed.emit(ExportStatus.ERROR_UNLOCK_LUKS) + + assert isinstance(page, PassphraseWizardPage) + assert page.error_details.isVisible() + + self.wizard.next() + + # Ok + page.passphrase_field.setText("substantial improvements encrypt accordingly") + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_WRITABLE) + + self.wizard.next() + self.mock_export.export_state_changed.emit(ExportStatus.ERROR_EXPORT_CLEANUP) + + page = self.wizard.currentPage() + assert isinstance(page, FinalPage) + assert page.body.text() == STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP) + + def test_wizard_hides_error_details_on_success(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + assert self.wizard.currentPage().error_details.isVisible() + + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_LOCKED) + self.wizard.next() + self.wizard.back() + assert not self.wizard.currentPage().error_details.isVisible() From 2e0994c7da6f948892d2290f996b9d46dc80bfe0 Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 2 Feb 2024 18:15:03 -0500 Subject: [PATCH 11/22] Remove encryption_method from json metadata --- client/securedrop_client/export.py | 2 +- export/tests/disk/test_service.py | 3 +-- export/tests/test_archive.py | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index b18be2a68e..d84e1b497f 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -37,7 +37,7 @@ class Export(QObject): _PRINT_METADATA = {"device": "printer"} _DISK_FN = "archive.sd-export" - _DISK_METADATA = {"device": "disk", "encryption_method": "luks"} + _DISK_METADATA = {"device": "disk"} _DISK_ENCRYPTION_KEY_NAME = "encryption_key" _DISK_EXPORT_DIR = "export_data" diff --git a/export/tests/disk/test_service.py b/export/tests/disk/test_service.py index 13a729507f..73dc0210ac 100644 --- a/export/tests/disk/test_service.py +++ b/export/tests/disk/test_service.py @@ -60,8 +60,7 @@ def _setup_submission(cls) -> Archive: metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: f.write( - '{"device": "disk", "encryption_method":' - ' "luks", "encryption_key": "hunter1"}' + '{"device": "disk", "encryption_key": "hunter1"}' ) return submission.set_metadata(Metadata(temp_folder).validate()) diff --git a/export/tests/test_archive.py b/export/tests/test_archive.py index 7c09b83d67..37510f0c84 100644 --- a/export/tests/test_archive.py +++ b/export/tests/test_archive.py @@ -436,7 +436,7 @@ def test_invalid_config(capsys): temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write('{"device": "asdf", "encryption_method": "OHNO"}') + f.write('{"device": "asdf"}') with pytest.raises(ExportException) as ex: Metadata(temp_folder).validate() @@ -450,7 +450,7 @@ def test_malformed_config(capsys): temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write('{"device": "asdf", "encryption_method": {"OHNO", "MALFORMED"}') + f.write('{"device": {"OHNO", "MALFORMED"}') with pytest.raises(ExportException) as ex: Metadata(temp_folder).validate() From 929df1f4a0c3fbca7cf31b0b6e2c5735886e701b Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 2 Feb 2024 18:20:45 -0500 Subject: [PATCH 12/22] Remove unused export pyqtSignals --- client/securedrop_client/export.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index d84e1b497f..80b5ac4d2d 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -44,20 +44,12 @@ class Export(QObject): # New, replacement for export success and error statuses export_state_changed = pyqtSignal(object) - # Emit ExportStatus - export_preflight_check_succeeded = pyqtSignal(object) - export_succeeded = pyqtSignal(object) - print_preflight_check_succeeded = pyqtSignal(object) print_succeeded = pyqtSignal(object) # Used for both print and export export_completed = pyqtSignal(object) - # Emit ExportError(status=ExportStatus) - export_preflight_check_failed = pyqtSignal(object) - export_failed = pyqtSignal(object) - print_preflight_check_failed = pyqtSignal(object) print_failed = pyqtSignal(object) @@ -94,11 +86,10 @@ def run_export_preflight_checks(self) -> None: metadata=self._USB_TEST_METADATA, ) status = self._run_qrexec_export(archive_path) - self.export_preflight_check_succeeded.emit(status) + self.export_state_changed.emit(status) except ExportError as e: logger.error("Export preflight failed") - self.export_preflight_check_failed.emit(e) if e.status: self.export_state_changed.emit(e.status) @@ -130,13 +121,11 @@ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: status = self._run_qrexec_export(archive_path) self.export_state_changed.emit(status) - self.export_succeeded.emit(status) logger.debug(f"Status {status}") except ExportError as e: logger.error("Export failed") logger.debug(f"Export failed: {e}") - self.export_failed.emit(e) if e.status and isinstance(e.status, ExportStatus): self.export_state_changed.emit(e.status) @@ -145,8 +134,6 @@ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: # Emit a generic error self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) - self.export_completed.emit(filepaths) - def print(self, filepaths: List[str]) -> None: """ Bundle files at self._filepaths_list into tarball and send for From 45fac2cac33455945e0d554533d640467e04260d Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 2 Feb 2024 18:26:11 -0500 Subject: [PATCH 13/22] Remove export_dialog.py and tests --- client/securedrop_client/gui/actions.py | 18 +- .../gui/conversation/__init__.py | 2 +- .../gui/conversation/export/__init__.py | 1 - .../gui/conversation/export/export_dialog.py | 352 --------------- client/tests/conftest.py | 14 +- .../gui/conversation/export/test_dialog.py | 352 --------------- .../conversation/export/test_file_dialog.py | 405 ------------------ client/tests/gui/test_actions.py | 4 +- client/tests/gui/test_widgets.py | 10 +- 9 files changed, 24 insertions(+), 1134 deletions(-) delete mode 100644 client/securedrop_client/gui/conversation/export/export_dialog.py delete mode 100644 client/tests/gui/conversation/export/test_dialog.py delete mode 100644 client/tests/gui/conversation/export/test_file_dialog.py diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 2e231f949a..ff189f086a 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -213,8 +213,7 @@ def __init__( @pyqtSlot() def _on_triggered(self) -> None: """ - (Re-)generates the conversation transcript and opens a confirmation dialog to export it, - in the manner of the existing ExportDialog. + (Re-)generates the conversation transcript and opens export wizard. """ file_path = ( Path(self.controller.data_dir) @@ -236,8 +235,8 @@ def _on_triggered(self) -> None: # by the operating system. with open(file_path, "r") as f: export_device = ExportDevice() - dialog = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) - dialog.exec() + wizard = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) + wizard.exec() class ExportConversationAction(QAction): # pragma: nocover @@ -265,9 +264,8 @@ def __init__( @pyqtSlot() def _on_triggered(self) -> None: """ - (Re-)generates the conversation transcript and opens a confirmation dialog to export it - alongside all the (attached) files that are downloaded, in the manner - of the existing ExportDialog. + (Re-)generates the conversation transcript and opens export wizard to export it + alongside all the (attached) files that are downloaded. """ if self._state is not None: id = self._state.selected_conversation @@ -293,7 +291,7 @@ def _prepare_to_export(self) -> None: """ (Re-)generates the conversation transcript and opens a confirmation dialog to export it alongside all the (attached) files that are downloaded, in the manner - of the existing ExportDialog. + of the existing ExportWizard. """ transcript_location = ( Path(self.controller.data_dir) @@ -333,12 +331,12 @@ def _prepare_to_export(self) -> None: else: summary = _("all files and transcript") - dialog = ExportWizard( + wizard = ExportWizard( export_device, summary, [str(file_location) for file_location in file_locations], ) - dialog.exec() + wizard.exec() def _on_confirmation_dialog_accepted(self) -> None: self._prepare_to_export() diff --git a/client/securedrop_client/gui/conversation/__init__.py b/client/securedrop_client/gui/conversation/__init__.py index 11729f8017..219c004655 100644 --- a/client/securedrop_client/gui/conversation/__init__.py +++ b/client/securedrop_client/gui/conversation/__init__.py @@ -4,6 +4,6 @@ # Import classes here to make possible to import them from securedrop_client.gui.conversation from .delete import DeleteConversationDialog # noqa: F401 from .export import Export as ExportDevice # noqa: F401 -from .export import ExportDialog as ExportDialog # noqa: F401 +from .export import ExportWizard as ExportWizard # noqa: F401 from .export import PrintDialog as PrintFileDialog # noqa: F401 from .export import PrintTranscriptDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/__init__.py b/client/securedrop_client/gui/conversation/export/__init__.py index c929b33ab4..328c19e436 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,5 +1,4 @@ from ....export import Export # noqa: F401 -from .export_dialog import ExportDialog # noqa: F401 from .export_wizard import ExportWizard # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 from .print_transcript_dialog import PrintTranscriptDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/export_dialog.py b/client/securedrop_client/gui/conversation/export/export_dialog.py deleted file mode 100644 index 11cf9d82e0..0000000000 --- a/client/securedrop_client/gui/conversation/export/export_dialog.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -A dialog that allows journalists to export sensitive files to a USB drive. -""" -from gettext import gettext as _ -from typing import List, Optional - -from pkg_resources import resource_string -from PyQt5.QtCore import QSize, Qt, pyqtSlot -from PyQt5.QtGui import QColor, QFont -from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QLineEdit, QVBoxLayout, QWidget - -from securedrop_client.export_status import ExportError, ExportStatus -from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel -from securedrop_client.gui.base.checkbox import SDCheckBox - -from ....export import Export - - -class ExportDialog(ModalDialog): - DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8") - - PASSPHRASE_LABEL_SPACING = 0.5 - NO_MARGIN = 0 - FILENAME_WIDTH_PX = 260 - - def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> None: - """ - Args: - export (Export): manages the export and returns status information to the - dialog - - summary_text (str): String descriptor of what is being exported (will be - displayed to user). For single-item exports, the filename can be used; for - multifile exports, a summary (such as f"{len(filepaths) files"}) can be used. - - filepaths (List[str]): list of complete non-relative paths to items targeted for - export. Filepaths should be checked by the controller before this dialog launches, - although the dialog performs basic before attempting to export to ensure the file - is present. (In the current implementation, are held open by a context manager to - prevent the scenario where the file is deleted while the dialog is open). - """ - super().__init__() - self.setStyleSheet(self.DIALOG_CSS) - - self._device = export - self.filepaths = filepaths - - self.summary_text = SecureQLabel( - summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX - ).text() - # Hold onto the error status we receive from the Export VM - self.error_status: Optional[ExportStatus] = None - - # Connect device signals to slots - self._device.export_preflight_check_succeeded.connect( - self._on_export_preflight_check_succeeded - ) - self._device.export_preflight_check_failed.connect(self._on_export_preflight_check_failed) - self._device.export_succeeded.connect(self._on_export_succeeded) - self._device.export_failed.connect(self._on_export_failed) - - # Connect parent signals to slots - self.continue_button.setEnabled(False) - self.continue_button.clicked.connect(self._run_preflight) - - # Dialog content - self.starting_header = _( - "Preparing to export:
" '{}' - ).format(self.summary_text) - self.ready_header = _( - "Ready to export:
" '{}' - ).format(self.summary_text) - self.insert_usb_header = _("Insert encrypted USB drive") - self.passphrase_header = _("Enter passphrase for USB drive") - self.success_header = _("Export successful") - self.error_header = _("Export failed") - self.starting_message = _( - "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files 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 file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - self.exporting_message = _("Exporting: {}").format(self.summary_text) - self.insert_usb_message = _( - "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." - ) - self.passphrase_error_message = _("The passphrase provided did not work. Please try again.") - self.generic_error_message = _("See your administrator for help.") - 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.setObjectName("FileDialog_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) - passphrase_label = SecureQLabel(_("Passphrase")) - 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) - effect = QGraphicsDropShadowEffect(self) - effect.setOffset(0, -1) - effect.setBlurRadius(4) - effect.setColor(QColor("#aaa")) - self.passphrase_field.setGraphicsEffect(effect) - - check = SDCheckBox() - check.checkbox.stateChanged.connect(self.passphrase_field.on_toggle_password_Action) - - passphrase_form_layout.addWidget(passphrase_label) - passphrase_form_layout.addWidget(self.passphrase_field) - passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight) - self.body_layout.addWidget(self.passphrase_form) - self.passphrase_form.hide() - - self._show_starting_instructions() - self.start_animate_header() - self._run_preflight() - - def _show_starting_instructions(self) -> None: - self.header.setText(self.starting_header) - self.body.setText(self.starting_message) - self.adjustSize() - - def _show_passphrase_request_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export) - self.header.setText(self.passphrase_header) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.error_details.hide() - self.body.hide() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - # Retrying an incorrect passphrase - def _show_passphrase_request_message_again(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export) - 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_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - def _show_success_message(self) -> None: - 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() - - def _show_insert_usb_message(self) -> None: - 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() - - def _show_insert_encrypted_usb_message(self) -> None: - 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() - - def _show_generic_error_message(self) -> None: - 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( # nosemgrep: semgrep.untranslated-gui-string - "{}: {}".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() - - @pyqtSlot() - def _run_preflight(self) -> None: - self._device.run_export_preflight_checks() - - @pyqtSlot() - def _export(self, checked: bool = False) -> None: - self.start_animate_activestate() - self.cancel_button.setEnabled(False) - self.passphrase_field.setDisabled(True) - - # TODO: If the drive is already unlocked, the passphrase field will be empty. - # This is ok, but could violate expectations. The password should be passed - # via qrexec in future, to avoid writing it to even a temporary file at all. - self._device.export(self.filepaths, self.passphrase_field.text()) - - @pyqtSlot(object) - def _on_export_preflight_check_succeeded(self, result: ExportStatus) -> None: - # If the continue button is disabled then this is the result of a background preflight check - self.stop_animate_header() - self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) - self.header.setText(self.ready_header) - if not self.continue_button.isEnabled(): - self.continue_button.clicked.disconnect() - if result == ExportStatus.DEVICE_WRITABLE: - # Skip password prompt, we're there - self.continue_button.clicked.connect(self._export) - else: # result == ExportStatus.DEVICE_LOCKED - self.continue_button.clicked.connect(self._show_passphrase_request_message) - self.continue_button.setEnabled(True) - self.continue_button.setFocus() - return - - # Skip passphrase prompt if device is unlocked - if result == ExportStatus.DEVICE_WRITABLE: - self._export() - else: - self._show_passphrase_request_message() - - @pyqtSlot(object) - def _on_export_preflight_check_failed(self, error: ExportError) -> None: - self.stop_animate_header() - self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) - self._update_dialog(error.status) - - @pyqtSlot(object) - def _on_export_succeeded(self, status: ExportStatus) -> None: - self.stop_animate_activestate() - self._show_success_message() - - @pyqtSlot(object) - def _on_export_failed(self, error: ExportError) -> None: - self.stop_animate_activestate() - self.cancel_button.setEnabled(True) - self.passphrase_field.setDisabled(False) - self._update_dialog(error.status) - - def _update_dialog(self, error_status: ExportStatus) -> None: - 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.ERROR_UNLOCK_LUKS: - self.continue_button.clicked.connect(self._show_passphrase_request_message_again) - elif self.error_status == ExportStatus.NO_DEVICE_DETECTED: # fka USB_NOT_CONNECTED - self.continue_button.clicked.connect(self._show_insert_usb_message) - elif ( - self.error_status == ExportStatus.INVALID_DEVICE_DETECTED - ): # fka DISK_ENCRYPTION_NOT_SUPPORTED_ERROR - 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) - self.continue_button.setFocus() - else: - if self.error_status == ExportStatus.ERROR_UNLOCK_LUKS: - self._show_passphrase_request_message_again() - elif self.error_status == ExportStatus.NO_DEVICE_DETECTED: - self._show_insert_usb_message() - elif self.error_status == ExportStatus.INVALID_DEVICE_DETECTED: - self._show_insert_encrypted_usb_message() - else: - self._show_generic_error_message() - - -# def _ui_preparing_to_export(self): -# pass - - -# def _ui_ready_export_no_passphrase_prompt(self): -# pass - - -# def _ui_ready_export_passphrase_prompt(self): -# pass - - -# def _ui_ready_export_retry_passphrase(self): -# pass - - -# def _ui_no_devices(self): -# pass - - -# def _ui_too_many_devices(self): -# pass - - -def _ui_invalid_device(self): - pass - - -def _ui_error_export_did_not_complete(self): - pass - - -def _ui_error_after_export_complete(self): - pass - - -# Dialog states: -# Preparing to Export.... (preflight) -# Ready to export (no passphrase just "export") - -# please enter your passphrase (->"export") - -# Please insert a USB (no_device) -# Please re-enter your passphrase (-> "export") -# Error, time to close (invalid device) -# Too many options (multi-device) diff --git a/client/tests/conftest.py b/client/tests/conftest.py index eea47c5587..a8be1255ca 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -97,38 +97,38 @@ def print_transcript_dialog(mocker, homedir): @pytest.fixture(scope="function") -def export_dialog_multifile(mocker, homedir): +def export_wizard_multifile(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.ExportDialog( + wizard = conversation.ExportWizard( export_device, "3 files", ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"], ) - yield dialog + yield wizard @pytest.fixture(scope="function") -def export_dialog(mocker, homedir): +def export_wizard(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.ExportDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) + dialog = conversation.ExportWizard(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @pytest.fixture(scope="function") -def export_transcript_dialog(mocker, homedir): +def export_transcript_wizard(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.ExportDialog( + dialog = conversation.ExportWizard( export_device, "transcript.txt", ["/some/path/transcript.txt"] ) diff --git a/client/tests/gui/conversation/export/test_dialog.py b/client/tests/gui/conversation/export/test_dialog.py deleted file mode 100644 index 0d6903441d..0000000000 --- a/client/tests/gui/conversation/export/test_dialog.py +++ /dev/null @@ -1,352 +0,0 @@ -from securedrop_client.export_status import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportDialog -from tests.helper import app # noqa: F401 - - -def test_ExportDialog_init(mocker): - _show_starting_instructions_fn = mocker.patch( - "securedrop_client.gui.conversation.ExportDialog._show_starting_instructions" - ) - - export_dialog = ExportDialog( - mocker.MagicMock(), "3 files", ["mock.jpg", "memo.txt", "transcript.txt"] - ) - - _show_starting_instructions_fn.assert_called_once_with() - assert export_dialog.passphrase_form.isHidden() - - -def test_ExportDialog__show_starting_instructions(mocker, export_dialog_multifile): - export_dialog_multifile._show_starting_instructions() - - # "3 files" comes from the export_dialog fixture - assert ( - export_dialog_multifile.header.text() == "Preparing to export:" - "
" - '3 files' - ) - assert ( - export_dialog_multifile.body.text() - == "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files 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 file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog___show_passphrase_request_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message() - - assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" - assert not export_dialog_multifile.header.isHidden() - assert export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert export_dialog_multifile.body.isHidden() - assert not export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message_again() - - assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" - assert ( - export_dialog_multifile.error_details.text() - == "The passphrase provided did not work. Please try again." - ) - assert export_dialog_multifile.body.isHidden() - assert not export_dialog_multifile.header.isHidden() - assert export_dialog_multifile.header_line.isHidden() - assert not export_dialog_multifile.error_details.isHidden() - assert export_dialog_multifile.body.isHidden() - assert not export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_success_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_success_message() - - assert export_dialog_multifile.header.text() == "Export successful" - assert ( - export_dialog_multifile.body.text() - == "Remember to be careful when working with files outside of your Workstation machine." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_usb_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_insert_usb_message() - - assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog_multifile.body.text() - == "Please insert one of the export drives provisioned specifically " - "for the SecureDrop Workstation." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_insert_encrypted_usb_message() - - assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog_multifile.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_dialog_multifile.body.text() - == "Please insert one of the export drives provisioned specifically for the SecureDrop " - "Workstation." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert not export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_generic_error_message(mocker, export_dialog_multifile): - export_dialog_multifile.error_status = "mock_error_status" - - export_dialog_multifile._show_generic_error_message() - - assert export_dialog_multifile.header.text() == "Export failed" - assert ( - export_dialog_multifile.body.text() == "mock_error_status: See your administrator for help." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__export(mocker, export_dialog_multifile): - device = mocker.MagicMock() - device.export = mocker.MagicMock() - export_dialog_multifile._device = device - export_dialog_multifile.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") - - export_dialog_multifile._export() - - device.export.assert_called_once_with( - export_dialog_multifile.filepaths, - "mock_passphrase", - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - export_dialog_multifile._on_export_preflight_check_succeeded( - ExportStatus.PRINT_PREFLIGHT_SUCCESS - ) - - export_dialog_multifile._show_passphrase_request_message.assert_not_called() - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_passphrase_request_message - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() - export_dialog_multifile.continue_button.setEnabled(True) - - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - - export_dialog_multifile._show_passphrase_request_message.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_continue_enabled_and_device_unlocked( - mocker, export_dialog_multifile -): - export_dialog_multifile._export = mocker.MagicMock() - export_dialog_multifile.continue_button.setEnabled(True) - - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - - export_dialog_multifile._export.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_dialog_multifile -): - assert not export_dialog_multifile.continue_button.isEnabled() - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - assert export_dialog_multifile.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_dialog_multifile -): - assert not export_dialog_multifile.continue_button.isEnabled() - export_dialog_multifile._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_dialog_multifile.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog_multifile): - export_dialog_multifile._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog_multifile._on_export_preflight_check_failed(error) - - export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__on_export_succeeded(mocker, export_dialog_multifile): - export_dialog_multifile._show_success_message = mocker.MagicMock() - - export_dialog_multifile._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) - - export_dialog_multifile._show_success_message.assert_called_once_with() - - -def test_ExportDialog__on_export_failed(mocker, export_dialog_multifile): - export_dialog_multifile._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog_multifile._on_export_failed(error) - - export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_insert_usb_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_insert_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_dialog_multifile._show_insert_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message_again = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_passphrase_request_message_again - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) # fka BAD_PASSPHRASE - export_dialog_multifile._show_passphrase_request_message_again.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_insert_encrypted_usb_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog( - ExportStatus.INVALID_DEVICE_DETECTED - ) # DISK_ENCRYPTION_NOT_SUPPORTED_ERROR - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_insert_encrypted_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_dialog_multifile._show_insert_encrypted_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_generic_error_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_generic_error_message - ) - assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog_multifile._show_generic_error_message.assert_called_once_with() - assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR - - -def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_dialog_multifile): - export_dialog_multifile._show_generic_error_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog("Some Unknown Error Status") - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_generic_error_message - ) - assert export_dialog_multifile.error_status == "Some Unknown Error Status" - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog("Some Unknown Error Status") - export_dialog_multifile._show_generic_error_message.assert_called_once_with() - assert export_dialog_multifile.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/conversation/export/test_file_dialog.py b/client/tests/gui/conversation/export/test_file_dialog.py deleted file mode 100644 index a9ee020be8..0000000000 --- a/client/tests/gui/conversation/export/test_file_dialog.py +++ /dev/null @@ -1,405 +0,0 @@ -from securedrop_client.export_status import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportDialog -from tests.helper import app # noqa: F401 - - -def test_ExportDialog_init(mocker): - _show_starting_instructions_fn = mocker.patch( - "securedrop_client.gui.conversation.ExportDialog._show_starting_instructions" - ) - - export_file_dialog = ExportDialog(mocker.MagicMock(), "mock.jpg", ["/mock/path/to/file"]) - - _show_starting_instructions_fn.assert_called_once_with() - assert export_file_dialog.passphrase_form.isHidden() - - -def test_ExportDialog_init_sanitizes_filename(mocker): - secure_qlabel = mocker.patch( - "securedrop_client.gui.conversation.export.export_dialog.SecureQLabel" - ) - mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") - filename = '' - - ExportDialog(mocker.MagicMock(), filename, ["/mock/path/to/file"]) - - secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) - - -def test_ExportDialog__show_starting_instructions(mocker, export_dialog_multifile): - export_dialog_multifile._show_starting_instructions() - - # file123.jpg comes from the export_file_dialog fixture - assert ( - export_dialog_multifile.header.text() == "Preparing to export:" - "
" - '3 files' - ) - assert ( - export_dialog_multifile.body.text() - == "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files 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 file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -# The summary text is different for a dialog with one file vs 3 files -def test_ExportDialog__show_starting_instructions_single_file(mocker, export_dialog): - export_dialog._show_starting_instructions() - - # file123.jpg comes from the export_file_dialog fixture - assert ( - export_dialog.header.text() == "Preparing to export:" - "
" - 'file123.jpg' - ) - assert ( - export_dialog.body.text() == "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files 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 file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() - - -def test_ExportDialog___show_passphrase_request_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message() - - assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" - assert not export_dialog_multifile.header.isHidden() - assert export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert export_dialog_multifile.body.isHidden() - assert not export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message_again() - - assert export_dialog_multifile.header.text() == "Enter passphrase for USB drive" - assert ( - export_dialog_multifile.error_details.text() - == "The passphrase provided did not work. Please try again." - ) - assert export_dialog_multifile.body.isHidden() - assert not export_dialog_multifile.header.isHidden() - assert export_dialog_multifile.header_line.isHidden() - assert not export_dialog_multifile.error_details.isHidden() - assert export_dialog_multifile.body.isHidden() - assert not export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_success_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_success_message() - - assert export_dialog_multifile.header.text() == "Export successful" - assert ( - export_dialog_multifile.body.text() - == "Remember to be careful when working with files outside of your Workstation machine." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_usb_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_insert_usb_message() - - assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog_multifile.body.text() - == "Please insert one of the export drives provisioned specifically " - "for the SecureDrop Workstation." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog_multifile): - export_dialog_multifile._show_insert_encrypted_usb_message() - - assert export_dialog_multifile.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog_multifile.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_dialog_multifile.body.text() - == "Please insert one of the export drives provisioned specifically for the SecureDrop " - "Workstation." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert not export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__show_generic_error_message(mocker, export_dialog_multifile): - export_dialog_multifile.error_status = "mock_error_status" - - export_dialog_multifile._show_generic_error_message() - - assert export_dialog_multifile.header.text() == "Export failed" - assert ( - export_dialog_multifile.body.text() == "mock_error_status: See your administrator for help." - ) - assert not export_dialog_multifile.header.isHidden() - assert not export_dialog_multifile.header_line.isHidden() - assert export_dialog_multifile.error_details.isHidden() - assert not export_dialog_multifile.body.isHidden() - assert export_dialog_multifile.passphrase_form.isHidden() - assert not export_dialog_multifile.continue_button.isHidden() - assert not export_dialog_multifile.cancel_button.isHidden() - - -def test_ExportDialog__export_file(mocker, export_dialog_multifile): - device = mocker.MagicMock() - device.export = mocker.MagicMock() - export_dialog_multifile._device = device - export_dialog_multifile.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") - - export_dialog_multifile._export() - - device.export.assert_called_once_with(export_dialog_multifile.filepaths, "mock_passphrase") - - -def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - - export_dialog_multifile._show_passphrase_request_message.assert_not_called() - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_passphrase_request_message - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_device_unlocked( - mocker, export_dialog_multifile -): - export_dialog_multifile._export = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - - export_dialog_multifile._export.assert_not_called() - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._export - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_passphrase_request_message = mocker.MagicMock() - export_dialog_multifile.continue_button.setEnabled(True) - - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - - export_dialog_multifile._show_passphrase_request_message.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_unlocked_device_when_continue_enabled( - mocker, export_dialog_multifile -): - export_dialog_multifile._export = mocker.MagicMock() - export_dialog_multifile.continue_button.setEnabled(True) - - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - - export_dialog_multifile._export.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_dialog_multifile -): - assert not export_dialog_multifile.continue_button.isEnabled() - export_dialog_multifile._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - assert export_dialog_multifile.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_dialog_multifile -): - assert not export_dialog_multifile.continue_button.isEnabled() - export_dialog_multifile._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_dialog_multifile.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog_multifile): - export_dialog_multifile._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog_multifile._on_export_preflight_check_failed(error) - - export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__on_export_succeeded(mocker, export_dialog_multifile): - export_dialog_multifile._show_success_message = mocker.MagicMock() - - export_dialog_multifile._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) - - export_dialog_multifile._show_success_message.assert_called_once_with() - - -def test_ExportDialog__on_export_failed(mocker, export_dialog_multifile): - export_dialog_multifile._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog_multifile._on_export_failed(error) - - export_dialog_multifile._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_insert_usb_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_insert_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.NO_DEVICE_DETECTED) - export_dialog_multifile._show_insert_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_dialog_multifile): - export_dialog_multifile._show_passphrase_request_message_again = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_passphrase_request_message_again - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) - export_dialog_multifile._show_passphrase_request_message_again.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_insert_encrypted_usb_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_insert_encrypted_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED) - export_dialog_multifile._show_insert_encrypted_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( - mocker, export_dialog_multifile -): - export_dialog_multifile._show_generic_error_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_generic_error_message - ) - assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog_multifile._show_generic_error_message.assert_called_once_with() - assert export_dialog_multifile.error_status == ExportStatus.CALLED_PROCESS_ERROR - - -def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_dialog_multifile): - export_dialog_multifile._show_generic_error_message = mocker.MagicMock() - export_dialog_multifile.continue_button = mocker.MagicMock() - export_dialog_multifile.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog_multifile._update_dialog("Some Unknown Error Status") - export_dialog_multifile.continue_button.clicked.connect.assert_called_once_with( - export_dialog_multifile._show_generic_error_message - ) - assert export_dialog_multifile.error_status == "Some Unknown Error Status" - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog_multifile.continue_button, "isEnabled", return_value=True) - export_dialog_multifile._update_dialog("Some Unknown Error Status") - export_dialog_multifile._show_generic_error_message.assert_called_once_with() - assert export_dialog_multifile.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py index c52a2b7210..82f4a9f387 100644 --- a/client/tests/gui/test_actions.py +++ b/client/tests/gui/test_actions.py @@ -289,7 +289,7 @@ def test_trigger(self, _): class TestExportConversationTranscriptAction(unittest.TestCase): - @patch("securedrop_client.gui.actions.ExportDialog") + @patch("securedrop_client.gui.actions.ExportWizard") def test_trigger(self, _): with managed_locale(): locale.setlocale(locale.LC_ALL, ("en_US", "latin-1")) @@ -318,7 +318,7 @@ def test_trigger(self, _): class TestExportConversationAction(unittest.TestCase): - @patch("securedrop_client.gui.actions.ExportDialog") + @patch("securedrop_client.gui.actions.ExportWizard") def test_trigger(self, _): with managed_locale(): locale.setlocale(locale.LC_ALL, ("en_US", "latin-1")) diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index 4ef404f718..8348c4625a 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3600,10 +3600,12 @@ 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) - dialog = mocker.patch("securedrop_client.gui.conversation.ExportDialog") + wizard = mocker.patch("securedrop_client.gui.conversation.ExportWizard") fw._on_export_clicked() - dialog.assert_called_once_with(export_device(), file.filename, [file_location]) + wizard.assert_called_once_with( + export_device(), file.filename, [file_location] + ), wizard.call_args def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): @@ -3630,12 +3632,12 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): mocker.patch("PyQt5.QtWidgets.QDialog.exec") controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=False) - dialog = mocker.patch("securedrop_client.gui.conversation.ExportDialog") + wizard = mocker.patch("securedrop_client.gui.conversation.ExportWizard") fw._on_export_clicked() controller.run_export_preflight_checks.assert_not_called() - dialog.assert_not_called() + wizard.assert_not_called() def test_FileWidget__on_print_clicked(mocker, session, source): From fd7fdab223b0714cd50bd6bf54f87d7039feaf74 Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 2 Feb 2024 18:54:38 -0500 Subject: [PATCH 14/22] Export wizard improvements. Add Error page for unrecoverable errors. --- .../gui/conversation/export/export_wizard.py | 71 +++++++++---------- .../export/export_wizard_constants.py | 7 +- .../conversation/export/export_wizard_page.py | 54 +++++++++++--- client/securedrop_client/gui/widgets.py | 5 +- client/tests/gui/test_widgets.py | 4 +- 5 files changed, 88 insertions(+), 53 deletions(-) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index 1ac1952074..d48076691c 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -10,13 +10,15 @@ from securedrop_client.export import Export from securedrop_client.export_status import ExportStatus from securedrop_client.gui.base import SecureQLabel -from securedrop_client.gui.conversation.export.export_wizard_constants import Pages +from securedrop_client.gui.conversation.export.export_wizard_constants import Pages, STATUS_MESSAGES from securedrop_client.gui.conversation.export.export_wizard_page import ( + ErrorPage, FinalPage, InsertUSBPage, PassphraseWizardPage, PreflightPage, ) +from securedrop_client.resources import load_movie logger = logging.getLogger(__name__) @@ -31,8 +33,8 @@ class ExportWizard(QWizard): FILENAME_WIDTH_PX = 260 BUTTON_CSS = resource_string(__name__, "dialog_button.css").decode("utf-8") - # TODO: If the drive is unlocked, we don't need a passphrase; - # if we do, it's populated later + # If the drive is unlocked, we don't need a passphrase; if we do need one, + # it's populated later. PASS_PLACEHOLDER_FIELD = "" def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> None: @@ -48,6 +50,9 @@ def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> N # Signal from qrexec command runner self.export.export_state_changed.connect(self.on_status_received) + # Clean up export on dialog closed signal + self.finished.connect(self.export.end_process) + self._set_layout() self._set_pages() self._style_buttons() @@ -66,9 +71,6 @@ def text(self) -> str: return self.body.text() def _style_buttons(self) -> None: - # When the dialog launches, the export preflight call is executed - # on the first screen, as well as for validation on the USB insert page. - # Otherwise, all calls are to export self.next_button = self.button(QWizard.WizardButton.NextButton) self.next_button.clicked.connect(self.request_export) self.next_button.setStyleSheet(self.BUTTON_CSS) @@ -76,30 +78,24 @@ def _style_buttons(self) -> None: self.cancel_button.setStyleSheet(self.BUTTON_CSS) # Activestate animation - # TODO: we may not use this - # self.button_animation = load_movie("activestate-wide.gif") - # self.button_animation.setScaledSize(QSize(32, 32)) - # self.button_animation.frameChanged.connect(self.animate_activestate) + self.button_animation = load_movie("activestate-wide.gif") + self.button_animation.setScaledSize(QSize(32, 32)) + self.button_animation.frameChanged.connect(self.animate_activestate) - # TODO: may not style buttons like this def animate_activestate(self) -> None: self.next_button.setIcon(QIcon(self.button_animation.currentPixmap())) - # TODO def start_animate_activestate(self) -> None: self.button_animation.start() - self.next_button.setText("") self.next_button.setMinimumSize(QSize(142, 43)) # Reset widget stylesheets self.next_button.setStyleSheet("") self.next_button.setObjectName("ModalDialog_primary_button_active") self.next_button.setStyleSheet(self.BUTTON_CSS) - # TODO- move to parent class def stop_animate_activestate(self) -> None: self.next_button.setIcon(QIcon()) self.button_animation.stop() - self.next_button.setText(_("CONTINUE")) # Reset widget stylesheets self.next_button.setStyleSheet("") self.next_button.setObjectName("ModalDialog_primary_button") @@ -117,6 +113,7 @@ def _set_layout(self) -> None: def _set_pages(self) -> None: for page in [ self._create_preflight(), + self._create_errorpage(), self._create_insert_usb(), self._create_passphrase_prompt(), self._create_done(), @@ -132,14 +129,17 @@ def _set_focus(self, which: QWizard.WizardButton) -> None: self.button(which).setFocus() def request_export(self) -> None: + logger.debug("Request export") + # Registered fields let us access the passphrase field + # of the PassphraseRequestPage from the wizard parent passphrase_untrusted = self.field("passphrase") if str(passphrase_untrusted) is not None: - # Export is shell-escaped self.export.export(self.filepaths, str(passphrase_untrusted)) else: self.export.export(self.filepaths, self.PASS_PLACEHOLDER_FIELD) def request_export_preflight(self) -> None: + logger.debug("Request preflight check") self.export.run_export_preflight_checks() @pyqtSlot(object) @@ -179,10 +179,16 @@ def on_status_received(self, status: ExportStatus) -> None: # Someone may have yanked out or unmounted a USB if target: - # If the user is stuck on the same page, show them a hint - should_show_hint = (target == self.currentId()) self.rewind(target) - self.currentPage().update_content(status, should_show_hint) + + # If the user is stuck, show them a hint. + should_show_hint = target == self.currentId() + logger.debug(f"{status.value} - should show hint is {should_show_hint}") + + page = self.currentPage() + # Sometimes self.currentPage() is None + if page and should_show_hint: + page.update_content(status, should_show_hint) # Update status self.current_status = status @@ -199,28 +205,21 @@ def end_wizard_with_error(self, error: ExportStatus) -> None: If and end state is reached, display message and let user end the wizard. """ + if isinstance(self.currentPage(), PreflightPage): + self.next() + else: + while self.currentId() > Pages.ERROR: + self.back() page = self.currentPage() + page.set_complete(False) + page.update_content(error) - # There's no way to advance a wizard to an arbitrary page - # in PyQt5 - if page: - # Disable "next" on terminal error pages - page.set_complete(False) - if isinstance(page, PassphraseWizardPage): - # Advance one page and display the error - self.next() - self.page(self.currentId()).update_content(error) - elif isinstance(page, FinalPage): - page.update_content(error) - else: - # Should be unreachable - logger.error("Tried to end early, but user should cancel") - - # readywhen: valid usb inserted def _create_preflight(self) -> QWizardPage: return PreflightPage(self.export, self.summary_text) - # readywhen: usb inserted + def _create_errorpage(self) -> QWizardPage: + return ErrorPage(self.export, "") + def _create_insert_usb(self) -> QWizardPage: return InsertUSBPage(self.export, self.summary_text) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py index 7236350ec2..2d432c1d24 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -11,9 +11,10 @@ # Sequential list of pages (the order matters) class Pages(IntEnum): PREFLIGHT = 0 - INSERT_USB = 1 - UNLOCK_USB = 2 - EXPORT_DONE = 3 + ERROR = 1 + INSERT_USB = 2 + UNLOCK_USB = 3 + EXPORT_DONE = 4 # Human-readable status info diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py index 6a1a35a9ac..29aa78a669 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -214,6 +214,7 @@ def __init__(self, export, summary): ) super().__init__(export, header=header, body=body) + self.start_animate_header() self.export.run_export_preflight_checks() def nextId(self): @@ -226,8 +227,37 @@ def nextId(self): elif self.status == ExportStatus.DEVICE_LOCKED: logger.debug("Device locked - prompt for passphrase") return Pages.UNLOCK_USB + elif self.status in ( + ExportStatus.CALLED_PROCESS_ERROR, + ExportStatus.DEVICE_ERROR, + ExportStatus.UNEXPECTED_RETURN_STATUS, + ): + logger.debug("Error during preflight - show error page") + return Pages.ERROR else: - return super().nextId() + return Pages.INSERT_USB + + def on_status_received(self, status: ExportStatus): + self.stop_animate_header() + if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE): + header = _( + "Ready to export:
" '{}' + ).format(self.header_text) + self.header.setText(header) + self.status = status + + +class ErrorPage(ExportWizardPage): + def __init__(self, export, summary): + header = _("Export Failed") + summary = "" # todo + + super().__init__(export, header=header, body=summary) + + def on_status_received(self, status: ExportStatus): + body = STATUS_MESSAGES.get(status) + self.body.setText(body) + self.status = status class InsertUSBPage(ExportWizardPage): @@ -244,7 +274,12 @@ def __init__(self, export, summary): @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: super().on_status_received(status) - self.update_content(status) + should_show_hint = status in ( + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ) + self.update_content(status, should_show_hint) def validatePage(self) -> bool: """ @@ -264,14 +299,11 @@ def validatePage(self) -> bool: ExportStatus.NO_DEVICE_DETECTED, ExportStatus.INVALID_DEVICE_DETECTED, ): - self.update_content(self.status) + self.update_content(self.status, should_show_hint=True) else: # Shouldn't reach # Status may be None here logger.warning("InsertUSBPage encountered unexpected status") - - # will return DEVICE_WRITABLE, DEVICE_LOCKED, or an error status - self.export.run_export_preflight_checks() return False def nextId(self): @@ -299,13 +331,13 @@ def on_status_received(self, status: ExportStatus) -> None: self.update_content(status) def update_content(self, status: ExportStatus, should_show_hint: bool = False): - if self.status == ExportStatus.SUCCESS_EXPORT: + if status == ExportStatus.SUCCESS_EXPORT: header = _("Export successful") body = _( "Remember to be careful when working with files " "outside of your Workstation machine." ) - elif self.status == ExportStatus.ERROR_EXPORT_CLEANUP: + elif status == ExportStatus.ERROR_EXPORT_CLEANUP: header = header = _("Export sucessful, but drive was not locked") body = STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP) @@ -368,4 +400,8 @@ def _build_layout(self) -> QVBoxLayout: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: super().on_status_received(status) - self.update_content(status) + should_show_hint = status in ( + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.ERROR_UNLOCK_GENERIC, + ) + self.update_content(status, should_show_hint) diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 4c8155dab1..19b2f789ca 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -2462,9 +2462,8 @@ def _on_export_clicked(self) -> None: export_device = conversation.ExportDevice() - self.export_dialog = ExportWizard(export_device, self.file.filename, [file_location]) - # fka conversation.ExportDialog - self.export_dialog.show() + self.export_wizard = ExportWizard(export_device, self.file.filename, [file_location]) + self.export_wizard.show() @pyqtSlot() def _on_print_clicked(self) -> None: diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index 8348c4625a..0c3d49b8cc 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3600,12 +3600,12 @@ 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) - wizard = mocker.patch("securedrop_client.gui.conversation.ExportWizard") + wizard = mocker.patch("securedrop_client.gui.conversation.export.ExportWizard") fw._on_export_clicked() wizard.assert_called_once_with( export_device(), file.filename, [file_location] - ), wizard.call_args + ), f"{wizard.call_args}" def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): From 13c5e24ba9a12cee21d1a45f7ea688aba4db891b Mon Sep 17 00:00:00 2001 From: Ro Date: Sun, 4 Feb 2024 20:01:08 -0500 Subject: [PATCH 15/22] Use QProcess instead of subprocess for qrexec commands --- client/securedrop_client/export.py | 224 +++++++++++++++++++---------- 1 file changed, 148 insertions(+), 76 deletions(-) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 80b5ac4d2d..1c4f930a60 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -1,14 +1,13 @@ import json import logging import os -import subprocess import tarfile from io import BytesIO from shlex import quote from tempfile import TemporaryDirectory -from typing import List, Optional +from typing import Callable, List, Optional -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtCore import QProcess, QObject, pyqtSignal from securedrop_client.export_status import ExportError, ExportStatus @@ -41,18 +40,20 @@ class Export(QObject): _DISK_ENCRYPTION_KEY_NAME = "encryption_key" _DISK_EXPORT_DIR = "export_data" - # New, replacement for export success and error statuses + # Emit export states export_state_changed = pyqtSignal(object) + # Emit print states print_preflight_check_succeeded = pyqtSignal(object) print_succeeded = pyqtSignal(object) - # Used for both print and export export_completed = pyqtSignal(object) print_preflight_check_failed = pyqtSignal(object) print_failed = pyqtSignal(object) + process = None # Optional[QProcess] + def run_printer_preflight_checks(self) -> None: """ Make sure the Export VM is started. @@ -65,8 +66,9 @@ def run_printer_preflight_checks(self) -> None: archive_fn=self._PRINTER_PREFLIGHT_FN, metadata=self._PRINTER_PREFLIGHT_METADATA, ) - status = self._run_qrexec_export(archive_path) - self.print_preflight_check_succeeded.emit(status) + self._run_qrexec_export( + archive_path, self._on_print_preflight_success, self._on_print_prefight_error + ) except ExportError as e: logger.error("Print preflight failed") logger.debug(f"Print preflight failed: {e}") @@ -85,8 +87,10 @@ def run_export_preflight_checks(self) -> None: archive_fn=self._USB_TEST_FN, metadata=self._USB_TEST_METADATA, ) - status = self._run_qrexec_export(archive_path) - self.export_state_changed.emit(status) + # Emits status via on_process_completed() + self._run_qrexec_export( + archive_path, self._on_export_process_finished, self._on_export_process_error + ) except ExportError as e: logger.error("Export preflight failed") @@ -118,55 +122,32 @@ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: metadata=metadata, filepaths=filepaths, ) - status = self._run_qrexec_export(archive_path) - self.export_state_changed.emit(status) - - logger.debug(f"Status {status}") - - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - - if e.status and isinstance(e.status, ExportStatus): - self.export_state_changed.emit(e.status) - else: - logger.error("ExportError, no status supplied") - # Emit a generic error - self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) - - def print(self, filepaths: List[str]) -> None: - """ - Bundle files at self._filepaths_list into tarball and send for - printing via qrexec. - """ - try: - logger.debug("Beginning print") - with TemporaryDirectory() as tmp_dir: - archive_path = self._create_archive( - archive_dir=tmp_dir, - archive_fn=self._PRINT_FN, - metadata=self._PRINT_METADATA, - filepaths=filepaths, + # Emits status through callbacks + self._run_qrexec_export( + archive_path, self._on_export_process_finished, self._on_export_process_error ) - status = self._run_qrexec_export(archive_path) - self.print_succeeded.emit(status) - logger.debug(f"Status {status}") - except ExportError as e: + except IOError as e: logger.error("Export failed") logger.debug(f"Export failed: {e}") - self.print_failed.emit(e) + self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) - self.export_completed.emit(filepaths) - - def _run_qrexec_export(self, archive_path: str) -> ExportStatus: + def _run_qrexec_export( + self, archive_path: str, success_callback: Callable, error_callback: Callable + ) -> None: """ - Make the subprocess call to send the archive to the Export VM, where the archive will be - processed. + Send the archive to the Export VM, where the archive will be processed. + Uses qrexec-client-vm (via QProcess). Results are emitted via the + `on_process_finished` callback; errors are reported via `on_process_error`. Args: archive_path (str): The path to the archive to be processed. + success_callback, err_callback: Callback functions to connect to the success and + error signals of QProcess. They are included to accommodate the print functions, + which still use separate signals for print preflight, print, and error states, but + can be removed in favour of a generic success callback and error callback when the + print code is updated. Returns: str: The export status returned from the Export VM processing script. @@ -176,43 +157,134 @@ def _run_qrexec_export(self, archive_path: str) -> ExportStatus: trying to start the Export VM when the USB device is not attached, or (2) when the return code from `check_output` is not 0. """ + logger.debug(f"Preparing to open {archive_path} in sd-devices...") + + # There are already talks of switching to a QVM-RPC implementation for unlocking devices + # and exporting files, so it's important to remember to shell-escape what we pass to the + # shell, even if for the time being we're already protected against shell injection via + # Python's implementation of subprocess, see + # https://docs.python.org/3/library/subprocess.html#security-considerations + qrexec = "/usr/bin/qrexec-client-vm" + args = [ + quote("--"), + quote("sd-devices"), + quote("qubes.OpenInVM"), + quote("/usr/lib/qubes/qopen-in-vm"), + quote("--view-only"), + quote("--"), + quote(archive_path), + ] + + self.process = QProcess() + # self.process.readyReadStandardError.connect(success_callback) + + self.process.finished.connect(success_callback) + self.process.errorOccurred.connect(error_callback) + + self.process.start(qrexec, args) + + def _on_export_process_finished(self): + """ + Callback to handle and emit QProcess results. Method signature + cannot change. + """ + out = self.process.readAllStandardOutput().data().decode("utf-8") + + # securedrop-export writes status to stderr + err = self.process.readAllStandardError() + + logger.debug(f"stdout: {out}") + logger.debug(f"stderr: {err}") + try: - # There are already talks of switching to a QVM-RPC implementation for unlocking devices - # and exporting files, so it's important to remember to shell-escape what we pass to the - # shell, even if for the time being we're already protected against shell injection via - # Python's implementation of subprocess, see - # https://docs.python.org/3/library/subprocess.html#security-considerations - output = subprocess.check_output( - [ - quote("qrexec-client-vm"), - quote("--"), - quote("sd-devices"), - quote("qubes.OpenInVM"), - quote("/usr/lib/qubes/qopen-in-vm"), - quote("--view-only"), - quote("--"), - quote(archive_path), - ], - stderr=subprocess.STDOUT, - ) - result = output.decode("utf-8").strip() + result = err.data().decode("utf-8").strip() if result: - + logger.debug(f"Result is {result}") # This is a bit messy, but make sure we are just taking the last line - # (no-op if no newline) + # (no-op if no newline, since we already stripped whitespace above) status_string = result.split("\n")[-1] - return ExportStatus(status_string) + self.export_state_changed.emit(ExportStatus(status_string)) + else: logger.error("Export subprocess did not return a value we could parse") - raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) except ValueError as e: logger.debug(f"Export subprocess returned unexpected value: {e}") - raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) - except subprocess.CalledProcessError as e: - logger.error("Subprocess failed") - logger.debug(f"Subprocess failed: {e}") - raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) + + def _on_export_process_error(self): + """ + Callback, called if QProcess cannot complete. Method signature + cannot change. + """ + err = self.process.readAllStandardError().data().decode("utf-8") + + logger.error(f"Export process error: {err}") + self.export_state_changed.emit(ExportStatus.CALLED_PROCESS_ERROR) + + def _on_print_preflight_success(self): + logger.debug("Print preflight success") + self.print_preflight_check_succeeded.emit() + + def _on_print_prefight_error(self): + logger.debug("Print preflight error") + self.print_preflight_check_failed.emit(ExportStatus.PRINT_PREFLIGHT_SUCCESS) + + # Todo: not sure if we need to connect here, since the print dialog is managed by sd-devices. + # We can probably use the export callback. + def _on_print_sucess(self): + logger.debug("Print success") + self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) + self.export_completed.emit() + + def end_process(self) -> None: + logger.debug("Terminate process") + if self.process is not None and not self.process.waitForFinished(50): + self.process.terminate() + + def _on_print_error(self): + # securedrop-export writes status to stderr + err = self.process.readAllStandardError() + logger.debug(f"Print error: {err}") + + try: + result = err.data().decode("utf-8").strip() + if result: + logger.debug(f"Result is {result}") + status = ExportStatus(result) + self.print_failed.emit(ExportError(status)) + else: + logger.error("Print error, but no value we could parse") + self.print_failed.emit(ExportStatus.ERROR_PRINT) + + except ValueError as e: + logger.debug(f"Export subprocess returned unexpected value: {e}") + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) + + def print(self, filepaths: List[str]) -> None: + """ + Bundle files at filepaths into tarball and send for + printing via qrexec. + """ + try: + logger.debug("Beginning print") + + with TemporaryDirectory() as tmp_dir: + archive_path = self._create_archive( + archive_dir=tmp_dir, + archive_fn=self._PRINT_FN, + metadata=self._PRINT_METADATA, + filepaths=filepaths, + ) + self._run_qrexec_export(archive_path, self._on_print_sucess, self._on_print_error) + + except IOError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.print_failed.emit(e) + + self.export_completed.emit(filepaths) def _create_archive( self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] From 27525f55b3a9b43dd8410d381eb661edfb37b46c Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 2 Feb 2024 20:47:30 -0500 Subject: [PATCH 16/22] (WIP) Add functional tests for Export Wizard --- client/tests/conftest.py | 104 +- ... => test_export_wizard_device_locked.yaml} | 0 ...izard_dialog_device_already_unlocked.yaml} | 0 ...est_export_wizard_no_device_then_fail.yaml | 1518 +++++++++++++++++ .../functional/test_export_file_dialog.py | 123 -- .../functional/test_export_file_wizard.py | 272 +++ .../gui/conversation/export/test_device.py | 46 +- .../conversation/export/test_export_wizard.py | 13 + 8 files changed, 1924 insertions(+), 152 deletions(-) rename client/tests/functional/cassettes/{test_export_file_dialog_locked.yaml => test_export_wizard_device_locked.yaml} (100%) rename client/tests/functional/cassettes/{test_export_file_dialog_device_already_unlocked.yaml => test_export_wizard_dialog_device_already_unlocked.yaml} (100%) create mode 100644 client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml delete mode 100644 client/tests/functional/test_export_file_dialog.py create mode 100644 client/tests/functional/test_export_file_wizard.py diff --git a/client/tests/conftest.py b/client/tests/conftest.py index a8be1255ca..8607ce8269 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -5,6 +5,7 @@ from configparser import ConfigParser from datetime import datetime from uuid import uuid4 +from unittest import mock import pytest from PyQt5.QtCore import Qt @@ -22,6 +23,7 @@ Source, make_session_maker, ) +from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -47,7 +49,7 @@ TIME_CLICK_ACTION = 1000 TIME_RENDER_SOURCE_LIST = 20000 TIME_RENDER_CONV_VIEW = 1000 -TIME_RENDER_EXPORT_DIALOG = 1000 +TIME_RENDER_EXPORT_WIZARD = 1000 TIME_FILE_DOWNLOAD = 5000 @@ -168,13 +170,105 @@ def homedir(i18n): @pytest.fixture(scope="function") -def mock_export(): +def mock_export_locked(): + """ + Represents the following scenario: + * Locked USB already inserted + * "Export" clicked, export wizard launched + * Passphrase successfully entered on first attempt (and export suceeeds) + """ + device = conversation.ExportDevice() + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.DEVICE_LOCKED + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = mock.MagicMock() + device.export.side_effect = [ + lambda filepaths, passphrase: device.export_state_changed.emit( + ExportStatus.DEVICE_WRITABLE + ), + lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.SUCCESS_EXPORT), + ] + + return device + + +@pytest.fixture(scope="function") +def mock_export_unlocked(): + """ + Represents the following scenario: + * USB already inserted and unlocked by the user + * Export wizard launched + * Export succeeds + """ + device = conversation.ExportDevice() + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.DEVICE_WRITABLE + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = lambda filepaths, passphrase: device.export_state_changed.emit( + ExportStatus.SUCCESS_EXPORT + ) + + return device + + +@pytest.fixture(scope="function") +def mock_export_no_usb_then_bad_passphrase_then_fail(): + """ + Represents the following scenario: + * Export wizard launched + * Locked USB inserted + * Mistyped Passphrase + * Correct passphrase + * Export fails + """ + device = conversation.ExportDevice() + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.NO_DEVICE_DETECTED + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = mock.MagicMock() + device.export.side_effect = [ + lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.DEVICE_LOCKED), + lambda filepaths, passphrase: device.export_state_changed.emit( + ExportStatus.ERROR_UNLOCK_LUKS + ), + lambda filepaths, passphrase: device.export_state_changed.emit( + ExportStatus.DEVICE_WRITABLE + ), + lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.ERROR_EXPORT), + ] + + return device + + +@pytest.fixture(scope="function") +def mock_export_fail_early(): + """ + Represents the following scenario: + * Locked USB inserted + * Export wizard launched + * Unrecoverable error before export happens + (eg, mount error) + """ device = conversation.ExportDevice() - device.run_export_preflight_checks = lambda dir: None - device.run_printer_preflight_checks = lambda dir: None + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.DEVICE_LOCKED + ) + device.run_printer_preflight_checks = lambda: None device.print = lambda filepaths: None - device.export = lambda filepaths, passphrase: None + device.export = mock.MagicMock() + device.export = lambda filepaths, passphrase: device.export_state_changed.emit( + ExportStatus.ERROR_MOUNT + ) return device diff --git a/client/tests/functional/cassettes/test_export_file_dialog_locked.yaml b/client/tests/functional/cassettes/test_export_wizard_device_locked.yaml similarity index 100% rename from client/tests/functional/cassettes/test_export_file_dialog_locked.yaml rename to client/tests/functional/cassettes/test_export_wizard_device_locked.yaml diff --git a/client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml b/client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml similarity index 100% rename from client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml rename to client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml diff --git a/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml b/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml new file mode 100644 index 0000000000..d59c25ebbe --- /dev/null +++ b/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml @@ -0,0 +1,1518 @@ +interactions: +- request: + body: '{"username": "journalist", "passphrase": "correct horse battery staple + profanity oil chewy", "one_time_code": "123456"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '119' + User-Agent: + - python-requests/2.31.0 + method: POST + uri: http://localhost:8081/api/v1/token + response: + body: + string: '{"expiration":"2023-12-08T21:31:36.503560Z","journalist_first_name":null,"journalist_last_name":null,"journalist_uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9","token":"IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM"} + + ' + headers: + Connection: + - close + Content-Length: + - '265' + Content-Type: + - application/json + Date: + - Fri, 08 Dec 2023 19:31:36 GMT + Server: + - Werkzeug/2.2.3 Python/3.8.10 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8081/api/v1/users + response: + body: + string: '{"users":[{"first_name":null,"last_name":null,"username":"journalist","uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9"},{"first_name":null,"last_name":null,"username":"dellsberg","uuid":"ac647c21-82f5-4d19-8350-6657a7d32f6b"},{"first_name":null,"last_name":null,"username":"deleted","uuid":"200a587e-b40c-48eb-b18a-0d1263f8af2e"}]} + + ' + headers: + Connection: + - close + Content-Length: + - '329' + Content-Type: + - application/json + Date: + - Fri, 08 Dec 2023 19:31:36 GMT + Server: + - Werkzeug/2.2.3 Python/3.8.10 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8081/api/v1/sources + response: + body: + string: '{"sources":[{"add_star_url":"/api/v1/sources/1924d581-a3af-45c6-a3c9-0ec2f1205bc1/add_star","interaction_count":6,"is_flagged":false,"is_starred":false,"journalist_designation":"oriental + hutch","key":{"fingerprint":"DF4DC2E19F0A6A304C8C3188AEF8C5E2BD8AE199","public":"-----BEGIN + PGP PUBLIC KEY BLOCK-----\nComment: DF4D C2E1 9F0A 6A30 4C8C 3188 AEF8 C5E2 + BD8A E199\nComment: Source Key Date: Mon, 5 Feb 2024 11:54:00 -0500 Subject: [PATCH 17/22] Handle error case after preflight failure. --- .../gui/conversation/export/export_wizard.py | 5 +++-- .../gui/conversation/export/export_wizard_page.py | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index d48076691c..941ab349e2 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -206,12 +206,13 @@ def end_wizard_with_error(self, error: ExportStatus) -> None: end the wizard. """ if isinstance(self.currentPage(), PreflightPage): - self.next() + # Update its status so it shows error next self.currentPage() + # self.next() + logger.debug("On preflight page") else: while self.currentId() > Pages.ERROR: self.back() page = self.currentPage() - page.set_complete(False) page.update_content(error) def _create_preflight(self) -> QWizardPage: diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py index 29aa78a669..1c5dc9b612 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -172,6 +172,7 @@ def stop_animate_header(self) -> None: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: + logger.debug(f"Child page received status {status.value}") self.status = status self.completeChanged.emit() # Some children (not the Prefight Page) may wish to call update_content here @@ -237,6 +238,7 @@ def nextId(self): else: return Pages.INSERT_USB + @pyqtSlot(object) def on_status_received(self, status: ExportStatus): self.stop_animate_header() if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE): @@ -259,6 +261,9 @@ def on_status_received(self, status: ExportStatus): self.body.setText(body) self.status = status + def isComplete(self) -> bool: + return False + class InsertUSBPage(ExportWizardPage): def __init__(self, export, summary): From 3f9be9c31789dda3ea10ae191d2a1888a071c3dd Mon Sep 17 00:00:00 2001 From: Ro Date: Mon, 5 Feb 2024 15:09:10 -0500 Subject: [PATCH 18/22] Address tmpdir scope issue by holding a reference to the export tmpdir, and cleaning up in _cleanup_tmpdir(). --- client/securedrop_client/export.py | 133 +++++++++++++---------------- 1 file changed, 61 insertions(+), 72 deletions(-) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 1c4f930a60..63107e3f03 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -2,9 +2,10 @@ import logging import os import tarfile +import shutil from io import BytesIO from shlex import quote -from tempfile import TemporaryDirectory +from tempfile import TemporaryDirectory, mkdtemp from typing import Callable, List, Optional from PyQt5.QtCore import QProcess, QObject, pyqtSignal @@ -53,54 +54,40 @@ class Export(QObject): print_failed = pyqtSignal(object) process = None # Optional[QProcess] + tmpdir = None # Note: context-managed tmpdir goes out of scope too quickly, so we create then clean it up def run_printer_preflight_checks(self) -> None: """ Make sure the Export VM is started. """ logger.info("Beginning printer preflight check") - try: - with TemporaryDirectory() as tmp_dir: - archive_path = self._create_archive( - archive_dir=tmp_dir, - archive_fn=self._PRINTER_PREFLIGHT_FN, - metadata=self._PRINTER_PREFLIGHT_METADATA, - ) - self._run_qrexec_export( - archive_path, self._on_print_preflight_success, self._on_print_prefight_error - ) - except ExportError as e: - logger.error("Print preflight failed") - logger.debug(f"Print preflight failed: {e}") - self.print_preflight_check_failed.emit(e) + self.tmpdir = mkdtemp() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._PRINTER_PREFLIGHT_FN, + metadata=self._PRINTER_PREFLIGHT_METADATA, + ) + self._run_qrexec_export( + archive_path, self._on_print_preflight_success, self._on_print_prefight_error + ) def run_export_preflight_checks(self) -> None: """ Run preflight check to verify that a valid USB device is connected. """ - try: - logger.debug("Beginning export preflight check") - - with TemporaryDirectory() as tmp_dir: - archive_path = self._create_archive( - archive_dir=tmp_dir, - archive_fn=self._USB_TEST_FN, - metadata=self._USB_TEST_METADATA, - ) - # Emits status via on_process_completed() - self._run_qrexec_export( - archive_path, self._on_export_process_finished, self._on_export_process_error - ) - - except ExportError as e: - logger.error("Export preflight failed") - - if e.status: - self.export_state_changed.emit(e.status) - else: - logger.error("ExportError, no status supplied") - # Emit a generic error - self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) + logger.debug("Beginning export preflight check") + + self.tmpdir = mkdtemp() + + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._USB_TEST_FN, + metadata=self._USB_TEST_METADATA, + ) + # Emits status via on_process_completed() + self._run_qrexec_export( + archive_path, self._on_export_process_finished, self._on_export_process_error + ) def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: """ @@ -115,18 +102,18 @@ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: if passphrase: metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase - with TemporaryDirectory() as tmp_dir: - archive_path = self._create_archive( - archive_dir=tmp_dir, - archive_fn=self._DISK_FN, - metadata=metadata, - filepaths=filepaths, - ) + self.tmpdir = mkdtemp() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._DISK_FN, + metadata=metadata, + filepaths=filepaths, + ) - # Emits status through callbacks - self._run_qrexec_export( - archive_path, self._on_export_process_finished, self._on_export_process_error - ) + # Emits status through callbacks + self._run_qrexec_export( + archive_path, self._on_export_process_finished, self._on_export_process_error + ) except IOError as e: logger.error("Export failed") @@ -148,17 +135,9 @@ def _run_qrexec_export( which still use separate signals for print preflight, print, and error states, but can be removed in favour of a generic success callback and error callback when the print code is updated. - - Returns: - str: The export status returned from the Export VM processing script. - - Raises: - ExportError: Raised if (1) CalledProcessError is encountered, which can occur when - trying to start the Export VM when the USB device is not attached, or (2) when - the return code from `check_output` is not 0. + Any callbacks must call _cleanup_tmpdir() to remove the temporary directory that held + the files to be exported. """ - logger.debug(f"Preparing to open {archive_path} in sd-devices...") - # There are already talks of switching to a QVM-RPC implementation for unlocking devices # and exporting files, so it's important to remember to shell-escape what we pass to the # shell, even if for the time being we're already protected against shell injection via @@ -176,24 +155,28 @@ def _run_qrexec_export( ] self.process = QProcess() - # self.process.readyReadStandardError.connect(success_callback) self.process.finished.connect(success_callback) self.process.errorOccurred.connect(error_callback) self.process.start(qrexec, args) + def _cleanup_tmpdir(self): + """ + Should be called in all qrexec completion callbacks. + """ + if self.tmpdir and os.path.exists(self.tmpdir): + shutil.rmtree(self.tmpdir) + def _on_export_process_finished(self): """ Callback to handle and emit QProcess results. Method signature cannot change. """ - out = self.process.readAllStandardOutput().data().decode("utf-8") - + self._cleanup_tmpdir() # securedrop-export writes status to stderr err = self.process.readAllStandardError() - logger.debug(f"stdout: {out}") logger.debug(f"stderr: {err}") try: @@ -218,33 +201,39 @@ def _on_export_process_error(self): Callback, called if QProcess cannot complete. Method signature cannot change. """ + self._cleanup_tmpdir() err = self.process.readAllStandardError().data().decode("utf-8") logger.error(f"Export process error: {err}") self.export_state_changed.emit(ExportStatus.CALLED_PROCESS_ERROR) def _on_print_preflight_success(self): + self._cleanup_tmpdir() + logger.debug("Print preflight success") self.print_preflight_check_succeeded.emit() def _on_print_prefight_error(self): + self._cleanup_tmpdir() logger.debug("Print preflight error") self.print_preflight_check_failed.emit(ExportStatus.PRINT_PREFLIGHT_SUCCESS) # Todo: not sure if we need to connect here, since the print dialog is managed by sd-devices. # We can probably use the export callback. def _on_print_sucess(self): + self._cleanup_tmpdir() logger.debug("Print success") self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) self.export_completed.emit() def end_process(self) -> None: + self._cleanup_tmpdir() logger.debug("Terminate process") if self.process is not None and not self.process.waitForFinished(50): self.process.terminate() def _on_print_error(self): - # securedrop-export writes status to stderr + self._cleanup_tmpdir() err = self.process.readAllStandardError() logger.debug(f"Print error: {err}") @@ -270,14 +259,14 @@ def print(self, filepaths: List[str]) -> None: try: logger.debug("Beginning print") - with TemporaryDirectory() as tmp_dir: - archive_path = self._create_archive( - archive_dir=tmp_dir, - archive_fn=self._PRINT_FN, - metadata=self._PRINT_METADATA, - filepaths=filepaths, - ) - self._run_qrexec_export(archive_path, self._on_print_sucess, self._on_print_error) + self.tmpdir = mkdtemp() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._PRINT_FN, + metadata=self._PRINT_METADATA, + filepaths=filepaths, + ) + self._run_qrexec_export(archive_path, self._on_print_sucess, self._on_print_error) except IOError as e: logger.error("Export failed") From 22a968e7b4ade67931395489bbfd4c8f75f2199c Mon Sep 17 00:00:00 2001 From: Ro Date: Mon, 5 Feb 2024 18:55:05 -0500 Subject: [PATCH 19/22] docstrings for print and export callbacks --- client/securedrop_client/export.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 63107e3f03..a8eed80fee 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -170,8 +170,8 @@ def _cleanup_tmpdir(self): def _on_export_process_finished(self): """ - Callback to handle and emit QProcess results. Method signature - cannot change. + Callback, handle and emit QProcess result. As with all such callbacks, + the method signature cannot change. """ self._cleanup_tmpdir() # securedrop-export writes status to stderr @@ -198,8 +198,8 @@ def _on_export_process_finished(self): def _on_export_process_error(self): """ - Callback, called if QProcess cannot complete. Method signature - cannot change. + Callback, called if QProcess cannot complete export. As with all such, the method + signature cannot change. """ self._cleanup_tmpdir() err = self.process.readAllStandardError().data().decode("utf-8") @@ -208,12 +208,18 @@ def _on_export_process_error(self): self.export_state_changed.emit(ExportStatus.CALLED_PROCESS_ERROR) def _on_print_preflight_success(self): + """ + Print preflight success callback. + """ self._cleanup_tmpdir() logger.debug("Print preflight success") self.print_preflight_check_succeeded.emit() def _on_print_prefight_error(self): + """ + Print Preflight error callback. + """ self._cleanup_tmpdir() logger.debug("Print preflight error") self.print_preflight_check_failed.emit(ExportStatus.PRINT_PREFLIGHT_SUCCESS) @@ -227,12 +233,20 @@ def _on_print_sucess(self): self.export_completed.emit() def end_process(self) -> None: + """ + Tell QProcess to quit if it hasn't already. + Connected to the ExportWizard's `finished` signal, which fires + when the dialog is closed, cancelled, or finished. + """ self._cleanup_tmpdir() logger.debug("Terminate process") if self.process is not None and not self.process.waitForFinished(50): self.process.terminate() def _on_print_error(self): + """ + Error callback for print qrexec. + """ self._cleanup_tmpdir() err = self.process.readAllStandardError() logger.debug(f"Print error: {err}") From c6ec54611018151432a4b0115f700ae5428f7904 Mon Sep 17 00:00:00 2001 From: Ro Date: Mon, 5 Feb 2024 18:57:47 -0500 Subject: [PATCH 20/22] (WIP) Simpler signaling logic in main wizard page. Basic working transitions. --- .../gui/conversation/export/export_wizard.py | 34 ++--- .../conversation/export/export_wizard_page.py | 122 ++++++++++++------ 2 files changed, 89 insertions(+), 67 deletions(-) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index 941ab349e2..de33aeadc7 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -111,14 +111,14 @@ def _set_layout(self) -> None: ) def _set_pages(self) -> None: - for page in [ - self._create_preflight(), - self._create_errorpage(), - self._create_insert_usb(), - self._create_passphrase_prompt(), - self._create_done(), + for id, page in [ + (Pages.PREFLIGHT, self._create_preflight()), + (Pages.ERROR, self._create_errorpage()), + (Pages.INSERT_USB, self._create_insert_usb()), + (Pages.UNLOCK_USB, self._create_passphrase_prompt()), + (Pages.EXPORT_DONE, self._create_done()), ]: - self.addPage(page) + self.setPage(id, page) # Nice to have, but steals the focus from the password field after 1 character is typed. # Probably another way to have it be based on validating the status @@ -152,7 +152,7 @@ def on_status_received(self, status: ExportStatus) -> None: To update the text on an individual page, the page listens for this signal and can call `update_content` in the listener. """ - logger.debug(f"Wizard received {status.value}") + logger.debug(f"Wizard received {status.value}. Current page is {type(self.currentPage())}") # Unrecoverable - end the wizard if status in [ @@ -178,18 +178,9 @@ def on_status_received(self, status: ExportStatus) -> None: target = Pages.UNLOCK_USB # Someone may have yanked out or unmounted a USB - if target: + if target and self.currentId() > target: self.rewind(target) - # If the user is stuck, show them a hint. - should_show_hint = target == self.currentId() - logger.debug(f"{status.value} - should show hint is {should_show_hint}") - - page = self.currentPage() - # Sometimes self.currentPage() is None - if page and should_show_hint: - page.update_content(status, should_show_hint) - # Update status self.current_status = status @@ -207,8 +198,7 @@ def end_wizard_with_error(self, error: ExportStatus) -> None: """ if isinstance(self.currentPage(), PreflightPage): # Update its status so it shows error next self.currentPage() - # self.next() - logger.debug("On preflight page") + logger.debug("On preflight page, no reordering needed") else: while self.currentId() > Pages.ERROR: self.back() @@ -227,9 +217,5 @@ def _create_insert_usb(self) -> QWizardPage: def _create_passphrase_prompt(self) -> QWizardPage: return PassphraseWizardPage(self.export) - # def _create_export(self) -> QWizardPage: - # return ExportPage(self.export) - - # readywhen: all done def _create_done(self) -> QWizardPage: return FinalPage(self.export) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py index 1c5dc9b612..b6d9a3e05d 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -173,8 +173,9 @@ def stop_animate_header(self) -> None: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: logger.debug(f"Child page received status {status.value}") - self.status = status - self.completeChanged.emit() + # Child pages should overwrite this method + # self.status = status + # self.completeChanged.emit() # Some children (not the Prefight Page) may wish to call update_content here def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None: @@ -195,6 +196,7 @@ def update_content(self, status: ExportStatus, should_show_hint: bool = False) - class PreflightPage(ExportWizardPage): def __init__(self, export, summary): + self.summary = summary header = _( "Preparing to export:
" '{}' ).format(summary) @@ -241,10 +243,16 @@ def nextId(self): @pyqtSlot(object) def on_status_received(self, status: ExportStatus): self.stop_animate_header() - if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE): + if status in ( + ExportStatus.DEVICE_LOCKED, + ExportStatus.DEVICE_WRITABLE, + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ): header = _( "Ready to export:
" '{}' - ).format(self.header_text) + ).format(self.summary) self.header.setText(header) self.status = status @@ -256,17 +264,13 @@ def __init__(self, export, summary): super().__init__(export, header=header, body=summary) - def on_status_received(self, status: ExportStatus): - body = STATUS_MESSAGES.get(status) - self.body.setText(body) - self.status = status - def isComplete(self) -> bool: return False class InsertUSBPage(ExportWizardPage): def __init__(self, export, summary): + self.summary = summary header = _("Ready to export:
" '{}').format( summary ) @@ -278,38 +282,39 @@ def __init__(self, export, summary): @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - super().on_status_received(status) + logger.debug(f"InsertUSB received {status.value}") should_show_hint = status in ( ExportStatus.NO_DEVICE_DETECTED, ExportStatus.MULTI_DEVICE_DETECTED, ExportStatus.INVALID_DEVICE_DETECTED, ) self.update_content(status, should_show_hint) + self.status = status - def validatePage(self) -> bool: - """ - Override method to implement custom validation logic, which prevents the - wizard from advancing past this stage unless preconditions are met, and - shows an error-specific hint to the user. - """ - if self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.DEVICE_LOCKED): - self.error_details.hide() - return True - else: - logger.debug(f"Status is {self.status}, rechecking") - - # Show the user a hint - if self.status in ( - ExportStatus.MULTI_DEVICE_DETECTED, - ExportStatus.NO_DEVICE_DETECTED, - ExportStatus.INVALID_DEVICE_DETECTED, - ): - self.update_content(self.status, should_show_hint=True) - else: - # Shouldn't reach - # Status may be None here - logger.warning("InsertUSBPage encountered unexpected status") - return False + # def validatePage(self) -> bool: + # """ + # Override method to implement custom validation logic, which prevents the + # wizard from advancing past this stage unless preconditions are met, and + # shows an error-specific hint to the user. + # """ + # if self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.DEVICE_LOCKED): + # self.error_details.hide() + # return True + # else: + # logger.debug(f"Status is {self.status}") + + # # Show the user a hint + # if self.status in ( + # ExportStatus.MULTI_DEVICE_DETECTED, + # ExportStatus.NO_DEVICE_DETECTED, + # ExportStatus.INVALID_DEVICE_DETECTED, + # ): + # self.update_content(self.status, should_show_hint=True) + # else: + # # Shouldn't reach + # # Status may be None here + # logger.warning("InsertUSBPage encountered unexpected status") + # return False def nextId(self): """ @@ -318,8 +323,14 @@ def nextId(self): if self.status == ExportStatus.DEVICE_WRITABLE: logger.debug("Skip password prompt") return Pages.EXPORT_DONE + elif self.status == ExportStatus.DEVICE_LOCKED: + return Pages.UNLOCK_USB + elif self.status in (ExportStatus.UNEXPECTED_RETURN_STATUS, ExportStatus.DEVICE_ERROR): + return Pages.ERROR else: - return super().nextId() + next = super().nextId() + logger.error("Unexpected status on InsertUSBPage {status.value}, nextID is {next}") + return next class FinalPage(ExportWizardPage): @@ -332,10 +343,13 @@ def __init__(self, export: Export) -> None: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - super().on_status_received(status) + logger.debug(f"Final page received status {status}") self.update_content(status) + self.status = status def update_content(self, status: ExportStatus, should_show_hint: bool = False): + header = None + body = None if status == ExportStatus.SUCCESS_EXPORT: header = _("Export successful") body = _( @@ -347,13 +361,11 @@ def update_content(self, status: ExportStatus, should_show_hint: bool = False): body = STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP) else: - header = _("Export failed") - if not self.status: - self.status = ExportStatus.UNEXPECTED_RETURN_STATUS - body = STATUS_MESSAGES.get(self.status) + header = _("Working...") self.header.setText(header) - self.body.setText(body) + if body: + self.body.setText(body) class PassphraseWizardPage(ExportWizardPage): @@ -398,15 +410,39 @@ def _build_layout(self) -> QVBoxLayout: passphrase_form_layout.addWidget(self.passphrase_field) passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight) - # Add it layout.addWidget(self.passphrase_form) return layout @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - super().on_status_received(status) + logger.debug(f"Passphrase page rececived {status.value}") should_show_hint = status in ( ExportStatus.ERROR_UNLOCK_LUKS, ExportStatus.ERROR_UNLOCK_GENERIC, ) self.update_content(status, should_show_hint) + self.status = status + + def validate(self): + return self.status == ExportStatus.DEVICE_WRITABLE + + def nextId(self): + if self.status == ExportStatus.SUCCESS_EXPORT: + return Pages.EXPORT_DONE + elif self.status in (ExportStatus.ERROR_UNLOCK_LUKS, ExportStatus.ERROR_UNLOCK_GENERIC): + return Pages.UNLOCK_USB + elif self.status in ( + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ): + return Pages.INSERT_USB + elif self.status in ( + ExportStatus.ERROR_MOUNT, + ExportStatus.ERROR_EXPORT, + ExportStatus.ERROR_EXPORT_CLEANUP, + ExportStatus.UNEXPECTED_RETURN_STATUS, + ): + return Pages.ERROR + else: + return super().nextId() From ca3d7541c43bad8e9fffb24e9e4eee3c0604ccaa Mon Sep 17 00:00:00 2001 From: Ro Date: Mon, 5 Feb 2024 21:44:33 -0500 Subject: [PATCH 21/22] Page validation and layout fixes --- client/securedrop_client/export.py | 38 ++++---- .../gui/conversation/export/export_wizard.py | 1 + .../export/export_wizard_constants.py | 9 +- .../conversation/export/export_wizard_page.py | 89 +++++++++++-------- 4 files changed, 74 insertions(+), 63 deletions(-) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index a8eed80fee..30d4bebd42 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -212,17 +212,25 @@ def _on_print_preflight_success(self): Print preflight success callback. """ self._cleanup_tmpdir() - - logger.debug("Print preflight success") - self.print_preflight_check_succeeded.emit() - + err = self.process.readAllStandardError().data().decode("utf-8") + try: + status = ExportStatus(err) + self.print_preflight_check_succeeded.emit(status) + logger.debug("Print preflight success") + + except ValueError as error: + logger.debug(f"Print preflight check failed: {error}") + logger.error("Print preflight check failed") + self.print_preflight_check_failed.emit(ExportStatus.ERROR_PRINT) + def _on_print_prefight_error(self): """ Print Preflight error callback. """ self._cleanup_tmpdir() - logger.debug("Print preflight error") - self.print_preflight_check_failed.emit(ExportStatus.PRINT_PREFLIGHT_SUCCESS) + err = self.process.readAllStandardError().data().decode("utf-8") + logger.debug(f"Print preflight error: {err}") + self.print_preflight_check_failed.emit(ExportStatus.ERROR_PRINT) # Todo: not sure if we need to connect here, since the print dialog is managed by sd-devices. # We can probably use the export callback. @@ -230,7 +238,8 @@ def _on_print_sucess(self): self._cleanup_tmpdir() logger.debug("Print success") self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) - self.export_completed.emit() + # TODO: Previously emitted [filepaths] + self.export_completed.emit([]) def end_process(self) -> None: """ @@ -250,20 +259,7 @@ def _on_print_error(self): self._cleanup_tmpdir() err = self.process.readAllStandardError() logger.debug(f"Print error: {err}") - - try: - result = err.data().decode("utf-8").strip() - if result: - logger.debug(f"Result is {result}") - status = ExportStatus(result) - self.print_failed.emit(ExportError(status)) - else: - logger.error("Print error, but no value we could parse") - self.print_failed.emit(ExportStatus.ERROR_PRINT) - - except ValueError as e: - logger.debug(f"Export subprocess returned unexpected value: {e}") - self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) + self.print_failed.emit(ExportStatus.ERROR_PRINT) def print(self, filepaths: List[str]) -> None: """ diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index de33aeadc7..2630b3b981 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -188,6 +188,7 @@ def rewind(self, target: Pages) -> None: """ Navigate back to target page. """ + logger.debug(f"Wizard: rewind from {self.currentId()} to {target}") while self.currentId() > target: self.back() diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py index 2d432c1d24..eccb767a00 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -7,8 +7,12 @@ Export wizard page ordering, human-readable status messages """ - -# Sequential list of pages (the order matters) +# Sequential list of pages (the enum value matters as a ranked ordering.) +# The reason the 'error' page is second is because the other pages have +# validation logic that means they can't be bypassed by QWizard::next. +# When we need to show an error, it's easier to go 'back' to the error +# page and set it to be a FinalPage than it is to try to skip the conditional +# pages. PyQt6 introduces behaviour that may deprecate this requirement. class Pages(IntEnum): PREFLIGHT = 0 ERROR = 1 @@ -16,7 +20,6 @@ class Pages(IntEnum): UNLOCK_USB = 3 EXPORT_DONE = 4 - # Human-readable status info STATUS_MESSAGES = { ExportStatus.NO_DEVICE_DETECTED: _("No device detected"), diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py index b6d9a3e05d..a3b2cf93a2 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -9,7 +9,9 @@ QGraphicsDropShadowEffect, QHBoxLayout, QLabel, + QLayout, QLineEdit, + QSizePolicy, QVBoxLayout, QWidget, QWizardPage, @@ -100,7 +102,7 @@ def _build_layout(self) -> QVBoxLayout: self.header.setObjectName("ModalDialog_header") header_container_layout.addWidget(self.header_icon) header_container_layout.addWidget(self.header_spinner_label) - header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter) + header_container_layout.addWidget(self.header, alignment=Qt.AlignLeft) # Prev: AlignCenter header_container_layout.addStretch() self.header_line = QWidget() self.header_line.setObjectName("ModalDialog_header_line") @@ -110,6 +112,7 @@ def _build_layout(self) -> QVBoxLayout: self.body.setObjectName("ModalDialog_body") self.body.setWordWrap(True) self.body.setScaledContents(True) + body_container = QWidget() self.body_layout = QVBoxLayout() self.body_layout.setContentsMargins( @@ -117,6 +120,10 @@ def _build_layout(self) -> QVBoxLayout: ) body_container.setLayout(self.body_layout) self.body_layout.addWidget(self.body) + self.body_layout.setSizeConstraint(QLayout.SetMinimumSize) + + # TODO: it's either like this, or in the parent layout elements + self.body_layout.setSizeConstraint(QLayout.SetMinimumSize) # Widget for displaying error messages (hidden by default) self.error_details = QLabel() @@ -138,7 +145,6 @@ def _build_layout(self) -> QVBoxLayout: parent_layout.addWidget(header_container) parent_layout.addWidget(self.header_line) parent_layout.addWidget(body_container) - # parent_layout.addStretch(1) parent_layout.addWidget(self.error_details) # parent_layout.setSizeConstraint(QLayout.SetFixedSize) @@ -172,11 +178,7 @@ def stop_animate_header(self) -> None: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - logger.debug(f"Child page received status {status.value}") - # Child pages should overwrite this method - # self.status = status - # self.completeChanged.emit() - # Some children (not the Prefight Page) may wish to call update_content here + raise NotImplementedError("Children must implement") def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None: """ @@ -256,7 +258,6 @@ def on_status_received(self, status: ExportStatus): self.header.setText(header) self.status = status - class ErrorPage(ExportWizardPage): def __init__(self, export, summary): header = _("Export Failed") @@ -266,7 +267,10 @@ def __init__(self, export, summary): def isComplete(self) -> bool: return False - + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus): + pass class InsertUSBPage(ExportWizardPage): def __init__(self, export, summary): @@ -284,37 +288,39 @@ def __init__(self, export, summary): def on_status_received(self, status: ExportStatus) -> None: logger.debug(f"InsertUSB received {status.value}") should_show_hint = status in ( - ExportStatus.NO_DEVICE_DETECTED, ExportStatus.MULTI_DEVICE_DETECTED, ExportStatus.INVALID_DEVICE_DETECTED, - ) + ) or (self.status == status == ExportStatus.NO_DEVICE_DETECTED) self.update_content(status, should_show_hint) self.status = status + self.completeChanged.emit() + if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE): + self.wizard().next() + + def validatePage(self) -> bool: + """ + Override method to implement custom validation logic, which + shows an error-specific hint to the user. + """ + if self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.DEVICE_LOCKED): + self.error_details.hide() + return True + else: + logger.debug(f"Status is {self.status}") + + # Show the user a hint + if self.status in ( + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ): + self.update_content(self.status, should_show_hint=True) + return False + else: + # Status may be None here + logger.warning("InsertUSBPage encountered unexpected status") + return super().validatePage() - # def validatePage(self) -> bool: - # """ - # Override method to implement custom validation logic, which prevents the - # wizard from advancing past this stage unless preconditions are met, and - # shows an error-specific hint to the user. - # """ - # if self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.DEVICE_LOCKED): - # self.error_details.hide() - # return True - # else: - # logger.debug(f"Status is {self.status}") - - # # Show the user a hint - # if self.status in ( - # ExportStatus.MULTI_DEVICE_DETECTED, - # ExportStatus.NO_DEVICE_DETECTED, - # ExportStatus.INVALID_DEVICE_DETECTED, - # ): - # self.update_content(self.status, should_show_hint=True) - # else: - # # Shouldn't reach - # # Status may be None here - # logger.warning("InsertUSBPage encountered unexpected status") - # return False def nextId(self): """ @@ -410,7 +416,7 @@ def _build_layout(self) -> QVBoxLayout: passphrase_form_layout.addWidget(self.passphrase_field) passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight) - layout.addWidget(self.passphrase_form) + layout.insertWidget(1, self.passphrase_form) return layout @pyqtSlot(object) @@ -422,9 +428,14 @@ def on_status_received(self, status: ExportStatus) -> None: ) self.update_content(status, should_show_hint) self.status = status - - def validate(self): - return self.status == ExportStatus.DEVICE_WRITABLE + self.completeChanged.emit() + if status in (ExportStatus.SUCCESS_EXPORT, ExportStatus.ERROR_EXPORT_CLEANUP): + self.wizard().next() + + def validatePage(self): + # Also to add: DEVICE_BUSY for unmounting. + # This shouldn't stop us from going "back" to an error page + return self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.SUCCESS_EXPORT, ExportStatus.ERROR_EXPORT_CLEANUP) def nextId(self): if self.status == ExportStatus.SUCCESS_EXPORT: From fe2f5cb6a0026e6050d717c3098157ed6693e0a9 Mon Sep 17 00:00:00 2001 From: Ro Date: Tue, 6 Feb 2024 09:56:38 -0500 Subject: [PATCH 22/22] Fix print error signals --- client/securedrop_client/export.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 30d4bebd42..8ab0a61de5 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -202,7 +202,7 @@ def _on_export_process_error(self): signature cannot change. """ self._cleanup_tmpdir() - err = self.process.readAllStandardError().data().decode("utf-8") + err = self.process.readAllStandardError().data().decode("utf-8").strip() logger.error(f"Export process error: {err}") self.export_state_changed.emit(ExportStatus.CALLED_PROCESS_ERROR) @@ -212,29 +212,29 @@ def _on_print_preflight_success(self): Print preflight success callback. """ self._cleanup_tmpdir() - err = self.process.readAllStandardError().data().decode("utf-8") + output = self.process.readAllStandardError().data().decode("utf-8").strip() try: - status = ExportStatus(err) + status = ExportStatus(output) self.print_preflight_check_succeeded.emit(status) logger.debug("Print preflight success") except ValueError as error: logger.debug(f"Print preflight check failed: {error}") logger.error("Print preflight check failed") - self.print_preflight_check_failed.emit(ExportStatus.ERROR_PRINT) + self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) def _on_print_prefight_error(self): """ Print Preflight error callback. """ self._cleanup_tmpdir() - err = self.process.readAllStandardError().data().decode("utf-8") + err = self.process.readAllStandardError().data().decode("utf-8").strip() logger.debug(f"Print preflight error: {err}") - self.print_preflight_check_failed.emit(ExportStatus.ERROR_PRINT) + self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) # Todo: not sure if we need to connect here, since the print dialog is managed by sd-devices. # We can probably use the export callback. - def _on_print_sucess(self): + def _on_print_success(self): self._cleanup_tmpdir() logger.debug("Print success") self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) @@ -259,7 +259,7 @@ def _on_print_error(self): self._cleanup_tmpdir() err = self.process.readAllStandardError() logger.debug(f"Print error: {err}") - self.print_failed.emit(ExportStatus.ERROR_PRINT) + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) def print(self, filepaths: List[str]) -> None: """ @@ -276,12 +276,12 @@ def print(self, filepaths: List[str]) -> None: metadata=self._PRINT_METADATA, filepaths=filepaths, ) - self._run_qrexec_export(archive_path, self._on_print_sucess, self._on_print_error) + self._run_qrexec_export(archive_path, self._on_print_success, self._on_print_error) except IOError as e: logger.error("Export failed") logger.debug(f"Export failed: {e}") - self.print_failed.emit(e) + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) self.export_completed.emit(filepaths)