From d468bd9d42bf501b086348f8d0d52e05373d80df Mon Sep 17 00:00:00 2001 From: Ro Date: Thu, 5 Oct 2023 15:05:24 -0400 Subject: [PATCH 01/10] Preliminary Veracrypt Support and refactoring (client, export): Export: Add new qrexec status values. DRY up qrexec calling methods. Replace ExportStatus values with new values. Combine disk-test and usb-test. Rename method names for clarity. Use udisksctl for locking, unlocking, and mounting. Use json output of lsblk to simplify device parsing. Use pexpect to handle passphrase unlock prompt and drive mounting. Add EncryptionScheme.VERACRYPT. Add methods for retrieving unlocked VeraCrypt drive and unlocking VC drive. Add "/etc/udisks2/tcrypt.conf" so udisks will check for VeraCrypt volumes. Remove encryption_method parameter from metadata.json. Pass export_error flag to cleanup method and flush stdout/err before exit. Address mounting race condition by checking udisks info prior to attempting mount. Client: Make Device dependency on Export service explicit. Pass ExportStatus in print dialog. Refactor GUI tests to account for ExportStatus changes to file dialog, print dialog, and Device. --- client/securedrop_client/export.py | 234 +-- client/securedrop_client/export_status.py | 55 + client/securedrop_client/gui/actions.py | 8 +- .../gui/conversation/export/device.py | 31 +- .../gui/conversation/export/file_dialog.py | 41 +- .../gui/conversation/export/print_dialog.py | 12 +- client/securedrop_client/gui/widgets.py | 4 +- client/tests/conftest.py | 42 +- ...t_file_dialog_device_already_unlocked.yaml | 1518 +++++++++++++++++ .../test_export_file_dialog_locked.yaml | 1517 ++++++++++++++++ .../functional/test_export_file_dialog.py | 73 +- .../gui/conversation/export/test_device.py | 369 +--- .../gui/conversation/export/test_dialog.py | 33 +- .../conversation/export/test_file_dialog.py | 47 +- .../conversation/export/test_print_dialog.py | 20 +- .../export/test_print_transcript_dialog.py | 14 +- .../export/test_transcript_dialog.py | 20 +- client/tests/integration/conftest.py | 22 +- client/tests/test_export.py | 352 ++-- debian/securedrop-export.install | 1 + export/Makefile | 3 +- export/README.md | 35 +- export/files/tcrypt.conf | 0 export/poetry.lock | 27 +- export/pyproject.toml | 1 + export/securedrop_export/archive.py | 17 +- export/securedrop_export/disk/__init__.py | 4 +- export/securedrop_export/disk/cli.py | 671 ++++---- .../securedrop_export/disk/legacy_service.py | 156 -- .../securedrop_export/disk/legacy_status.py | 25 - export/securedrop_export/disk/service.py | 127 +- export/securedrop_export/disk/status.py | 18 +- export/securedrop_export/disk/volume.py | 38 +- export/securedrop_export/main.py | 74 +- export/tests/disk/test_cli.py | 686 ++++---- export/tests/disk/test_service.py | 180 +- export/tests/disk/test_volume.py | 50 - export/tests/files/sample_export.sd-export | Bin 0 -> 714 bytes export/tests/files/sample_print.sd-export | Bin 0 -> 754 bytes export/tests/lsblk_sample.py | 162 ++ export/tests/test_archive.py | 33 +- export/tests/test_main.py | 158 +- 42 files changed, 4781 insertions(+), 2097 deletions(-) create mode 100644 client/securedrop_client/export_status.py create mode 100644 client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml create mode 100644 client/tests/functional/cassettes/test_export_file_dialog_locked.yaml create mode 100644 export/files/tcrypt.conf delete mode 100644 export/securedrop_export/disk/legacy_service.py delete mode 100644 export/securedrop_export/disk/legacy_status.py delete mode 100644 export/tests/disk/test_volume.py create mode 100644 export/tests/files/sample_export.sd-export create mode 100644 export/tests/files/sample_print.sd-export create mode 100644 export/tests/lsblk_sample.py diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index a99fdf48d..366f68eab 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -4,7 +4,6 @@ import subprocess import tarfile import threading -from enum import Enum from io import BytesIO from shlex import quote from tempfile import TemporaryDirectory @@ -12,6 +11,8 @@ from PyQt5.QtCore import QObject, pyqtBoundSignal, pyqtSignal, pyqtSlot +from securedrop_client.export_status import ExportStatus + logger = logging.getLogger(__name__) @@ -20,24 +21,6 @@ def __init__(self, status: "ExportStatus"): self.status: "ExportStatus" = status -class ExportStatus(Enum): - # On the way to success - USB_CONNECTED = "USB_CONNECTED" - DISK_ENCRYPTED = "USB_ENCRYPTED" - - # Not too far from success - USB_NOT_CONNECTED = "USB_NOT_CONNECTED" - BAD_PASSPHRASE = "USB_BAD_PASSPHRASE" - - # Failure - CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR" - DISK_ENCRYPTION_NOT_SUPPORTED_ERROR = "USB_ENCRYPTION_NOT_SUPPORTED" - ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION" - UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS" - PRINTER_NOT_FOUND = "ERROR_PRINTER_NOT_FOUND" - MISSING_PRINTER_URI = "ERROR_MISSING_PRINTER_URI" - - class Export(QObject): """ This class sends files over to the Export VM so that they can be copied to a luks-encrypted USB @@ -66,17 +49,21 @@ class Export(QObject): DISK_ENCRYPTION_KEY_NAME = "encryption_key" DISK_EXPORT_DIR = "export_data" - # Set up signals for communication with the controller - preflight_check_call_failure = pyqtSignal(object) - preflight_check_call_success = pyqtSignal() - export_usb_call_failure = pyqtSignal(object) - export_usb_call_success = pyqtSignal() - export_completed = pyqtSignal(list) + # 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) - printer_preflight_success = pyqtSignal() + # 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) - print_call_success = pyqtSignal() + + # Emit List[str] of filepaths + export_completed = pyqtSignal(list) def __init__( self, @@ -112,7 +99,7 @@ def connect_signals( if print_preflight_check_requested is not None: print_preflight_check_requested.connect(self.run_printer_preflight) - def _export_archive(cls, archive_path: str) -> Optional[ExportStatus]: + 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. @@ -149,12 +136,8 @@ def _export_archive(cls, archive_path: str) -> Optional[ExportStatus]: ) result = output.decode("utf-8").strip() - # No status is returned for successful `disk`, `printer-test`, and `print` calls. - # This will change in a future release of sd-export. - if result: - return ExportStatus(result) - else: - return None + return ExportStatus(result) + except ValueError as e: logger.debug(f"Export subprocess returned unexpected value: {e}") raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) @@ -164,7 +147,7 @@ def _export_archive(cls, archive_path: str) -> Optional[ExportStatus]: raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) def _create_archive( - cls, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] + cls, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] ) -> str: """ Create the archive to be sent to the Export VM. @@ -234,114 +217,55 @@ def _add_file_to_archive( archive.add(filepath, arcname=arcname, recursive=False) - def _run_printer_preflight(self, archive_dir: str) -> None: - """ - Make sure printer is ready. - """ - archive_path = self._create_archive( - archive_dir, self.PRINTER_PREFLIGHT_FN, self.PRINTER_PREFLIGHT_METADATA - ) - - status = self._export_archive(archive_path) - if status: - raise ExportError(status) - - def _run_usb_test(self, archive_dir: str) -> None: - """ - Run usb-test. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - - Raises: - ExportError: Raised if the usb-test does not return a USB_CONNECTED status. - """ - archive_path = self._create_archive(archive_dir, self.USB_TEST_FN, self.USB_TEST_METADATA) - status = self._export_archive(archive_path) - if status and status != ExportStatus.USB_CONNECTED: - raise ExportError(status) - - def _run_disk_test(self, archive_dir: str) -> None: - """ - Run disk-test. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - - Raises: - ExportError: Raised if the usb-test does not return a DISK_ENCRYPTED status. - """ - archive_path = self._create_archive(archive_dir, self.DISK_TEST_FN, self.DISK_TEST_METADATA) - - status = self._export_archive(archive_path) - if status and status != ExportStatus.DISK_ENCRYPTED: - raise ExportError(status) - - def _run_disk_export(self, archive_dir: str, filepaths: List[str], passphrase: str) -> None: - """ - Run disk-test. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - - Raises: - ExportError: Raised if the usb-test does not return a DISK_ENCRYPTED status. - """ - metadata = self.DISK_METADATA.copy() - metadata[self.DISK_ENCRYPTION_KEY_NAME] = passphrase - archive_path = self._create_archive(archive_dir, self.DISK_FN, metadata, filepaths) - - status = self._export_archive(archive_path) - if status: - raise ExportError(status) - - def _run_print(self, archive_dir: str, filepaths: List[str]) -> None: + def _build_archive_and_export( + self, metadata: dict, filename: str, filepaths: List[str] = [] + ) -> ExportStatus: """ - Create "printer" archive to send to Export VM. - - Args: - archive_dir (str): The path to the directory in which to create the archive. + 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. """ - metadata = self.PRINT_METADATA.copy() - archive_path = self._create_archive(archive_dir, self.PRINT_FN, metadata, filepaths) - status = self._export_archive(archive_path) - if status: - raise ExportError(status) + 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 the usb device is connected and luks-encrypted. + Run preflight checks to verify that a valid USB device is connected. """ - with TemporaryDirectory() as temp_dir: - try: - logger.debug( - "beginning preflight checks in thread {}".format( - threading.current_thread().ident - ) - ) - self._run_usb_test(temp_dir) - self._run_disk_test(temp_dir) - logger.debug("completed preflight checks: success") - self.preflight_check_call_success.emit() - except ExportError as e: - logger.debug("completed preflight checks: failure") - self.preflight_check_call_failure.emit(e) + 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. """ - with TemporaryDirectory() as temp_dir: - try: - self._run_printer_preflight(temp_dir) - self.printer_preflight_success.emit() - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.printer_preflight_failure.emit(e) + 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: @@ -352,18 +276,21 @@ def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None filepath: The path of file to export. passphrase: The passphrase to unlock the luks-encrypted usb disk drive. """ - with TemporaryDirectory() as temp_dir: - try: - logger.debug( - "beginning export from thread {}".format(threading.current_thread().ident) - ) - self._run_disk_export(temp_dir, filepaths, passphrase) - self.export_usb_call_success.emit() - logger.debug("Export successful") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.export_usb_call_failure.emit(e) + 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) @@ -375,18 +302,19 @@ def print(self, filepaths: List[str]) -> None: Args: filepath: The path of file to export. """ - with TemporaryDirectory() as temp_dir: - try: - logger.debug( - "beginning printer from thread {}".format(threading.current_thread().ident) - ) - self._run_print(temp_dir, filepaths) - self.print_call_success.emit() - logger.debug("Print successful") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.print_call_failure.emit(e) + 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) diff --git a/client/securedrop_client/export_status.py b/client/securedrop_client/export_status.py new file mode 100644 index 000000000..2c2a19924 --- /dev/null +++ b/client/securedrop_client/export_status.py @@ -0,0 +1,55 @@ +from enum import Enum + + +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. + """ + + # Export + NO_DEVICE_DETECTED = "NO_DEVICE_DETECTED" + INVALID_DEVICE_DETECTED = "INVALID_DEVICE_DETECTED" # Multi partitioned, not encrypted, etc + MULTI_DEVICE_DETECTED = "MULTI_DEVICE_DETECTED" # Not currently supported + UKNOWN_DEVICE_DETECTED = "UNKNOWN_DEVICE_DETECTED" # Badly-formatted USB or VeraCrypt/TC + + DEVICE_LOCKED = "DEVICE_LOCKED" # One valid device detected, and it's locked + DEVICE_WRITABLE = ( + "DEVICE_WRITABLE" # One valid device detected, and it's unlocked (and mounted) + ) + + ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" + ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" + ERROR_MOUNT = "ERROR_MOUNT" # Unlocked but not mounted + + SUCCESS_EXPORT = "SUCCESS_EXPORT" + ERROR_EXPORT = "ERROR_EXPORT" # Could not write to disk + + # Export succeeds but drives were not properly unmounted + ERROR_EXPORT_CLEANUP = "ERROR_EXPORT_CLEANUP" + + DEVICE_ERROR = "DEVICE_ERROR" # Something went wrong while trying to check the device + + # Print + ERROR_MULTIPLE_PRINTERS_FOUND = "ERROR_MULTIPLE_PRINTERS_FOUND" + ERROR_PRINTER_NOT_FOUND = "ERROR_PRINTER_NOT_FOUND" + ERROR_PRINTER_NOT_SUPPORTED = "ERROR_PRINTER_NOT_SUPPORTED" + ERROR_PRINTER_DRIVER_UNAVAILABLE = "ERROR_PRINTER_DRIVER_UNAVAILABLE" + ERROR_PRINTER_INSTALL = "ERROR_PRINTER_INSTALL" + ERROR_PRINTER_URI = "ERROR_PRINTER_URI" # new + + # Print error + ERROR_PRINT = "ERROR_PRINT" + + # New + PRINT_PREFLIGHT_SUCCESS = "PRINTER_PREFLIGHT_SUCCESS" + TEST_SUCCESS = "PRINTER_TEST_SUCCESS" + PRINT_SUCCESS = "PRINTER_SUCCESS" + + ERROR_UNKNOWN = "ERROR_GENERIC" # Unknown printer error, backwards-compatible + + # Misc + CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR" + ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION" + UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS" diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 5d8420b86..c4dfd6a70 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 state +from securedrop_client import export, 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) + self._export_device = ConversationExportDevice(controller, export.getService()) self.triggered.connect(self._on_triggered) @@ -212,7 +212,7 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller) + self._export_device = ConversationExportDevice(controller, export.getService()) self.triggered.connect(self._on_triggered) @@ -267,7 +267,7 @@ def __init__( self._source = source self._state = app_state - self._export_device = ConversationExportDevice(controller) + self._export_device = ConversationExportDevice(controller, export.getService()) 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 dfa3c69f9..9cf61dd06 100644 --- a/client/securedrop_client/gui/conversation/export/device.py +++ b/client/securedrop_client/gui/conversation/export/device.py @@ -4,7 +4,7 @@ from PyQt5.QtCore import QObject, pyqtSignal -from securedrop_client import export +from securedrop_client.export import Export from securedrop_client.logic import Controller logger = logging.getLogger(__name__) @@ -19,27 +19,32 @@ class Device(QObject): """ export_preflight_check_requested = pyqtSignal() - export_preflight_check_succeeded = pyqtSignal() - export_preflight_check_failed = pyqtSignal(object) + print_preflight_check_requested = pyqtSignal() - export_requested = pyqtSignal(list, str) - export_succeeded = pyqtSignal() + # Emit ExportStatus + export_preflight_check_succeeded = pyqtSignal(object) + export_succeeded = pyqtSignal(object) + + print_preflight_check_succeeded = pyqtSignal(object) + print_succeeded = pyqtSignal(object) + + # Emit ExportError(status=ExportStatus) + export_preflight_check_failed = pyqtSignal(object) export_failed = pyqtSignal(object) - export_completed = pyqtSignal(list) - print_preflight_check_requested = pyqtSignal() - print_preflight_check_succeeded = pyqtSignal() 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) - print_succeeded = pyqtSignal() - print_failed = pyqtSignal(object) - def __init__(self, controller: Controller) -> None: + def __init__(self, controller: Controller, export_service: Export) -> None: super().__init__() self._controller = controller - self._export_service = export.getService() + self._export_service = export_service self._export_service.connect_signals( self.export_preflight_check_requested, @@ -103,6 +108,7 @@ def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: logger.info("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) @@ -123,6 +129,7 @@ def print_file(self, file_uuid: str) -> None: logger.info("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]) diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py index 4db886dd2..414d2c8b1 100644 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ b/client/securedrop_client/gui/conversation/export/file_dialog.py @@ -9,7 +9,8 @@ from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QLineEdit, QVBoxLayout, QWidget -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export import ExportError +from securedrop_client.export_status import ExportStatus from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel from securedrop_client.gui.base.checkbox import SDCheckBox @@ -211,22 +212,34 @@ def _export_file(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_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) - @pyqtSlot() - def _on_export_preflight_check_succeeded(self) -> None: + @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() - self.continue_button.clicked.connect(self._show_passphrase_request_message) + if result == ExportStatus.DEVICE_WRITABLE: + # Skip password prompt, we're there + self.continue_button.clicked.connect(self._export_file) + 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 - self._show_passphrase_request_message() + # Skip passphrase prompt if device is unlocked + if result == ExportStatus.DEVICE_WRITABLE: + self._export_file() + else: + self._show_passphrase_request_message() @pyqtSlot(object) def _on_export_preflight_check_failed(self, error: ExportError) -> None: @@ -234,8 +247,8 @@ def _on_export_preflight_check_failed(self, error: ExportError) -> None: self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) self._update_dialog(error.status) - @pyqtSlot() - def _on_export_succeeded(self) -> None: + @pyqtSlot(object) + def _on_export_succeeded(self, status: ExportStatus) -> None: self.stop_animate_activestate() self._show_success_message() @@ -251,11 +264,13 @@ def _update_dialog(self, error_status: ExportStatus) -> None: # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.continue_button.clicked.disconnect() - if self.error_status == ExportStatus.BAD_PASSPHRASE: + if self.error_status == ExportStatus.ERROR_UNLOCK_LUKS: self.continue_button.clicked.connect(self._show_passphrase_request_message_again) - elif self.error_status == ExportStatus.USB_NOT_CONNECTED: + 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.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR: + 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) @@ -263,11 +278,11 @@ def _update_dialog(self, error_status: ExportStatus) -> None: self.continue_button.setEnabled(True) self.continue_button.setFocus() else: - if self.error_status == ExportStatus.BAD_PASSPHRASE: + if self.error_status == ExportStatus.ERROR_UNLOCK_LUKS: self._show_passphrase_request_message_again() - elif self.error_status == ExportStatus.USB_NOT_CONNECTED: + elif self.error_status == ExportStatus.NO_DEVICE_DETECTED: self._show_insert_usb_message() - elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR: + elif self.error_status == ExportStatus.INVALID_DEVICE_DETECTED: self._show_insert_encrypted_usb_message() else: self._show_generic_error_message() diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index e3ce34ba2..32e160bd1 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -3,7 +3,8 @@ from PyQt5.QtCore import QSize, pyqtSlot -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export import ExportError +from securedrop_client.export_status import ExportStatus from securedrop_client.gui.base import ModalDialog, SecureQLabel from .device import Device @@ -98,7 +99,10 @@ def _print_file(self) -> None: self.close() @pyqtSlot() - def _on_print_preflight_check_succeeded(self) -> None: + def _on_print_preflight_check_succeeded(self, status: ExportStatus) -> None: + # We don't use the ExportStatus for now for "success" status, + # but in future work we will migrate towards a wizard-style dialog, where + # success and intermediate status values all use the same PyQt slot. # 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("printer.svg", svg_size=QSize(64, 64)) @@ -120,7 +124,7 @@ def _on_print_preflight_check_failed(self, error: ExportError) -> None: # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.continue_button.clicked.disconnect() - if error.status == ExportStatus.PRINTER_NOT_FOUND: + if error.status == ExportStatus.ERROR_PRINTER_NOT_FOUND: self.continue_button.clicked.connect(self._show_insert_usb_message) else: self.continue_button.clicked.connect(self._show_generic_error_message) @@ -128,7 +132,7 @@ def _on_print_preflight_check_failed(self, error: ExportError) -> None: self.continue_button.setEnabled(True) self.continue_button.setFocus() else: - if error.status == ExportStatus.PRINTER_NOT_FOUND: + if error.status == ExportStatus.ERROR_PRINTER_NOT_FOUND: self._show_insert_usb_message() else: self._show_generic_error_message() diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 85364a594..a03f5b905 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -60,7 +60,7 @@ QWidget, ) -from securedrop_client import state +from securedrop_client import export, state from securedrop_client.db import ( DraftReply, File, @@ -2255,7 +2255,7 @@ def __init__( self.controller = controller - self._export_device = conversation.ExportDevice(controller) + self._export_device = conversation.ExportDevice(controller, export.getService()) 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 9266c0957..a7918bbc1 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -4,6 +4,7 @@ import tempfile from configparser import ConfigParser from datetime import datetime +from typing import List from uuid import uuid4 import pytest @@ -22,6 +23,7 @@ Source, make_session_maker, ) +from securedrop_client.export import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -167,17 +169,39 @@ def homedir(i18n): yield tmpdir +class MockExportService(export.Service): + """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(): - """An export service that assumes the Qubes RPC calls are successful and skips them.""" - export_service = export.Service() - # Ensure the export_service doesn't rely on Qubes OS: - export_service._run_disk_test = lambda dir: None - export_service._run_usb_test = lambda dir: None - export_service._run_disk_export = lambda dir, paths, passphrase: None - export_service._run_printer_preflight = lambda dir: None - export_service._run_print = lambda dir, paths: None - return export_service + return MockExportService(unlocked=False) + + +@pytest.fixture(scope="function") +def mock_export_service_unlocked_device(): + return MockExportService(unlocked=True) @pytest.fixture(scope="function") diff --git a/client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml b/client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml new file mode 100644 index 000000000..d59c25ebb --- /dev/null +++ b/client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.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 "Metadata": logger.info("Parsing archive metadata") json_config = json.loads(f.read()) self.export_method = json_config.get("device", None) - self.encryption_method = json_config.get("encryption_method", None) self.encryption_key = json_config.get("encryption_key", None) - logger.info( - "Target: {}, encryption_method {}".format( - self.export_method, self.encryption_method - ) - ) + self.encryption_method = json_config.get("encryption_method", None) + logger.info("Command: {}".format(self.export_method)) except Exception as ex: logger.error("Metadata parsing failure") @@ -54,12 +49,6 @@ def validate(self) -> "Metadata": try: logger.debug("Validate export action") self.command = Command(self.export_method) - if ( - self.command is Command.EXPORT - and self.encryption_method not in self.SUPPORTED_ENCRYPTION_METHODS - ): - logger.error("Unsupported encryption method") - raise ExportException(sdstatus=Status.ERROR_ARCHIVE_METADATA) except ValueError as v: raise ExportException(sdstatus=Status.ERROR_ARCHIVE_METADATA) from v @@ -95,7 +84,5 @@ def set_metadata(self, metadata: Metadata) -> "Archive": """ self.command = metadata.command if self.command is Command.EXPORT: - # When we support multiple encryption types, we will also want to add the - # encryption_method here self.encryption_key = metadata.encryption_key return self diff --git a/export/securedrop_export/disk/__init__.py b/export/securedrop_export/disk/__init__.py index e61094546..760c6e07c 100644 --- a/export/securedrop_export/disk/__init__.py +++ b/export/securedrop_export/disk/__init__.py @@ -1,2 +1,2 @@ -from .legacy_service import Service as LegacyService # noqa: F401 -from .legacy_status import Status as LegacyStatus # noqa: F401 +from .service import Service # noqa: F401 +from .status import Status # noqa: F401 diff --git a/export/securedrop_export/disk/cli.py b/export/securedrop_export/disk/cli.py index abdc0c104..1148e17b6 100644 --- a/export/securedrop_export/disk/cli.py +++ b/export/securedrop_export/disk/cli.py @@ -1,8 +1,12 @@ +import json import logging import os +import pexpect +import re import subprocess +import time -from typing import List, Optional, Union +from typing import Optional, Union from securedrop_export.exceptions import ExportException @@ -11,338 +15,378 @@ logger = logging.getLogger(__name__) +_DEVMAPPER_PREFIX = "/dev/mapper/" +_DEV_PREFIX = "/dev/" +_UDISKS_PREFIX = ( + "MODEL REVISION SERIAL DEVICE\n" + "--------------------------------------------------------------------------\n" +) + class CLI: """ - A Python wrapper for various shell commands required to detect, map, and - mount Export devices. + A Python wrapper for shell commands required to detect, map, and + mount USB devices. - CLI callers must handle ExportException and all exceptions and exit with - sys.exit(0) so that another program does not attempt to open the submission. + CLI callers must handle ExportException. """ - # Default mountpoint (unless drive is already mounted manually by the user) - _DEFAULT_MOUNTPOINT = "/media/usb" - - def get_connected_devices(self) -> List[str]: + def get_volume(self) -> Union[Volume, MountedVolume]: """ - List all block devices attached to VM that are disks and not partitions. - Return list of all removable connected block devices. - - Raise ExportException if any commands fail. + Search for valid connected device. + Raise ExportException on error. """ logger.info("Checking connected volumes") try: - lsblk = subprocess.Popen( - ["lsblk", "-o", "NAME,TYPE"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - grep = subprocess.Popen( - ["grep", "disk"], - stdin=lsblk.stdout, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + usbs = ( + subprocess.check_output(["udisksctl", "status"]) + .decode("utf-8") + .removeprefix(_UDISKS_PREFIX) + .strip() + .split("\n") ) - command_output = grep.stdout.readlines() # type: ignore[union-attr] - - # The first word in each element of the command_output list is the device name - attached_devices = [x.decode("utf8").split()[0] for x in command_output] - - except subprocess.CalledProcessError as ex: - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - - return self._get_removable_devices(attached_devices) - - def _get_removable_devices(self, attached_devices: List[str]) -> List[str]: - """ - Determine which block devices are USBs by selecting those that are removable. - """ - logger.info("Checking removable devices") - usb_devices = [] - for device in attached_devices: - is_removable = False - try: - removable = subprocess.check_output( - ["cat", f"/sys/class/block/{device}/removable"], - stderr=subprocess.PIPE, - ) - - # removable is "0" for non-removable device, "1" for removable, - # convert that into a Python boolean - is_removable = bool(int(removable.decode("utf8").strip())) - except subprocess.CalledProcessError: - # Not a removable device - continue - - if is_removable: - usb_devices.append(f"/dev/{device}") - - logger.info(f"{len(usb_devices)} connected") - return usb_devices - - def get_partitioned_device(self, blkid: str) -> str: - """ - Given a string representing a block device, return string that includes correct partition - (such as "/dev/sda" or "/dev/sda1"). - - Raise ExportException if partition check fails or device has unsupported partition scheme - (currently, multiple partitions are unsupported). - """ - device_and_partitions = self._check_partitions(blkid) - - if device_and_partitions: - partition_count = ( - device_and_partitions.decode("utf-8").split("\n").count("part") - ) - logger.debug(f"Counted {partition_count} partitions") - if partition_count > 1: - # We don't currently support devices with multiple partitions + # Collect a space-separated list of USB device names. + # Format: + # Label (may contain spaces) Revision Serial# Device + # The last string is the device identifier (/dev/{device}). + targets = [] + for i in usbs: + item = i.strip().split() + if len(item) > 0: + targets.append(item[-1]) + + if len(targets) == 0: + logger.info("No USB devices found") + raise ExportException(sdstatus=Status.NO_DEVICE_DETECTED) + elif len(targets) > 1: logger.error( - f"Multiple partitions not supported ({partition_count} partitions" - f" on {blkid})" + "Too many possibilities! Detach a storage device before continuing." ) - raise ExportException(sdstatus=Status.INVALID_DEVICE_DETECTED) - - # redefine device to /dev/sda if disk is encrypted, /dev/sda1 if partition encrypted - if partition_count == 1: - logger.debug("One partition found") - blkid += "1" + raise ExportException(sdstatus=Status.MULTI_DEVICE_DETECTED) - return blkid + # lsblk -o NAME,RM,RO,TYPE,MOUNTPOINT,FSTYPE --json + # devices such as /dev/xvda are marked as "removable", + # which is why we do the previous check to pick a device + # recognized by udisks2 + lsblk = subprocess.check_output( + [ + "lsblk", + "--output", + "NAME,RO,TYPE,MOUNTPOINT,FSTYPE", + "--json", + ] + ).decode("utf-8") + + lsblk_json = json.loads(lsblk) + if not lsblk_json.get("blockdevices"): + logger.error("Unrecoverable: could not parse lsblk.") + raise ExportException(sdstatus=Status.DEVICE_ERROR) + + volumes = [] + for device in lsblk_json.get("blockdevices"): + if device.get("name") in targets and device.get("ro") is False: + logger.debug( + f"Checking removable, writable device {_DEV_PREFIX}{device.get('name')}" + ) + # Inspect partitions or whole volume. + # For sanity, we will only support encrypted partitions one level deep. + if "children" in device: + for partition in device.get("children"): + # /dev/sdX1, /dev/sdX2 etc + item = self._get_supported_volume(partition) + if item: + volumes.append(item) + # /dev/sdX + else: + item = self._get_supported_volume(device) + if item: + volumes.append(item) + + if len(volumes) != 1: + logger.error(f"Need one target, got {len(volumes)}") + raise ExportException(sdstatus=Status.INVALID_DEVICE_DETECTED) + else: + logger.debug(f"Export target is {volumes[0].device_name}") + return volumes[0] - else: - # lsblk did not return output we could process - logger.error("Error checking device partitions") - raise ExportException(sdstatus=Status.DEVICE_ERROR) - - def _check_partitions(self, blkid: str) -> bytes: - try: - logger.debug(f"Checking device partitions on {blkid}") - device_and_partitions = subprocess.check_output( - ["lsblk", "-o", "TYPE", "--noheadings", blkid], stderr=subprocess.PIPE - ) - return device_and_partitions + except json.JSONDecodeError as err: + logger.error(err) + raise ExportException(sdstatus=Status.DEVICE_ERROR) from err except subprocess.CalledProcessError as ex: - logger.error(f"Error checking block device {blkid}") raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - def is_luks_volume(self, device: str) -> bool: + def _get_supported_volume( + self, device: dict + ) -> Optional[Union[Volume, MountedVolume]]: """ - Given a string representing a volume (/dev/sdX or /dev/sdX1), return True if volume is - LUKS-encrypted, otherwise False. + Given JSON-formatted lsblk output for one device, determine if it + is suitably partitioned and return it to be used for export, + mounting it if possible. + + Supported volumes: + * Unlocked Veracrypt drives + * Locked or unlocked LUKS drives + * No more than one encrypted partition (multiple non-encrypted partitions + are OK as they will be ignored). + + Note: It would be possible to support other unlocked encrypted drives, as long as + udisks2 can tell they contain an encrypted partition. """ - isLuks = False - - try: - logger.debug("Checking if target device is luks encrypted") - - # cryptsetup isLuks returns 0 if the device is a luks volume - # subprocess will throw if the device is not luks (rc !=0) - subprocess.check_call(["sudo", "cryptsetup", "isLuks", device]) - - isLuks = True - - except subprocess.CalledProcessError: - # Not necessarily an error state, just means the volume is not LUKS encrypted - logger.info("Target device is not LUKS-encrypted") - - return isLuks - - def _get_luks_name_from_headers(self, device: str) -> str: - """ - Dump LUKS header and determine name of volume. - - Raise ExportException if errors encounterd during attempt to parse LUKS headers. - """ - logger.debug("Get LUKS name from headers") - try: - luks_header = subprocess.check_output( - ["sudo", "cryptsetup", "luksDump", device] - ) - if luks_header: - luks_header_list = luks_header.decode("utf-8").split("\n") - for line in luks_header_list: - items = line.split("\t") - if "UUID" in items[0]: - return "luks-" + items[1] - - # If no header or no UUID field, we can't use this drive - logger.error( - f"Failed to get UUID from LUKS header; {device} may not be correctly formatted" - ) - raise ExportException(sdstatus=Status.INVALID_DEVICE_DETECTED) - except subprocess.CalledProcessError as ex: - logger.error("Failed to dump LUKS header") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex + device_name = device.get("name") + device_fstype = device.get("fstype") + + vol = Volume(f"{_DEV_PREFIX}{device_name}", EncryptionScheme.UNKNOWN) + + if device_fstype == "crypto_LUKS": + logger.debug(f"{device_name} is LUKS-encrypted") + vol.encryption = EncryptionScheme.LUKS + + children = device.get("children") + if children: + if len(children) != 1: + logger.error(f"Unexpected volume format on {vol.device_name}") + return None + elif children[0].get("type") != "crypt": + return None + else: + # It's an unlocked drive, possibly mounted + mapped_name = f"{_DEVMAPPER_PREFIX}{children[0].get('name')}" + + # Unlocked VC/TC drives will still have EncryptionScheme.UNKNOWN; + # see if we can do better + if vol.encryption == EncryptionScheme.UNKNOWN: + vol.encryption = self._is_it_veracrypt(vol) + + if children[0].get("mountpoint"): + logger.debug(f"{vol.device_name} is mounted") + + return MountedVolume( + device_name=vol.device_name, + unlocked_name=mapped_name, + encryption=vol.encryption, + mountpoint=children[0].get("mountpoint"), + ) + else: + # To opportunistically mount any unlocked encrypted partition + # (i.e. third-party devices such as IronKeys), remove this condition. + if vol.encryption in ( + EncryptionScheme.LUKS, + EncryptionScheme.VERACRYPT, + ): + logger.debug( + f"{device_name} is unlocked but unmounted; attempting mount" + ) + return self._mount_volume(vol, mapped_name) + + # Locked VeraCrypt drives are rejected here (EncryptionScheme.UNKNOWN) + if vol.encryption in (EncryptionScheme.LUKS, EncryptionScheme.VERACRYPT): + logger.debug(f"{vol.device_name} is supported export target") + return vol + else: + logger.debug(f"No suitable volume found on {vol.device_name}") + return None - def get_luks_volume(self, device: str) -> Union[Volume, MountedVolume]: + def _is_it_veracrypt(self, volume: Volume) -> EncryptionScheme: """ - Given a string corresponding to a LUKS-partitioned volume, return a corresponding Volume - object. - - If LUKS volume is already mounted, existing mountpoint will be preserved and a - MountedVolume object will be returned. - If LUKS volume is unlocked but not mounted, volume will be mounted at _DEFAULT_MOUNTPOINT, - and a MountedVolume object will be returned. - - If device is still locked, mountpoint will not be set, and a Volume object will be retuned. - Once the decrpytion passphrase is available, call unlock_luks_volume(), passing the Volume - object and passphrase to unlock the volume. - - Raise ExportException if errors are encountered. + Helper. Best-effort detection of unlocked VeraCrypt drives. + Udisks2 requires the flag file /etc/udisks2/tcrypt.conf to + enable VC detection, which we will ship with the `securedrop-export` package. """ try: - mapped_name = self._get_luks_name_from_headers(device) - logger.debug(f"Mapped name is {mapped_name}") - - # Setting the mapped_name does not mean the device has already been unlocked. - luks_volume = Volume( - device_name=device, - mapped_name=mapped_name, - encryption=EncryptionScheme.LUKS, - ) - - # If the device has been unlocked, we can see if it's mounted and - # use the existing mountpoint, or mount it ourselves. - # Either way, return a MountedVolume. - if os.path.exists(os.path.join("/dev/mapper/", mapped_name)): - return self.mount_volume(luks_volume) - - # It's still locked + info = subprocess.check_output( + [ + "udisksctl", + "info", + "--block-device", + f"{volume.device_name}", + ] + ).decode("utf-8") + if "IdType: crypto_TCRYPT\n" in info: + return EncryptionScheme.VERACRYPT + elif "IdType: crypto_LUKS\n" in info: + # Don't downgrade LUKS to UNKNOWN if someone + # calls this method on a LUKS drive + return EncryptionScheme.LUKS else: - return luks_volume + return EncryptionScheme.UNKNOWN + except subprocess.CalledProcessError as err: + logger.debug(f"Error checking disk info of {volume.device_name}") + logger.error(err) + # Not a showstopper + return EncryptionScheme.UNKNOWN + + def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: + """ + Unlock and mount an encrypted volume. If volume is already mounted, preserve + existing mountpoint. - except ExportException: - logger.error("Failed to return luks volume") - raise + Throws ExportException if errors are encountered during device unlocking. - def unlock_luks_volume(self, volume: Volume, decryption_key: str) -> Volume: + `pexpect.ExeptionPexpect` can't be try/caught, since it's not a + child of BaseException, but instead, exceptions can be included + in the list of results to check for. (See + https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect) """ - Unlock a LUKS-encrypted volume. + logger.debug("Unlocking volume {}".format(volume.device_name)) + + command = f"udisksctl unlock --block-device {volume.device_name}" + prompt = ["Passphrase: ", pexpect.EOF, pexpect.TIMEOUT] + expected = [ + f"Unlocked {volume.device_name} as (.*)\.", + "GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Device " # string continues + f"{volume.device_name} is already unlocked as (.*)\.", + "GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Error " # string continues + f"unlocking {volume.device_name}: Failed to activate device: Incorrect passphrase", + pexpect.EOF, + pexpect.TIMEOUT, + ] + unlock_error = Status.ERROR_UNLOCK_GENERIC + + child = pexpect.spawn(command) + index = child.expect(prompt) + if index != 0: + logger.error("Did not receive disk unlock prompt") + raise ExportException(sdstatus=Status.ERROR_UNLOCK_GENERIC) + else: + logger.debug("Passing key") + child.sendline(encryption_key) + index = child.expect(expected) + if index == 0 or index == 1: + # We know what format the string is in + dm_name = child.match.group(1).decode("utf-8").strip() + logger.debug(f"Device is unlocked as {dm_name}") - Raise ExportException if errors are encountered during device unlocking. - """ - if volume.encryption is not EncryptionScheme.LUKS: - logger.error("Must call unlock_luks_volume() on LUKS-encrypted device") - raise ExportException(sdstatus=Status.DEVICE_ERROR) + child.close() + if (child.exitstatus) not in (0, 1): + logger.warning(f"pexpect: child exited with {child.exitstatus}") - try: - logger.debug("Unlocking luks volume {}".format(volume.device_name)) - p = subprocess.Popen( - [ - "sudo", - "cryptsetup", - "luksOpen", - volume.device_name, - volume.mapped_name, - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - logger.debug("Passing key") - p.communicate(input=str.encode(decryption_key, "utf-8")) - rc = p.returncode - - if rc == 0: - return Volume( - device_name=volume.device_name, - mapped_name=volume.mapped_name, - encryption=EncryptionScheme.LUKS, - ) - else: - logger.error("Bad volume passphrase") - raise ExportException(sdstatus=Status.ERROR_UNLOCK_LUKS) + # dm_name format is /dev/dm-X + return self._mount_volume(volume, dm_name) - except subprocess.CalledProcessError as ex: - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex + elif index == 2: + # Still an error, but we can report more specific error to the user + logger.debug("Bad volume passphrase") + unlock_error = Status.ERROR_UNLOCK_LUKS - def _get_mountpoint(self, volume: Volume) -> Optional[str]: - """ - Check for existing mountpoint. - Raise ExportException if errors encountered during command. - """ - logger.debug("Checking mountpoint") - try: - output = subprocess.check_output( - ["lsblk", "-o", "MOUNTPOINT", "--noheadings", volume.device_name] - ) - return output.decode("utf-8").strip() + # Any other index values are also an error. Clean up and raise + child.close() - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise ExportException(sdstatus=Status.ERROR_MOUNT) from ex + logger.error(f"Error encountered while unlocking {volume.device_name}") + raise ExportException(sdstatus=unlock_error) - def mount_volume(self, volume: Volume) -> MountedVolume: + def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolume: """ - Given an unlocked LUKS volume, return MountedVolume object. + Given an unlocked volume, mount volume in /media/user/ by udisksctl and + return MountedVolume object. - If volume is already mounted, mountpoint is not changed. Otherwise, - volume is mounted at _DEFAULT_MOUNTPOINT. + Unlocked name could be `/dev/mapper/$id` or `/dev/dm-X`. Raise ExportException if errors are encountered during mounting. + + `pexpect.ExeptionPexpect` can't be try/caught, since it's not a + child of BaseException, but instead, exceptions can be included + in the list of results to check for. (See + https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect) """ - if not volume.unlocked: - logger.error("Volume is not unlocked.") - raise ExportException(sdstatus=Status.ERROR_MOUNT) - mountpoint = self._get_mountpoint(volume) + info = f"udisksctl info --block-device {volume.device_name}" + # \x1b[37mPreferredDevice:\x1b[0m /dev/sdaX\r\n + expected_info = [ + f"*PreferredDevice:[\t+]{volume.device_name}\r\n", + "*Error looking up object for device*", + pexpect.EOF, + pexpect.TIMEOUT, + ] + max_retries = 3 + + unlock = f"udisksctl mount --block-device {full_unlocked_name}" + + # We can't pass {full_unlocked_name} in the match statement since even if we + # pass in /dev/mapper/xxx, udisks2 may refer to the disk as /dev/dm-X. + expected_unlock = [ + f"Mounted * at (.*)", + f"Error mounting *: GDBus.Error:org." # string continues + "freedesktop.UDisks2.Error.AlreadyMounted: " # string continues + "Device .* is already mounted at `(.*)'", + f"Error looking up object for device *.", + pexpect.EOF, + pexpect.TIMEOUT, + ] + mountpoint = None + + logger.debug(f"Check to make sure udisks identified {volume.device_name} " + "(unlocked as {full_unlocked_name})") + for _ in range(max_retries): + child = pexpect.spawn(info) + index = child.expect(expected_info) + logger.debug(f"Results from udisks info: {volume.device_name}, " + "before: {child.before}, after: {child.after}") + child.close() + + if index != 0: + logger.debug(f"index {index}") + logger.warning( + f"udisks can't identify {volume.device_name}, retrying..." + ) + time.sleep(0.5) + else: + print(f"udisks found {volume.device_name}") + break - if mountpoint: - logger.info("The device is already mounted--use existing mountpoint") - return MountedVolume.from_volume(volume, mountpoint) + logger.info(f"Mount {full_unlocked_name} using udisksctl") + child = pexpect.spawn(unlock) + index = child.expect(expected_unlock) - else: - logger.info("Mount volume at default mountpoint") - return self._mount_at_mountpoint(volume, self._DEFAULT_MOUNTPOINT) + logger.debug( + f"child: {str(child.match)}, before: {child.before}, after: {child.after}" + ) - def _mount_at_mountpoint(self, volume: Volume, mountpoint: str) -> MountedVolume: - """ - Mount a volume at the supplied mountpoint, creating the mountpoint directory and - adjusting permissions (user:user) if need be. `mountpoint` must be a full path. + if index == 0: + # As above, we know the format + mountpoint = child.match.group(1).decode("utf-8").strip() + logger.debug(f"Successfully mounted device at {mountpoint}") - Return MountedVolume object. - Raise ExportException if unable to mount volume at target mountpoint. - """ - if not os.path.exists(mountpoint): - try: - subprocess.check_call(["sudo", "mkdir", mountpoint]) - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise ExportException(sdstatus=Status.ERROR_MOUNT) from ex + elif index == 1: + # Mountpoint needs a bit of help. It arrives in the form `/path/to/mountpoint'. + # including the one backtick, single quote, and the period + mountpoint = child.match.group(1).decode("utf-8").strip() + logger.debug(f"Device already mounted at {mountpoint}") - # Mount device /dev/mapper/{mapped_name} at /media/usb/ - mapped_device_path = os.path.join( - volume.MAPPED_VOLUME_PREFIX, volume.mapped_name - ) + elif index == 2: + logger.debug("Device is not ready") - try: - logger.info(f"Mounting volume at {mountpoint}") - subprocess.check_call(["sudo", "mount", mapped_device_path, mountpoint]) - subprocess.check_call(["sudo", "chown", "-R", "user:user", mountpoint]) + child.close() - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise ExportException(sdstatus=Status.ERROR_MOUNT) from ex + if mountpoint: + return MountedVolume( + device_name=volume.device_name, + unlocked_name=full_unlocked_name, + encryption=volume.encryption, + mountpoint=mountpoint, + ) - return MountedVolume.from_volume(volume, mountpoint) + logger.error("Could not get mountpoint") + raise ExportException(sdstatus=Status.ERROR_MOUNT) def write_data_to_device( self, + device: MountedVolume, submission_tmpdir: str, submission_target_dirname: str, - device: MountedVolume, ): """ Move files to drive (overwrites files with same filename) and unmount drive. + Drive is unmounted and files are cleaned up as part of the `finally` block to ensure that cleanup happens even if export fails or only partially succeeds. """ try: + # Flag to pass to cleanup method + is_error = False + target_path = os.path.join(device.mountpoint, submission_target_dirname) subprocess.check_call(["mkdir", target_path]) @@ -356,74 +400,99 @@ def write_data_to_device( except (subprocess.CalledProcessError, OSError) as ex: logger.error(ex) + + # Ensure we report an export error out after cleanup + is_error = True raise ExportException(sdstatus=Status.ERROR_EXPORT) from ex finally: - self.cleanup_drive_and_tmpdir(device, submission_tmpdir) + self.cleanup(device, submission_tmpdir, is_error) - def cleanup_drive_and_tmpdir(self, volume: MountedVolume, submission_tmpdir: str): + def cleanup( + self, + volume: MountedVolume, + submission_tmpdir: str, + is_error: bool = False, + should_close_volume: bool = True, + ): """ Post-export cleanup method. Unmount and lock drive and remove temporary - directory. Currently called at end of `write_data_to_device()` to ensure - device is always locked after export. + directory. - Raise ExportException if errors during cleanup are encountered. + Raises ExportException if errors during cleanup are encountered. + + Method is called whether or not export succeeds; if `is_error` is True, + will report export error status on error (insted of cleanup status). """ + error_status = Status.ERROR_EXPORT if is_error else Status.ERROR_EXPORT_CLEANUP + logger.debug("Syncing filesystems") try: subprocess.check_call(["sync"]) - umounted = self._unmount_volume(volume) - if umounted: - self._close_luks_volume(umounted) self._remove_temp_directory(submission_tmpdir) + # Future configurable option + if should_close_volume: + self._close_volume(volume) + except subprocess.CalledProcessError as ex: logger.error("Error syncing filesystem") - raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex + raise ExportException(sdstatus=error_status) from ex - def _unmount_volume(self, volume: MountedVolume) -> Volume: + def _close_volume(self, mv: MountedVolume) -> Volume: """ - Helper. Unmount volume + Unmount and close volume. """ - if os.path.exists(volume.mountpoint): - logger.debug(f"Unmounting drive from {volume.mountpoint}") + if os.path.exists(mv.mountpoint): + logger.debug(f"Unmounting drive {mv.unlocked_name} from {mv.mountpoint}") try: - subprocess.check_call(["sudo", "umount", volume.mountpoint]) + subprocess.check_call( + [ + "udisksctl", + "unmount", + "--block-device", + f"{mv.unlocked_name}", + ] + ) except subprocess.CalledProcessError as ex: + logger.error(ex) logger.error("Error unmounting device") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex + + raise ExportException(sdstatus=Status.ERROR_UNMOUNT_VOLUME_BUSY) from ex else: logger.info("Mountpoint does not exist; volume was already unmounted") - return Volume( - device_name=volume.device_name, - mapped_name=volume.mapped_name, - encryption=volume.encryption, - ) - - def _close_luks_volume(self, unlocked_device: Volume) -> None: - """ - Helper. Close LUKS volume - """ - if os.path.exists(os.path.join("/dev/mapper", unlocked_device.mapped_name)): - logger.debug("Locking luks volume {}".format(unlocked_device)) + if os.path.exists(f"{mv.unlocked_name}"): + logger.debug(f"Closing drive {mv.device_name}") try: subprocess.check_call( - ["sudo", "cryptsetup", "luksClose", unlocked_device.mapped_name] + [ + "udisksctl", + "lock", + "--block-device", + f"{mv.device_name}", + ] ) except subprocess.CalledProcessError as ex: logger.error("Error closing device") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex + raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex + else: + logger.info("Mapped entry does not exist; volume was already closed") + + return Volume( + device_name=f"{_DEV_PREFIX}{mv.device_name}", + encryption=mv.encryption, + ) def _remove_temp_directory(self, tmpdir: str): """ - Helper. Remove temporary directory used during archive export. + Helper. Remove temporary directory used during export. """ logger.debug(f"Deleting temporary directory {tmpdir}") try: subprocess.check_call(["rm", "-rf", tmpdir]) except subprocess.CalledProcessError as ex: logger.error("Error removing temporary directory") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex + raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex diff --git a/export/securedrop_export/disk/legacy_service.py b/export/securedrop_export/disk/legacy_service.py deleted file mode 100644 index 3dbe6acaa..000000000 --- a/export/securedrop_export/disk/legacy_service.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging - -from securedrop_export.exceptions import ExportException - -from .cli import CLI -from .legacy_status import Status as LegacyStatus -from .status import Status as Status -from .volume import MountedVolume - -logger = logging.getLogger(__name__) - - -class Service: - def __init__(self, submission, cli=None): - self.submission = submission - self.cli = cli or CLI() - - def check_connected_devices(self) -> LegacyStatus: - """ - Check if single USB is inserted. - """ - logger.info("Export archive is usb-test") - - try: - all_devices = self.cli.get_connected_devices() - num_devices = len(all_devices) - - except ExportException as ex: - logger.error(f"Error encountered during USB check: {ex.sdstatus.value}") - # Use legacy status instead of new status values - raise ExportException(sdstatus=LegacyStatus.LEGACY_ERROR_USB_CHECK) from ex - - if num_devices == 0: - raise ExportException(sdstatus=LegacyStatus.LEGACY_USB_NOT_CONNECTED) - elif num_devices == 1: - return LegacyStatus.LEGACY_USB_CONNECTED - elif num_devices > 1: - raise ExportException( - sdstatus=LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - ) - else: - # Unreachable, num_devices is a non-negative integer, - # and we handled all possible cases already - raise ValueError(f"unreachable: num_devices is negative: {num_devices}") - - def check_disk_format(self) -> LegacyStatus: - """ - Check if volume is correctly formatted for export. - """ - try: - all_devices = self.cli.get_connected_devices() - - if len(all_devices) == 1: - device = self.cli.get_partitioned_device(all_devices[0]) - logger.info("Check if LUKS") - if not self.cli.is_luks_volume(device): - raise ExportException( - sdstatus=LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - ) - # We can support checking if a drive is already unlocked, but for - # backwards compatibility, this is the only expected status - # at this stage - return LegacyStatus.LEGACY_USB_ENCRYPTED - else: - logger.error("Multiple partitions not supported") - return LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - - except ExportException as ex: - logger.error( - f"Error encountered during disk format check: {ex.sdstatus.value}" - ) - # Return legacy status values for now for ongoing client compatibility - if ex.sdstatus in [s for s in Status]: - status = self._legacy_status(ex.sdstatus) - raise ExportException(sdstatus=status) - elif ex.sdstatus: - raise - else: - raise ExportException(sdstatus=LegacyStatus.LEGACY_USB_DISK_ERROR) - - def export(self): - """ - Export all files to target device. - """ - logger.info("Export archive is disk") - - try: - all_devices = self.cli.get_connected_devices() - - if len(all_devices) == 1: - device = self.cli.get_partitioned_device(all_devices[0]) - - # Decide what kind of volume it is - logger.info("Check if LUKS") - if self.cli.is_luks_volume(device): - volume = self.cli.get_luks_volume(device) - logger.info("Check if writable") - if not isinstance(volume, MountedVolume): - logger.info("Not writable-will try unlocking") - volume = self.cli.unlock_luks_volume( - volume, self.submission.encryption_key - ) - mounted_volume = self.cli.mount_volume(volume) - - logger.info(f"Export submission to {mounted_volume.mountpoint}") - self.cli.write_data_to_device( - self.submission.tmpdir, - self.submission.target_dirname, - mounted_volume, - ) - # This is SUCCESS_EXPORT, but the 0.7.0 client is not expecting - # a return status from a successful export operation. - # When the client is updated, we will return SUCCESS_EXPORT here. - - else: - # Another kind of drive: VeraCrypt/TC, or unsupported. - # For now this is an error--in future there will be support - # for additional encryption formats - logger.error(f"Export failed because {device} is not supported") - raise ExportException( - sdstatus=LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - ) - - except ExportException as ex: - logger.error( - f"Error encountered during disk format check: {ex.sdstatus.value}" - ) - # Return legacy status values for now for ongoing client compatibility - if ex.sdstatus in [s for s in Status]: - status = self._legacy_status(ex.sdstatus) - raise ExportException(sdstatus=status) - elif ex.sdstatus: - raise - else: - raise ExportException(sdstatus=LegacyStatus.LEGACY_ERROR_GENERIC) - - def _legacy_status(self, status: Status) -> LegacyStatus: - """ - Backwards-compatibility - status values that client (@0.7.0) is expecting. - """ - logger.info(f"Convert to legacy: {status.value}") - if status is Status.ERROR_MOUNT: - return LegacyStatus.LEGACY_ERROR_USB_MOUNT - elif status in [Status.ERROR_EXPORT, Status.ERROR_EXPORT_CLEANUP]: - return LegacyStatus.LEGACY_ERROR_USB_WRITE - elif status in [Status.ERROR_UNLOCK_LUKS, Status.ERROR_UNLOCK_GENERIC]: - return LegacyStatus.LEGACY_USB_BAD_PASSPHRASE - elif status in [ - Status.INVALID_DEVICE_DETECTED, - Status.MULTI_DEVICE_DETECTED, - ]: - return LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - # The other status values, such as Status.NO_DEVICE_DETECTED, are not returned by the - # CLI, so we don't need to check for them here - else: - return LegacyStatus.LEGACY_ERROR_GENERIC diff --git a/export/securedrop_export/disk/legacy_status.py b/export/securedrop_export/disk/legacy_status.py deleted file mode 100644 index 77f0fa6ce..000000000 --- a/export/securedrop_export/disk/legacy_status.py +++ /dev/null @@ -1,25 +0,0 @@ -from securedrop_export.status import BaseStatus - - -class Status(BaseStatus): - LEGACY_ERROR_GENERIC = "ERROR_GENERIC" - - # Legacy USB preflight related - LEGACY_USB_CONNECTED = "USB_CONNECTED" # Success - LEGACY_USB_NOT_CONNECTED = "USB_NOT_CONNECTED" - LEGACY_ERROR_USB_CHECK = "ERROR_USB_CHECK" - - # Legacy USB Disk preflight related errors - LEGACY_USB_ENCRYPTED = "USB_ENCRYPTED" # Success - LEGACY_USB_ENCRYPTION_NOT_SUPPORTED = "USB_ENCRYPTION_NOT_SUPPORTED" - - # Can be raised during disk format check - LEGACY_USB_DISK_ERROR = "USB_DISK_ERROR" - - # Legacy Disk export errors - LEGACY_USB_BAD_PASSPHRASE = "USB_BAD_PASSPHRASE" - LEGACY_ERROR_USB_MOUNT = "ERROR_USB_MOUNT" - LEGACY_ERROR_USB_WRITE = "ERROR_USB_WRITE" - - # New - SUCCESS_EXPORT = "SUCCESS_EXPORT" diff --git a/export/securedrop_export/disk/service.py b/export/securedrop_export/disk/service.py index 1db9a8338..6a7b7cdfd 100644 --- a/export/securedrop_export/disk/service.py +++ b/export/securedrop_export/disk/service.py @@ -1,24 +1,23 @@ import logging -from securedrop_export.archive import Archive - from .cli import CLI from .status import Status -from .volume import Volume, MountedVolume +from .volume import MountedVolume, Volume +from securedrop_export.archive import Archive from securedrop_export.exceptions import ExportException - logger = logging.getLogger(__name__) class Service: """ - Checks that can be performed against the device(s). + Actions that can be performed against USB device(s). This is the "API" portion of the export workflow. """ - def __init__(self, cli: CLI): + def __init__(self, submission: Archive, cli: CLI = CLI()): self.cli = cli + self.submission = submission def scan_all_devices(self) -> Status: """ @@ -26,89 +25,55 @@ def scan_all_devices(self) -> Status: status. """ try: - all_devices = self.cli.get_connected_devices() - number_devices = len(all_devices) - - if number_devices == 0: - return Status.NO_DEVICE_DETECTED - elif number_devices > 1: - return Status.MULTI_DEVICE_DETECTED + volume = self.cli.get_volume() + if isinstance(volume, MountedVolume): + return Status.DEVICE_WRITABLE + elif isinstance(volume, Volume): + return Status.DEVICE_LOCKED else: - return self.scan_single_device(all_devices[0]) + # Above will return MountedVolume, Volume, or raise error; + # this shouldn't be reachable + raise ExportException(sdstatus=Status.DEVICE_ERROR) except ExportException as ex: - logger.error(ex) - return Status.DEVICE_ERROR # Could not assess devices + logger.debug(ex) + status = ex.sdstatus if ex.sdstatus is not None else Status.DEVICE_ERROR + logger.error(f"Encountered {status.value} while checking volumes") + return status - def scan_single_device(self, blkid: str) -> Status: + def export(self) -> Status: """ - Given a string representing a single block device, see if it - is a suitable export target and return information about its state. + Export material to USB drive. """ try: - target = self.cli.get_partitioned_device(blkid) - - # See if it's a LUKS drive - if self.cli.is_luks_volume(target): - # Returns Volume or throws ExportException - self.volume = self.cli.get_luks_volume(target) - - # See if it's unlocked and mounted - if isinstance(self.volume, MountedVolume): - logger.debug("LUKS device is already mounted") - return Status.DEVICE_WRITABLE + volume = self.cli.get_volume() + if isinstance(volume, MountedVolume): + logger.debug("Mounted volume detected, exporting files") + self.cli.write_data_to_device( + volume, self.submission.tmpdir, self.submission.target_dirname + ) + return Status.SUCCESS_EXPORT + elif isinstance(volume, Volume): + if self.submission.encryption_key is not None: + logger.debug("Volume is locked, try unlocking") + mv = self.cli.unlock_volume(volume, self.submission.encryption_key) + if isinstance(mv, MountedVolume): + logger.debug("Export to device") + # Exports then locks the drive. + # If the export succeeds but the drive is in use, will raise + # exception. + self.cli.write_data_to_device( + mv, self.submission.tmpdir, self.submission.target_dirname + ) + return Status.SUCCESS_EXPORT + else: + raise ExportException(sdstatus=Status.ERROR_UNLOCK_GENERIC) else: - # Prompt for passphrase + logger.info("Volume is locked and no key has been provided") return Status.DEVICE_LOCKED - else: - # Might be VeraCrypt, might be madness - logger.info("LUKS drive not found") - - # Currently we don't support anything other than LUKS. - # In future, we will support TC/VC volumes as well - return Status.INVALID_DEVICE_DETECTED except ExportException as ex: - logger.error(ex) - if ex.sdstatus: - return ex.sdstatus - else: - return Status.DEVICE_ERROR - - def unlock_device(self, passphrase: str, volume: Volume) -> Status: - """ - Given provided passphrase, unlock target volume. Currently, - LUKS volumes are supported. - """ - if volume: - try: - self.volume = self.cli.unlock_luks_volume(volume, passphrase) - - if isinstance(volume, MountedVolume): - return Status.DEVICE_WRITABLE - else: - return Status.ERROR_UNLOCK_LUKS - - except ExportException as ex: - logger.error(ex) - return Status.ERROR_UNLOCK_LUKS - else: - # Trying to unlock devices before having an active device - logger.warning("Tried to unlock_device but no current volume detected.") - return Status.NO_DEVICE_DETECTED - - def write_to_device(self, volume: MountedVolume, data: Archive) -> Status: - """ - Export data to volume. CLI unmounts and locks volume on completion, even - if export was unsuccessful. - """ - try: - self.cli.write_data_to_device(data.tmpdir, data.target_dirname, volume) - return Status.SUCCESS_EXPORT - - except ExportException as ex: - logger.error(ex) - if ex.sdstatus: - return ex.sdstatus - else: - return Status.ERROR_EXPORT + logger.debug(ex) + status = ex.sdstatus if ex.sdstatus is not None else Status.ERROR_EXPORT + logger.error(f"Enountered {status.value} while trying to export") + return status diff --git a/export/securedrop_export/disk/status.py b/export/securedrop_export/disk/status.py index 7ce713913..ca04ccaef 100644 --- a/export/securedrop_export/disk/status.py +++ b/export/securedrop_export/disk/status.py @@ -3,24 +3,30 @@ class Status(BaseStatus): NO_DEVICE_DETECTED = "NO_DEVICE_DETECTED" + INVALID_DEVICE_DETECTED = ( - "INVALID_DEVICE_DETECTED" # Multi partitioned, not encrypted, etc + "INVALID_DEVICE_DETECTED" # Not encrypted, or partitions too many/too nested ) + MULTI_DEVICE_DETECTED = "MULTI_DEVICE_DETECTED" # Not currently supported - DEVICE_LOCKED = "DEVICE_LOCKED" # One device detected, and it's locked + DEVICE_LOCKED = "DEVICE_LOCKED" # One valid device detected, and it's locked DEVICE_WRITABLE = ( - "DEVICE_WRITABLE" # One device detected, and it's unlocked (and mounted) + "DEVICE_WRITABLE" # One valid device detected, and it's unlocked (and mounted) ) - ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" - ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" + ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" # Bad passphrase (LUKS) + ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" # May not be used ERROR_MOUNT = "ERROR_MOUNT" # Unlocked but not mounted SUCCESS_EXPORT = "SUCCESS_EXPORT" ERROR_EXPORT = "ERROR_EXPORT" # Could not write to disk - # export succeeds but drives were not properly unmounted + # Export succeeds but drive was not unmounted because the volume is busy. + # This could happen if the user has an application using the drive elsewhere + ERROR_UNMOUNT_VOLUME_BUSY = "ERROR_UNMOUNT_VOLUME_BUSY" + + # Export succeeds but drives were not properly unmounted (generic) ERROR_EXPORT_CLEANUP = "ERROR_EXPORT_CLEANUP" DEVICE_ERROR = ( diff --git a/export/securedrop_export/disk/volume.py b/export/securedrop_export/disk/volume.py index aae7d9332..09ba45f96 100644 --- a/export/securedrop_export/disk/volume.py +++ b/export/securedrop_export/disk/volume.py @@ -1,5 +1,4 @@ from enum import Enum -import os class EncryptionScheme(Enum): @@ -9,25 +8,21 @@ class EncryptionScheme(Enum): UNKNOWN = 0 LUKS = 1 + VERACRYPT = 2 class Volume: - MAPPED_VOLUME_PREFIX = "/dev/mapper/" - """ A volume on a removable device. - Volumes have a device name ("/dev/sdX"), a mapped name ("/dev/mapper/xxx"), an encryption - scheme, and a mountpoint if they are mounted. + Volumes have a device name ("/dev/sdX") and an encryption scheme. """ def __init__( self, device_name: str, - mapped_name: str, encryption: EncryptionScheme, ): self.device_name = device_name - self.mapped_name = mapped_name self.encryption = encryption @property @@ -41,39 +36,22 @@ def encryption(self, scheme: EncryptionScheme): else: self._encryption = EncryptionScheme.UNKNOWN - @property - def unlocked(self) -> bool: - return ( - self.mapped_name is not None - and self.encryption is not EncryptionScheme.UNKNOWN - and os.path.exists( - os.path.join(self.MAPPED_VOLUME_PREFIX, self.mapped_name) - ) - ) - class MountedVolume(Volume): """ An unlocked and mounted Volume. + + Device name (from Volume) and unlocked name + are full paths (/dev/sdX, /dev/dm-X, /dev/mapper/idx). """ def __init__( self, device_name: str, - mapped_name: str, + unlocked_name: str, encryption: EncryptionScheme, mountpoint: str, ): - super().__init__( - device_name=device_name, mapped_name=mapped_name, encryption=encryption - ) + super().__init__(device_name=device_name, encryption=encryption) + self.unlocked_name = unlocked_name self.mountpoint = mountpoint - - @classmethod - def from_volume(cls, vol: Volume, mountpoint: str): - return cls( - device_name=vol.device_name, - mapped_name=vol.mapped_name, - encryption=vol.encryption, - mountpoint=mountpoint, - ) diff --git a/export/securedrop_export/main.py b/export/securedrop_export/main.py index bc55ae159..4535a1dc7 100755 --- a/export/securedrop_export/main.py +++ b/export/securedrop_export/main.py @@ -1,3 +1,5 @@ +import contextlib +import io import os import shutil import platform @@ -11,8 +13,7 @@ from securedrop_export.directory import safe_mkdir from securedrop_export.exceptions import ExportException -from securedrop_export.disk import LegacyService as ExportService -from securedrop_export.disk import LegacyStatus +from securedrop_export.disk import Service as ExportService from securedrop_export.print import Service as PrintService from logging.handlers import TimedRotatingFileHandler, SysLogHandler @@ -43,6 +44,8 @@ def entrypoint(): Non-zero exit values will cause the system to try alternative solutions for mimetype handling, which we want to avoid. + + The program is called with the archive name as the first argument. """ status, submission = None, None @@ -54,7 +57,8 @@ def entrypoint(): # Halt if target file is absent if not os.path.exists(data_path): - logger.info("Archive is not found {}.".format(data_path)) + logger.error("Archive not found at provided path.") + logger.debug("Archive missing, path: {}".format(data_path)) status = Status.ERROR_FILE_NOT_FOUND else: @@ -70,16 +74,18 @@ def entrypoint(): submission.set_metadata(metadata) logger.info(f"Start {metadata.command.value} service") status = _start_service(submission) + logger.info(f"Status: {status.value}") - except ExportException as ex: - logger.error(f"Encountered exception {ex.sdstatus.value}, exiting") + # Gotta catch'em all. A nonzero exit status will cause other programs + # to try to handle the files, which we don't want. + except Exception as ex: logger.error(ex) - status = ex.sdstatus - - except Exception as exc: - logger.error("Encountered exception during export, exiting") - logger.error(exc) - status = Status.ERROR_GENERIC + if isinstance(ex, ExportException): + logger.error(f"Encountered exception {ex.sdstatus.value}, exiting") + status = ex.sdstatus + else: + logger.error("Encountered exception during export, exiting") + status = Status.ERROR_GENERIC finally: _exit_gracefully(submission, status) @@ -125,7 +131,7 @@ def _configure_logging(): raise ExportException(sdstatus=Status.ERROR_LOGGING) from ex -def _start_service(submission: Archive) -> LegacyStatus: +def _start_service(submission: Archive) -> BaseStatus: """ Start print or export service. """ @@ -140,10 +146,11 @@ def _start_service(submission: Archive) -> LegacyStatus: # Export routines elif submission.command is Command.EXPORT: return ExportService(submission).export() - elif submission.command is Command.CHECK_USBS: - return ExportService(submission).check_connected_devices() - elif submission.command is Command.CHECK_VOLUME: - return ExportService(submission).check_disk_format() + elif ( + submission.command is Command.CHECK_USBS + or submission.command is Command.CHECK_VOLUME + ): + return ExportService(submission).scan_all_devices() # Unreachable raise ExportException( @@ -151,16 +158,12 @@ def _start_service(submission: Archive) -> LegacyStatus: ) -def _exit_gracefully(submission: Archive, status: Optional[BaseStatus] = None): +def _exit_gracefully(submission: Archive, status: BaseStatus): """ Write status code, ensure file cleanup, and exit with return code 0. Non-zero exit values will cause the system to try alternative solutions for mimetype handling, which we want to avoid. """ - if status: - logger.info(f"Exit gracefully with status: {status.value}") - else: - logger.info("Exit gracefully (no status code supplied)") try: # If the file archive was extracted, delete before returning if submission and os.path.isdir(submission.tmpdir): @@ -177,13 +180,30 @@ def _exit_gracefully(submission: Archive, status: Optional[BaseStatus] = None): sys.exit(0) -def _write_status(status: Optional[BaseStatus]): +def _write_status(status: BaseStatus): """ - Write string to stderr. + Write status string to stderr. Flush stderr and stdout before we exit. """ - if status: - logger.info(f"Write status {status.value}") + logger.info(f"Write status {status.value}") + try: + # First we will log errors from stderr elsewhere + tmp_stderr = io.StringIO() + tmp_stdout = io.StringIO() + with contextlib.redirect_stderr(tmp_stderr), contextlib.redirect_stdout( + tmp_stdout + ): + sys.stderr.flush() + sys.stdout.flush() + if len(tmp_stderr.getvalue()) > 0: + logger.error(f"Error capture: {tmp_stderr.getvalue()}") + if len(tmp_stdout.getvalue()) > 0: + logger.info(f"stdout capture: {tmp_stderr.getvalue()}") + sys.stderr.write(status.value) sys.stderr.write("\n") - else: - logger.info("No status value supplied") + sys.stderr.flush() + sys.stdout.flush() + except BrokenPipeError: + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + os.dup2(devnull, sys.stderr.fileno()) diff --git a/export/tests/disk/test_cli.py b/export/tests/disk/test_cli.py index 798980905..2723d5f36 100644 --- a/export/tests/disk/test_cli.py +++ b/export/tests/disk/test_cli.py @@ -1,7 +1,10 @@ import pytest +from pexpect import ExceptionPexpect from unittest import mock import subprocess +import pexpect +import re from securedrop_export.disk.cli import CLI from securedrop_export.disk.volume import EncryptionScheme, Volume, MountedVolume @@ -10,24 +13,64 @@ from securedrop_export.archive import Archive +# Sample lsblk and udisk inputs for testing the parsing of different device conditions +from ..lsblk_sample import ( + UDISKS_STATUS_MULTI_CONNECTED, + UDISKS_STATUS_ONE_DEVICE_CONNECTED, + UDISKS_STATUS_NOTHING_CONNECTED, + ONE_DEVICE_LUKS_UNMOUNTED, + ONE_DEVICE_VC_UNLOCKED, + ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE, + ERROR_NO_SUPPORTED_DEVICE, + ERROR_UNENCRYPTED_DEVICE_MOUNTED, + ERROR_DEVICE_MULTI_ENC_PARTITION, + SINGLE_DEVICE_LOCKED, + SINGLE_PART_LUKS_WRITABLE, + SINGLE_PART_LUKS_UNLOCKED_UNMOUNTED, + SINGLE_PART_UNLOCKED_VC_UNMOUNTED, + SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED, + SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED, + SINGLE_PART_VC_WRITABLE, +) + +_PRETEND_LUKS_ID = "/dev/mapper/luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094" +_PRETEND_VC = "/dev/mapper/tcrypt-2049" _DEFAULT_USB_DEVICE = "/dev/sda" -_DEFAULT_USB_DEVICE_ONE_PART = "/dev/sda1" -_PRETEND_LUKS_ID = "luks-id-123456" -# Sample stdout from shell commands -_SAMPLE_OUTPUT_NO_PART = b"disk\ncrypt" # noqa -_SAMPLE_OUTPUT_ONE_PART = b"disk\npart\ncrypt" # noqa -_SAMPLE_OUTPUT_MULTI_PART = b"disk\npart\npart\npart\ncrypt" # noqa -_SAMPLE_OUTPUT_USB = b"/dev/sda" # noqa +# Lists for test paramaterization -_SAMPLE_LUKS_HEADER = b"\n\nUUID:\t123456-DEADBEEF" # noqa +supported_volumes_no_mount_required = [ + SINGLE_DEVICE_LOCKED, + SINGLE_PART_LUKS_WRITABLE, + SINGLE_PART_VC_WRITABLE, +] + +# Volume, expected device name, expected mapped device name +# (used to mount) +supported_volumes_mount_required = [ + (SINGLE_PART_UNLOCKED_VC_UNMOUNTED, "/dev/sda1", "/dev/mapper/tcrypt-2049"), + ( + SINGLE_PART_LUKS_UNLOCKED_UNMOUNTED, + "/dev/sda1", + "/dev/mapper/luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", + ), +] + +unsupported_volumes = [ + SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED, + SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED, +] class TestCli: """ Test the CLI wrapper that handless identification and locking/unlocking of USB volumes. + + This class is a wrapper around commandline tools like udsisks and lsblk, + so a lot of the testing involves supplying sample input and ensuring it + is parsed correctly (see `lsblk_sample.py`). """ @classmethod @@ -38,432 +81,281 @@ def setup_class(cls): def teardown_class(cls): cls.cli = None - def _setup_usb_devices(self, mocker, disks, is_removable): - """ - Helper function to set up mocked shell calls representing - the search for attached USB devices. - The original calls are `lsblk | grep disk` and - `cat /sys/class/block/{disk}/removable` - - Parameters: - disks (byte array): Array of disk names separated by newline. - is_removable (byte array): Array of removable status results (1 for removable), - separated by newline - """ - - # Patch commandline calls to `lsblk | grep disk` - command_output = mock.MagicMock() - command_output.stdout = mock.MagicMock() - command_output.stdout.readlines = mock.MagicMock(return_value=disks) - mocker.patch("subprocess.Popen", return_value=command_output) - - # Patch commandline call to 'cat /sys/class/block/{device}/removable' - - # Using side_effect with an iterable allows for different return value each time, - # which matches what would happen if iterating through list of devices - mocker.patch("subprocess.check_output", side_effect=is_removable) - - def test_get_connected_devices(self, mocker): - disks = [b"sda disk\n", b"sdb disk\n"] - removable = [b"1\n", b"1\n"] - - self._setup_usb_devices(mocker, disks, removable) - result = self.cli.get_connected_devices() - - assert result[0] == "/dev/sda" and result[1] == "/dev/sdb" - - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test_get_removable_devices_none_removable(self, mocker): - disks = [b"sda disk\n", b"sdb disk\n"] - removable = [b"0\n", b"0\n"] - - self._setup_usb_devices(mocker, disks, removable) + @mock.patch("subprocess.check_output") + def test_get_volume_no_devices(self, mock_sp): + mock_sp.side_effect = [ + UDISKS_STATUS_NOTHING_CONNECTED, + ERROR_NO_SUPPORTED_DEVICE, + ] - result = self.cli._get_removable_devices(disks) - assert len(result) == 0 - - @mock.patch( - "subprocess.Popen", side_effect=subprocess.CalledProcessError(1, "Popen") - ) - def test_get_connected_devices_error(self, mocked_subprocess): - with pytest.raises(ExportException): - self.cli.get_connected_devices() + with pytest.raises(ExportException) as ex: + self.cli.get_volume() + assert ex.value.sdstatus == Status.NO_DEVICE_DETECTED + + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_one_device(self, mock_sp, mock_mount): + mock_sp.side_effect = [ + UDISKS_STATUS_ONE_DEVICE_CONNECTED, + ONE_DEVICE_LUKS_UNMOUNTED, + ] + v = self.cli.get_volume() + + assert v.encryption == EncryptionScheme.LUKS + # todo: list call args, make this test more specific + + @mock.patch("subprocess.check_output") + def test_get_volume_multi_devices_error(self, mock_sp): + mock_sp.side_effect = [ + UDISKS_STATUS_MULTI_CONNECTED, + ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE, + ] + with pytest.raises(ExportException) as ex: + self.cli.get_volume() - @mock.patch("subprocess.check_output", return_value=_SAMPLE_OUTPUT_NO_PART) - def test_get_partitioned_device_no_partition(self, mocked_call): - assert ( - self.cli.get_partitioned_device(_DEFAULT_USB_DEVICE) == _DEFAULT_USB_DEVICE - ) + assert ex.value.sdstatus == Status.MULTI_DEVICE_DETECTED - @mock.patch("subprocess.check_output", return_value=_SAMPLE_OUTPUT_ONE_PART) - def test_get_partitioned_device_one_partition(self, mocked_call): - assert ( - self.cli.get_partitioned_device(_DEFAULT_USB_DEVICE) - == _DEFAULT_USB_DEVICE + "1" - ) - - @mock.patch("subprocess.check_output", return_value=_SAMPLE_OUTPUT_MULTI_PART) - def test_get_partitioned_device_multi_partition(self, mocked_call): + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_too_many_encrypted_partitions(self, mock_sp, mock_mount): + mock_sp.side_effect = [ + UDISKS_STATUS_ONE_DEVICE_CONNECTED, + ERROR_DEVICE_MULTI_ENC_PARTITION, + ] with pytest.raises(ExportException) as ex: - self.cli.get_partitioned_device(_SAMPLE_OUTPUT_MULTI_PART) + self.cli.get_volume() - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + assert ex.value.sdstatus == Status.INVALID_DEVICE_DETECTED - @mock.patch("subprocess.check_output", return_value=None) - def test_get_partitioned_device_lsblk_error(self, mocked_subprocess): + @mock.patch("securedrop_export.disk.cli.CLI._get_supported_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_no_encrypted_partition(self, mock_sp, mock_gsv): + mock_sp.side_effect = [ + UDISKS_STATUS_ONE_DEVICE_CONNECTED, + ERROR_UNENCRYPTED_DEVICE_MOUNTED, + ] with pytest.raises(ExportException) as ex: - self.cli.get_partitioned_device(_SAMPLE_OUTPUT_ONE_PART) + self.cli.get_volume() - assert ex.value.sdstatus is Status.DEVICE_ERROR + assert ex.value.sdstatus == Status.INVALID_DEVICE_DETECTED - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test_get_partitioned_device_multi_partition_error(self, mocked_call): - # Make sure we wrap CalledProcessError and throw our own exception + @mock.patch("securedrop_export.disk.cli.CLI._get_supported_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_empty_udisks_does_not_keep_checking(self, mock_sp, mock_gsv): + mock_sp.side_effect = [ + UDISKS_STATUS_NOTHING_CONNECTED, + ONE_DEVICE_VC_UNLOCKED, + ] + + # If udisks2 didn't find it, don't keep looking with pytest.raises(ExportException) as ex: - self.cli.get_partitioned_device(_DEFAULT_USB_DEVICE) + self.cli.get_volume() - assert ex.value.sdstatus is Status.DEVICE_ERROR + assert ex.value.sdstatus == Status.NO_DEVICE_DETECTED + mock_gsv.assert_not_called() - @mock.patch("subprocess.check_call", return_value=0) - def test_is_luks_volume_true(self, mocked_call): - # `sudo cryptsetup isLuks` returns 0 if true - assert self.cli.is_luks_volume(_SAMPLE_OUTPUT_ONE_PART) + @pytest.mark.parametrize("input", supported_volumes_no_mount_required) + def test__get_supported_volume_success_no_mount(self, input): + vol = self.cli._get_supported_volume(input) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test_is_luks_volume_false(self, mocked_subprocess): - # `sudo cryptsetup isLuks` returns 1 if false; CalledProcessError is thrown - assert not self.cli.is_luks_volume(_SAMPLE_OUTPUT_ONE_PART) + assert vol - @mock.patch("subprocess.check_output", return_value=_SAMPLE_LUKS_HEADER) - def test__get_luks_name_from_headers(self, mocked_subprocess): - result = self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) - - assert result is not None and result.split("-")[ - 1 - ] in _SAMPLE_LUKS_HEADER.decode("utf8") + @mock.patch("subprocess.check_output") + def test__get_supported_volume_locked_success(self, mock_subprocess): + vol = self.cli._get_supported_volume(SINGLE_DEVICE_LOCKED) + assert vol.device_name == "/dev/sda" + @pytest.mark.parametrize( + "input,expected_device,expected_devmapper", supported_volumes_mount_required + ) + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") @mock.patch( - "subprocess.check_output", return_value=b"corrupted-or-invalid-header\n" + "securedrop_export.disk.cli.CLI._is_it_veracrypt", + return_value=EncryptionScheme.VERACRYPT, ) - def test__get_luks_name_from_headers_error_invalid(self, mocked_subprocess): - with pytest.raises(ExportException) as ex: - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + def test__get_supported_volume_requires_mounting( + self, mock_v, mock_mount, input, expected_device, expected_devmapper + ): + self.cli._get_supported_volume(input) - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + mock_mount.assert_called_once() - @mock.patch("subprocess.check_output", return_value=b"\n") - def test__get_luks_name_from_headers_error_no_header(self, mocked_subprocess): - with pytest.raises(ExportException) as ex: - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + assert mock_mount.call_args_list[0][0][0].device_name == expected_device + assert mock_mount.call_args_list[0][0][1] == expected_devmapper - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + @pytest.mark.parametrize("input", unsupported_volumes) + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") + def test__get_supported_volume_none_supported(self, mock_subprocess, input): + result = self.cli._get_supported_volume(input) - @mock.patch("subprocess.check_output", return_value=None) - def test__get_luks_name_from_headers_error_nothing_returned( - self, mocked_subprocess - ): - with pytest.raises(ExportException) as ex: - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + assert result is None - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + @mock.patch("pexpect.spawn") + def test_unlock_success(self, mock_p): + child = mock_p() + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test__get_luks_name_from_headers_error(self, mocked_subprocess): - with pytest.raises(ExportException): - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + # This return value is derived from the "expected" list in the + # unlock_volume method (list item with index 0 is success) + child.expect.side_effect = [0, 0] + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = "/dev/dm-0".encode("utf-8") - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_output", return_value=_SAMPLE_LUKS_HEADER) - def test_get_luks_volume_already_unlocked(self, mocked_subprocess, mocked_os_call): - result = self.cli.get_luks_volume(_DEFAULT_USB_DEVICE_ONE_PART) + mv = mock.MagicMock(spec=MountedVolume) - assert result.encryption is EncryptionScheme.LUKS - assert result.unlocked + with mock.patch.object(self.cli, "_mount_volume") as mock_mount: + mock_mount.return_value = mv + result = self.cli.unlock_volume(vol, "a passw0rd!") - @mock.patch("os.path.exists", return_value=False) - @mock.patch("subprocess.check_output", return_value=_SAMPLE_LUKS_HEADER) - def test_get_luks_volume_still_locked(self, mocked_subprocess, mocked_os_call): - result = self.cli.get_luks_volume(_DEFAULT_USB_DEVICE_ONE_PART) + mock_mount.assert_called_once_with(vol, "/dev/dm-0") + assert isinstance(result, MountedVolume) - assert result.encryption is EncryptionScheme.LUKS - assert not result.unlocked + @mock.patch("pexpect.spawn") + def test_unlock_already_unlocked(self, mock_p): + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) + child = mock_p() + child.expect.side_effect = [0, 1] + child.match = mock.MagicMock(spec=re.Match) + error_msg = "/dev/dm-0".encode("utf-8") + child.match.group.return_value = error_msg + mv = mock.MagicMock(spec=MountedVolume) - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test_get_luks_volume_error(self, mocked_subprocess): - with pytest.raises(ExportException) as ex: - self.cli.get_luks_volume(_DEFAULT_USB_DEVICE_ONE_PART) + with mock.patch.object(self.cli, "_mount_volume") as mock_mount: + mock_mount.return_value = mv + result = self.cli.unlock_volume(vol, "a passw0rd!") - assert ex.value.sdstatus is Status.DEVICE_ERROR + mock_mount.assert_called_once_with(vol, "/dev/dm-0") + assert isinstance(result, MountedVolume) - @mock.patch("os.path.exists", return_value=True) - def test_unlock_luks_volume_success(self, mock_path, mocker): - mock_popen = mocker.MagicMock() - mock_popen_communicate = mocker.MagicMock() - mock_popen.returncode = 0 + @mock.patch("pexpect.spawn") + def test_unlock_remote_fail(self, mock_p): + child = mock_p() + child.expect.return_value = 3 + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) - mocker.patch("subprocess.Popen", return_value=mock_popen) - mocker.patch( - "subprocess.Popen.communicate", return_value=mock_popen_communicate - ) + with pytest.raises(ExportException) as ex: + self.cli.unlock_volume(vol, "a passw0rd!") - mapped_name = "luks-id-123456" - vol = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=mapped_name, - encryption=EncryptionScheme.LUKS, - ) - key = "a_key&_!" - result = self.cli.unlock_luks_volume(vol, key) - assert result.unlocked + assert ex.value.sdstatus == Status.ERROR_UNLOCK_GENERIC - @mock.patch("os.path.exists", return_value=True) - def test_unlock_luks_volume_not_luks(self, mocker): - mock_popen = mocker.MagicMock() - mock_popen.communicate = mocker.MagicMock() - mock_popen.communicate.returncode = 1 # An error unlocking + @mock.patch("pexpect.spawn") + def test_unlock_luks_bad_passphrase(self, mock_p): + child = mock_p() - mocker.patch("subprocess.Popen", mock_popen) + # This return value is derived from the "expected" list in the + # unlock_volume method (list item with index 1 is the "Bad passphrase" + # error) + child.expect.side_effect = [0, 2] + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - vol = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.UNKNOWN, - ) - key = "a key!" + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) with pytest.raises(ExportException) as ex: - self.cli.unlock_luks_volume(vol, key) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - def test_unlock_luks_volume_passphrase_failure(self, mocker): - mock_popen = mocker.MagicMock() - mock_popen.communicate = mocker.MagicMock() - mock_popen.communicate.returncode = 1 # An error unlocking + self.cli.unlock_volume(vol, "a mistaken p4ssw0rd!") - mocker.patch("subprocess.Popen", mock_popen) + assert ex.value.sdstatus == Status.ERROR_UNLOCK_LUKS - vol = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - key = "a key!" + @mock.patch("pexpect.spawn") + def test_unlock_fail(self, mock_p): + child = mock_p() - with pytest.raises(ExportException): - self.cli.unlock_luks_volume(vol, key) + # This is derived from the "expected" list in the unlock_volume method + # (list item with index 3 is the "pexpect.EOF" error) + child.expect.side_effect = [0, 3] + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - @mock.patch( - "subprocess.Popen", side_effect=subprocess.CalledProcessError(1, "Popen") - ) - def test_unlock_luks_volume_luksOpen_exception(self, mocked_subprocess): - pd = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - key = "a key!" + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) with pytest.raises(ExportException) as ex: - self.cli.unlock_luks_volume(pd, key) + self.cli.unlock_volume(vol, "a passw0rd!") - assert ex.value.sdstatus is Status.DEVICE_ERROR + assert ex.value.sdstatus == Status.ERROR_UNLOCK_GENERIC - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_output", return_value=b"\n") - @mock.patch("subprocess.check_call", return_value=0) - def test_mount_volume(self, mocked_call, mocked_output, mocked_path): - vol = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - mv = self.cli.mount_volume(vol) - assert isinstance(mv, MountedVolume) - assert mv.mountpoint is self.cli._DEFAULT_MOUNTPOINT + @mock.patch("pexpect.spawn") + def test__mount_volume_already_mounted(self, mock_p): + child = mock_p() + child.expect.return_value = 1 + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_output", return_value=b"/dev/pretend/luks-id-123456\n" - ) - @mock.patch("subprocess.check_call", return_value=0) - def test_mount_volume_already_mounted( - self, mocked_output, mocked_call, mocked_path - ): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + md = MountedVolume( + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, encryption=EncryptionScheme.LUKS, + mountpoint="/media/usb", ) - result = self.cli.mount_volume(md) - assert result.mountpoint == "/dev/pretend/luks-id-123456" + result = self.cli._mount_volume(md, _PRETEND_LUKS_ID) + + assert result.mountpoint == "/media/usb" assert isinstance(result, MountedVolume) - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_output", return_value=b"\n") - @mock.patch("subprocess.check_call", return_value=0) - def test_mount_volume_mkdir(self, mocked_output, mocked_subprocess, mocked_path): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - mv = self.cli.mount_volume(md) - assert mv.mapped_name == _PRETEND_LUKS_ID - assert isinstance(mv, MountedVolume) + @mock.patch("pexpect.spawn") + def test__mount_volume_success(self, mock_p): + child = mock_p() + child.expect.return_value = 0 + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - @mock.patch("subprocess.check_output", return_value=b"\n") - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test_mount_volume_error(self, mocked_subprocess, mocked_output): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + md = MountedVolume( + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, encryption=EncryptionScheme.LUKS, + mountpoint="/media/usb", ) - with pytest.raises(ExportException) as ex: - self.cli.mount_volume(md) - - assert ex.value.sdstatus is Status.ERROR_MOUNT - - @mock.patch("os.path.exists", return_value=False) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test_mount_at_mountpoint_mkdir_error(self, mocked_subprocess, mocked_path): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) + result = self.cli._mount_volume(md, _PRETEND_LUKS_ID) - with pytest.raises(ExportException) as ex: - self.cli._mount_at_mountpoint(md, self.cli._DEFAULT_MOUNTPOINT) + assert result.mountpoint == "/media/usb" + assert isinstance(result, MountedVolume) - assert ex.value.sdstatus is Status.ERROR_MOUNT + @mock.patch("pexpect.spawn") + def test__mount_volume_error(self, mock_p): + child = mock_p() + child.expect.return_value = 2 - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test_mount_at_mountpoint_mounting_error(self, mocked_subprocess, mocked_path): md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + device_name="/dev/sda", encryption=EncryptionScheme.LUKS, ) with pytest.raises(ExportException) as ex: - self.cli._mount_at_mountpoint(md, self.cli._DEFAULT_MOUNTPOINT) + self.cli._mount_volume(md, _PRETEND_LUKS_ID) assert ex.value.sdstatus is Status.ERROR_MOUNT - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_call", return_value=0) - def test__unmount_volume(self, mocked_subprocess, mocked_mountpath): + @mock.patch("subprocess.check_call") + def test__unmount_volume(self, mock_sp): + mock_sp.returncode = 0 mounted = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, + device_name="/dev/sda", + unlocked_name=f"/dev/mapper{_PRETEND_LUKS_ID}", + mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) - result = self.cli._unmount_volume(mounted) + result = self.cli._close_volume(mounted) assert not isinstance(result, MountedVolume) - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test__unmount_volume_error(self, mocked_subprocess, mocked_mountpath): - mounted = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, - encryption=EncryptionScheme.LUKS, - ) - - with pytest.raises(ExportException) as ex: - self.cli._unmount_volume(mounted) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_call", return_value=0) - def test__close_luks_volume(self, mocked_subprocess, mocked_os_call): - mapped = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - - # If call completes without error, drive was successfully closed with luksClose - self.cli._close_luks_volume(mapped) - - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test__close_luks_volume_error(self, mocked_subprocess, mocked_os_call): - mapped = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - - with pytest.raises(ExportException) as ex: - self.cli._close_luks_volume(mapped) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test__remove_temp_directory_error(self, mocked_subprocess): - with pytest.raises(ExportException): - self.cli._remove_temp_directory("tmp") - @mock.patch("subprocess.check_call", return_value=0) def test_write_to_disk(self, mock_check_call): # Temporarily patch a method, to later assert it is called - patch = mock.patch.object(self.cli, "cleanup_drive_and_tmpdir") + patch = mock.patch.object(self.cli, "cleanup") patch.return_value = mock.MagicMock() patch.start() vol = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, + mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) submission = Archive("testfile") - self.cli.write_data_to_device(submission.tmpdir, submission.target_dirname, vol) - self.cli.cleanup_drive_and_tmpdir.assert_called_once() + self.cli.write_data_to_device(vol, submission.tmpdir, submission.target_dirname) + self.cli.cleanup.assert_called_once() # Don't want to patch it indefinitely though, that will mess with the other tests patch.stop() @@ -473,24 +365,24 @@ def test_write_to_disk(self, mock_check_call): side_effect=subprocess.CalledProcessError(1, "check_call"), ) def test_write_to_disk_error_still_does_cleanup(self, mock_call): - # see above - patch internal method only for this test - patch = mock.patch.object(self.cli, "cleanup_drive_and_tmpdir") + # patch internal method only for this test + patch = mock.patch.object(self.cli, "cleanup") patch.return_value = mock.MagicMock() patch.start() vol = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, + mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) submission = Archive("testfile") with pytest.raises(ExportException): self.cli.write_data_to_device( - submission.tmpdir, submission.target_dirname, vol + vol, submission.tmpdir, submission.target_dirname ) - self.cli.cleanup_drive_and_tmpdir.assert_called_once() + self.cli.cleanup.assert_called_once() patch.stop() @@ -498,33 +390,48 @@ def test_write_to_disk_error_still_does_cleanup(self, mock_call): "subprocess.check_call", side_effect=subprocess.CalledProcessError(1, "check_call"), ) - def test_cleanup_drive_and_tmpdir_error(self, mocked_subprocess): + def test_cleanup_error(self, mock_popen): submission = Archive("testfile") mock_volume = mock.MagicMock(Volume) with pytest.raises(ExportException) as ex: - self.cli.cleanup_drive_and_tmpdir(mock_volume, submission.tmpdir) + self.cli.cleanup(mock_volume, submission.tmpdir) assert ex.value.sdstatus is Status.ERROR_EXPORT_CLEANUP + @mock.patch( + "subprocess.check_call", + side_effect=subprocess.CalledProcessError(1, "check_call"), + ) + def test_cleanup_error_reports_exporterror_if_flagged(self, mock_popen): + submission = Archive("testfile") + mock_volume = mock.MagicMock(Volume) + + with pytest.raises(ExportException) as ex: + self.cli.cleanup(mock_volume, submission.tmpdir, is_error=True) + assert ex.value.sdstatus is Status.ERROR_EXPORT + + @mock.patch("os.path.exists", return_value=False) @mock.patch("subprocess.check_call", return_value=0) - def test_cleanup_drive_and_tmpdir(self, mock_subprocess, mocked_path): + def test_cleanup(self, mock_subprocess, mocked_path): submission = Archive("testfile") vol = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + device_name=_DEFAULT_USB_DEVICE, encryption=EncryptionScheme.LUKS, ) - mv = MountedVolume.from_volume(vol, mountpoint=self.cli._DEFAULT_MOUNTPOINT) + mv = MountedVolume( + vol.device_name, + f"/dev/mapper/{_PRETEND_LUKS_ID}", + vol.encryption, + mountpoint="/media/usb", + ) - close_patch = mock.patch.object(self.cli, "_close_luks_volume") + close_patch = mock.patch.object(self.cli, "_close_volume") remove_tmpdir_patch = mock.patch.object(self.cli, "_remove_temp_directory") - close_mock = close_patch.start() rm_tpdir_mock = remove_tmpdir_patch.start() - # That was all setup. Here's our test - self.cli.cleanup_drive_and_tmpdir(mv, submission.tmpdir) + self.cli.cleanup(mv, submission.tmpdir) close_mock.assert_called_once() rm_tpdir_mock.assert_called_once_with(submission.tmpdir) @@ -533,35 +440,18 @@ def test_cleanup_drive_and_tmpdir(self, mock_subprocess, mocked_path): close_patch.stop() remove_tmpdir_patch.stop() - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test_mountpoint_error(self, mock_subprocess): - with pytest.raises(ExportException) as ex: - self.cli._get_mountpoint( - Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - ) - - assert ex.value.sdstatus is Status.ERROR_MOUNT - - @mock.patch("os.path.exists", return_value=False) - def test_mount_mkdir_fails(self, mocked_path): - mock_mountpoint = mock.patch.object(self.cli, "_get_mountpoint") - mock_mountpoint.return_value = None - - vol = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, + @mock.patch("pexpect.spawn") + def test_parse_correct_mountpoint_from_pexpect(self, mock_pexpect): + child = mock_pexpect() + child.expect.return_value = 1 + child.match.return_value = re.match( + r"`(\w+)'\.\r\n".encode("utf-8"), + "Error mounting /dev/dm-1: GDBus.Error:org." + "freedesktop.UDisks2.Error.AlreadyMounted: " + "Device /dev/sda1 is already mounted at `/dev/dm-0'.\r\n".encode("utf-8"), ) - mock.patch.object(vol, "unlocked", return_value=True) - - with pytest.raises(ExportException) as ex: - self.cli.mount_volume(vol) - assert ex.value.sdstatus is Status.ERROR_MOUNT + mv = self.cli._mount_volume( + Volume("/dev/sda1", EncryptionScheme.VERACRYPT), "/dev/dm-1" + ) + assert mv.unlocked_name == "/dev/dm-0" diff --git a/export/tests/disk/test_service.py b/export/tests/disk/test_service.py index d7053e1d0..13a729507 100644 --- a/export/tests/disk/test_service.py +++ b/export/tests/disk/test_service.py @@ -1,39 +1,47 @@ -import pytest from unittest import mock import os import tempfile from securedrop_export.exceptions import ExportException -from securedrop_export.disk.legacy_status import Status as LegacyStatus -from securedrop_export.disk.status import Status as Status +from securedrop_export.disk.status import Status from securedrop_export.disk.volume import Volume, MountedVolume, EncryptionScheme from securedrop_export.archive import Archive, Metadata -from securedrop_export.disk.legacy_service import Service +from securedrop_export.disk.service import Service from securedrop_export.disk.cli import CLI -SAMPLE_OUTPUT_LSBLK_NO_PART = b"disk\ncrypt" # noqa -SAMPLE_OUTPUT_USB = "/dev/sda" # noqa +SAMPLE_OUTPUT_USB = "/dev/sda" SAMPLE_OUTPUT_USB_PARTITIONED = "/dev/sda1" class TestExportService: @classmethod def setup_class(cls): - cls.mock_cli = mock.MagicMock(CLI) + cls.mock_cli = mock.MagicMock(spec=CLI) cls.mock_submission = cls._setup_submission() cls.mock_luks_volume_unmounted = Volume( device_name=SAMPLE_OUTPUT_USB, - mapped_name="fake-luks-id-123456", encryption=EncryptionScheme.LUKS, ) cls.mock_luks_volume_mounted = MountedVolume( device_name=SAMPLE_OUTPUT_USB, - mapped_name="fake-luks-id-123456", + unlocked_name="/dev/mapper/fake-luks-id-123456", mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) + cls.mock_vc_volume_mounted = MountedVolume( + device_name=SAMPLE_OUTPUT_USB, + unlocked_name="/dev/mapper/mock-veracrypt-vol", + encryption=EncryptionScheme.VERACRYPT, + mountpoint="/media/usb/", + ) + + cls.mock_vc_volume_locked = Volume( + device_name=SAMPLE_OUTPUT_USB, + encryption=EncryptionScheme.UNKNOWN, + ) + cls.service = Service(cls.mock_submission, cls.mock_cli) @classmethod @@ -59,145 +67,73 @@ def _setup_submission(cls) -> Archive: return submission.set_metadata(Metadata(temp_folder).validate()) def setup_method(self, method): - """ - By default, mock CLI will return the "happy path" of a correctly-formatted LUKS drive. - Override this behaviour in the target method as required, for example to simulate CLI - errors. `teardown_method()` will reset the side effects so they do not affect subsequent - test methods. - """ - self.mock_cli.get_connected_devices.return_value = [SAMPLE_OUTPUT_USB] - self.mock_cli.get_partitioned_device.return_value = ( - SAMPLE_OUTPUT_USB_PARTITIONED - ) - self.mock_cli.get_luks_volume.return_value = self.mock_luks_volume_unmounted - self.mock_cli.mount_volume.return_value = self.mock_luks_volume_mounted + pass def teardown_method(self, method): self.mock_cli.reset_mock(return_value=True, side_effect=True) - def test_check_usb(self): - status = self.service.check_connected_devices() - - assert status is LegacyStatus.LEGACY_USB_CONNECTED - - def test_no_devices_connected(self): - self.mock_cli.get_connected_devices.return_value = [] - with pytest.raises(ExportException) as ex: - self.service.check_connected_devices() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_NOT_CONNECTED - - def test_too_many_devices_connected(self): - self.mock_cli.get_connected_devices.return_value = [ - SAMPLE_OUTPUT_USB, - "/dev/sdb", - ] - with pytest.raises(ExportException) as ex: - self.service.check_connected_devices() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - - def test_device_is_not_luks(self): - self.mock_cli.is_luks_volume.return_value = False - - # When VeraCrypt is supported, this will no longer be an exception - # and the return status will change - with pytest.raises(ExportException) as ex: - self.service.check_disk_format() + def test_scan_all_device_is_locked(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_unmounted + status = self.service.scan_all_devices() - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED + assert status == Status.DEVICE_LOCKED - def test_check_usb_error(self): - self.mock_cli.get_connected_devices.side_effect = ExportException( - sdstatus=LegacyStatus.LEGACY_ERROR_USB_CHECK + def test_scan_all_no_devices_connected(self): + self.mock_cli.get_volume.side_effect = ExportException( + sdstatus=Status.NO_DEVICE_DETECTED ) - with pytest.raises(ExportException) as ex: - self.service.check_connected_devices() + assert self.service.scan_all_devices() == Status.NO_DEVICE_DETECTED - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_CHECK - - def test_check_disk_format(self): - status = self.service.check_disk_format() - - assert status is LegacyStatus.LEGACY_USB_ENCRYPTED - - def test_check_disk_format_error(self): - self.mock_cli.get_partitioned_device.side_effect = ExportException( - sdstatus=Status.INVALID_DEVICE_DETECTED + def test_scan_all_too_many_devices_connected(self): + self.mock_cli.get_volume.side_effect = ExportException( + sdstatus=Status.MULTI_DEVICE_DETECTED ) - with pytest.raises(ExportException) as ex: - self.service.check_disk_format() - - # We still return the legacy status for now - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED + assert self.service.scan_all_devices() == Status.MULTI_DEVICE_DETECTED - def test_export(self): - # Currently, a successful export does not return a success status. - # When the client is updated, this will change to assert EXPORT_SUCCESS - # is returned. - self.service.export() + def test_scan_all_devices_error(self): + self.mock_cli.get_volume.side_effect = ExportException("zounds!") - def test_export_disk_not_supported(self): - self.mock_cli.is_luks_volume.return_value = False + assert self.service.scan_all_devices() == Status.DEVICE_ERROR - with pytest.raises(ExportException) as ex: - self.service.export() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - - def test_export_write_error(self): - self.mock_cli.is_luks_volume.return_value = True - self.mock_cli.write_data_to_device.side_effect = ExportException( - sdstatus=LegacyStatus.LEGACY_ERROR_USB_WRITE - ) + def test_scan_all_device_is_unlocked_vc(self): + self.mock_cli.get_volume.return_value = self.mock_vc_volume_mounted - with pytest.raises(ExportException) as ex: - self.service.export() + assert self.service.scan_all_devices() == Status.DEVICE_WRITABLE - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_WRITE + def test_export_already_mounted_no_cleanup(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_mounted + with mock.patch.object(self.mock_cli, "write_data_to_device") as mock_write: + result = self.service.export() - def test_export_throws_new_exception_return_legacy_status(self): - self.mock_cli.get_connected_devices.side_effect = ExportException( - sdstatus=Status.ERROR_MOUNT + assert result == Status.SUCCESS_EXPORT + mock_write.assert_called_once_with( + self.mock_luks_volume_mounted, + self.mock_submission.tmpdir, + self.mock_submission.target_dirname, ) - with pytest.raises(ExportException) as ex: - self.service.export() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_MOUNT - - @mock.patch("os.path.exists", return_value=True) - def test_write_error_returns_legacy_status(self, mock_path): - self.mock_cli.is_luks_volume.return_value = True + def test_export_write_error(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_mounted self.mock_cli.write_data_to_device.side_effect = ExportException( sdstatus=Status.ERROR_EXPORT ) - with pytest.raises(ExportException) as ex: - self.service.export() + assert self.service.export() == Status.ERROR_EXPORT - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_WRITE - - @mock.patch("os.path.exists", return_value=True) - def test_unlock_error_returns_legacy_status(self, mock_path): - self.mock_cli.unlock_luks_volume.side_effect = ExportException( + def test_export_unlock_error(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_unmounted + self.mock_cli.unlock_volume.side_effect = ExportException( sdstatus=Status.ERROR_UNLOCK_LUKS ) - with pytest.raises(ExportException) as ex: - self.service.export() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_BAD_PASSPHRASE - - @mock.patch("os.path.exists", return_value=True) - def test_unexpected_error_returns_legacy_status_generic(self, mock_path): - self.mock_cli.unlock_luks_volume.side_effect = ExportException( - sdstatus=Status.DEVICE_ERROR - ) + assert self.service.export() == Status.ERROR_UNLOCK_LUKS - with pytest.raises(ExportException) as ex: - self.service.export() + def test_export_unlock_error_unspecified(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_unmounted + with mock.patch.object(self.mock_cli, "unlock_volume") as mock_unlock: + mock_unlock.side_effect = ExportException("oh no!") + result = self.service.export() - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_GENERIC + assert result == Status.ERROR_EXPORT diff --git a/export/tests/disk/test_volume.py b/export/tests/disk/test_volume.py deleted file mode 100644 index 10d4c6894..000000000 --- a/export/tests/disk/test_volume.py +++ /dev/null @@ -1,50 +0,0 @@ -from unittest import mock - -from securedrop_export.disk.volume import Volume, MountedVolume, EncryptionScheme - - -class TestVolume: - def test_overwrite_valid_encryption_scheme(self): - volume = Volume( - device_name="/dev/sda", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - assert volume.encryption is EncryptionScheme.LUKS - volume.encryption = None - assert volume.encryption is EncryptionScheme.UNKNOWN - - @mock.patch("os.path.exists", return_value=True) - def test_is_unlocked_true(self, mock_os_path): - volume = Volume( - device_name="/dev/sda1", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - - assert volume.unlocked - - @mock.patch("os.path.exists", return_value=False) - def test_is_unlocked_false_no_path(self, mock_os_path): - volume = Volume( - device_name="/dev/sda1", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - - assert not volume.unlocked - - -class TestMountedVolume: - @mock.patch("os.path.exists", return_value=True) - def test_is_unlocked_true(self, mock_os_path): - volume = Volume( - device_name="/dev/sda1", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - - mounted_volume = MountedVolume.from_volume(volume, mountpoint="/media/usb") - - assert mounted_volume.unlocked - assert mounted_volume.mountpoint == "/media/usb" diff --git a/export/tests/files/sample_export.sd-export b/export/tests/files/sample_export.sd-export new file mode 100644 index 0000000000000000000000000000000000000000..dab6433525ebaf9599ddb292ebad04665e3da54b GIT binary patch literal 714 zcmV;*0yX^~iwFP!000001MQd1j@mE~$2s#9v+1E1HsFvD39Tyi(kIwsrIm??BuipP zwzC0MmHH5U!ahmIVc8WcEvOZ+{qRpAfy~$*GX9U9a6C9W6C#~V+?A!t-gU=-#mSVh zgt2r+_tP|*4j?&yXoEp(EY|>ZMSa-su>CJ39OJrC+TICn`I+WIpZ_E|mj8&dPA9t0 zq9~d9{9l6o{7|+&sdZ&%74tr9VVv?;x+#B{NEO(0Xnz@ zT=TlA5Y!SlG!-u;SX^6JmUOdiD#baW%qLORmb_A`wr)OU-OTM7`BDl;^!(sz9O6Uqj@d%KL8c=_>RMPb* zR4H>B$hF`3Y0MN=C;>~YYCtKROF#53*bykbwOYP%){Z)G;cme24k$QIu6g0y@PWED zWiBbOj7tl;G^^*KjS7cvowAd%uYaq#G?^|Nd&(McO#f*dz1aUH(PZlNe+kTX&EHM} z=(MspA3kjgXais@Da{z3Q=KSnp<4RQ^{Q>IhSxoZydPa@cOf7vxaOHfZNRiy;bixD z!W1j+Tnj@y6$VMu+nsj%&2si9fujLVuPy*6TlxU)4XOcf-B+e-_1z zdHr94FM+^^GQ(gFfuN->povE7x85{v&Ap?^Q7GYzRl71Tj4RwY%SU*Z>nXR-mWCVC w6q*}^Tp?{5P~%nb_1{`KJsyw83_st`XXTmi>SZsImUT~a0P z=omv`z?LGx#D+u}+ZY?$7$AP60TU;aY{p3>xCh?=w?I;;MA7J&)OH&9Q6)!?^L?)0 zzMq|n{i&`a#psfhw6dOY7ET@hDtXLBS1XWQwo=|A8AbuKxwg_CfrC4nS2nSS}&p761h060{wFQ5A!N9d}mBB^R3F zUQd_PVOsrf_G|}(%btr-PPlg9?7>5a zFCIB@?D)XRk5^%Pgco$ z7A%tI=!5-ZH^fsEMW8`&ICz}GjMeoqNbTXQw)AatgrA<0TXXrSq z-w?K@STIl4S@0&Bpw}C@>MWdO!Cdnjv@DU29Gnx*$0acbn!DwW1Jyp5&wFUdh8URx z7J{bj6#Goq5Q|sDV!7B1V4@O=M{pf>r-P3S4`II{#a{bqztLL%e_6>6=Gf`{r)0Cu z`mf3vHLm}vn&tJsC!F(&mADuIY(Wf$-3l;W=O_jS^bmk~w}Rt42zYkUim%+nd_&L} z26<$_ctsjdSa1jd$Hi77jteZ?M9GoTae-+U3doDCn#6a24~LU6+omaa2m!y0kYTlr z9>);5%R7b`zw=?3m|wi)yG~bfH2klse}4Z{vaSFB$Vx`fa{u>&k+d0~OAM6GfplYM kMP7Q8`-{Wja5x+ehr{7;I2;a#!}%xp4g~bamjEaL0Nz}4zyJUM literal 0 HcmV?d00001 diff --git a/export/tests/lsblk_sample.py b/export/tests/lsblk_sample.py new file mode 100644 index 000000000..9e45b4c77 --- /dev/null +++ b/export/tests/lsblk_sample.py @@ -0,0 +1,162 @@ +""" +Sample output from `lsblk` used as input in `test_cli.py` +""" + +# udisks2 Status +UDISKS_STATUS_NOTHING_CONNECTED = ( + b"MODEL REVISION SERIAL DEVICE" + b"\n--------------------------------------------------------------------------\n" +) + +UDISKS_STATUS_ONE_DEVICE_CONNECTED = ( + b"MODEL REVISION SERIAL DEVICE\n" + b"--------------------------------------------------------------------------\n" + b"ADATA USB Flash Drive 1.00 2761505420110004 sda \n" +) +UDISKS_STATUS_MULTI_CONNECTED = ( + b"MODEL REVISION SERIAL DEVICE\n" + b"--------------------------------------------------------------------------\n" + b"ADATA USB Flash Drive 1.00 2761505420110004 sda \n" + b"Kingston DataTraveler 3.0 PMAP 40B0767E212CE481165906A8 sdb \n" +) + +# CLI.get_volume(): Supported +ONE_DEVICE_LUKS_UNMOUNTED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ONE_DEVICE_VC_UNLOCKED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"tcrypt-2049", "ro":false, "type":"crypt", "mountpoint":null, "fstype":"vfat"}\n ]\n },\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ONE_DEVICE_VC_MOUNTED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"tcrypt-2049", "ro":false, "type":"crypt", "mountpoint":null, "fstype":"vfat"}\n ]\n },\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS",\n "children": [\n {"name":"luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", "ro":false, "type":"crypt", "mountpoint":"/media/user/tx2", "fstype":"ext4"}\n ]\n }\n ]\n },\n {"name":"sdb", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sdb1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sdb2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ERROR_NO_SUPPORTED_DEVICE = b'{\n "blockdevices": [\n {"name":"sdb", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sdb1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sdb2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ERROR_UNENCRYPTED_DEVICE_MOUNTED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"decoy", "ro":false, "type":"part", "mountpoint":"/media/usb", "fstype":"vfat"}\n ]\n },\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +# CLI.get_volume(): Unsupported +ERROR_DEVICE_MULTI_ENC_PARTITION = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS"},\n {"name":"sda3", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +# Cli._get_supported_volume(): Supported + +SINGLE_DEVICE_LOCKED = { + "name": "sda", + "type": "disk", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", +} + +SINGLE_PART_LUKS_WRITABLE = { + "name": "sda1", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", + "children": [ + { + "name": "luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", + "type": "crypt", + "rm": False, + "mountpoint": "/media/usb", + "fstype": "ext4", + } + ], +} + +SINGLE_PART_VC_WRITABLE = { + "name": "sda1", + "rm": True, + "ro": False, + "type": "part", + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "tcrypt-2049", + "rm": False, + "ro": False, + "type": "crypt", + "mountpoint": "/media/usb", + "fstype": "vfat", + } + ], +} + +SINGLE_PART_LUKS_UNLOCKED_UNMOUNTED = { + "name": "sda1", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", + "children": [ + { + "name": "luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", + "type": "crypt", + "rm": False, + "mountpoint": None, + "fstype": "ext4", + } + ], +} + + +SINGLE_PART_UNLOCKED_VC_UNMOUNTED = { + "name": "sda1", + "rm": True, + "ro": False, + "type": "part", + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "tcrypt-2049", + "rm": False, + "ro": False, + "type": "crypt", + "mountpoint": None, + "fstype": "vfat", + } + ], +} + +# Cli._get_supported_volume(): Unsupported + +SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED = { + "name": "sda2", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "sda2p1", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", + } + ], +} + +SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED = { + "name": "sda2", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "unencrypted", + "type": "part", + "rm": False, + "mountpoint": "/media/unencrypted", + "fstype": "ext4", + } + ], +} diff --git a/export/tests/test_archive.py b/export/tests/test_archive.py index 57791a82e..7c09b83d6 100644 --- a/export/tests/test_archive.py +++ b/export/tests/test_archive.py @@ -22,7 +22,6 @@ def test_extract_tarball(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -73,7 +72,6 @@ def test_extract_tarball_with_symlink(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -108,7 +106,6 @@ def test_extract_tarball_raises_if_doing_path_traversal(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -147,7 +144,6 @@ def test_extract_tarball_raises_if_doing_path_traversal_with_dir(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -184,7 +180,6 @@ def test_extract_tarball_raises_if_doing_path_traversal_with_symlink(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -223,7 +218,6 @@ def test_extract_tarball_raises_if_doing_path_traversal_with_symlink_linkname(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -260,7 +254,6 @@ def test_extract_tarball_raises_if_name_has_unsafe_absolute_path(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -303,7 +296,6 @@ def test_extract_tarball_raises_if_name_has_unsafe_absolute_path_with_symlink(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -347,7 +339,6 @@ def test_extract_tarball_raises_if_name_has_unsafe_absolute_path_with_symlink_to with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -380,7 +371,6 @@ def test_extract_tarball_raises_if_linkname_has_unsafe_absolute_path(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -426,7 +416,6 @@ def test_valid_printer_test_config(capsys): config = Metadata(temp_folder).validate() assert config.encryption_key is None - assert config.encryption_method is None def test_valid_printer_config(capsys): @@ -439,23 +428,6 @@ def test_valid_printer_config(capsys): config = Metadata(temp_folder).validate() assert config.encryption_key is None - assert config.encryption_method is None - - -def test_invalid_encryption_config(capsys): - Archive("testfile") - - temp_folder = tempfile.mkdtemp() - metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) - with open(metadata, "w") as f: - f.write( - '{"device": "disk", "encryption_method": "base64", "encryption_key": "hunter1"}' - ) - - with pytest.raises(ExportException) as ex: - Metadata(temp_folder).validate() - - assert ex.value.sdstatus is Status.ERROR_ARCHIVE_METADATA def test_invalid_config(capsys): @@ -491,14 +463,11 @@ def test_valid_encryption_config(capsys): temp_folder = tempfile.mkdtemp() 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"}' - ) + f.write('{"device": "disk", "encryption_key": "hunter1"}') config = Metadata(temp_folder).validate() assert config.encryption_key == "hunter1" - assert config.encryption_method == "luks" @mock.patch("json.loads", side_effect=json.decoder.JSONDecodeError("ugh", "badjson", 0)) diff --git a/export/tests/test_main.py b/export/tests/test_main.py index 06da9ef66..ebad33eff 100644 --- a/export/tests/test_main.py +++ b/export/tests/test_main.py @@ -1,13 +1,13 @@ import pytest -import tempfile -import os from unittest import mock import shutil +from pathlib import Path from securedrop_export.archive import Archive, Metadata, Status as ArchiveStatus from securedrop_export.status import BaseStatus from securedrop_export.command import Command from securedrop_export.exceptions import ExportException +from securedrop_export.disk.status import Status as ExportStatus from securedrop_export.main import ( Status, @@ -18,107 +18,123 @@ _configure_logging, ) -SUBMISSION_SAMPLE_ARCHIVE = "pretendfile.tar.gz" +_PRINT_SAMPLE_ARCHIVE = "sample_print.sd-export" +_EXPORT_SAMPLE_ARCHIVE = "sample_export.sd-export" class TestMain: def setup_method(self, method): - # This can't be a class method, since we expect sysexit during this test suite, - # which - self.submission = Archive("pretendfile.tar.gz") - assert os.path.exists(self.submission.tmpdir) + # These can't be class setup methods, since we expect sysexit during this test suite + self.print_archive_path = Path.cwd() / "tests/files" / _PRINT_SAMPLE_ARCHIVE + assert self.print_archive_path.exists() + + self.export_archive_path = Path.cwd() / "tests/files" / _EXPORT_SAMPLE_ARCHIVE + assert self.export_archive_path.exists() + + self.print_submission = Archive(str(self.print_archive_path)) + assert Path(self.print_submission.tmpdir).exists() + + self.export_submission = Archive(str(self.export_archive_path)) + assert Path(self.export_submission.tmpdir).exists() def teardown_method(self, method): - if os.path.exists(self.submission.tmpdir): - shutil.rmtree(self.submission.tmpdir) - self.submission = None + if Path(self.print_submission.tmpdir).exists(): + shutil.rmtree(self.print_submission.tmpdir) + + if Path(self.export_submission.tmpdir).exists(): + shutil.rmtree(self.export_submission.tmpdir) - def test_exit_gracefully_no_exception(self, capsys): + def _did_exit_gracefully(self, exit, capsys, status: BaseStatus) -> bool: + """ + Helper. True if exited with 0, writing supplied status to stderr. + """ + captured = capsys.readouterr() + + return ( + exit.value.code == 0 + and captured.err.rstrip().endswith(status.value) + and captured.out == "" + ) + + def test__exit_gracefully_no_exception(self, capsys): with pytest.raises(SystemExit) as sysexit: - _exit_gracefully(self.submission, Status.ERROR_GENERIC) + # `ERROR_GENERIC` is just a placeholder status here + _exit_gracefully(self.print_submission, Status.ERROR_GENERIC) assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_GENERIC) - def test_exit_gracefully_exception(self, capsys): - with pytest.raises(SystemExit) as sysexit: - _exit_gracefully(self.submission, Status.ERROR_GENERIC) + @mock.patch( + "securedrop_export.main.shutil.rmtree", + side_effect=FileNotFoundError("oh no", 0), + ) + def test__exit_gracefully_even_with_cleanup_exception(self, mock_rm, capsys): + with mock.patch( + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] + ), mock.patch( + "securedrop_export.main._start_service", + return_value=ExportStatus.SUCCESS_EXPORT, + ), pytest.raises( + SystemExit + ) as sysexit: + entrypoint() - # A graceful exit means a return code of 0 assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_GENERIC) + def test_entrypoint_success(self, capsys): + with mock.patch( + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] + ), mock.patch( + "securedrop_export.main._start_service", + return_value=ExportStatus.SUCCESS_EXPORT, + ), pytest.raises( + SystemExit + ) as sysexit: + entrypoint() + + assert self._did_exit_gracefully(sysexit, capsys, ExportStatus.SUCCESS_EXPORT) + @pytest.mark.parametrize("status", [s for s in Status]) - def test_write_status(self, status, capsys): + def test__write_status_success(self, status, capsys): _write_status(status) captured = capsys.readouterr() assert captured.err == status.value + "\n" @pytest.mark.parametrize("invalid_status", ["foo", ";ls", "&& echo 0", None]) - def test_write_status_error(self, invalid_status, capsys): + def test__write_status_will_not_write_bad_value(self, invalid_status, capsys): with pytest.raises(ValueError): _write_status(Status(invalid_status)) - def _did_exit_gracefully(self, exit, capsys, status: BaseStatus) -> bool: - """ - Helper. True if exited with 0, writing supplied status to stderr. - """ captured = capsys.readouterr() + assert captured.err == "" + assert captured.out == "" - return ( - exit.value.code == 0 - and captured.err.rstrip().endswith(status.value) - and captured.out == "" - ) - - @pytest.mark.parametrize("command", list(Command)) - @mock.patch("securedrop_export.main._configure_logging") - @mock.patch("os.path.exists", return_value=True) - def test_entrypoint_success_start_service(self, mock_log, mock_path, command): - metadata = os.path.join(self.submission.tmpdir, Metadata.METADATA_FILE) - - with open(metadata, "w") as f: - f.write(f'{{"device": "{command.value}", "encryption_method": "luks"}}') - + def test_entrypoint_success_start_service(self): with mock.patch( - "sys.argv", ["qvm-send-to-usb", SUBMISSION_SAMPLE_ARCHIVE] + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] ), mock.patch( "securedrop_export.main._start_service" - ) as mock_service, mock.patch( - "securedrop_export.main.Archive.extract_tarball", - return_value=self.submission, - ), pytest.raises( + ) as mock_service, pytest.raises( SystemExit ): entrypoint() - if command is not Command.START_VM: - assert self.submission.command == command - assert mock_service.call_args[0][0].archive == SUBMISSION_SAMPLE_ARCHIVE - mock_service.assert_called_once_with(self.submission) - - def test_valid_printer_test_config(self, capsys): - Archive("testfile") - temp_folder = tempfile.mkdtemp() - metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) - with open(metadata, "w") as f: - f.write('{"device": "printer-test"}') + assert mock_service.call_args[0][0].archive == self.export_archive_path + assert mock_service.call_args[0][0].command == Command.EXPORT - config = Metadata(temp_folder).validate() + def test_validate_metadata(self): + for archive_path in [self.print_archive_path, self.export_archive_path]: + archive = Archive(archive_path) + extracted = archive.extract_tarball() - assert config.encryption_key is None - assert config.encryption_method is None + assert Metadata(extracted.tmpdir).validate() @mock.patch( "securedrop_export.archive.safe_extractall", side_effect=ValueError("A tarball problem!"), ) - @mock.patch("securedrop_export.main.os.path.exists", return_value=True) - @mock.patch("securedrop_export.main.shutil.rmtree") - @mock.patch("securedrop_export.main._configure_logging") - def test_entrypoint_failure_extraction( - self, mock_log, mock_rm, mock_path, mock_extract, capsys - ): + def test_entrypoint_failure_extraction(self, mock_extract, capsys): with mock.patch( - "sys.argv", ["qvm-send-to-usb", SUBMISSION_SAMPLE_ARCHIVE] + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] ), pytest.raises(SystemExit) as sysexit: entrypoint() @@ -149,9 +165,10 @@ def test_entrypoint_fails_unexpected(self, mock_mkdir, capsys): assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_GENERIC) - @mock.patch("os.path.exists", return_value=False) - def test_entrypoint_archive_path_fails(self, mock_path, capsys): - with pytest.raises(SystemExit) as sysexit: + def test_entrypoint_archive_path_fails(self, capsys): + with mock.patch( + "sys.argv", ["qvm-send-to-usb", "THIS_FILE_DOES_NOT_EXIST.sd-export"] + ), pytest.raises(SystemExit) as sysexit: entrypoint() assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_FILE_NOT_FOUND) @@ -171,14 +188,15 @@ def test__start_service_calls_correct_services(self, command): if command is Command.START_VM: pytest.skip("Command does not start a service") - self.submission.command = command + mock_submission = Archive("mock_submission.sd-export") + mock_submission.command = command with mock.patch("securedrop_export.main.PrintService") as ps, mock.patch( "securedrop_export.main.ExportService" ) as es: - _start_service(self.submission) + _start_service(mock_submission) if command in [Command.PRINT, Command.PRINTER_TEST, Command.PRINTER_PREFLIGHT]: - assert ps.call_args[0][0] is self.submission + assert ps.call_args[0][0] is mock_submission else: - assert es.call_args[0][0] is self.submission + assert es.call_args[0][0] is mock_submission From fc2cd877ede83c83e68832244dca3f41bc85ba7c Mon Sep 17 00:00:00 2001 From: Ro Date: Thu, 1 Feb 2024 15:29:13 -0500 Subject: [PATCH 02/10] Generate build-requirements.txt --- export/build-requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/export/build-requirements.txt b/export/build-requirements.txt index e69de29bb..172081b68 100644 --- a/export/build-requirements.txt +++ b/export/build-requirements.txt @@ -0,0 +1,2 @@ +pexpect==4.9.0 --hash=sha256:9eaf9cf5e3332373fab8184f455314ac01ebbb33e110331bc9e8e12daea1f68e +ptyprocess==0.7.0 --hash=sha256:b6194d9cb391fd7e02697548610c8eb18ba0226af2a9584bda50605958dc1a6b From 61ea8dc489110a171a4528247ef33c6ef7365e3c Mon Sep 17 00:00:00 2001 From: Ro Date: Wed, 24 Jan 2024 19:55:43 -0500 Subject: [PATCH 03/10] Client Veracrypt preparation (refactor): Combine export.py and device.py and avoid long-running thread/singleton pattern for export service. 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. Export and print use single method signature across components. Device does not depend on filepaths. --- client/securedrop_client/app.py | 9 +- client/securedrop_client/export.py | 356 ++++++-------- client/securedrop_client/export_status.py | 12 +- client/securedrop_client/gui/actions.py | 33 +- .../gui/conversation/__init__.py | 6 +- .../gui/conversation/export/__init__.py | 6 +- .../gui/conversation/export/device.py | 135 ------ .../gui/conversation/export/dialog.py | 55 --- .../{file_dialog.py => export_dialog.py} | 37 +- .../gui/conversation/export/print_dialog.py | 13 +- .../export/print_transcript_dialog.py | 14 +- .../conversation/export/transcript_dialog.py | 56 --- client/securedrop_client/gui/widgets.py | 20 +- client/tests/conftest.py | 55 +-- .../functional/test_export_file_dialog.py | 18 +- .../gui/conversation/export/test_device.py | 317 ++++++++---- .../gui/conversation/export/test_dialog.py | 379 ++++++++------- .../conversation/export/test_file_dialog.py | 437 +++++++++-------- .../conversation/export/test_print_dialog.py | 6 +- .../export/test_print_transcript_dialog.py | 2 +- .../export/test_transcript_dialog.py | 351 -------------- client/tests/gui/test_actions.py | 43 +- client/tests/gui/test_widgets.py | 14 +- client/tests/integration/conftest.py | 56 +-- .../tests/integration/test_styles_sdclient.py | 4 +- client/tests/test_export.py | 453 ------------------ 26 files changed, 953 insertions(+), 1934 deletions(-) delete mode 100644 client/securedrop_client/gui/conversation/export/device.py delete mode 100644 client/securedrop_client/gui/conversation/export/dialog.py rename client/securedrop_client/gui/conversation/export/{file_dialog.py => export_dialog.py} (93%) delete mode 100644 client/securedrop_client/gui/conversation/export/transcript_dialog.py delete mode 100644 client/tests/gui/conversation/export/test_transcript_dialog.py delete mode 100644 client/tests/test_export.py diff --git a/client/securedrop_client/app.py b/client/securedrop_client/app.py index 8e9fe9e9c..8a6a990c4 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 index 366f68eab..59ae3f2f8 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -3,103 +3,159 @@ 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 PyQt5.QtCore import QObject, pyqtSignal -from securedrop_client.export_status import ExportStatus +from securedrop_client.export_status import ExportError, 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 + 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, which you can learn more about in the README for the - securedrop-export repository. - """ + Files are archived in a specified format, (see `export` README). - METADATA_FN = "metadata.json" + A list of valid filepaths must be supplied. + """ - USB_TEST_FN = "usb-test.sd-export" - USB_TEST_METADATA = {"device": "usb-test"} + _METADATA_FN = "metadata.json" - PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" - PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} + _USB_TEST_FN = "usb-test.sd-export" + _USB_TEST_METADATA = {"device": "usb-test"} - DISK_TEST_FN = "disk-test.sd-export" - DISK_TEST_METADATA = {"device": "disk-test"} + _PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" + _PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} - PRINT_FN = "print_archive.sd-export" - PRINT_METADATA = {"device": "printer"} + _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" + _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) + 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_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: + export_preflight_check_failed = pyqtSignal(object) + export_failed = pyqtSignal(object) + + print_preflight_check_failed = pyqtSignal(object) + print_failed = pyqtSignal(object) + + 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, + ) + 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}") + self.print_preflight_check_failed.emit(e) + + 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, + ) + 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(self, filepaths: List[str], passphrase: Optional[str]) -> None: + """ + Bundle filepaths into a tarball and send to encrypted USB via qrexec, + optionally supplying a passphrase to unlock encrypted drives. + """ + try: + logger.debug(f"Begin exporting {len(filepaths)} item(s)") + + # Edit metadata template to include passphrase + metadata = self._DISK_METADATA.copy() + 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, + ) + status = self._run_qrexec_export(archive_path) + + 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) + + self.export_completed.emit(filepaths) + + 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, + ) + status = self._run_qrexec_export(archive_path) + 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_completed.emit(filepaths) + + 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. @@ -147,7 +203,7 @@ def _run_qrexec_export(cls, archive_path: str) -> ExportStatus: raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) def _create_archive( - cls, 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. @@ -164,20 +220,35 @@ def _create_archive( 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) + 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 + missing_count = 0 for filepath in filepaths: - cls._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) 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") + raise ExportError(ExportStatus.ERROR_MISSING_FILES) return archive_path def _add_virtual_file_to_archive( - cls, archive: tarfile.TarFile, filename: str, filedata: dict + 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. @@ -195,7 +266,7 @@ def _add_virtual_file_to_archive( archive.addfile(tarinfo, filedata_bytes) def _add_file_to_archive( - cls, archive: tarfile.TarFile, filepath: str, prevent_name_collisions: bool = False + 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 @@ -206,7 +277,7 @@ def _add_file_to_archive( 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) + 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) @@ -216,126 +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) - - @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 2c2a19924..da475c3fa 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 @@ -53,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 c4dfd6a70..ecca68466 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -12,15 +12,11 @@ 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 -from securedrop_client.gui.conversation import ExportDevice as ConversationExportDevice -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, ) @@ -160,8 +156,6 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller, export.getService()) - self.triggered.connect(self._on_triggered) @pyqtSlot() @@ -189,8 +183,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 = ExportDevice() dialog = PrintConversationTranscriptDialog( - self._export_device, TRANSCRIPT_FILENAME, str(file_path) + export, TRANSCRIPT_FILENAME, [str(file_path)] ) dialog.exec() @@ -212,15 +207,13 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller, export.getService()) - self.triggered.connect(self._on_triggered) @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 ExportFileDialog. + in the manner of the existing ExportDialog. """ file_path = ( Path(self.controller.data_dir) @@ -241,9 +234,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 = ExportConversationTranscriptDialog( - self._export_device, TRANSCRIPT_FILENAME, str(file_path) - ) + export_device = ExportDevice() + dialog = ExportDialog(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) dialog.exec() @@ -267,8 +259,6 @@ def __init__( self._source = source self._state = app_state - self._export_device = ConversationExportDevice(controller, export.getService()) - self.triggered.connect(self._on_triggered) @pyqtSlot() @@ -276,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 @@ -302,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) @@ -331,6 +321,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() files = [ stack.enter_context(open(file_location, "r")) for file_location in file_locations ] @@ -341,8 +332,8 @@ def _prepare_to_export(self) -> None: else: summary = _("all files and transcript") - dialog = ExportConversationDialog( - self._export_device, + 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 29142e98d..11729f801 100644 --- a/client/securedrop_client/gui/conversation/__init__.py +++ b/client/securedrop_client/gui/conversation/__init__.py @@ -3,9 +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 Dialog as ExportDialog # noqa: F401 -from .export import FileDialog as ExportFileDialog # noqa: F401 +from .export import Export as ExportDevice # 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 7da54e94c..8de6acbe7 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,6 +1,4 @@ -from .device import Device # noqa: F401 -from .dialog import Dialog # noqa: F401 -from .file_dialog import FileDialog # noqa: F401 +from ....export import Export # 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/device.py b/client/securedrop_client/gui/conversation/export/device.py deleted file mode 100644 index 9cf61dd06..000000000 --- a/client/securedrop_client/gui/conversation/export/device.py +++ /dev/null @@ -1,135 +0,0 @@ -import logging -import os -from typing import List - -from PyQt5.QtCore import QObject, pyqtSignal - -from securedrop_client.export import Export -from securedrop_client.logic import Controller - -logger = logging.getLogger(__name__) - - -class Device(QObject): - """Abstracts an export service for use in GUI components. - - 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. - """ - - export_preflight_check_requested = pyqtSignal() - print_preflight_check_requested = pyqtSignal() - - # Emit ExportStatus - export_preflight_check_succeeded = pyqtSignal(object) - export_succeeded = pyqtSignal(object) - - print_preflight_check_succeeded = pyqtSignal(object) - print_succeeded = 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) - - # 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: - 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. - """ - logger.info("Running printer preflight check") - self.print_preflight_check_requested.emit() - - def run_export_preflight_checks(self) -> None: - """ - Run preflight checks to make sure the Export VM is configured correctly. - """ - logger.info("Running export preflight check") - self.export_preflight_check_requested.emit() - - 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) - - 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) - - 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. - """ - 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))) - - 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) - - 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]) - - 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.info("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]) 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 c71ebe2d8..000000000 --- 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 .device import Device -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: Device, summary: str, file_locations: List[str]) -> None: - super().__init__(device, "", summary) - - self.file_locations = file_locations - - @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()) - - @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 93% rename from client/securedrop_client/gui/conversation/export/file_dialog.py rename to client/securedrop_client/gui/conversation/export/export_dialog.py index 414d2c8b1..b1bdbde7e 100644 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ b/client/securedrop_client/gui/conversation/export/export_dialog.py @@ -2,36 +2,37 @@ 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 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 ExportError, ExportStatus 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): +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: Device, file_uuid: str, file_name: 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.file_uuid = file_uuid - self.file_name = SecureQLabel( - file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX + 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() # Hold onto the error status we receive from the Export VM self.error_status: Optional[ExportStatus] = None @@ -51,10 +52,10 @@ def __init__(self, device: Device, file_uuid: str, file_name: 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") @@ -74,7 +75,7 @@ def __init__(self, device: Device, file_uuid: str, file_name: 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." @@ -128,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() @@ -140,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")) @@ -208,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) @@ -216,7 +217,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: @@ -228,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) @@ -237,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/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index 32e160bd1..40eaa7c88 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -1,23 +1,22 @@ from gettext import gettext as _ -from typing import Optional +from typing import List, Optional 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 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_uuid: str, file_name: str) -> None: + def __init__(self, device: Export, 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() @@ -95,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 9f47735ce..b6508fa06 100644 --- a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py @@ -1,8 +1,10 @@ +from typing import List + from PyQt5.QtCore import QSize, pyqtSlot from securedrop_client.gui.conversation.export import PrintDialog -from .device import Device +from ....export import Export class PrintTranscriptDialog(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: Export, 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 deleted file mode 100644 index 331819707..000000000 --- a/client/securedrop_client/gui/conversation/export/transcript_dialog.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -A dialog that allows journalists to export sensitive files to a USB drive. -""" -from gettext import gettext as _ - -from PyQt5.QtCore import pyqtSlot - -from .device import Device -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: Device, file_name: str, transcript_location: str) -> None: - super().__init__(device, "", file_name) - - self.transcript_location = transcript_location - - 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()) - - @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 a03f5b905..38c15e063 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,8 +2255,6 @@ def __init__( self.controller = controller - self._export_device = conversation.ExportDevice(controller, export.getService()) - 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 - self.export_dialog = conversation.ExportFileDialog( - self._export_device, self.uuid, self.file.filename + export_device = conversation.ExportDevice() + + self.export_dialog = conversation.ExportDialog( + export_device, self.file.filename, [file_location] ) self.export_dialog.show() @@ -2469,9 +2472,14 @@ 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() + + 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 a7918bbc1..eea47c558 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -4,14 +4,13 @@ import tempfile from configparser import ConfigParser from datetime import datetime -from typing import List from uuid import uuid4 import pytest 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 +22,6 @@ Source, make_session_maker, ) -from securedrop_client.export import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -80,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 @@ -92,14 +90,14 @@ 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 @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) @@ -114,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, "file_UUID", "file123.jpg") + dialog = conversation.ExportDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @@ -130,8 +128,8 @@ def export_transcript_dialog(mocker, homedir): export_device = mocker.MagicMock(spec=conversation.ExportDevice) - dialog = conversation.ExportTranscriptDialog( - export_device, "transcript.txt", "/some/path/transcript.txt" + dialog = conversation.ExportDialog( + export_device, "transcript.txt", ["/some/path/transcript.txt"] ) yield dialog @@ -169,39 +167,16 @@ def homedir(i18n): yield tmpdir -class MockExportService(export.Service): - """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() + device.run_export_preflight_checks = lambda dir: None + device.run_printer_preflight_checks = lambda dir: None + device.print = lambda filepaths: None + device.export = lambda filepaths, passphrase: None -@pytest.fixture(scope="function") -def mock_export_service_unlocked_device(): - return MockExportService(unlocked=True) + return device @pytest.fixture(scope="function") diff --git a/client/tests/functional/test_export_file_dialog.py b/client/tests/functional/test_export_file_dialog.py index 1fd160266..700d89763 100644 --- a/client/tests/functional/test_export_file_dialog.py +++ b/client/tests/functional/test_export_file_dialog.py @@ -17,11 +17,11 @@ ) -def _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export_service): +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.export.getService", return_value=mock_export_service) + mocker.patch("securedrop_client.export.Export", return_value=mock_export) gui, controller = functional_test_logged_in_context @@ -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_service -): +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_service - ) + export_dialog = _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export) assert export_dialog.passphrase_form.isHidden() is True @@ -102,15 +98,13 @@ 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, 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_service_unlocked_device - ) + 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 88a599162..6b44fd338 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/gui/conversation/export/test_device.py @@ -1,144 +1,255 @@ +import os +import subprocess +import tarfile +from tempfile import NamedTemporaryFile, TemporaryDirectory from unittest import mock -from PyQt5.QtTest import QSignalSpy +import pytest -from securedrop_client.export import Export -from securedrop_client.gui.conversation.export import Device -from securedrop_client.logic import Controller +from securedrop_client.export_status import ExportError, ExportStatus +from securedrop_client.gui.conversation.export import Export from tests import factory - +_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_FILEDIR = "/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) - - # Reset any manually-changed mock controller values before next test + cls.device = None + + # 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 = Export() + 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_FILEDIR) @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 + cls.device._create_archive = None + + def test_Device_run_printer_preflight_checks(self, mock_subprocess): + device = Export() + device._create_archive = mock.MagicMock() + device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE + + with mock.patch( + "securedrop_client.export.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + 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): + mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") + + 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.export.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.print([self.mock_file_location]) + + 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] - self.device.run_printer_preflight_checks() + def test_Device_print_file_file_missing(self, mock_subprocess, mocker): + device = Export() + warning_logger = mocker.patch("securedrop_client.export.logger.warning") - assert len(print_preflight_check_requested_emissions) == 1 + log_msg = "File not found at specified filepath, skipping" - def test_Device_run_print_file(self): - # file = factory.File(source=factory.Source()) - file = self.mock_file - print_requested_emissions = QSignalSpy(self.device.print_requested) + device.print("some-missing-file-uuid") - self.device.print_file(file.uuid) + assert log_msg in warning_logger.call_args[0] + mock_subprocess.assert_not_called() - assert len(print_requested_emissions) == 1 - - def test_Device_print_transcript(self): - print_requested_emissions = QSignalSpy(self.device.print_requested) - - filepath = "some/file/path" + def test_Device_run_export_preflight_checks(self, mock_subprocess): + with mock.patch( + "securedrop_client.export.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.run_export_preflight_checks() + mock_subprocess.assert_called_once() - self.device.print_transcript(filepath) - - assert len(print_requested_emissions) == 1 - assert print_requested_emissions[0] == [["some/file/path"]] - - def test_Device_print_file_file_missing(self, 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._create_archive.assert_called_once_with( + archive_dir=_MOCK_FILEDIR, + archive_fn=self.device._USB_TEST_FN, + metadata=self.device._USB_TEST_METADATA, ) + mock_subprocess.assert_called_once() + assert _QREXEC_EXPORT_COMMAND in mock_subprocess.call_args[0] - self.device.print_file(file.uuid) + def test_Device_run_export_preflight_checks_with_error(self, mock_sp): + mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") - path = str(file.location(self.mock_controller.data_dir)) - log_msg = f"Cannot find file in {path}" + with mock.patch("securedrop_client.export.logger.error") as err: + self.device.run_export_preflight_checks() - warning_logger.assert_called_once_with(log_msg) + assert "Export preflight failed" in err.call_args[0] - def test_Device_print_file_when_orig_file_already_exists(self): - file = self.mock_file - print_requested_emissions = QSignalSpy(self.device.print_requested) + def test_Device_export_file_missing(self, mock_subprocess, mocker): + device = Export() - self.device.print_file(file.uuid) + warning_logger = mocker.patch("securedrop_client.export.logger.warning") + with mock.patch( + "securedrop_client.export.tarfile.open", + return_value=mock.MagicMock(), + ), mock.patch( + "securedrop_client.export.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + device.export(["/not/a/real/location"], "mock passphrase") - assert len(print_requested_emissions) == 1 - self.mock_controller.get_file.assert_called_with(file.uuid) + 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_run_export_preflight_checks(self): - export_preflight_check_requested_emissions = QSignalSpy( - self.device.export_preflight_check_requested + def test_Device_export(self, mock_subprocess): + filepath = "some/file/path" + passphrase = "passphrase" + + with mock.patch( + "securedrop_client.export.TemporaryDirectory", + return_value=self.mock_tmpdir, + ): + self.device.export([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_FILEDIR, + 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.run_export_preflight_checks() - - assert len(export_preflight_check_requested_emissions) == 1 + @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_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") + assert self.device._run_qrexec_export(_PATH_TO_PRETEND_ARCHIVE) == enum - assert len(export_requested_emissions) == 1 - - def test_Device_export_file_to_usb_drive_file_missing(self, 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" + 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) - 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) - file = self.mock_file - - self.device.export_file_to_usb_drive(file.uuid, "mock passphrase") + assert e.value.status == ExportStatus.UNEXPECTED_RETURN_STATUS - assert len(export_requested_emissions) == 1 - self.mock_controller.get_file.assert_called_with(file.uuid) + 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) - def test_Device_export_transcript(self): - export_requested_emissions = QSignalSpy(self.device.export_requested) - filepath = "some/file/path" - - self.device.export_transcript(filepath, "passphrase") - - assert len(export_requested_emissions) == 1 - assert export_requested_emissions[0] == [["some/file/path"], "passphrase"] + assert e.value.status == ExportStatus.CALLED_PROCESS_ERROR - def test_Device_export_files(self): - export_requested_emissions = QSignalSpy(self.device.export_requested) + @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 - filepaths = ["some/file/path", "some/other/file/path"] - - self.device.export_files(filepaths, "passphrase") + 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. + """ + archive_path = None + 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 = Export() + + 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 = Export() + 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 = Export() + archive_path = None + 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( + tmpdir, + "mock.sd-export", + {}, + [f1.name, f2.name, transcript.name], + ) + 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) + + 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/conversation/export/test_dialog.py b/client/tests/gui/conversation/export/test_dialog.py index 335176ed3..0d6903441 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 @@ -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_files.assert_called_once_with( - ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"], + device.export.assert_called_once_with( + 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 e0b610155..a9ee020be 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 import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportFileDialog +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.ExportFileDialog._show_starting_instructions" + "securedrop_client.gui.conversation.ExportDialog._show_starting_instructions" ) - export_file_dialog = ExportFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") + 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(), "mock_uuid", 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_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,319 +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_file_to_usb_drive.assert_called_once_with( - export_file_dialog.file_uuid, "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_print_dialog.py b/client/tests/gui/conversation/export/test_print_dialog.py index d21765fdb..0bd4836f8 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 @@ -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_print_transcript_dialog.py b/client/tests/gui/conversation/export/test_print_transcript_dialog.py index a59a8e441..af8679784 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 deleted file mode 100644 index f0abfa859..000000000 --- a/client/tests/gui/conversation/export/test_transcript_dialog.py +++ /dev/null @@ -1,351 +0,0 @@ -from securedrop_client.export 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_transcript = 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_transcript.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 8f3bde2cb..c52a2b721 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 = ( + # lambda transcript: action._export_device.print_succeeded.emit() + # ) action.trigger() @@ -288,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")) @@ -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() @@ -316,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")) @@ -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 = ( + # lambda transcript: action._export_device.print_succeeded.emit() + # ) action.trigger() diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index 11b4234bc..4ef404f71 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3587,6 +3587,9 @@ 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( @@ -3597,10 +3600,10 @@ 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.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): @@ -3627,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() @@ -3637,7 +3640,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 +3649,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 +3669,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 80d3a4911..c00880041 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -1,22 +1,18 @@ 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.conversation.export import Export from securedrop_client.gui.main import Window from securedrop_client.logic import Controller from tests import factory @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 +64,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,23 +147,20 @@ 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() - # 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 +def mock_export(mocker): + device = Export() + + """A export that assumes the Qubes RPC calls are successful and skips them.""" + 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") -def print_dialog(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.export.getService", - return_value=mock_export_service, - ) +def print_dialog(mocker, homedir): + mocker.patch("securedrop_client.export.Export", return_value=mock_export) app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -193,10 +182,10 @@ def print_dialog(mocker, homedir, mock_export_service): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller, mock_export_service) gui.setup(controller) gui.login_dialog.close() - 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 @@ -206,11 +195,8 @@ 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): + mocker.patch("securedrop_client.export.Export", return_value=mock_export) app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -229,10 +215,10 @@ 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) gui.setup(controller) gui.login_dialog.close() - dialog = conversation.ExportFileDialog(export_device, "file_uuid", "file_name") + export_device = conversation.ExportDevice() + 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 3fab23e9e..fa5d0484a 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): diff --git a/client/tests/test_export.py b/client/tests/test_export.py deleted file mode 100644 index d5e43b4f3..000000000 --- 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 99c855acd108c8aa29861b0073fba8fae0d0a432 Mon Sep 17 00:00:00 2001 From: Ro Date: Mon, 29 Jan 2024 14:58:22 -0500 Subject: [PATCH 04/10] Use QWizard instead of modal dialog for file export flow. Add Error page for unrecoverable errors. Use QProcess instead of subprocess for qrexec commands. Improve error-handling in export.py. --- client/securedrop_client/export.py | 332 ++-- client/securedrop_client/gui/actions.py | 21 +- .../gui/conversation/__init__.py | 2 +- .../gui/conversation/export/__init__.py | 2 +- .../gui/conversation/export/dialog_button.css | 29 + .../conversation/export/dialog_message.css | 13 + .../gui/conversation/export/export_dialog.py | 289 ---- .../gui/conversation/export/export_wizard.py | 222 +++ .../export/export_wizard_constants.py | 47 + .../conversation/export/export_wizard_page.py | 464 +++++ client/securedrop_client/gui/widgets.py | 7 +- client/tests/conftest.py | 118 +- ... => 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 | 238 ++- .../gui/conversation/export/test_dialog.py | 352 ---- .../conversation/export/test_export_wizard.py | 154 ++ .../conversation/export/test_file_dialog.py | 405 ----- client/tests/gui/test_actions.py | 4 +- client/tests/gui/test_widgets.py | 10 +- 23 files changed, 3221 insertions(+), 1401 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 delete mode 100644 client/securedrop_client/gui/conversation/export/export_dialog.py 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 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 delete mode 100644 client/tests/gui/conversation/export/test_dialog.py create mode 100644 client/tests/gui/conversation/export/test_export_wizard.py delete mode 100644 client/tests/gui/conversation/export/test_file_dialog.py diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 59ae3f2f8..85560fd45 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -1,14 +1,14 @@ import json import logging import os -import subprocess import tarfile +import shutil from io import BytesIO from shlex import quote -from tempfile import TemporaryDirectory -from typing import List, Optional +from tempfile import TemporaryDirectory, mkdtemp +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,61 +41,62 @@ class Export(QObject): _DISK_ENCRYPTION_KEY_NAME = "encryption_key" _DISK_EXPORT_DIR = "export_data" - # Emit ExportStatus - export_preflight_check_succeeded = pyqtSignal(object) - export_succeeded = pyqtSignal(object) + # 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) - # Emit ExportError(status=ExportStatus) - export_preflight_check_failed = pyqtSignal(object) - export_failed = pyqtSignal(object) - print_preflight_check_failed = pyqtSignal(object) 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") + self.tmpdir = mkdtemp() + 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, - ) - status = self._run_qrexec_export(archive_path) - self.print_preflight_check_succeeded.emit(status) + 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_complete, 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) + logger.error("Error creating archive: {e}") + 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") + 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, - ) - status = self._run_qrexec_export(archive_path) - self.export_preflight_check_succeeded.emit(status) + try: + self.tmpdir = mkdtemp() - except ExportError as e: - logger.error("Export preflight failed") - self.export_preflight_check_failed.emit(e) + 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_complete, self._on_export_process_error + ) + except ExportError: + logger.error("Export preflight check failed during archive creation") + self._on_export_process_error() def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: """ @@ -110,97 +111,216 @@ 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, - ) - status = self._run_qrexec_export(archive_path) + self.tmpdir = mkdtemp() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._DISK_FN, + metadata=metadata, + filepaths=filepaths, + ) - self.export_succeeded.emit(status) - logger.debug(f"Status {status}") + # Emits status through callbacks + self._run_qrexec_export( + archive_path, self._on_export_process_complete, self._on_export_process_error + ) - except ExportError as e: + except IOError as e: logger.error("Export failed") logger.debug(f"Export failed: {e}") - self.export_failed.emit(e) + self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) + + # ExportStatus.ERROR_MISSING_FILES + except ExportError as err: + if err.status: + logger.error("Export failed while creating archive") + self.export_state_changed.emit(ExportError(err.status)) + else: + logger.error("Export failed while creating archive (no status supplied)") + self.export_state_changed.emit(ExportError(ExportStatus.ERROR_EXPORT)) + + def _run_qrexec_export( + self, archive_path: str, success_callback: Callable, error_callback: Callable + ) -> None: + """ + Send the archive to the Export VM, where the archive will be processed. + Uses qrexec-client-vm (via QProcess). Results are emitted via the + `finished` signal; errors are reported via `onError`. User-defined callback + functions must be connected to those signals. - self.export_completed.emit(filepaths) + 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. + Any callbacks must call _cleanup_tmpdir() to remove the temporary directory that held + the files to be exported. + """ + # 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.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 print(self, filepaths: List[str]) -> None: + def _on_export_process_complete(self): """ - Bundle files at self._filepaths_list into tarball and send for - printing via qrexec. + Callback, handle and emit QProcess result. As with all such callbacks, + the method signature cannot change. """ - try: - logger.debug("Beginning print") + self._cleanup_tmpdir() + # securedrop-export writes status to stderr + if self.process: + err = self.process.readAllStandardError() + + logger.debug(f"stderr: {err}") + + try: + 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, since we already stripped whitespace above) + status_string = result.split("\n")[-1] + self.export_state_changed.emit(ExportStatus(status_string)) - 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}") + else: + logger.error("Export preflight returned empty result") + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.print_failed.emit(e) + except ValueError as e: + logger.debug(f"Export preflight returned unexpected value: {e}") + logger.error("Export preflight returned unexpected value") + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) - self.export_completed.emit(filepaths) + def _on_export_process_error(self): + """ + Callback, called if QProcess cannot complete export. As with all such, the method + signature cannot change. + """ + self._cleanup_tmpdir() + if self.process: + 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) - def _run_qrexec_export(self, archive_path: str) -> ExportStatus: + def _on_print_preflight_complete(self): + """ + Print preflight completion callback. """ - Make the subprocess call to send the archive to the Export VM, where the archive will be - processed. + self._cleanup_tmpdir() + if self.process: + output = self.process.readAllStandardError().data().decode("utf-8").strip() + try: + status = ExportStatus(output) + if status == ExportStatus.PRINT_PREFLIGHT_SUCCESS: + self.print_preflight_check_succeeded.emit(status) + logger.debug("Print preflight success") + else: + logger.debug(f"Print preflight failure ({status.value})") + self.print_preflight_check_failed.emit(ExportError(status)) - Args: - archive_path (str): The path to the archive to be processed. + except ValueError as error: + logger.debug(f"Print preflight check failed: {error}") + logger.error("Print preflight check failed") + self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) - Returns: - str: The export status returned from the Export VM processing script. + def _on_print_prefight_error(self): + """ + Print Preflight error callback. Occurs when the QProcess itself fails. + """ + self._cleanup_tmpdir() + if self.process: + err = self.process.readAllStandardError().data().decode("utf-8").strip() + logger.debug(f"Print preflight error: {err}") + 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_success(self): + self._cleanup_tmpdir() + logger.debug("Print success") + self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) + # TODO: Previously emitted [filepaths] + 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}") + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) - 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. + def print(self, filepaths: List[str]) -> None: + """ + Bundle files at filepaths into tarball and send for + printing via qrexec. """ 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, + logger.debug("Beginning print") + + self.tmpdir = mkdtemp() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._PRINT_FN, + metadata=self._PRINT_METADATA, + filepaths=filepaths, ) - result = output.decode("utf-8").strip() + 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(ExportError(ExportStatus.ERROR_PRINT)) - return ExportStatus(result) + # ExportStatus.ERROR_MISSING_FILES + except ExportError as err: + if err.status: + logger.error("Print failed while creating archive") + self.print_failed.emit(ExportError(err.status)) + else: + logger.error("Print failed while creating archive (no status supplied)") + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) - 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_completed.emit(filepaths) def _create_archive( self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index ecca68466..ff189f086 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 @@ -212,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) @@ -235,8 +235,8 @@ 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.exec() + wizard = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) + wizard.exec() class ExportConversationAction(QAction): # pragma: nocover @@ -264,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 @@ -292,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) @@ -332,12 +331,12 @@ def _prepare_to_export(self) -> None: else: summary = _("all files and transcript") - dialog = ExportDialog( + 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 11729f801..219c00465 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 8de6acbe7..328c19e43 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,4 +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/dialog_button.css b/client/securedrop_client/gui/conversation/export/dialog_button.css new file mode 100644 index 000000000..132952a4b --- /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 000000000..20415fe9b --- /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 deleted file mode 100644 index b1bdbde7e..000000000 --- a/client/securedrop_client/gui/conversation/export/export_dialog.py +++ /dev/null @@ -1,289 +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, device: Export, summary_text: str, filepaths: List[str]) -> None: - super().__init__() - self.setStyleSheet(self.DIALOG_CSS) - - self._device = device - 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() - # 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() - - 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() 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 000000000..2630b3b98 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -0,0 +1,222 @@ +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, 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__) + + +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") + + # 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: + 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) + + # Clean up export on dialog closed signal + self.finished.connect(self.export.end_process) + + 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: + 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 + self.button_animation = load_movie("activestate-wide.gif") + self.button_animation.setScaledSize(QSize(32, 32)) + self.button_animation.frameChanged.connect(self.animate_activestate) + + def animate_activestate(self) -> None: + self.next_button.setIcon(QIcon(self.button_animation.currentPixmap())) + + def start_animate_activestate(self) -> None: + self.button_animation.start() + 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) + + def stop_animate_activestate(self) -> None: + self.next_button.setIcon(QIcon()) + self.button_animation.stop() + # 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 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.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 + # 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: + 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: + 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) + 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}. Current page is {type(self.currentPage())}") + + # 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 and self.currentId() > target: + self.rewind(target) + + # Update status + self.current_status = status + + 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() + + 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): + # Update its status so it shows error next self.currentPage() + logger.debug("On preflight page, no reordering needed") + else: + while self.currentId() > Pages.ERROR: + self.back() + page = self.currentPage() + page.update_content(error) + + def _create_preflight(self) -> QWizardPage: + return PreflightPage(self.export, self.summary_text) + + def _create_errorpage(self) -> QWizardPage: + return ErrorPage(self.export, "") + + 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_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 000000000..ce8c0f3af --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -0,0 +1,47 @@ +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 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 + INSERT_USB = 2 + UNLOCK_USB = 3 + EXPORT_DONE = 4 + + +# 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 000000000..0609bc086 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -0,0 +1,464 @@ +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, + QLayout, + QLineEdit, + QSizePolicy, + 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.AlignLeft) # Prev: 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) + 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() + 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.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: + raise NotImplementedError("Children must implement") + + 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): + self.summary = 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.start_animate_header() + 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 + 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 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, + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ): + header = _( + "Ready to export:
" '{}' + ).format(self.summary) + 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 isComplete(self) -> bool: + return False + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus): + pass + + +class InsertUSBPage(ExportWizardPage): + def __init__(self, export, summary): + self.summary = 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: + logger.debug(f"InsertUSB received {status.value}") + should_show_hint = status in ( + 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 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 + 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: + next = super().nextId() + logger.error("Unexpected status on InsertUSBPage {status.value}, nextID is {next}") + return next + + +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: + 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 = _( + "Remember to be careful when working with files " + "outside of your Workstation machine." + ) + elif status == ExportStatus.ERROR_EXPORT_CLEANUP: + header = header = _("Export sucessful, but drive was not locked") + body = STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP) + + else: + header = _("Working...") + + self.header.setText(header) + if body: + 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) + + layout.insertWidget(1, self.passphrase_form) + return layout + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + 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 + 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: + 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() diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 38c15e063..19b2f789c 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,10 +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.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/conftest.py b/client/tests/conftest.py index eea47c558..8607ce826 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 @@ -97,38 +99,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"] ) @@ -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 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.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: 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 = 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 000000000..d59c25ebb --- /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 , archive_fn='usb-test.sd-export', metadata={'device': 'usb-test'}) self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_FILEDIR, + archive_dir=self.mock_tmpdir, archive_fn=self.device._USB_TEST_FN, metadata=self.device._USB_TEST_METADATA, ) - 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): - mock_sp.side_effect = subprocess.CalledProcessError(1, "check_output") + def test_Device_run_export_preflight_checks_with_error(self): + spy = QSignalSpy(self.device.export_state_changed) + + with mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch.object(self.device, "_create_archive"), mock.patch( + "securedrop_client.export.QProcess" + ) as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + mock_qproc.start.side_effect = ( + lambda proc, args: self.device._on_export_process_complete() + ) + mock_qproc.readAllStandardError = mock.MagicMock() + mock_qproc.readAllStandardError.data.return_value = b"Houston, we have a problem\n" - 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] + assert len(spy) == 1 and spy[0] == ExportStatus.UNEXPECTED_RETURN_STATUS - def test_Device_export_file_missing(self, mock_subprocess, mocker): + def test_Device_export_file_missing(self, mocker): device = Export() warning_logger = mocker.patch("securedrop_client.export.logger.warning") @@ -129,62 +175,67 @@ def test_Device_export_file_missing(self, mock_subprocess, mocker): "securedrop_client.export.tarfile.open", return_value=mock.MagicMock(), ), mock.patch( - "securedrop_client.export.TemporaryDirectory", + "securedrop_client.export.mkdtemp", return_value=self.mock_tmpdir, - ): + ), mock.patch( + "securedrop_client.export.QProcess" + ) as mock_qprocess: device.export(["/not/a/real/location"], "mock passphrase") + mock_qprocess.assert_not_called() + 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(self, mock_subprocess): + def test_Device_export(self): filepath = "some/file/path" passphrase = "passphrase" + expected_metadata = self.device._DISK_METADATA.copy() + expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase + with mock.patch( - "securedrop_client.export.TemporaryDirectory", + "securedrop_client.export.mkdtemp", return_value=self.mock_tmpdir, - ): + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() self.device.export([filepath], passphrase) - expected_metadata = self.device._DISK_METADATA.copy() - expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase + mock_qproc.start.assert_called_once() + assert mock_qproc.start.call_args[0] == _QREXEC_EXPORT_COMMAND self.device._create_archive.assert_called_once_with( - archive_dir=_MOCK_FILEDIR, + archive_dir=self.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] @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") + def test__run_qrexec_success(self, status): enum = ExportStatus(status) + with mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch.object( + self.device, "_on_export_process_complete" + ) as mock_callback: + mock_qproc = mock_qprocess.return_value + mock_qproc.finished = mock.MagicMock() + mock_qproc.start = mock.MagicMock() + mock_qproc.start.side_effect = ( + lambda proc, args: self.device._on_export_process_complete() + ) + mock_qproc.readAllStandardError.return_value = f"{status}\n".encode("utf-8") - assert self.device._run_qrexec_export(_PATH_TO_PRETEND_ARCHIVE) == enum - - 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 - - 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) + self.device._run_qrexec_export( + _PATH_TO_PRETEND_ARCHIVE, + mock_callback, + self.device._on_export_process_error, + ) - assert e.value.status == ExportStatus.CALLED_PROCESS_ERROR + mock_qproc.start.assert_called_once() @mock.patch("securedrop_client.export.tarfile") - def test__add_virtual_file_to_archive(self, mock_tarfile, mock_sp): + def test__add_virtual_file_to_archive(self, mock_tarfile): mock_tarinfo = mock.MagicMock(spec=tarfile.TarInfo) mock_tarfile.TarInfo.return_value = mock_tarinfo @@ -213,7 +264,7 @@ def test__create_archive(self, mocker): assert not os.path.exists(archive_path) - def test__create_archive_with_an_export_file(self, mocker): + def test__create_archive_with_an_export_file(self): device = Export() archive_path = None with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: @@ -225,7 +276,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): + def test__create_archive_with_multiple_export_files(self): device = Export() archive_path = None with TemporaryDirectory() as tmpdir, NamedTemporaryFile() as f1, NamedTemporaryFile() as f2: @@ -242,14 +293,19 @@ def test__create_archive_with_multiple_export_files(self, mocker): assert not os.path.exists(archive_path) - def test__tmpdir_cleaned_up_on_exception(self, mock_sp): + def test__tmpdir_cleaned_up_on_exception(self): """ 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) + with mock.patch( + "securedrop_client.export.mkdtemp", return_value=self.mock_tmpdir + ) as tmpdir, mock.patch("securedrop_client.export.QProcess") as qprocess, mock.patch.object( + self.device, "_cleanup_tmpdir" + ) as mock_cleanup: + mock_qproc = qprocess.return_value + mock_qproc.readAllStandardError.data.return_value = b"Something awful happened!\n" + mock_qproc.start = lambda proc, args: self.device._on_export_process_error() + self.device.run_printer_preflight_checks() + assert self.device.tmpdir == self.mock_tmpdir + mock_cleanup.assert_called() 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 0d6903441..000000000 --- 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_export_wizard.py b/client/tests/gui/conversation/export/test_export_wizard.py new file mode 100644 index 000000000..24124e72d --- /dev/null +++ b/client/tests/gui/conversation/export/test_export_wizard.py @@ -0,0 +1,154 @@ +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 ( + ErrorPage, + 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() + + def test_wizard_only_shows_error_page_on_unrecoverable_error(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.UNEXPECTED_RETURN_STATUS) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), ErrorPage) 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 a9ee020be..000000000 --- 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 c52a2b721..82f4a9f38 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 4ef404f71..0c3d49b8c 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.export.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] + ), f"{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 8aae9b76a38af2f69772a7e46c4aa5e2b458f4ce Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 2 Feb 2024 18:15:03 -0500 Subject: [PATCH 05/10] 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 85560fd45..68fef30b6 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 13a729507..73dc0210a 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 7c09b83d6..37510f0c8 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 f77cd726776ce7a07e6ea3278e70ca9fb53fc992 Mon Sep 17 00:00:00 2001 From: Ro Date: Wed, 7 Feb 2024 13:17:57 -0500 Subject: [PATCH 06/10] Stay on same page while export or preflight check is taking place in background. Apply activestate animation during async work. Add QWizard styling. Update text fixtures. --- client/securedrop_client/export.py | 35 +++-- client/securedrop_client/gui/base/inputs.py | 4 +- .../gui/conversation/export/dialog_button.css | 29 ---- .../gui/conversation/export/export_wizard.py | 148 ++++++++++++------ .../conversation/export/export_wizard_page.py | 137 ++++++++-------- .../export/{dialog.css => wizard.css} | 23 +-- .../gui/conversation/export/wizard_button.css | 47 ++++++ ...{dialog_message.css => wizard_message.css} | 4 +- client/tests/conftest.py | 14 +- .../functional/test_export_file_wizard.py | 78 ++++++--- .../export/test_device.py => test_export.py} | 124 +++++++++++---- 11 files changed, 398 insertions(+), 245 deletions(-) delete mode 100644 client/securedrop_client/gui/conversation/export/dialog_button.css rename client/securedrop_client/gui/conversation/export/{dialog.css => wizard.css} (78%) create mode 100644 client/securedrop_client/gui/conversation/export/wizard_button.css rename client/securedrop_client/gui/conversation/export/{dialog_message.css => wizard_message.css} (76%) rename client/tests/{gui/conversation/export/test_device.py => test_export.py} (73%) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 68fef30b6..3dcc9e7ba 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -1,14 +1,14 @@ import json import logging import os -import tarfile import shutil +import tarfile from io import BytesIO from shlex import quote -from tempfile import TemporaryDirectory, mkdtemp +from tempfile import mkdtemp from typing import Callable, List, Optional -from PyQt5.QtCore import QProcess, QObject, pyqtSignal +from PyQt5.QtCore import QObject, QProcess, pyqtSignal from securedrop_client.export_status import ExportError, ExportStatus @@ -54,7 +54,7 @@ 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 + tmpdir = None # mkdtemp directory must be cleaned up when QProcess completes def run_printer_preflight_checks(self) -> None: """ @@ -62,6 +62,7 @@ def run_printer_preflight_checks(self) -> None: """ logger.info("Beginning printer preflight check") self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) try: archive_path = self._create_archive( @@ -73,7 +74,7 @@ def run_printer_preflight_checks(self) -> None: archive_path, self._on_print_preflight_complete, self._on_print_prefight_error ) except ExportError as e: - logger.error("Error creating archive: {e}") + logger.error(f"Error creating archive: {e}") self._on_print_prefight_error() def run_export_preflight_checks(self) -> None: @@ -84,6 +85,7 @@ def run_export_preflight_checks(self) -> None: try: self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) archive_path = self._create_archive( archive_dir=self.tmpdir, @@ -112,6 +114,8 @@ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) + archive_path = self._create_archive( archive_dir=self.tmpdir, archive_fn=self._DISK_FN, @@ -180,14 +184,14 @@ def _run_qrexec_export( self.process.start(qrexec, args) - def _cleanup_tmpdir(self): + def _cleanup_tmpdir(self) -> None: """ 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_complete(self): + def _on_export_process_complete(self) -> None: """ Callback, handle and emit QProcess result. As with all such callbacks, the method signature cannot change. @@ -217,7 +221,7 @@ def _on_export_process_complete(self): logger.error("Export preflight returned unexpected value") self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) - def _on_export_process_error(self): + def _on_export_process_error(self) -> None: """ Callback, called if QProcess cannot complete export. As with all such, the method signature cannot change. @@ -229,7 +233,7 @@ def _on_export_process_error(self): logger.error(f"Export process error: {err}") self.export_state_changed.emit(ExportStatus.CALLED_PROCESS_ERROR) - def _on_print_preflight_complete(self): + def _on_print_preflight_complete(self) -> None: """ Print preflight completion callback. """ @@ -250,7 +254,7 @@ def _on_print_preflight_complete(self): logger.error("Print preflight check failed") self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) - def _on_print_prefight_error(self): + def _on_print_prefight_error(self) -> None: """ Print Preflight error callback. Occurs when the QProcess itself fails. """ @@ -262,7 +266,7 @@ def _on_print_prefight_error(self): # 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_success(self): + def _on_print_success(self) -> None: self._cleanup_tmpdir() logger.debug("Print success") self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) @@ -280,13 +284,16 @@ def end_process(self) -> None: if self.process is not None and not self.process.waitForFinished(50): self.process.terminate() - def _on_print_error(self): + def _on_print_error(self) -> None: """ Error callback for print qrexec. """ self._cleanup_tmpdir() - err = self.process.readAllStandardError() - logger.debug(f"Print error: {err}") + if self.process: + err = self.process.readAllStandardError() + logger.debug(f"Print error: {err}") + else: + logger.error("Print error (stderr unavailable)") self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) def print(self, filepaths: List[str]) -> None: diff --git a/client/securedrop_client/gui/base/inputs.py b/client/securedrop_client/gui/base/inputs.py index 2fbbc49d5..3a5473e8a 100644 --- a/client/securedrop_client/gui/base/inputs.py +++ b/client/securedrop_client/gui/base/inputs.py @@ -16,7 +16,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -from PyQt5.QtWidgets import QDialog, QLineEdit +from PyQt5.QtWidgets import QLineEdit, QWidget class PasswordEdit(QLineEdit): @@ -24,7 +24,7 @@ class PasswordEdit(QLineEdit): A LineEdit with icons to show/hide password entries """ - def __init__(self, parent: QDialog) -> None: + def __init__(self, parent: QWidget) -> None: super().__init__(parent) self.setEchoMode(QLineEdit.Password) diff --git a/client/securedrop_client/gui/conversation/export/dialog_button.css b/client/securedrop_client/gui/conversation/export/dialog_button.css deleted file mode 100644 index 132952a4b..000000000 --- a/client/securedrop_client/gui/conversation/export/dialog_button.css +++ /dev/null @@ -1,29 +0,0 @@ -#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/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index 2630b3b98..eda3ac72c 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -5,14 +5,16 @@ from pkg_resources import resource_string from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtGui import QIcon, QKeyEvent +from PyQt5.QtWidgets import QAbstractButton # noqa: F401 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, STATUS_MESSAGES +from securedrop_client.gui.conversation.export.export_wizard_constants import Pages from securedrop_client.gui.conversation.export.export_wizard_page import ( ErrorPage, + ExportWizardPage, FinalPage, InsertUSBPage, PassphraseWizardPage, @@ -31,7 +33,9 @@ class ExportWizard(QWizard): PASSPHRASE_LABEL_SPACING = 0.5 NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 - BUTTON_CSS = resource_string(__name__, "dialog_button.css").decode("utf-8") + FILE_OPTIONS_FONT_SPACING = 1.6 + BUTTON_CSS = resource_string(__name__, "wizard_button.css").decode("utf-8") + WIZARD_CSS = resource_string(__name__, "wizard.css").decode("utf-8") # If the drive is unlocked, we don't need a passphrase; if we do need one, # it's populated later. @@ -50,59 +54,85 @@ 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 + # Sends cleanup signal to export if wizard is closed or completed. + # (Avoid orphaned QProcess) self.finished.connect(self.export.end_process) + # Activestate animation + self.button_animation = load_movie("activestate-wide.gif") + self.button_animation.setScaledSize(QSize(32, 32)) + self.button_animation.frameChanged.connect(self._animate_activestate) + + # Buttons + self.next_button = self.button(QWizard.WizardButton.NextButton) # type: QAbstractButton + self.cancel_button = self.button(QWizard.WizardButton.CancelButton) # type: QAbstractButton + self.back_button = self.button(QWizard.WizardButton.BackButton) # type: QAbstractButton + self.finish_button = self.button(QWizard.WizardButton.FinishButton) # type: QAbstractButton + + self._style_buttons() self._set_layout() self._set_pages() - self._style_buttons() + self.adjustSize() def keyPressEvent(self, event: QKeyEvent) -> None: + """ + Allow for keyboard navigation of wizard buttons. + """ if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return: if self.cancel_button.hasFocus(): self.cancel_button.click() + elif self.back_button.hasFocus(): + self.back_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: - self.next_button = self.button(QWizard.WizardButton.NextButton) - self.next_button.clicked.connect(self.request_export) + """ + Style QWizard buttons and connect "Next" button click event to + request_export slot. + """ + self.next_button.setObjectName("QWizardButton_PrimaryButton") self.next_button.setStyleSheet(self.BUTTON_CSS) - self.cancel_button = self.button(QWizard.WizardButton.CancelButton) + self.next_button.setMinimumSize(QSize(130, 40)) + self.next_button.setMaximumHeight(40) + self.next_button.clicked.connect(self.request_export) + + self.cancel_button.setObjectName("QWizardButton_GenericButton") self.cancel_button.setStyleSheet(self.BUTTON_CSS) + self.cancel_button.setMinimumSize(QSize(130, 40)) + self.cancel_button.setMaximumHeight(40) - # Activestate animation - self.button_animation = load_movie("activestate-wide.gif") - self.button_animation.setScaledSize(QSize(32, 32)) - self.button_animation.frameChanged.connect(self.animate_activestate) + self.back_button.setObjectName("QWizardButton_GenericButton") + self.back_button.setStyleSheet(self.BUTTON_CSS) + self.back_button.setMinimumSize(QSize(130, 40)) + self.back_button.setMaximumHeight(40) + + self.finish_button.setObjectName("QWizardButton_GenericButton") + self.finish_button.setStyleSheet(self.BUTTON_CSS) + self.finish_button.setMinimumSize(QSize(130, 40)) + self.finish_button.setMaximumHeight(40) - def animate_activestate(self) -> None: + self.setButtonText(QWizard.WizardButton.NextButton, _("CONTINUE")) + self.setButtonText(QWizard.WizardButton.CancelButton, _("CANCEL")) + self.setButtonText(QWizard.WizardButton.FinishButton, _("DONE")) + self.setButtonText(QWizard.WizardButton.BackButton, _("BACK")) + + def _animate_activestate(self) -> None: self.next_button.setIcon(QIcon(self.button_animation.currentPixmap())) - def start_animate_activestate(self) -> None: + def _start_animate_activestate(self) -> None: self.button_animation.start() - 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) - def stop_animate_activestate(self) -> None: + def _stop_animate_activestate(self) -> None: self.next_button.setIcon(QIcon()) self.button_animation.stop() - # 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.setWindowTitle(f"Export {self.summary_text}") # TODO (il8n) + self.setObjectName("QWizard_export") + self.setStyleSheet(self.WIZARD_CSS) self.setModal(False) self.setOptions( QWizard.NoBackButtonOnLastPage @@ -119,17 +149,29 @@ def _set_pages(self) -> None: (Pages.EXPORT_DONE, self._create_done()), ]: self.setPage(id, page) + self.adjustSize() - # 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() - + @pyqtSlot() def request_export(self) -> None: + """ + Handler for "next" button clicks. Start animation and request export. + (The export proceeds only as far as it's able, which is why it's + possible to trigger the same method on every dialog page). + + The Preflight QWizardPage triggers the preflight check itself when + it is created, so there is no corresponding `request_export_preflight` + method. + """ logger.debug("Request export") + # While we're waiting for the results to come back, stay on the same page. + # This prevents the dialog from briefly flashing one page and then + # advancing to a subsequent page (for example, flashing the "Insert a USB" + # page before detecting the USB and advancing to the "Unlock USB" page) + page = self.currentPage() + if isinstance(page, ExportWizardPage): + page.set_complete(False) + self._start_animate_activestate() + # Registered fields let us access the passphrase field # of the PassphraseRequestPage from the wizard parent passphrase_untrusted = self.field("passphrase") @@ -138,22 +180,28 @@ def request_export(self) -> None: 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) 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. + Receive status updates from export process. The QWizard is responsible for + listening for a status that requires it to adjust its own position outside of the + normal wizard control flow, such as jumping to an error page if an unrecoverable error + status is encountered, or "rewinding" to a previous page if an unexpected status is + encountered (USB device removed after proceeding past that part of the workflow). + + Child QWizardPages also implement this listener in order to update their own UI and store + a reference to the current status. - To update the text on an individual page, the page listens - for this signal and can call `update_content` in the listener. + Advancing through the normal QWizard control flow is handled by child pages. """ logger.debug(f"Wizard received {status.value}. Current page is {type(self.currentPage())}") + # Release the page (page was held during "next" button click event) + page = self.currentPage() + if isinstance(page, ExportWizardPage): + page.set_complete(True) + self._stop_animate_activestate() + # Unrecoverable - end the wizard if status in [ ExportStatus.ERROR_MOUNT, @@ -194,23 +242,25 @@ def rewind(self, target: Pages) -> None: def end_wizard_with_error(self, error: ExportStatus) -> None: """ - If and end state is reached, display message and let user + If an end state is reached, display message and let user end the wizard. """ + logger.debug("End wizard with error") if isinstance(self.currentPage(), PreflightPage): - # Update its status so it shows error next self.currentPage() logger.debug("On preflight page, no reordering needed") else: while self.currentId() > Pages.ERROR: self.back() + logger.debug(f"Target: {Pages.ERROR}. Actual: {self.currentId()}") page = self.currentPage() - page.update_content(error) + if isinstance(page, ExportWizardPage): + page.update_content(error) def _create_preflight(self) -> QWizardPage: return PreflightPage(self.export, self.summary_text) def _create_errorpage(self) -> QWizardPage: - return ErrorPage(self.export, "") + 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_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py index 0609bc086..712f2e40d 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -1,5 +1,6 @@ import logging from gettext import gettext as _ +from typing import Optional from pkg_resources import resource_string from PyQt5.QtCore import QSize, Qt, pyqtSlot @@ -9,9 +10,7 @@ QGraphicsDropShadowEffect, QHBoxLayout, QLabel, - QLayout, QLineEdit, - QSizePolicy, QVBoxLayout, QWidget, QWizardPage, @@ -30,13 +29,16 @@ class ExportWizardPage(QWizardPage): """ - Base class for all export wizard pages. Individual pages should inherit + Base class for all export wizard pages. Individual pages must inherit from this class to: - * include additional layout items - * implement dynamic ordering (i.e., if the next window varies + * Implement `on_status_received`, a listener that receives export + statuses in order to update the UI and store a reference to the + current state. + * 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 + * Implement custom validation (logic that prevents a user from skipping to the next page until conditions are met) Every wizard page has: @@ -44,18 +46,17 @@ class ExportWizardPage(QWizardPage): * 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") + WIZARD_CSS = resource_string(__name__, "wizard.css").decode("utf-8") + ERROR_DETAILS_CSS = resource_string(__name__, "wizard_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: + def __init__(self, export: Export, header: str, body: Optional[str]) -> None: parent = QApplication.activeWindow() super().__init__(parent) self.export = export @@ -66,12 +67,14 @@ def __init__(self, export: Export, header: str, body: str) -> None: self.setLayout(self._build_layout()) - # Listen for export updates from export + # Listen for export updates from export. + # Pages will receive signals even if they are not the current active page. 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) + Flag a page as being incomplete. (Disables Next button and prevents + user from advancing to next page) """ self._is_complete = is_complete @@ -82,8 +85,8 @@ def _build_layout(self) -> QVBoxLayout: """ Create parent layout, draw elements, return parent layout """ - self.setStyleSheet(self.DIALOG_CSS) - parent_layout = QVBoxLayout() + self.setStyleSheet(self.WIZARD_CSS) + parent_layout = QVBoxLayout(self) parent_layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) # Header for icon and task title @@ -91,25 +94,25 @@ def _build_layout(self) -> QVBoxLayout: 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_icon.setObjectName("QWizard_header_icon") self.header_spinner = QPixmap() self.header_spinner_label = QLabel() - self.header_spinner_label.setObjectName("ModalDialog_header_spinner") + self.header_spinner_label.setObjectName("QWizard_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") + self.header.setObjectName("QWizard_header") + header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter) header_container_layout.addWidget(self.header_icon) header_container_layout.addWidget(self.header_spinner_label) - 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") + self.header_line.setObjectName("QWizard_header_line") # Body to display instructions and forms self.body = QLabel() - self.body.setObjectName("ModalDialog_body") + self.body.setObjectName("QWizard_body") self.body.setWordWrap(True) self.body.setScaledContents(True) @@ -120,15 +123,14 @@ 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() - self.error_details.setObjectName("ModalDialog_error_details") + self.error_details.setObjectName("QWizard_error_details") self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) + self.error_details.setContentsMargins( + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) self.error_details.setWordWrap(True) self.error_details.hide() @@ -139,38 +141,26 @@ def _build_layout(self) -> QVBoxLayout: # Populate text content self.header.setText(self.header_text) - self.body.setText(self.body_text) + if self.body_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.addWidget(self.error_details) - # parent_layout.setSizeConstraint(QLayout.SetFixedSize) + parent_layout.addStretch() 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) @@ -190,14 +180,16 @@ def update_content(self, status: ExportStatus, should_show_hint: bool = False) - status = ExportStatus.UNEXPECTED_RETURN_STATUS if should_show_hint: - self.error_details.setText(STATUS_MESSAGES.get(status)) - self.error_details.show() + message = STATUS_MESSAGES.get(status) + if message: + self.error_details.setText(message) + self.error_details.show() else: self.error_details.hide() class PreflightPage(ExportWizardPage): - def __init__(self, export, summary): + def __init__(self, export: Export, summary: str) -> None: self.summary = summary header = _( "Preparing to export:
" '{}' @@ -222,7 +214,7 @@ def __init__(self, export, summary): self.start_animate_header() self.export.run_export_preflight_checks() - def nextId(self): + def nextId(self) -> int: """ Override builtin to allow bypassing the password page if device is unlocked. """ @@ -243,39 +235,30 @@ def nextId(self): return Pages.INSERT_USB @pyqtSlot(object) - def on_status_received(self, status: ExportStatus): + def on_status_received(self, status: ExportStatus) -> None: self.stop_animate_header() - 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.summary) - self.header.setText(header) + header = _("Ready to export:
" '{}').format( + self.summary + ) + self.header.setText(header) self.status = status class ErrorPage(ExportWizardPage): - def __init__(self, export, summary): + def __init__(self, export: Export): header = _("Export Failed") - summary = "" # todo - - super().__init__(export, header=header, body=summary) + super().__init__(export, header=header, body=None) def isComplete(self) -> bool: return False @pyqtSlot(object) - def on_status_received(self, status: ExportStatus): + def on_status_received(self, status: ExportStatus) -> None: pass class InsertUSBPage(ExportWizardPage): - def __init__(self, export, summary): + def __init__(self, export: Export, summary: str) -> None: self.summary = summary header = _("Ready to export:
" '{}').format( summary @@ -292,11 +275,17 @@ def on_status_received(self, status: ExportStatus) -> None: should_show_hint = status in ( ExportStatus.MULTI_DEVICE_DETECTED, ExportStatus.INVALID_DEVICE_DETECTED, - ) or (self.status == status == ExportStatus.NO_DEVICE_DETECTED) + ) or ( + self.status == status == ExportStatus.NO_DEVICE_DETECTED + and isinstance(self.wizard().currentPage, InsertUSBPage) + ) self.update_content(status, should_show_hint) self.status = status self.completeChanged.emit() - if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE): + if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE) and isinstance( + self.wizard().currentPage(), InsertUSBPage + ): + logger.debug("Device detected - advance the wizard") self.wizard().next() def validatePage(self) -> bool: @@ -323,7 +312,7 @@ def validatePage(self) -> bool: logger.warning("InsertUSBPage encountered unexpected status") return super().validatePage() - def nextId(self): + def nextId(self) -> int: """ Override builtin to allow bypassing the password page if device unlocked """ @@ -336,7 +325,8 @@ def nextId(self): return Pages.ERROR else: next = super().nextId() - logger.error("Unexpected status on InsertUSBPage {status.value}, nextID is {next}") + value = self.status.value if self.status else "(no status supplied)" + logger.debug(f"Unexpected status on InsertUSBPage: {value}. nextID is {next}") return next @@ -354,7 +344,7 @@ def on_status_received(self, status: ExportStatus) -> None: self.update_content(status) self.status = status - def update_content(self, status: ExportStatus, should_show_hint: bool = False): + def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None: header = None body = None if status == ExportStatus.SUCCESS_EXPORT: @@ -380,7 +370,7 @@ class PassphraseWizardPage(ExportWizardPage): Wizard page that includes a passphrase prompt field """ - def __init__(self, export): + def __init__(self, export: Export) -> None: header = _("Enter passphrase for USB drive") super().__init__(export, header, body=None) @@ -389,7 +379,7 @@ def _build_layout(self) -> QVBoxLayout: # Passphrase Form self.passphrase_form = QWidget() - self.passphrase_form.setObjectName("ModalDialog_passphrase_form") + self.passphrase_form.setObjectName("QWizard_passphrase_form") passphrase_form_layout = QVBoxLayout() passphrase_form_layout.setContentsMargins( self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN @@ -430,10 +420,13 @@ def on_status_received(self, status: ExportStatus) -> None: self.update_content(status, should_show_hint) self.status = status self.completeChanged.emit() - if status in (ExportStatus.SUCCESS_EXPORT, ExportStatus.ERROR_EXPORT_CLEANUP): + if status in ( + ExportStatus.SUCCESS_EXPORT, + ExportStatus.ERROR_EXPORT_CLEANUP, + ) and isinstance(self.wizard().currentPage(), PassphraseWizardPage): self.wizard().next() - def validatePage(self): + def validatePage(self) -> bool: # Also to add: DEVICE_BUSY for unmounting. # This shouldn't stop us from going "back" to an error page return self.status in ( @@ -442,7 +435,7 @@ def validatePage(self): ExportStatus.ERROR_EXPORT_CLEANUP, ) - def nextId(self): + def nextId(self) -> int: if self.status == ExportStatus.SUCCESS_EXPORT: return Pages.EXPORT_DONE elif self.status in (ExportStatus.ERROR_UNLOCK_LUKS, ExportStatus.ERROR_UNLOCK_GENERIC): diff --git a/client/securedrop_client/gui/conversation/export/dialog.css b/client/securedrop_client/gui/conversation/export/wizard.css similarity index 78% rename from client/securedrop_client/gui/conversation/export/dialog.css rename to client/securedrop_client/gui/conversation/export/wizard.css index 1814ed903..958cf0229 100644 --- a/client/securedrop_client/gui/conversation/export/dialog.css +++ b/client/securedrop_client/gui/conversation/export/wizard.css @@ -1,4 +1,4 @@ -#ModalDialog { +#QWizard_export { min-width: 800px; max-width: 800px; min-height: 300px; @@ -6,7 +6,7 @@ background-color: #fff; } -#ModalDialog_header_icon, #ModalDialog_header_spinner { +#QWizard_header_icon, #QWizard_header_spinner { min-width: 80px; max-width: 80px; min-height: 64px; @@ -14,7 +14,7 @@ margin: 0px 0px 0px 30px; } -#ModalDialog_header { +#QWizard_header { min-height: 68px; max-height: 68px; margin: 0; @@ -24,7 +24,7 @@ color: #2a319d; } -#ModalDialog_header_line { +#QWizard_header_line { margin: 0; min-height: 2px; max-height: 2px; @@ -32,7 +32,7 @@ border: none; } -#ModalDialog_body { +#QWizard_body { font-family: 'Montserrat'; font-size: 16px; color: #302aa3; @@ -40,7 +40,7 @@ padding: 0; } -#ModalDialogConfirmation { +#QWizardConfirmation { font-family: 'Montserrat'; font-size: 16px; font-weight: 600; @@ -48,15 +48,8 @@ margin: 0; } -#ModalDialog.dangerous #ModalDialogConfirmation { - color: #ff3366; -} - -#ModalDialog_button_box { - border: 1px solid #ff0000; -} -#ModalDialog_button_box QPushButton { +#QWizard_button_box QWizardButton { margin: 0px 0px 0px 12px; height: 40px; margin: 0; @@ -69,7 +62,7 @@ color: #2a319d; } -#ModalDialog_button_box QPushButton::disabled { +#QWizard_button_box QWizardButton::disabled { border: 2px solid rgba(42, 49, 157, 0.4); color: rgba(42, 49, 157, 0.4); } diff --git a/client/securedrop_client/gui/conversation/export/wizard_button.css b/client/securedrop_client/gui/conversation/export/wizard_button.css new file mode 100644 index 000000000..2ccf29214 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/wizard_button.css @@ -0,0 +1,47 @@ +#QWizardButton_PrimaryButton { + background-color: #2a319d; + color: #f1f1f6; + border: 2px solid #2a319d; + font-family: 'Montserrat'; + font-weight: 500; + margin: 0px; + font-size: 15px; + padding: 11px 18px; + max-height: 40px; + min-height: 40px; +} + +#QWizardButton_PrimaryButton:hover { + background-color: #05a6fe; + border: 2px solid #05a6fe; +} + +#QWizardButton_PrimaryButton:disabled { + border: 2px solid rgba(42, 49, 157, 0.4); + background-color: 2px solid rgba(42, 49, 157, 0.4); + color: #c2c4e3; +} + +#QWizardButton_GenericButton { + background-color: #f1f1f6; + color: #2a319d; + border: 2px solid #2a319d; + margin: 0; + font-family: 'Montserrat'; + font-weight: 500; + font-size: 15px; + padding: 11px 18px; + max-height: 40px; + min-height: 40px; +} + +#QWizardButton_GenericButton:hover { + color: #05a6fe; + border: 2px solid #05a6fe; +} + +#QWizardButton_GenericButton:disabled { + border: 2px solid #c2c4e3; + background-color: #c2c4e3; + color: #e1e2f1; +} diff --git a/client/securedrop_client/gui/conversation/export/dialog_message.css b/client/securedrop_client/gui/conversation/export/wizard_message.css similarity index 76% rename from client/securedrop_client/gui/conversation/export/dialog_message.css rename to client/securedrop_client/gui/conversation/export/wizard_message.css index 20415fe9b..fa0aa8d35 100644 --- a/client/securedrop_client/gui/conversation/export/dialog_message.css +++ b/client/securedrop_client/gui/conversation/export/wizard_message.css @@ -1,11 +1,11 @@ -#ModalDialog_error_details { +#QWizard_error_details { margin: 0px 40px 0px 36px; font-family: 'Montserrat'; font-size: 16px; color: #ff0064; } -#ModalDialog_error_details_active { +#QWizard_error_details_active { margin: 0px 40px 0px 36px; font-family: 'Montserrat'; font-size: 16px; diff --git a/client/tests/conftest.py b/client/tests/conftest.py index 8607ce826..b161d25fe 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -4,8 +4,8 @@ import tempfile from configparser import ConfigParser from datetime import datetime -from uuid import uuid4 from unittest import mock +from uuid import uuid4 import pytest from PyQt5.QtCore import Qt @@ -180,15 +180,13 @@ def mock_export_locked(): device = conversation.ExportDevice() device.run_export_preflight_checks = lambda: device.export_state_changed.emit( - ExportStatus.DEVICE_LOCKED + 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_WRITABLE - ), + lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.DEVICE_LOCKED), lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.SUCCESS_EXPORT), ] @@ -218,14 +216,14 @@ def mock_export_unlocked(): @pytest.fixture(scope="function") -def mock_export_no_usb_then_bad_passphrase_then_fail(): +def mock_export_no_usb_then_bad_passphrase(): """ Represents the following scenario: * Export wizard launched * Locked USB inserted * Mistyped Passphrase * Correct passphrase - * Export fails + * Export succeeds """ device = conversation.ExportDevice() @@ -243,7 +241,7 @@ def mock_export_no_usb_then_bad_passphrase_then_fail(): lambda filepaths, passphrase: device.export_state_changed.emit( ExportStatus.DEVICE_WRITABLE ), - lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.ERROR_EXPORT), + lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.SUCCESS_EXPORT), ] return device diff --git a/client/tests/functional/test_export_file_wizard.py b/client/tests/functional/test_export_file_wizard.py index f36d27593..75b009a85 100644 --- a/client/tests/functional/test_export_file_wizard.py +++ b/client/tests/functional/test_export_file_wizard.py @@ -7,6 +7,13 @@ import pytest from PyQt5.QtCore import Qt +from securedrop_client.gui.conversation.export.export_wizard_page import ( + ErrorPage, + FinalPage, + InsertUSBPage, + PassphraseWizardPage, + PreflightPage, +) from securedrop_client.gui.widgets import FileWidget, SourceConversationWrapper from tests.conftest import ( TIME_CLICK_ACTION, @@ -16,13 +23,6 @@ TIME_RENDER_SOURCE_LIST, ) -from securedrop_client.gui.conversation.export.export_wizard_page import ( - PreflightPage, - InsertUSBPage, - PassphraseWizardPage, - FinalPage, -) - def _setup_export(functional_test_logged_in_context, qtbot, mocker, mock_export): """ @@ -129,13 +129,13 @@ def check_password_page(): qtbot.wait(TIME_CLICK_ACTION) assert isinstance( - export_wizard.currentPage, FinalPage + export_wizard.currentPage(), FinalPage ), f"Actual: {export_wizard.currentPage()} ({export_wizard.currentId()})" @pytest.mark.vcr() def test_export_wizard_dialog_device_already_unlocked( - functional_test_logged_in_context, qtbot, mocker, mock_export_no_usb_then_locked + functional_test_logged_in_context, qtbot, mocker, mock_export_unlocked ): """ Download a file, export it, and verify that the export is complete. @@ -146,7 +146,7 @@ def test_export_wizard_dialog_device_already_unlocked( * Export success """ export_wizard = _setup_export( - functional_test_logged_in_context, qtbot, mocker, mock_export_no_usb_then_locked + functional_test_logged_in_context, qtbot, mocker, mock_export_unlocked ) assert isinstance( @@ -174,11 +174,11 @@ def check_insert_usb_page(): @pytest.mark.vcr() -def test_export_wizard_no_device_then_fail( +def test_export_wizard_no_device_then_bad_passphrase( functional_test_logged_in_context, qtbot, mocker, - mock_export_no_usb_then_bad_passphrase_then_fail, + mock_export_no_usb_then_bad_passphrase, ): """ Download a file, attempt export, encounter error that terminates the wizard early. @@ -189,14 +189,13 @@ def test_export_wizard_no_device_then_fail( * Insert USB * Enter passphrase incorrectly * Re-enter passphrase - * Export fails (eg USB is full) - * Wizard should still advance to final page, but show error state + * Export succeeds """ export_wizard = _setup_export( functional_test_logged_in_context, qtbot, mocker, - mock_export_no_usb_then_bad_passphrase_then_fail, + mock_export_no_usb_then_bad_passphrase, ) assert isinstance( @@ -250,7 +249,7 @@ def check_password_page_with_error_details(): export_wizard.currentPage().passphrase_field, "correct passwords unlock swimmingly!" ) - def error_mount_page(): + def final_page(): """ After an incorrect password, the 'error details' should be visible with a message about incorrect passphrase. @@ -258,15 +257,54 @@ def error_mount_page(): assert isinstance( export_wizard.currentPage(), FinalPage ), f"Actual: {export_wizard.currentPage()} ({export_wizard.currentId()})" - assert export_wizard.currentPage().header.text() == "Export failed" - qtbot.mouseClick(export_wizard.currentPage().passphrase_field, Qt.LeftButton) + qtbot.mouseClick(export_wizard.next_button, Qt.LeftButton) qtbot.wait(TIME_CLICK_ACTION) - qtbot.waitUntil(error_mount_page, timeout=TIME_CLICK_ACTION) + qtbot.waitUntil(final_page, timeout=TIME_CLICK_ACTION) + + assert isinstance( + export_wizard.currentPage, FinalPage + ), f"Actual: {export_wizard.currentPage()} ({export_wizard.currentId()})" + +@pytest.mark.vcr() +def test_export_wizard_error( + functional_test_logged_in_context, qtbot, mocker, mock_export_fail_early +): + """ + Represents the following scenario: + * Locked USB inserted + * Export wizard launched + * Unrecoverable error before export happens + (eg, mount error) + """ + export_wizard = _setup_export( + functional_test_logged_in_context, qtbot, mocker, mock_export_fail_early + ) + + assert isinstance( + export_wizard.currentPage(), PreflightPage + ), f"Actual: {export_wizard.currentPage()} ({export_wizard.currentId()})" + + def check_password_page(): + assert isinstance( + export_wizard.currentPage(), PassphraseWizardPage + ), f"Actual: {export_wizard.currentPage()} ({export_wizard.currentId()})" + + # Move to "insert usb" screen + qtbot.mouseClick(export_wizard.next_button, Qt.LeftButton) + qtbot.wait(TIME_CLICK_ACTION) + qtbot.waitUntil(check_password_page, timeout=TIME_CLICK_ACTION) + + def check_error_page(): + assert isinstance( + export_wizard.currentPage(), ErrorPage + ), f"Actual: {export_wizard.currentPage()} ({export_wizard.currentId()})" + + # Continue exporting the file qtbot.mouseClick(export_wizard.next_button, Qt.LeftButton) qtbot.wait(TIME_CLICK_ACTION) assert isinstance( - export_wizard.currentPage, FinalPage + export_wizard.currentPage(), ErrorPage ), f"Actual: {export_wizard.currentPage()} ({export_wizard.currentId()})" diff --git a/client/tests/gui/conversation/export/test_device.py b/client/tests/test_export.py similarity index 73% rename from client/tests/gui/conversation/export/test_device.py rename to client/tests/test_export.py index 5313630b3..0244f2bdb 100644 --- a/client/tests/gui/conversation/export/test_device.py +++ b/client/tests/test_export.py @@ -4,8 +4,8 @@ from unittest import mock import pytest - from PyQt5.QtTest import QSignalSpy + from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation.export import Export from tests import factory @@ -25,6 +25,19 @@ ) _MOCK_FILEDIR = "/tmp/mock_tmpdir/" +# A few different status values to be used in test paramaterization +_SAMPLE_EXPORT = [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_WRITABLE, + ExportStatus.ERROR_MOUNT, + ExportStatus.ERROR_MISSING_FILES, + ExportStatus.SUCCESS_EXPORT, +] +_SAMPLE_PRINT_PREFLIGHT_FAIL = [ + ExportStatus.ERROR_PRINTER_NOT_FOUND, + ExportStatus.ERROR_PRINTER_DRIVER_UNAVAILABLE, +] + class TestDevice: @classmethod @@ -48,20 +61,19 @@ def teardown_method(cls): cls.device._create_archive = None def test_Device_run_printer_preflight_checks(self): - device = Export() - device._create_archive = mock.MagicMock() - device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE - with mock.patch( "securedrop_client.export.mkdtemp", return_value=self.mock_tmpdir, - ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch.object( + self.device, "_create_archive" + ) as mock_archive: + mock_archive.return_value = _PATH_TO_PRETEND_ARCHIVE mock_qproc = mock_qprocess.return_value mock_qproc.start = mock.MagicMock() mock_qproc.readAllStandardError.return_value = ( ExportStatus.PRINT_PREFLIGHT_SUCCESS.value.encode("utf-8") ) - device.run_printer_preflight_checks() + self.device.run_printer_preflight_checks() mock_qproc.start.assert_called_once() assert ( @@ -69,24 +81,34 @@ def test_Device_run_printer_preflight_checks(self): ), f"Actual: {mock_qproc.start.call_args[0]}" def test_Device_run_print_preflight_checks_with_error(self): - spy = QSignalSpy(self.device.export_state_changed) + spy = QSignalSpy(self.device.print_preflight_check_failed) with mock.patch( "securedrop_client.export.mkdtemp", return_value=self.mock_tmpdir, - ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch.object( + self.device, "_create_archive" + ) as mock_archive, mock.patch( + "shutil.rmtree" + ) as mock_rmtree: + mock_archive.return_value = _PATH_TO_PRETEND_ARCHIVE mock_qproc = mock_qprocess.return_value mock_qproc.start = mock.MagicMock() mock_qproc.start.side_effect = ( - lambda proc, args: mock_qproc.finished.emit() + lambda proc, args: self.device._on_print_preflight_complete() ) # This ain't doin it - mock_qproc.readAllStandardError.return_value = b"Not a real status\n" + mock_qproc.readAllStandardError.data.return_value = b"Not a real status\n" self.device.run_printer_preflight_checks() mock_qproc.start.assert_called_once() + mock_rmtree.assert_called_once() - # TODO - # assert len(spy) == 1 and spy[0] == ExportStatus.UNEXPECTED_RETURN_STATUS + # Note: in future can return UNEXPECTED_RETURN_STATUS instead + assert ( + len(spy) == 1 + and isinstance(spy[0][0], ExportError) + and spy[0][0].status == ExportStatus.ERROR_PRINT + ) def test_Device_print(self): with mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch( @@ -95,10 +117,11 @@ def test_Device_print(self): ): mock_qproc = mock_qprocess.return_value mock_qproc.start = mock.MagicMock() + self.device.print([self.mock_file_location]) - mock_qprocess.start.assert_called_once() - assert mock_qprocess.start.call_args[0] == _QREXEC_EXPORT_COMMAND + mock_qproc.start.assert_called_once() + assert mock_qproc.start.call_args[0] == _QREXEC_EXPORT_COMMAND self.device._create_archive.assert_called_once_with( archive_dir=self.mock_tmpdir, @@ -107,9 +130,10 @@ def test_Device_print(self): filepaths=[self.mock_file_location], ) - def test_Device_print_file_file_missing(self, mocker): + @mock.patch("shutil.rmtree") + def test_Device_print_file_file_missing(self, mock_shutil): device = Export() - spy = QSignalSpy(device.export_state_changed) + spy = QSignalSpy(device.print_failed) with mock.patch( "securedrop_client.export.mkdtemp", @@ -121,8 +145,13 @@ def test_Device_print_file_file_missing(self, mocker): device.print("some-missing-file-uuid") mock_qproc.start.assert_not_called() - # TODO - # assert len(spy) == 1 and spy[0] == ExportError(ExportStatus.ERROR_MISSING_FILES) + + # Print doesn't use the new ERROR_MISSING_FILES status yet + assert ( + len(spy) == 1 + and isinstance(spy[0][0], ExportError) + and spy[0][0].status == ExportStatus.ERROR_PRINT + ) def test_Device_run_export_preflight_checks(self): with mock.patch( @@ -133,20 +162,18 @@ def test_Device_run_export_preflight_checks(self): mock_qproc.start = mock.MagicMock() self.device.run_export_preflight_checks() - # mock_qproc.start.call_args[0] - # '/usr/bin/qrexec-client-vm', ['--', 'sd-devices', 'qubes.OpenInVM', '/usr/lib/qubes/qopen-in-vm', '--view-only', '--', '/tmp/archive-pretend'] mock_qproc.start.assert_called_once() assert mock_qproc.start.call_args[0] == _QREXEC_EXPORT_COMMAND - # Call args: call(archive_dir=, archive_fn='usb-test.sd-export', metadata={'device': 'usb-test'}) self.device._create_archive.assert_called_once_with( archive_dir=self.mock_tmpdir, archive_fn=self.device._USB_TEST_FN, metadata=self.device._USB_TEST_METADATA, ) - def test_Device_run_export_preflight_checks_with_error(self): + @mock.patch("shutil.rmtree") + def test_Device_run_export_preflight_checks_with_error(self, mock_shutil): spy = QSignalSpy(self.device.export_state_changed) with mock.patch( @@ -154,7 +181,10 @@ def test_Device_run_export_preflight_checks_with_error(self): return_value=self.mock_tmpdir, ), mock.patch.object(self.device, "_create_archive"), mock.patch( "securedrop_client.export.QProcess" - ) as mock_qprocess: + ) as mock_qprocess, mock.patch.object( + self.device, "_create_archive" + ) as mock_archive: + mock_archive.return_value = _PATH_TO_PRETEND_ARCHIVE mock_qproc = mock_qprocess.return_value mock_qproc.start = mock.MagicMock() mock_qproc.start.side_effect = ( @@ -165,7 +195,7 @@ def test_Device_run_export_preflight_checks_with_error(self): self.device.run_export_preflight_checks() - assert len(spy) == 1 and spy[0] == ExportStatus.UNEXPECTED_RETURN_STATUS + assert len(spy) == 1 and spy[0][0] == ExportStatus.UNEXPECTED_RETURN_STATUS def test_Device_export_file_missing(self, mocker): device = Export() @@ -212,27 +242,53 @@ def test_Device_export(self): filepaths=[filepath], ) - @pytest.mark.parametrize("status", [i.value for i in ExportStatus]) - def test__run_qrexec_success(self, status): + @pytest.mark.parametrize("status", [i.value for i in _SAMPLE_EXPORT]) + def test__run_qrexec_sends_export_signal(self, status): + spy = QSignalSpy(self.device.export_state_changed) enum = ExportStatus(status) - with mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch.object( - self.device, "_on_export_process_complete" - ) as mock_callback: + with mock.patch("securedrop_client.export.QProcess") as mock_qprocess: mock_qproc = mock_qprocess.return_value mock_qproc.finished = mock.MagicMock() mock_qproc.start = mock.MagicMock() mock_qproc.start.side_effect = ( lambda proc, args: self.device._on_export_process_complete() ) - mock_qproc.readAllStandardError.return_value = f"{status}\n".encode("utf-8") + mock_qproc.readAllStandardError.return_value.data.return_value = f"{status}\n".encode( + "utf-8" + ) self.device._run_qrexec_export( _PATH_TO_PRETEND_ARCHIVE, - mock_callback, + self.device._on_export_process_complete, self.device._on_export_process_error, ) mock_qproc.start.assert_called_once() + assert len(spy) == 1 and spy[0][0] == enum + + @pytest.mark.parametrize("status", [i.value for i in _SAMPLE_PRINT_PREFLIGHT_FAIL]) + def test__run_qrexec_sends_print_failed_signal(self, status): + spy = QSignalSpy(self.device.print_preflight_check_failed) + enum = ExportStatus(status) + with mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.finished = mock.MagicMock() + mock_qproc.start = mock.MagicMock() + mock_qproc.start.side_effect = ( + lambda proc, args: self.device._on_print_preflight_complete() + ) + mock_qproc.readAllStandardError.return_value.data.return_value = f"{status}\n".encode( + "utf-8" + ) + + self.device._run_qrexec_export( + _PATH_TO_PRETEND_ARCHIVE, + self.device._on_print_preflight_complete, + self.device._on_print_prefight_error, + ) + + mock_qproc.start.assert_called_once() + assert len(spy) == 1 and isinstance(spy[0][0], ExportError) and spy[0][0].status == enum @mock.patch("securedrop_client.export.tarfile") def test__add_virtual_file_to_archive(self, mock_tarfile): @@ -300,7 +356,7 @@ def test__tmpdir_cleaned_up_on_exception(self): """ with mock.patch( "securedrop_client.export.mkdtemp", return_value=self.mock_tmpdir - ) as tmpdir, mock.patch("securedrop_client.export.QProcess") as qprocess, mock.patch.object( + ), mock.patch("securedrop_client.export.QProcess") as qprocess, mock.patch.object( self.device, "_cleanup_tmpdir" ) as mock_cleanup: mock_qproc = qprocess.return_value @@ -308,4 +364,4 @@ def test__tmpdir_cleaned_up_on_exception(self): mock_qproc.start = lambda proc, args: self.device._on_export_process_error() self.device.run_printer_preflight_checks() assert self.device.tmpdir == self.mock_tmpdir - mock_cleanup.assert_called() + mock_cleanup.assert_called_once() From 73ea9d24f43164b790d10d8617a803c973fa06bb Mon Sep 17 00:00:00 2001 From: Ro Date: Fri, 9 Feb 2024 16:07:36 -0500 Subject: [PATCH 07/10] Replace cryptsetup dependency with udisks2. --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 95fb5003e..6ad809729 100644 --- a/debian/control +++ b/debian/control @@ -14,7 +14,7 @@ Description: securedrop client for qubes workstation Package: securedrop-export Architecture: all -Depends: ${python3:Depends}, ${misc:Depends}, cryptsetup, cups, printer-driver-brlaser, printer-driver-hpcups, system-config-printer, xpp, libcups2-dev, python3-dev, libtool-bin, unoconv, gnome-disk-utility +Depends: ${python3:Depends}, ${misc:Depends}, udisks2, cups, printer-driver-brlaser, printer-driver-hpcups, system-config-printer, xpp, libcups2-dev, python3-dev, libtool-bin, unoconv, gnome-disk-utility Description: Submission export scripts for SecureDrop Workstation This package provides scripts used by the SecureDrop Qubes Workstation to export submissions from the client to external storage, via the sd-export From 93cd8f408da15b706b87b405460641abdbdbe262 Mon Sep 17 00:00:00 2001 From: Ro Date: Mon, 12 Feb 2024 17:08:01 -0500 Subject: [PATCH 08/10] Add dependency on types-pexpect (mypy). --- export/poetry.lock | 11 +++ export/pyproject.toml | 1 + export/securedrop_export/disk/cli.py | 114 ++++++++++++++++++--------- export/securedrop_export/main.py | 1 - export/tests/disk/test_cli.py | 17 ++-- export/tests/disk/test_service.py | 4 +- 6 files changed, 95 insertions(+), 53 deletions(-) diff --git a/export/poetry.lock b/export/poetry.lock index 2358699fa..6b1f3115e 100644 --- a/export/poetry.lock +++ b/export/poetry.lock @@ -1003,6 +1003,17 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "types-pexpect" +version = "4.9.0.20240207" +description = "Typing stubs for pexpect" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-pexpect-4.9.0.20240207.tar.gz", hash = "sha256:910e20f0f177aeee5f2808d1b3221e3a23dfa1ca3bb02f685c2788fce6ddeb73"}, + {file = "types_pexpect-4.9.0.20240207-py3-none-any.whl", hash = "sha256:22b3fdccf253a8627bac0d3169845743fe0b1dbc87e5d33a438faaf879eb1f7a"}, +] + [[package]] name = "types-setuptools" version = "68.2.0.0" diff --git a/export/pyproject.toml b/export/pyproject.toml index be8b8d976..6d185858e 100644 --- a/export/pyproject.toml +++ b/export/pyproject.toml @@ -19,6 +19,7 @@ pytest = "^7.4.0" pytest-cov = "^4.1.0" pytest-mock = "^3.11.1" semgrep = "^1.31.2" +types-pexpect = "^4.9.0.20240207" [tool.mypy] python_version = "3.9" diff --git a/export/securedrop_export/disk/cli.py b/export/securedrop_export/disk/cli.py index 1148e17b6..9a4f54231 100644 --- a/export/securedrop_export/disk/cli.py +++ b/export/securedrop_export/disk/cli.py @@ -2,10 +2,10 @@ import logging import os import pexpect -import re import subprocess import time +from re import Pattern from typing import Optional, Union from securedrop_export.exceptions import ExportException @@ -22,6 +22,26 @@ "--------------------------------------------------------------------------\n" ) +# pexpect allows for a complex type to be passed to `expect` in order to match with input +# that includes regular expressions, byte or string patterns, *or* pexpect.EOF and pexpect.TIMEOUT, +# but mypy needs a little help with it, so the below alias is used as a typehint. +# See https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect +PexpectList = Union[ + Pattern[str], + Pattern[bytes], + str, + bytes, + type[pexpect.EOF], + type[pexpect.TIMEOUT], + list[ + Union[ + Pattern[str], + Pattern[bytes], + Union[str, bytes, Union[type[pexpect.EOF], type[pexpect.TIMEOUT]]], + ] + ], +] + class CLI: """ @@ -83,7 +103,9 @@ def get_volume(self) -> Union[Volume, MountedVolume]: logger.error("Unrecoverable: could not parse lsblk.") raise ExportException(sdstatus=Status.DEVICE_ERROR) - volumes = [] + # mypy complains that this is a list[str], but it is a + # list[Union[Volume, MountedVolume]] + volumes = [] # type: ignore for device in lsblk_json.get("blockdevices"): if device.get("name") in targets and device.get("ro") is False: logger.debug( @@ -94,21 +116,21 @@ def get_volume(self) -> Union[Volume, MountedVolume]: if "children" in device: for partition in device.get("children"): # /dev/sdX1, /dev/sdX2 etc - item = self._get_supported_volume(partition) + item = self._get_supported_volume(partition) # type: ignore if item: - volumes.append(item) + volumes.append(item) # type: ignore # /dev/sdX else: - item = self._get_supported_volume(device) + item = self._get_supported_volume(device) # type: ignore if item: - volumes.append(item) + volumes.append(item) # type: ignore if len(volumes) != 1: logger.error(f"Need one target, got {len(volumes)}") raise ExportException(sdstatus=Status.INVALID_DEVICE_DETECTED) else: - logger.debug(f"Export target is {volumes[0].device_name}") - return volumes[0] + logger.debug(f"Export target is {volumes[0].device_name}") # type: ignore + return volumes[0] # type: ignore except json.JSONDecodeError as err: logger.error(err) @@ -232,16 +254,24 @@ def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: logger.debug("Unlocking volume {}".format(volume.device_name)) command = f"udisksctl unlock --block-device {volume.device_name}" - prompt = ["Passphrase: ", pexpect.EOF, pexpect.TIMEOUT] + + # pexpect allows for a match list that contains pexpect.EOF and pexpect.TIMEOUT + # as well as string/regex matches: + # https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect + prompt = [ + "Passphrase: ", + pexpect.EOF, + pexpect.TIMEOUT, + ] # type: PexpectList expected = [ - f"Unlocked {volume.device_name} as (.*)\.", + f"Unlocked {volume.device_name} as (.*)[^\r\n].", "GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Device " # string continues - f"{volume.device_name} is already unlocked as (.*)\.", + f"{volume.device_name} is already unlocked as (.*)[^\r\n].", "GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Error " # string continues f"unlocking {volume.device_name}: Failed to activate device: Incorrect passphrase", pexpect.EOF, pexpect.TIMEOUT, - ] + ] # type: PexpectList unlock_error = Status.ERROR_UNLOCK_GENERIC child = pexpect.spawn(command) @@ -254,8 +284,10 @@ def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: child.sendline(encryption_key) index = child.expect(expected) if index == 0 or index == 1: - # We know what format the string is in - dm_name = child.match.group(1).decode("utf-8").strip() + # We know what format the string is in. + # Pexpect includes a re.Match object at `child.match`, but this freaks mypy out: + # see https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect + dm_name = child.match.group(1).decode("utf-8").strip() # type: ignore logger.debug(f"Device is unlocked as {dm_name}") child.close() @@ -294,64 +326,68 @@ def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolum info = f"udisksctl info --block-device {volume.device_name}" # \x1b[37mPreferredDevice:\x1b[0m /dev/sdaX\r\n expected_info = [ - f"*PreferredDevice:[\t+]{volume.device_name}\r\n", - "*Error looking up object for device*", + f"PreferredDevice:[\t+]{volume.device_name}", + "Error looking up object for device", pexpect.EOF, pexpect.TIMEOUT, - ] + ] # type: PexpectList max_retries = 3 - unlock = f"udisksctl mount --block-device {full_unlocked_name}" + mount = f"udisksctl mount --block-device {full_unlocked_name}" # We can't pass {full_unlocked_name} in the match statement since even if we # pass in /dev/mapper/xxx, udisks2 may refer to the disk as /dev/dm-X. - expected_unlock = [ - f"Mounted * at (.*)", - f"Error mounting *: GDBus.Error:org." # string continues - "freedesktop.UDisks2.Error.AlreadyMounted: " # string continues - "Device .* is already mounted at `(.*)'", - f"Error looking up object for device *.", + expected_mount = [ + "Mounted .* at (.*)", + "Error mounting .*: GDBus.Error:org.freedesktop.UDisks2.Error.AlreadyMounted: " + "Device (.*) is already mounted at `(.*)'.", + "Error looking up object for device", pexpect.EOF, pexpect.TIMEOUT, - ] + ] # type: PexpectList mountpoint = None - logger.debug(f"Check to make sure udisks identified {volume.device_name} " - "(unlocked as {full_unlocked_name})") + logger.debug( + f"Check to make sure udisks identified {volume.device_name} " + f"(unlocked as {full_unlocked_name})" + ) for _ in range(max_retries): child = pexpect.spawn(info) index = child.expect(expected_info) - logger.debug(f"Results from udisks info: {volume.device_name}, " - "before: {child.before}, after: {child.after}") + logger.debug( + f"Results from udisks info: {volume.device_name}, " + f"before: {child.before}, after: {child.after}" + ) child.close() if index != 0: - logger.debug(f"index {index}") - logger.warning( + logger.debug( f"udisks can't identify {volume.device_name}, retrying..." ) time.sleep(0.5) else: - print(f"udisks found {volume.device_name}") + logger.debug(f"udisks found {volume.device_name}") break logger.info(f"Mount {full_unlocked_name} using udisksctl") - child = pexpect.spawn(unlock) - index = child.expect(expected_unlock) + child = pexpect.spawn(mount) + index = child.expect(expected_mount) logger.debug( f"child: {str(child.match)}, before: {child.before}, after: {child.after}" ) if index == 0: - # As above, we know the format - mountpoint = child.match.group(1).decode("utf-8").strip() + # As above, we know the format. + # Per https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect, + # `child.match` is a re.Match object + mountpoint = child.match.group(1).decode("utf-8").strip() # type: ignore logger.debug(f"Successfully mounted device at {mountpoint}") elif index == 1: - # Mountpoint needs a bit of help. It arrives in the form `/path/to/mountpoint'. - # including the one backtick, single quote, and the period - mountpoint = child.match.group(1).decode("utf-8").strip() + # Use udisks unlocked name + full_unlocked_name = child.match.group(1).decode("utf-8").strip() # type: ignore + mountpoint = child.match.group(2).decode("utf-8").strip() # type: ignore logger.debug(f"Device already mounted at {mountpoint}") elif index == 2: diff --git a/export/securedrop_export/main.py b/export/securedrop_export/main.py index 4535a1dc7..d04787c3b 100755 --- a/export/securedrop_export/main.py +++ b/export/securedrop_export/main.py @@ -5,7 +5,6 @@ import platform import logging import sys -from typing import Optional from securedrop_export.archive import Archive, Metadata from securedrop_export.command import Command diff --git a/export/tests/disk/test_cli.py b/export/tests/disk/test_cli.py index 2723d5f36..9b6b3cb77 100644 --- a/export/tests/disk/test_cli.py +++ b/export/tests/disk/test_cli.py @@ -1,9 +1,7 @@ import pytest -from pexpect import ExceptionPexpect from unittest import mock import subprocess -import pexpect import re from securedrop_export.disk.cli import CLI @@ -410,7 +408,6 @@ def test_cleanup_error_reports_exporterror_if_flagged(self, mock_popen): self.cli.cleanup(mock_volume, submission.tmpdir, is_error=True) assert ex.value.sdstatus is Status.ERROR_EXPORT - @mock.patch("os.path.exists", return_value=False) @mock.patch("subprocess.check_call", return_value=0) def test_cleanup(self, mock_subprocess, mocked_path): @@ -444,14 +441,14 @@ def test_cleanup(self, mock_subprocess, mocked_path): def test_parse_correct_mountpoint_from_pexpect(self, mock_pexpect): child = mock_pexpect() child.expect.return_value = 1 - child.match.return_value = re.match( - r"`(\w+)'\.\r\n".encode("utf-8"), - "Error mounting /dev/dm-1: GDBus.Error:org." - "freedesktop.UDisks2.Error.AlreadyMounted: " - "Device /dev/sda1 is already mounted at `/dev/dm-0'.\r\n".encode("utf-8"), - ) + child.match = mock.MagicMock() + child.match.group.side_effect = [ + "/dev/dm-0".encode("utf-8"), + "/media/usb".encode("utf-8"), + ] mv = self.cli._mount_volume( - Volume("/dev/sda1", EncryptionScheme.VERACRYPT), "/dev/dm-1" + Volume("/dev/sda1", EncryptionScheme.VERACRYPT), "/dev/mapper/vc" ) assert mv.unlocked_name == "/dev/dm-0" + assert mv.mountpoint == "/media/usb" diff --git a/export/tests/disk/test_service.py b/export/tests/disk/test_service.py index 73dc0210a..3b52fe985 100644 --- a/export/tests/disk/test_service.py +++ b/export/tests/disk/test_service.py @@ -59,9 +59,7 @@ def _setup_submission(cls) -> Archive: temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write( - '{"device": "disk", "encryption_key": "hunter1"}' - ) + f.write('{"device": "disk", "encryption_key": "hunter1"}') return submission.set_metadata(Metadata(temp_folder).validate()) From dc32ba2f8cdc4412020eff3679349e11596e037a Mon Sep 17 00:00:00 2001 From: Ro Date: Tue, 13 Feb 2024 11:52:00 -0500 Subject: [PATCH 09/10] Rewrite ExportWizard integration, unit, and functional tests. Include small bugfixes in cli (export). Extract localization strings. Simplify wizard control flow. Use pexpect list mode for arguments --- client/securedrop_client/export.py | 10 +- client/securedrop_client/export_status.py | 3 +- client/securedrop_client/gui/actions.py | 11 +- .../gui/conversation/__init__.py | 3 +- .../gui/conversation/export/__init__.py | 1 - .../gui/conversation/export/export_wizard.py | 123 +- .../export/export_wizard_constants.py | 11 +- .../conversation/export/export_wizard_page.py | 280 +-- .../gui/conversation/export/print_dialog.py | 12 +- .../gui/conversation/export/wizard.css | 12 +- .../gui/conversation/export/wizard_button.css | 4 +- .../conversation/export/wizard_message.css | 2 +- client/securedrop_client/gui/widgets.py | 12 +- client/securedrop_client/locale/messages.pot | 62 +- client/tests/conftest.py | 90 +- .../cassettes/test_export_file_dialog.yaml | 1685 ----------------- ...xport_wizard_device_already_unlocked.yaml} | 0 .../cassettes/test_export_wizard_error.yaml | 1517 +++++++++++++++ ...wizard_no_device_then_bad_passphrase.yaml} | 0 ...t_file_wizard.py => test_export_wizard.py} | 137 +- .../conversation/export/test_export_wizard.py | 33 +- .../conversation/export/test_print_dialog.py | 40 +- client/tests/gui/test_actions.py | 24 - client/tests/gui/test_widgets.py | 14 +- client/tests/integration/conftest.py | 36 +- .../tests/integration/test_styles_sdclient.py | 129 +- client/tests/test_export.py | 2 +- export/securedrop_export/archive.py | 4 +- export/securedrop_export/disk/cli.py | 155 +- export/securedrop_export/disk/service.py | 5 +- export/securedrop_export/disk/status.py | 8 +- export/securedrop_export/main.py | 59 +- export/securedrop_export/print/service.py | 15 +- export/securedrop_export/print/status.py | 2 +- export/tests/disk/test_cli.py | 40 +- export/tests/disk/test_service.py | 10 +- export/tests/print/test_service.py | 19 +- export/tests/test_archive.py | 9 +- export/tests/test_directory.py | 7 +- export/tests/test_exceptions.py | 5 +- export/tests/test_main.py | 21 +- 41 files changed, 2287 insertions(+), 2325 deletions(-) delete mode 100644 client/tests/functional/cassettes/test_export_file_dialog.yaml rename client/tests/functional/cassettes/{test_export_wizard_dialog_device_already_unlocked.yaml => test_export_wizard_device_already_unlocked.yaml} (100%) create mode 100644 client/tests/functional/cassettes/test_export_wizard_error.yaml rename client/tests/functional/cassettes/{test_export_wizard_no_device_then_fail.yaml => test_export_wizard_no_device_then_bad_passphrase.yaml} (100%) rename client/tests/functional/{test_export_file_wizard.py => test_export_wizard.py} (74%) diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 3dcc9e7ba..1a768bcf4 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -193,8 +193,9 @@ def _cleanup_tmpdir(self) -> None: def _on_export_process_complete(self) -> None: """ - Callback, handle and emit QProcess result. As with all such callbacks, - the method signature cannot change. + Callback, handle and emit results from QProcess. Information + can be read from stdout/err. This callback will be triggered + if the QProcess exits with return code 0. """ self._cleanup_tmpdir() # securedrop-export writes status to stderr @@ -270,8 +271,6 @@ def _on_print_success(self) -> None: self._cleanup_tmpdir() logger.debug("Print success") self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) - # TODO: Previously emitted [filepaths] - self.export_completed.emit([]) def end_process(self) -> None: """ @@ -305,6 +304,7 @@ def print(self, filepaths: List[str]) -> None: logger.debug("Beginning print") self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) archive_path = self._create_archive( archive_dir=self.tmpdir, archive_fn=self._PRINT_FN, @@ -327,8 +327,6 @@ def print(self, filepaths: List[str]) -> None: logger.error("Print failed while creating archive (no status supplied)") self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) - self.export_completed.emit(filepaths) - def _create_archive( self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] ) -> str: diff --git a/client/securedrop_client/export_status.py b/client/securedrop_client/export_status.py index da475c3fa..65a0c43e0 100644 --- a/client/securedrop_client/export_status.py +++ b/client/securedrop_client/export_status.py @@ -31,8 +31,9 @@ class ExportStatus(Enum): SUCCESS_EXPORT = "SUCCESS_EXPORT" ERROR_EXPORT = "ERROR_EXPORT" # Could not write to disk - # Export succeeds but drives were not properly unmounted + # Export succeeds but drives were not properly closed ERROR_EXPORT_CLEANUP = "ERROR_EXPORT_CLEANUP" + ERROR_UNMOUNT_VOLUME_BUSY = "ERROR_UNMOUNT_VOLUME_BUSY" DEVICE_ERROR = "DEVICE_ERROR" # Something went wrong while trying to check the device diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index ff189f086..1fdd66003 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -10,13 +10,13 @@ from typing import Callable, Optional from PyQt5.QtCore import Qt, pyqtSlot -from PyQt5.QtWidgets import QAction, QDialog, QMenu +from PyQt5.QtWidgets import QAction, QApplication, QDialog, QMenu from securedrop_client import state from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source +from securedrop_client.export import Export from securedrop_client.gui.base import ModalDialog -from securedrop_client.gui.conversation import ExportDevice from securedrop_client.gui.conversation import ( PrintTranscriptDialog as PrintConversationTranscriptDialog, ) @@ -184,7 +184,7 @@ 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() + export = Export() dialog = PrintConversationTranscriptDialog( export, TRANSCRIPT_FILENAME, [str(file_path)] ) @@ -234,7 +234,7 @@ 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() + export_device = Export() wizard = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) wizard.exec() @@ -320,7 +320,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() + export_device = Export() files = [ stack.enter_context(open(file_location, "r")) for file_location in file_locations ] @@ -335,6 +335,7 @@ def _prepare_to_export(self) -> None: export_device, summary, [str(file_location) for file_location in file_locations], + QApplication.activeWindow(), ) wizard.exec() diff --git a/client/securedrop_client/gui/conversation/__init__.py b/client/securedrop_client/gui/conversation/__init__.py index 219c00465..c9db19eec 100644 --- a/client/securedrop_client/gui/conversation/__init__.py +++ b/client/securedrop_client/gui/conversation/__init__.py @@ -3,7 +3,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 ExportWizard as ExportWizard # noqa: F401 -from .export import PrintDialog as PrintFileDialog # noqa: F401 +from .export import PrintDialog # 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 328c19e43..29f7a78c2 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,4 +1,3 @@ -from ....export import Export # 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_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index eda3ac72c..2043a5bd8 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -1,12 +1,12 @@ import logging from gettext import gettext as _ -from typing import List +from typing import List, Optional from pkg_resources import resource_string from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtGui import QIcon, QKeyEvent from PyQt5.QtWidgets import QAbstractButton # noqa: F401 -from PyQt5.QtWidgets import QApplication, QWizard, QWizardPage +from PyQt5.QtWidgets import QApplication, QWidget, QWizard, QWizardPage from securedrop_client.export import Export from securedrop_client.export_status import ExportStatus @@ -41,15 +41,26 @@ class ExportWizard(QWizard): # it's populated later. PASS_PLACEHOLDER_FIELD = "" - def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> None: - parent = QApplication.activeWindow() + def __init__( + self, + export: Export, + summary_text: str, + filepaths: List[str], + parent: Optional[QWidget] = None, + ) -> None: + # Normally, the active window is the right parent, but if the wizard is launched + # via another element (a modal dialog, such as the "Some files may not be exported" + # modal), the parent will be the modal dialog and the wizard layout will be affected. + # In those cases we want to be able to specify a different parent. + if not parent: + 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] + self.current_status: Optional[ExportStatus] = None # Signal from qrexec command runner self.export.export_state_changed.connect(self.on_status_received) @@ -58,17 +69,6 @@ def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> N # (Avoid orphaned QProcess) self.finished.connect(self.export.end_process) - # Activestate animation - self.button_animation = load_movie("activestate-wide.gif") - self.button_animation.setScaledSize(QSize(32, 32)) - self.button_animation.frameChanged.connect(self._animate_activestate) - - # Buttons - self.next_button = self.button(QWizard.WizardButton.NextButton) # type: QAbstractButton - self.cancel_button = self.button(QWizard.WizardButton.CancelButton) # type: QAbstractButton - self.back_button = self.button(QWizard.WizardButton.BackButton) # type: QAbstractButton - self.finish_button = self.button(QWizard.WizardButton.FinishButton) # type: QAbstractButton - self._style_buttons() self._set_layout() self._set_pages() @@ -93,26 +93,42 @@ def _style_buttons(self) -> None: Style QWizard buttons and connect "Next" button click event to request_export slot. """ + # Activestate animation + self.button_animation = load_movie("activestate-wide.gif") + self.button_animation.setScaledSize(QSize(32, 32)) + self.button_animation.frameChanged.connect(self._animate_activestate) + + # Buttons + self.next_button = self.button(QWizard.WizardButton.NextButton) # type: QAbstractButton + self.cancel_button = self.button(QWizard.WizardButton.CancelButton) # type: QAbstractButton + self.back_button = self.button(QWizard.WizardButton.BackButton) # type: QAbstractButton + self.finish_button = self.button(QWizard.WizardButton.FinishButton) # type: QAbstractButton + self.next_button.setObjectName("QWizardButton_PrimaryButton") self.next_button.setStyleSheet(self.BUTTON_CSS) - self.next_button.setMinimumSize(QSize(130, 40)) + self.next_button.setMinimumSize(QSize(142, 40)) self.next_button.setMaximumHeight(40) + self.next_button.setIconSize(QSize(21, 21)) self.next_button.clicked.connect(self.request_export) + self.next_button.setFixedSize(QSize(142, 40)) self.cancel_button.setObjectName("QWizardButton_GenericButton") self.cancel_button.setStyleSheet(self.BUTTON_CSS) - self.cancel_button.setMinimumSize(QSize(130, 40)) + self.cancel_button.setMinimumSize(QSize(142, 40)) self.cancel_button.setMaximumHeight(40) + self.cancel_button.setFixedSize(QSize(142, 40)) self.back_button.setObjectName("QWizardButton_GenericButton") self.back_button.setStyleSheet(self.BUTTON_CSS) - self.back_button.setMinimumSize(QSize(130, 40)) + self.back_button.setMinimumSize(QSize(142, 40)) self.back_button.setMaximumHeight(40) + self.back_button.setFixedSize(QSize(142, 40)) self.finish_button.setObjectName("QWizardButton_GenericButton") self.finish_button.setStyleSheet(self.BUTTON_CSS) - self.finish_button.setMinimumSize(QSize(130, 40)) + self.finish_button.setMinimumSize(QSize(142, 40)) self.finish_button.setMaximumHeight(40) + self.finish_button.setFixedSize(QSize(142, 40)) self.setButtonText(QWizard.WizardButton.NextButton, _("CONTINUE")) self.setButtonText(QWizard.WizardButton.CancelButton, _("CANCEL")) @@ -130,7 +146,8 @@ def _stop_animate_activestate(self) -> None: self.button_animation.stop() def _set_layout(self) -> None: - self.setWindowTitle(f"Export {self.summary_text}") # TODO (il8n) + title = ("Export %(summary)s") % {"summary": self.summary_text} + self.setWindowTitle(title) self.setObjectName("QWizard_export") self.setStyleSheet(self.WIZARD_CSS) self.setModal(False) @@ -183,79 +200,19 @@ def request_export(self) -> None: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: """ - Receive status updates from export process. The QWizard is responsible for - listening for a status that requires it to adjust its own position outside of the - normal wizard control flow, such as jumping to an error page if an unrecoverable error - status is encountered, or "rewinding" to a previous page if an unexpected status is - encountered (USB device removed after proceeding past that part of the workflow). - + Receive status update from export process in order to update the animation. Child QWizardPages also implement this listener in order to update their own UI and store a reference to the current status. - Advancing through the normal QWizard control flow is handled by child pages. + Adjusting the QWizard control flow based on ExportStatus is handled by each child page. """ - logger.debug(f"Wizard received {status.value}. Current page is {type(self.currentPage())}") - # Release the page (page was held during "next" button click event) page = self.currentPage() if isinstance(page, ExportWizardPage): page.set_complete(True) self._stop_animate_activestate() - - # 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 and self.currentId() > target: - self.rewind(target) - - # Update status self.current_status = status - 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() - - def end_wizard_with_error(self, error: ExportStatus) -> None: - """ - If an end state is reached, display message and let user - end the wizard. - """ - logger.debug("End wizard with error") - if isinstance(self.currentPage(), PreflightPage): - logger.debug("On preflight page, no reordering needed") - else: - while self.currentId() > Pages.ERROR: - self.back() - logger.debug(f"Target: {Pages.ERROR}. Actual: {self.currentId()}") - page = self.currentPage() - if isinstance(page, ExportWizardPage): - page.update_content(error) - def _create_preflight(self) -> QWizardPage: return PreflightPage(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 ce8c0f3af..546563d50 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -25,17 +25,24 @@ class Pages(IntEnum): # 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.MULTI_DEVICE_DETECTED: _( + "Too many USB devices detected; please insert one supported device." + ), ExportStatus.INVALID_DEVICE_DETECTED: _( "Either the drive is not encrypted or there is something else wrong with it." + "If this is a VeraCrypt drive, please unlock it from within `sd-devices`, then try again." ), 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_UNMOUNT_VOLUME_BUSY: _( + "Files were exported succesfully, but the USB device could not be unmounted." + ), ExportStatus.ERROR_EXPORT_CLEANUP: _( - "Files were exported succesfully, but the drive could not be unmounted" + "Files were exported succesfully, but some temporary files remain on disk." + "Reboot to remove them." ), ExportStatus.SUCCESS_EXPORT: _("Export successful"), ExportStatus.DEVICE_ERROR: _( 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 712f2e40d..983bbd551 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -6,7 +6,6 @@ from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtGui import QColor, QFont, QPixmap from PyQt5.QtWidgets import ( - QApplication, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, @@ -56,13 +55,22 @@ class ExportWizardPage(QWizardPage): NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 + # All pages should show the error page if these errors are encountered + UNRECOVERABLE_ERRORS = [ + ExportStatus.ERROR_MOUNT, + ExportStatus.ERROR_EXPORT, + ExportStatus.ERROR_MISSING_FILES, + ExportStatus.DEVICE_ERROR, + ExportStatus.CALLED_PROCESS_ERROR, + ExportStatus.UNEXPECTED_RETURN_STATUS, + ] + def __init__(self, export: Export, header: str, body: Optional[str]) -> None: - parent = QApplication.activeWindow() - super().__init__(parent) + super().__init__() self.export = export self.header_text = header self.body_text = body - self.status = None # Optional[ExportStatus] + self.status: Optional[ExportStatus] = None self._is_complete = True # Won't override parent method unless explicitly set to False self.setLayout(self._build_layout()) @@ -85,6 +93,7 @@ def _build_layout(self) -> QVBoxLayout: """ Create parent layout, draw elements, return parent layout """ + self.setObjectName("QWizard_export_page") self.setStyleSheet(self.WIZARD_CSS) parent_layout = QVBoxLayout(self) parent_layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) @@ -93,7 +102,10 @@ def _build_layout(self) -> QVBoxLayout: header_container = QWidget() header_container_layout = QHBoxLayout() header_container.setLayout(header_container_layout) - self.header_icon = SvgLabel("blank.svg", svg_size=QSize(64, 64)) + header_container.setContentsMargins( + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) + self.header_icon = SvgLabel("savetodisk.svg", svg_size=QSize(64, 64)) self.header_icon.setObjectName("QWizard_header_icon") self.header_spinner = QPixmap() self.header_spinner_label = QLabel() @@ -103,9 +115,9 @@ def _build_layout(self) -> QVBoxLayout: self.header_spinner_label.setPixmap(self.header_spinner) self.header = QLabel() self.header.setObjectName("QWizard_header") - header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter) 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("QWizard_header_line") @@ -170,10 +182,38 @@ def stop_animate_header(self) -> None: def on_status_received(self, status: ExportStatus) -> None: raise NotImplementedError("Children must implement") + def nextId(self) -> int: + """ + Override builtin QWizardPage nextId() method to create custom control flow. + """ + if self.status is not None: + if self.status in ( + ExportStatus.DEVICE_WRITABLE, + ExportStatus.SUCCESS_EXPORT, + ExportStatus.ERROR_UNMOUNT_VOLUME_BUSY, + ExportStatus.ERROR_EXPORT_CLEANUP, + ): + return Pages.EXPORT_DONE + elif self.status in ( + ExportStatus.DEVICE_LOCKED, + 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 self.UNRECOVERABLE_ERRORS: + return Pages.ERROR + + return super().nextId() + 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") @@ -190,6 +230,7 @@ def update_content(self, status: ExportStatus, should_show_hint: bool = False) - class PreflightPage(ExportWizardPage): def __init__(self, export: Export, summary: str) -> None: + self._should_autoskip_preflight = False self.summary = summary header = _( "Preparing to export:
" '{}' @@ -212,36 +253,57 @@ def __init__(self, export: Export, summary: str) -> None: super().__init__(export, header=header, body=body) self.start_animate_header() - self.export.run_export_preflight_checks() - def nextId(self) -> int: + # Don't need preflight check every time, just when the wizard is initialized + if self.status is None: + self.set_complete(False) + self.completeChanged.emit() + self.export.run_export_preflight_checks() + + def set_should_autoskip_preflight(self, should_autoskip: bool) -> None: """ - Override builtin to allow bypassing the password page if device is unlocked. + Provide setter for auto-advancing wizard past the Preflight page. + If True, as soon as a Status is available, the wizard will advance + to the appropriate page. """ - 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 - 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 Pages.INSERT_USB + self._should_autoskip_preflight = should_autoskip + + def should_autoskip_preflight(self) -> bool: + """ + Return True if Preflight page should be advanced automatically as soon as + a given status is available. + + This workaround exists to let users skip past the preflight page if they are + returned to it from a later page. This is required because in PyQt5, + QWizard cannot navigate to a specific page, meaning users who insert an + unlocked drive, then start the wizard, then encounter a problem are sent + "back" to this page rather than to the InsertUSBPage, since it wasn't in + their call stack. + + The autoskip combined with custom nextId logic in ExporWizardPage allows us + to emulate the desired behaviour. + """ + return self._should_autoskip_preflight @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: + self.status = status self.stop_animate_header() header = _("Ready to export:
" '{}').format( self.summary ) self.header.setText(header) - self.status = status + self.set_complete(True) + self.completeChanged.emit() + + if self.wizard() and isinstance(self.wizard().currentPage(), PreflightPage): + # Let users skip preflight screen if they have already seen it. The first time a status + # is received, autoskip is False, and a user has to manually click "Continue"; + # after that, it's True. + if self.should_autoskip_preflight(): + self.wizard().next() + else: + self.set_should_autoskip_preflight(True) class ErrorPage(ExportWizardPage): @@ -250,15 +312,21 @@ def __init__(self, export: Export): super().__init__(export, header=header, body=None) def isComplete(self) -> bool: + """ + Override isComplete() to always return False. This disables + the 'next' button on the error page and means users can + only go back to a previous page or exit the wizard. + """ return False @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - pass + self.status = status class InsertUSBPage(ExportWizardPage): def __init__(self, export: Export, summary: str) -> None: + self.no_device_hint = 0 self.summary = summary header = _("Ready to export:
" '{}').format( summary @@ -271,63 +339,37 @@ def __init__(self, export: Export, summary: str) -> None: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - logger.debug(f"InsertUSB received {status.value}") - should_show_hint = status in ( - ExportStatus.MULTI_DEVICE_DETECTED, - ExportStatus.INVALID_DEVICE_DETECTED, - ) or ( - self.status == status == ExportStatus.NO_DEVICE_DETECTED - and isinstance(self.wizard().currentPage, InsertUSBPage) - ) - self.update_content(status, should_show_hint) self.status = status - self.completeChanged.emit() - if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE) and isinstance( - self.wizard().currentPage(), InsertUSBPage - ): - logger.debug("Device detected - advance the wizard") - 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 ( + if self.wizard() and isinstance(self.wizard().currentPage(), InsertUSBPage): + logger.debug(f"InsertUSB received {status.value}") + if status in ( ExportStatus.MULTI_DEVICE_DETECTED, - ExportStatus.NO_DEVICE_DETECTED, ExportStatus.INVALID_DEVICE_DETECTED, + ExportStatus.DEVICE_WRITABLE, ): - self.update_content(self.status, should_show_hint=True) - return False + self.update_content(status, should_show_hint=True) + elif status == ExportStatus.NO_DEVICE_DETECTED: + if self.no_device_hint > 0: + self.update_content(status, should_show_hint=True) + self.no_device_hint += 1 else: - # Status may be None here - logger.warning("InsertUSBPage encountered unexpected status") - return super().validatePage() + # Hide the error hint, it visible, so that if the user navigates + # forward then back they don't see an unneeded hint + self.error_details.hide() + self.wizard().next() - def nextId(self) -> int: + def validatePage(self) -> bool: """ - Override builtin to allow bypassing the password page if device unlocked + Implement custom validation logic. """ - 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 + if self.status is not None: + return self.status not in ( + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ) else: - next = super().nextId() - value = self.status.value if self.status else "(no status supplied)" - logger.debug(f"Unexpected status on InsertUSBPage: {value}. nextID is {next}") - return next + return super().isComplete() class FinalPage(ExportWizardPage): @@ -340,9 +382,13 @@ def __init__(self, export: Export) -> None: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - logger.debug(f"Final page received status {status}") - self.update_content(status) self.status = status + self.update_content(status) + + # The completeChanged signal alerts the page to recheck its completion status, + # which we need to signal since we have custom isComplete() logic + if self.wizard() and isinstance(self.wizard().currentPage(), FinalPage): + self.completeChanged.emit() def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None: header = None @@ -353,9 +399,9 @@ def update_content(self, status: ExportStatus, should_show_hint: bool = False) - "Remember to be careful when working with files " "outside of your Workstation machine." ) - elif status == ExportStatus.ERROR_EXPORT_CLEANUP: - header = header = _("Export sucessful, but drive was not locked") - body = STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP) + elif status in (ExportStatus.ERROR_EXPORT_CLEANUP, ExportStatus.ERROR_UNMOUNT_VOLUME_BUSY): + header = _("Export sucessful, but drive was not locked") + body = STATUS_MESSAGES.get(status) else: header = _("Working...") @@ -364,6 +410,27 @@ def update_content(self, status: ExportStatus, should_show_hint: bool = False) - if body: self.body.setText(body) + def isComplete(self) -> bool: + """ + Override the default isComplete() implementation in order to disable the "Finish" + button while an export is taking place. (If the "Working...." header is being shown, + the export is still in progress and "Finish" should not be clickable.) + """ + if self.status: + return self.status not in ( + ExportStatus.DEVICE_WRITABLE, + ExportStatus.DEVICE_LOCKED, + ) + else: + return True + + def nextId(self) -> int: + """ + The final page should not have any custom nextId() logic. + Disable it to ensure the Finished button ("Done") is shown. + """ + return -1 + class PassphraseWizardPage(ExportWizardPage): """ @@ -390,6 +457,7 @@ def _build_layout(self) -> QVBoxLayout: font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING) passphrase_label.setFont(font) self.passphrase_field = PasswordEdit(self) + self.passphrase_form.setObjectName("QWizard_passphrase_form") self.passphrase_field.setEchoMode(QLineEdit.Password) effect = QGraphicsDropShadowEffect(self) effect.setOffset(0, -1) @@ -412,46 +480,20 @@ def _build_layout(self) -> QVBoxLayout: @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: - 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 - self.completeChanged.emit() - if status in ( - ExportStatus.SUCCESS_EXPORT, - ExportStatus.ERROR_EXPORT_CLEANUP, - ) and isinstance(self.wizard().currentPage(), PassphraseWizardPage): - self.wizard().next() + if self.wizard() and isinstance(self.wizard().currentPage(), PassphraseWizardPage): + logger.debug(f"Passphrase page received {status.value}") + if status in ( + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.ERROR_UNLOCK_GENERIC, + ): + self.update_content(status, should_show_hint=True) + else: + self.wizard().next() def validatePage(self) -> bool: - # 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, + return self.status not in ( + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.ERROR_UNLOCK_GENERIC, + ExportStatus.DEVICE_LOCKED, ) - - def nextId(self) -> int: - 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() diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index 40eaa7c88..11ab07c53 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -29,6 +29,10 @@ def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None ) self._device.print_preflight_check_failed.connect(self._on_print_preflight_check_failed) + # For now, connect both success and error signals to close the print dialog. + self._device.print_succeeded.connect(self._on_print_complete) + self._device.print_failed.connect(self._on_print_complete) + # Connect parent signals to slots self.continue_button.setEnabled(False) self.continue_button.clicked.connect(self._run_preflight) @@ -95,9 +99,15 @@ def _run_preflight(self) -> None: @pyqtSlot() def _print_file(self) -> None: self._device.print(self.filepaths) - self.close() @pyqtSlot() + def _on_print_complete(self) -> None: + """ + Send a signal to close the print dialog. + """ + self.close() + + @pyqtSlot(object) def _on_print_preflight_check_succeeded(self, status: ExportStatus) -> None: # We don't use the ExportStatus for now for "success" status, # but in future work we will migrate towards a wizard-style dialog, where diff --git a/client/securedrop_client/gui/conversation/export/wizard.css b/client/securedrop_client/gui/conversation/export/wizard.css index 958cf0229..580ded204 100644 --- a/client/securedrop_client/gui/conversation/export/wizard.css +++ b/client/securedrop_client/gui/conversation/export/wizard.css @@ -1,9 +1,13 @@ #QWizard_export { min-width: 800px; max-width: 800px; - min-height: 300px; + min-height: 500px; max-height: 800px; - background-color: #fff; + background: #ffffff; +} + +#QWizard_export_page { + background: #ffffff; } #QWizard_header_icon, #QWizard_header_spinner { @@ -67,7 +71,7 @@ color: rgba(42, 49, 157, 0.4); } -#FileDialog_passphrase_form QLabel { +#QWizard_passphrase_form QLabel { font-family: 'Montserrat'; font-weight: 500; font-size: 12px; @@ -75,7 +79,7 @@ padding-top: 6px; } -#FileDialog_passphrase_form QLineEdit { +#QWizard_passphrase_form QLineEdit { border-radius: 0px; min-height: 30px; max-height: 30px; diff --git a/client/securedrop_client/gui/conversation/export/wizard_button.css b/client/securedrop_client/gui/conversation/export/wizard_button.css index 2ccf29214..5925e5412 100644 --- a/client/securedrop_client/gui/conversation/export/wizard_button.css +++ b/client/securedrop_client/gui/conversation/export/wizard_button.css @@ -1,6 +1,6 @@ #QWizardButton_PrimaryButton { background-color: #2a319d; - color: #f1f1f6; + color: #ffffff; border: 2px solid #2a319d; font-family: 'Montserrat'; font-weight: 500; @@ -23,7 +23,7 @@ } #QWizardButton_GenericButton { - background-color: #f1f1f6; + background-color: #ffffff; color: #2a319d; border: 2px solid #2a319d; margin: 0; diff --git a/client/securedrop_client/gui/conversation/export/wizard_message.css b/client/securedrop_client/gui/conversation/export/wizard_message.css index fa0aa8d35..e3bf33ef3 100644 --- a/client/securedrop_client/gui/conversation/export/wizard_message.css +++ b/client/securedrop_client/gui/conversation/export/wizard_message.css @@ -5,7 +5,7 @@ color: #ff0064; } -#QWizard_error_details_active { +#QWizard_error_details:active { margin: 0px 40px 0px 36px; font-family: 'Montserrat'; font-size: 16px; diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 19b2f789c..714fef467 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -70,6 +70,7 @@ Source, User, ) +from securedrop_client.export import Export from securedrop_client.gui import conversation from securedrop_client.gui.actions import ( DeleteConversationAction, @@ -81,7 +82,6 @@ ) 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 @@ -2460,9 +2460,11 @@ def _on_export_clicked(self) -> None: logger.debug("Clicked export but file not downloaded") return - export_device = conversation.ExportDevice() + export_device = Export() - self.export_wizard = ExportWizard(export_device, self.file.filename, [file_location]) + self.export_wizard = conversation.ExportWizard( + export_device, self.file.filename, [file_location] + ) self.export_wizard.show() @pyqtSlot() @@ -2476,9 +2478,9 @@ def _on_print_clicked(self) -> None: filepath = self.file.location(self.controller.data_dir) - export_device = conversation.ExportDevice() + export_device = Export() - dialog = conversation.PrintFileDialog(export_device, self.file.filename, [filepath]) + dialog = conversation.PrintDialog(export_device, self.file.filename, [filepath]) dialog.exec() def _on_left_click(self) -> None: diff --git a/client/securedrop_client/locale/messages.pot b/client/securedrop_client/locale/messages.pot index 2710d75bb..d7d99c378 100644 --- a/client/securedrop_client/locale/messages.pot +++ b/client/securedrop_client/locale/messages.pot @@ -270,49 +270,82 @@ msgid_plural "{message_count} messages" msgstr[0] "" msgstr[1] "" -msgid "SUBMIT" +msgid "DONE" msgstr "" -msgid "Preparing to export:
{}" +msgid "BACK" msgstr "" -msgid "Ready to export:
{}" +msgid "Export {}" msgstr "" -msgid "Insert encrypted USB drive" +msgid "No device detected" msgstr "" -msgid "Enter passphrase for USB drive" +msgid "Too many USB devices detected; please insert one supported device." +msgstr "" + +msgid "Either the drive is not encrypted or there is something else wrong with it.If this is a VeraCrypt drive, please unlock it from within `sd-devices`, then try again." +msgstr "" + +msgid "The device is ready for export." +msgstr "" + +msgid "The device is locked." +msgstr "" + +msgid "The passphrase provided did not work. Please try again." +msgstr "" + +msgid "Error mounting drive" +msgstr "" + +msgid "Error during export" +msgstr "" + +msgid "Files were exported succesfully, but the USB device could not be unmounted." +msgstr "" + +msgid "Files were exported succesfully, but some temporary files remain on disk.Reboot to remove them." msgstr "" msgid "Export successful" msgstr "" -msgid "Export failed" +msgid "Error encountered with this device. See your administrator for help." msgstr "" -msgid "

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." +msgid "Files were moved or missing and could not be exported." msgstr "" -msgid "Exporting: {}" +msgid "Error encountered. Please contact support." msgstr "" -msgid "Please insert one of the export drives provisioned specifically for the SecureDrop Workstation." +msgid "Preparing to export:
{}" +msgstr "" + +msgid "

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." msgstr "" -msgid "Either the drive is not encrypted or there is something else wrong with it." +msgid "Ready to export:
{}" msgstr "" -msgid "The passphrase provided did not work. Please try again." +msgid "Export Failed" msgstr "" -msgid "See your administrator for help." +msgid "Please insert one of the export drives provisioned specifically for the SecureDrop Workstation." msgstr "" msgid "Remember to be careful when working with files outside of your Workstation machine." msgstr "" -msgid "DONE" +msgid "Export sucessful, but drive was not locked" +msgstr "" + +msgid "Working..." +msgstr "" + +msgid "Enter passphrase for USB drive" msgstr "" msgid "Preparing to print:
{}" @@ -333,6 +366,9 @@ msgstr "" msgid "Please connect your printer to a USB port." msgstr "" +msgid "See your administrator for help." +msgstr "" + msgid "YES, DELETE ENTIRE SOURCE ACCOUNT" msgstr "" diff --git a/client/tests/conftest.py b/client/tests/conftest.py index b161d25fe..40bde3b51 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 unittest import mock from uuid import uuid4 import pytest @@ -23,6 +22,7 @@ Source, make_session_maker, ) +from securedrop_client.export import Export from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window @@ -51,6 +51,7 @@ TIME_RENDER_CONV_VIEW = 1000 TIME_RENDER_EXPORT_WIZARD = 1000 TIME_FILE_DOWNLOAD = 5000 +TIME_KEYCLICK_ACTION = 5000 @pytest.fixture(scope="function") @@ -78,9 +79,9 @@ def lang(request): def print_dialog(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) - dialog = conversation.PrintFileDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) + dialog = conversation.PrintDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @@ -89,7 +90,7 @@ def print_dialog(mocker, homedir): def print_transcript_dialog(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) dialog = conversation.PrintTranscriptDialog( export_device, "transcript.txt", ["some/path/transcript.txt"] @@ -102,7 +103,7 @@ def print_transcript_dialog(mocker, homedir): def export_wizard_multifile(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) wizard = conversation.ExportWizard( export_device, @@ -117,7 +118,7 @@ def export_wizard_multifile(mocker, homedir): def export_wizard(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) dialog = conversation.ExportWizard(export_device, "file123.jpg", ["/mock/path/to/file"]) @@ -128,7 +129,7 @@ def export_wizard(mocker, homedir): def export_transcript_wizard(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) dialog = conversation.ExportWizard( export_device, "transcript.txt", ["/some/path/transcript.txt"] @@ -173,22 +174,29 @@ def homedir(i18n): def mock_export_locked(): """ Represents the following scenario: - * Locked USB already inserted - * "Export" clicked, export wizard launched + * No USB + * Export wizard launched + * USB inserted * Passphrase successfully entered on first attempt (and export suceeeds) """ - device = conversation.ExportDevice() + device = Export() + status = iter( + [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_LOCKED, + ExportStatus.SUCCESS_EXPORT, + ] + ) + + def get_status() -> ExportStatus: + return next(status) 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.SUCCESS_EXPORT), - ] + device.export = lambda filepaths, passphrase: device.export_state_changed.emit(get_status()) return device @@ -201,7 +209,7 @@ def mock_export_unlocked(): * Export wizard launched * Export succeeds """ - device = conversation.ExportDevice() + device = Export() device.run_export_preflight_checks = lambda: device.export_state_changed.emit( ExportStatus.DEVICE_WRITABLE @@ -220,29 +228,30 @@ def mock_export_no_usb_then_bad_passphrase(): """ Represents the following scenario: * Export wizard launched - * Locked USB inserted + * Locked USB detected * Mistyped Passphrase * Correct passphrase * Export succeeds """ - device = conversation.ExportDevice() + device = Export() + status = iter( + [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_LOCKED, + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.SUCCESS_EXPORT, + ] + ) + + def get_status() -> ExportStatus: + return next(status) 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.SUCCESS_EXPORT), - ] + device.export = lambda filepaths, passphrase: device.export_state_changed.emit(get_status()) return device @@ -251,22 +260,31 @@ def mock_export_no_usb_then_bad_passphrase(): def mock_export_fail_early(): """ Represents the following scenario: - * Locked USB inserted + * No USB inserted * Export wizard launched + * Locked USB inserted * Unrecoverable error before export happens (eg, mount error) """ - device = conversation.ExportDevice() + device = Export() + # why does it need an extra ERROR_MOUNT report? + status = iter( + [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_LOCKED, + ExportStatus.ERROR_MOUNT, + ] + ) + + def get_status() -> ExportStatus: + return next(status) device.run_export_preflight_checks = lambda: device.export_state_changed.emit( - ExportStatus.DEVICE_LOCKED + ExportStatus.NO_DEVICE_DETECTED ) device.run_printer_preflight_checks = lambda: None device.print = lambda filepaths: None - device.export = mock.MagicMock() - device.export = lambda filepaths, passphrase: device.export_state_changed.emit( - ExportStatus.ERROR_MOUNT - ) + device.export = lambda filepaths, passphrase: device.export_state_changed.emit(get_status()) return device diff --git a/client/tests/functional/cassettes/test_export_file_dialog.yaml b/client/tests/functional/cassettes/test_export_file_dialog.yaml deleted file mode 100644 index 9b62d31d1..000000000 --- a/client/tests/functional/cassettes/test_export_file_dialog.yaml +++ /dev/null @@ -1,1685 +0,0 @@ -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.26.0 - method: POST - uri: http://localhost:8081/api/v1/token - response: - body: - string: "{\n \"expiration\": \"2022-01-20T07:09:09.326384Z\", \n \"journalist_first_name\": - null, \n \"journalist_last_name\": null, \n \"journalist_uuid\": \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"token\": \"eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo\"\n}\n" - headers: - Content-Length: - - '317' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/users - response: - body: - string: "{\n \"users\": [\n {\n \"first_name\": null, \n \"last_name\": - null, \n \"username\": \"journalist\", \n \"uuid\": \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n - \ }, \n {\n \"first_name\": null, \n \"last_name\": null, \n - \ \"username\": \"dellsberg\", \n \"uuid\": \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ }\n ]\n}\n" - headers: - Content-Length: - - '324' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources - response: - body: - string: "{\n \"sources\": [\n {\n \"add_star_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"consistent synonym\", \n \"key\": - {\n \"fingerprint\": \"04EAA26CE5C74286E78299ADA6122A68D47035C3\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEADL8YaMOqcq70cdpry7h52gS+aPmIYnC2PStdwCojU0ntOI0B21\\nGQvOHmxgcwMvXfSqBBEYNIC3r3IRUouQgl3oOvf7+RK5GqDgnV3lcrm9wDKBE7he\\ncqBPfZ+5AcOcqubAYXUCSznMGoMIxbCtQWaOpiqGU2ruSpwlq4jukzdVXvo4Zb/L\\nHn89r7TJc4Udg3lz36gxp3Jm7aTdGX8VKafLFiuK2LT3lakgurUO87M8DIdULn04\\nMJaujBVxYmbCJnjLg/flhjRUA4PKw9Hdc9vYp/e0k/eueJsB+Xhixc7XCnh9eaZn\\nNOrMz+IHZ5AY77Gopq23cidWPWFj2b/+g9+k6/MUsg9S3tzYOJ+kU1vncZipnsnc\\nW+wJMlu2o6wU5nSPoNUf0JFN+rI/ZTsK3jjADMyIUIN0abXMZ/GeNoH4olsfJcSb\\nM/INzmXIoSAmEd6/gZ8d1dDJsPA9Wd1zBySWiHXzfpihEvSseCdZBYuBE9iSs/x0\\nG83FiOG1x5JtEl8Bc42m74KaeM8QjgujnpYODqYdnWI2VVH66GjOgYDbb72spEe2\\nXobdk8KtABq0yEav26ZmS0/Wqd4RD67mRbp0FRpblt5Bl4qb2fFy0jZeFQ8M0Msy\\nfF4YWDDgpkPSp0wINLrSWCDR9VkWTmIKW7F70aP/KjD1RN8421PesKKggwARAQAB\\ntHVTb3VyY2UgS2V5IDxDMjVZQkdOQVIzR05FNlRDWFBUM040VkVON01HRDNZUVA2\\nRVNHM1lIUkVEM1I2VzM3VEtMQkpKSjVIVEpZVkFNU1FDVlJRRE9KWEs2R1ZVRExD\\nSDIyNkdMSFU1TjZWS08zNUFDSzdZPT6JAk4EEwEKADgWIQQE6qJs5cdChueCma2m\\nEipo1HA1wwUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCmEipo\\n1HA1w7iLEAClnTccq87JEHCp9mJ0mT7BHPGakRNzzvyZj8xgW+jaIdFH3lF+x3vE\\nWoJzvUP3js+Cne/hd/+I1fWBMcEERajWPUSXC+pqEBsOdAWrJ4xi0zI32ofEuFGc\\noTVoXLhJnrzDZM1TqK58nwZZxjwL1XzuLtvkAz+utkbI7rnNXRQMzoR3LazUjz9+\\nArPFjaiDjxAsF90VELvBjKmC1tYSNrr/XEwl6yTXBagf2VchVLUE+Y/0ozTFv+Cz\\nLeiQh+EqE8xhKkuELLkNUjx6Z2oVK91MVrCTLvnxsNGyoSLyH7CWZeFodCQYF3k7\\nF/zGe9/KE6/n6uZ8EdjI50Rd/h99cYDbHt8ljDeqhu59V2xqzb+sTWpl7WliiVx3\\nbrboXxIFWuidXYJFlaXy3X342dTwqVDVE3rW+T0r77ZMO3MPMRrtbyjSL5+yqWuw\\nS/BLuhorFgNdxP/uMKIz89xAp2diQ+6USAOoEIaWkOk+f45s2bXyjS0EzmeowYRG\\n6IwgqLqopx2w8Mx8o2/3NkC0RfehkF0ideMHZpTXW2WAjApJcnXDDxDfwhr/xSwh\\nzS0dgD4dsdpRWoocv3zXnSv5L9JetZGYM0/CnxG8SjZ48zStjpsenKz8X0vDJAai\\nSlXnUn6TGzHZxuPyNegZ4hwLW4YlMkktJAZRLWZNW8BYQZGc03Z2DQ==\\n=lJ7v\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:08:49.528506Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/remove_star\", - \n \"replies_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies\", - \n \"submissions_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions\", - \n \"url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"uuid\": \"56d6777c-fdb6-474c-9d3b-0b7b43beabfa\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"concrete limerick\", \n \"key\": - {\n \"fingerprint\": \"CA8A176B4D5D3666ED88B03BC5E9954B1492AE1F\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEACtbh8mDuBbRxk7YGntX40e41q3r6mLgGmV5p26GZi3b/fAPoWA\\nJjo/Np5uBI+Ye/MZNZBl22aGIh3iamNXpywjrro1xCFryAhdFMj4eKuarekVbsNV\\nj0K5AWH2gomzJ27f+9+rkn+R5gtvRqeMA0tVu7pQQ7gw/n/1XIJ4X0M7oRHPWNAX\\nOvAJe/60jKTAiwNdgwE2a5aOTXrtXz20Je7bBq6TtKAWa9tdB+W2JUNH5IEmnhYA\\ntWw3/GliQHphPizpa4eE1jgF3IJtNf7hPTeJ7S50XXpolfmIaLYohWDuVi4LFVGC\\n2GGzasNefQJIoQXkK2UmYhhck0T4U5zwfl5RkuftOjGvHDa4U7bSRz3rl3MCzmGc\\nlvA028aMRrYg4nBu0ryVlVjAV93n8FTKasURjsyLVBfb+Fzxu1ebbG8rakvHbAbk\\nK25ZP+mNyu2QZ0WsM6j3C7afvAJDR0Mkj0KWBjc5JHMUtqupPwpK/8eswlecx7Yx\\ngLAwqkmYvFUiKjKAbUYbaOe4YJEUj4h/nxayXE2XhptLlL8m4oopflANRsqc00+F\\npQqcznyL0a89JKBmBaT8xPPK+GOtrs0EU9mz2IhAB4HxEKuVFuwOg7AIFLO6gRN/\\nLbqJvLz1IO3yM10O7gCb8ErPxrnByBkP417YWddnx9pPw0vPgPXy2lbo6QARAQAB\\ntHVTb3VyY2UgS2V5IDxPM1hKVUg2TkNaWEEzSlpOUlpSRlM2RlRaQURTUzNNVk5F\\nVFlNU0lRWjVZSDNUTDc2WFk3VjNQRTZSSkVINDRKMjZXM1pZMlVJNU9KMk00V0VG\\nWE1aRVdJWlBHS0NKN0VLTVRCQjJBPT6JAk4EEwEKADgWIQTKihdrTV02Zu2IsDvF\\n6ZVLFJKuHwUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRDF6ZVL\\nFJKuH04ID/9Xl2jbyBsu+JHS3fsMDSZE0L39HhqbRKqrUxq5U9vb3aWU3Imf3Tu3\\nez8Sp/aThXOJKuC9QeJ2gCIe9+V+OGVYvUl67P3xxKzIUmlLlk5cbosC9m/J4MMZ\\ndmSok8XBgOWYWuNbcCNiW0msfDijJH1diH6tDc9UEzcTvTbWHqbl3S27uwVced3O\\n8OAY0MGcB6Tw1yRBbv7fJ4nWKeu3kmzrepRQYh9cEMEf+pDE0RUkoORiQI5vmtzL\\nv454PfNGGuGijMQm64tYqe33fwMR0marbLyYXTSMlzEDF5AxeaKjDVI4kEe6eUT4\\n8kLsvRl2nPX1gbrBSkHSZ21/oMkhdlGhPyb4xKcqCVkzpQJpCsATmAkjtp/IHJib\\n2mu6TzhAIvANP5jqiGE128lZpPBILq3PIrhXqVDyLWpl6xTSHz7rhxVXtDHJZoIz\\n4QJM7Dl9V0s/UQ5hJdmx5L0aEP+7b46+3kvgbPvItaRiF11L7fRQwXMNoI8bm47T\\nbfW5nJK8p6O5VssHtFYqL9rKYBDdk6JYsiZ8xvTrqTRMK1xJEsuF3Tuv73JmMQtF\\n7wQq8rZg0cbINpJuOBRsvEAo6ATJBq+HOCAuqvhJ3Kx9lixLnURP4dybKJoTdWJP\\nSDgLwly7bulTF+fHQSlD9cypaLiw4cyzFubhw4OWEJYMAsYcbfBqYA==\\n=i+xf\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:08:51.571224Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/remove_star\", - \n \"replies_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies\", - \n \"submissions_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions\", - \n \"url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"uuid\": \"ae59153b-0871-411a-a72a-0f4c41a76ee0\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"indecorous creamery\", \n \"key\": - {\n \"fingerprint\": \"04DD6C14755616B9F944F87311961223C70DEA58\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEADEMD/A2IVlAmhB3Vu3jDlG3UFli/e20GXvfeW6S0PFEuvE9Po9\\nCjI43sFdMVvRUvtaIP5PE1zU4OuN1gi6jpKp5puulnddV6jP0GXqK+hqVXjiaf58\\nhUkuvpK2CaHf/5DvGdSW2IZLB9oP/UtWYTBUm7dER2Fc+rMY13fUMEsGKyJZ9wB3\\ny4CrJpMw7TNTefVx6vrlbCVEB4nksod+A7wteLILbeGj26D1A94vH1V4iLdOObW3\\npbTX4Yra1CpxclEsHyaS7tZ+4bQOmh0OdVG7ZW4MZPYp+1BIqt+e48042Rq1jIHu\\nHVApvHynPDt2tD/KiymDM3Bt69Dy9rHrWEFlWAS+Fpgo7qBQ9QF2fHWzpHQyhcTB\\nM3zQ2LraeOrBWgzjgCRIei+sga6w9Tjk8fMZKLl7HPkjRZxOFU4GJLjkxf3Lw1Av\\nCo3kQijDzj0nN/qyebcD2/v6vz5/5D8iS85fJdgLwds7ajXXgk9/M11Bkze1RT+2\\nYCmsUW999wF+AZmeR6ZFdUfcOpJE/99zs6GIRIo+ikPWiMcs4/7jAlrierrAtuhH\\nl6luFRIz6utBFWIleZosxnx3ZqRAv1DUdig3BnIliD3Y53y0cHbFFLOX428ZGKCp\\nJ9Of22l5XfMlT6B8NCJgRcQc77beedl+1XcQP64X+FgddottileDhsiRNQARAQAB\\ntHVTb3VyY2UgS2V5IDxHSlFBVTRBVUlOQVNBRk81R1I3N0NMNUpDWFRMV0FXWjJU\\nV1BKSExDSTRJWFpNRVFKWVlIRFVUWUFNWjJOVDJLUDdPN09GSlRRSkdTSFJJT1dP\\nM05aS0VGRU01QklHTkNWVkFFT0xRPT6JAk4EEwEKADgWIQQE3WwUdVYWuflE+HMR\\nlhIjxw3qWAUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRARlhIj\\nxw3qWEDyEACKKS0y7ApY7CMGuuU6BltrUyc7A5UcCe6vnCREX4662qkHgaDLmIpa\\nb5t+hvtOicEwegoFsBAnjnG+Vs+AU1DDzXREojZ0T39Hyq0PYS7HbDWJRUSfl//Z\\na566rtbdzv1GEc7hMAEi9pKplR3uEQlQAp1G6W1Yzf5WuwmdWMOactzbENJTnbc1\\nSBe/oKbH56UEMX7KLr5MODQ6IM+VCqRI/k6Px065q8scAeEQERwUFdy33BBzk+g7\\n/uYPC74NnfISP6Tj94oFEySs1HC2hIaZlUQor3ZJOzvZ3Vm8hix7JdjBVdqdHFmx\\n5+Ft211Va6v1dKCUW73GPvYkv0bt4CeAV9fhyQOSMSENTiNVVh8L2+dCXVQXhFUw\\n3Hmu/tOj+r2B8+vWWHuhbFjgeAiXFkHFDT1a3xZ98n5g5SNwoiBJDuyWjPgr0vG3\\n/+1wgTovRVbt62H1VgRsP49wMS9EBz1DV1q60GcWD40FNfkJx7W1T0RtUgpKp3hH\\nw06RJFAzeMJtXz89mFpIQfkVwBflne5HDQywIT8o0TnxAh06Q4ROqhFydDSB0HTv\\n6NJVRhiSiwGmYiZi4DVwv7exttrfv6h1TX99MjR1e3kjki/IjeI/pW42GgFUZVN5\\nWRzx2yiSIfz1rhBqnRAtZWConlmG2X3LRbUFtz1LHsbC8UqKdtlB1g==\\n=oWMO\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:08:57.846667Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/remove_star\", - \n \"replies_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies\", - \n \"submissions_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions\", - \n \"url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"uuid\": \"55fb95c1-cff3-430a-8c05-125c67c81a6a\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"conjunctive lavage\", \n \"key\": - {\n \"fingerprint\": \"F71969D1705E2E3E374B95992DA6D8DCEE36946B\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEADcaB1fww19PLIREowYKfNZiVoWpLYxRnw1U/Iz4JbnEJ7TuIlm\\n2Q46Hr9kR7zQVb5okjn40whN0JA3lJcfPZdjfxiCt8VYI7vacUxVZgXWJCR83vO4\\nNSD1YnZD5KXi0B6PGKIhry1Hqc+hzmMAFYGGdi4h5EKxinNmKTO+E3Zupeydm0KK\\nCBwXroROAs/5+s63oj5+nuqlPCTcEL3SGjH8zXIw+TN0mBhQhGlyqofIW/JEaviP\\n+frUL6WPa3AoUBE+TAF1rmXr30phZU271zfAYhe0B81gtrUTSg49uUYQuCf1xu92\\ngbuOmcYTQvdzgGDp8cNWL5cmQCdvoGTGH5PYodqMGcRfWqB1dl37RCsqDcCzssdv\\nJiUe8qC88n0tQl/gJOgniEhKEok5EiaGuuLz9j7waGB1aBgHLPsibDGQVyYn9ZYD\\na3E9cL0BHzsWJc9i1hFE2cmTXzmJ7rTXyvHSvidT6s2cljuih1Q6e5qNOcJPAuv9\\nY2xuZHn+rTaJSLM30X7PngrAP2jfepraz7zy2lE4Uex9dLQNPMcYhjPc9SwKjk8g\\njDkhCGW6daRCpzNUR/ydYGlfN00L6MPo0S3XG/x88f+OwqgfSpgrfSijqDTLxbo1\\nO4rTW+KSiVy2P9DfuLhZv+HcNiinY0EP3qbuuXKk7VSMeCir+HgeDce+pwARAQAB\\ntHVTb3VyY2UgS2V5IDxTV1pWS0hOTlBLQkIzVTJDRjNBMjRIUkJZRlRNNkNDU1U3\\nM05XQlhNTkw2NVFRWktEM1gzRllNTFVDVlBKRlhCRUJWRVkyQklKWVJLUUQ2NUFT\\nS1IzQTNaTVhaMkFOTkI2Sk1CVEJJPT6JAk4EEwEKADgWIQT3GWnRcF4uPjdLlZkt\\nptjc7jaUawUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRAtptjc\\n7jaUawViD/9O2J9EsxOmaSB3XI9q0EyLvOZPh+r9TeOGA4kqQlOH3PeMP1lxQ7v1\\n3LR7OCjM2pBNHww7rRkYNoJX4dA/UjZ1UcerIAbxa2Z4v7X69akKItw889UCW6Go\\ncUtco2XqkjaThsV/io19+6qFicrWAumpFtH2Dt8iVsHzOYWpijPK70AxJqODg+nK\\nv4k/+zqAePbLOCCCuvnhBduJCEd3dA0G7ow0H/AzgpPKOEswbYK6JJYX8Gsq9F3n\\ne+PkBJ3Op4/qUELYQYEBbF2qy+XPfhOZsJ4v/HDb+eutZNmATtpGZGNJznyLFoZX\\nbNI/U2XIlQYBDeYTOVbNPPVwoVucoXG1iGsp+2ZFvLgP4XGRxdH9oyiia9FC+id1\\nwCtS6dRWRKv1VJwVetGBncAdwmugCkQoJ/gGwcTkJLhOVyoZZruTR8aLOE+ArTUg\\nfgKBVpeT9he8ELDZFrPtAnDTpMS+RrVsF8Y1sih7O8VCxsxGRegKlQcxgPp7/MdG\\nwFOlulTqCSu+fZfkid4rvnRGcPRp1DQohwXiK/UpDIRYTPERHQTEm2fK29FzmruI\\nr4zotTaeHhztY5jrqZqzkMy6/teHE5CGq5mKQsXzQFjb5hKEYg4TwAazPRtH3WOo\\nkZ5ISlxKvOdf8jA9hWKFrREtk9t9blD0IA3ffzfO5aad+aZjnqsgyw==\\n=ccYc\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:09:00.294006Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/remove_star\", - \n \"replies_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies\", - \n \"submissions_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions\", - \n \"url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"uuid\": \"50c5fa95-eb69-49b6-8599-62b12cff7d7d\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"sixty-nine alliance\", \n \"key\": - {\n \"fingerprint\": \"7034A99B359CA2DD3F57E251437B6C3C6984302F\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEAC8d/LgDtvyeg/SNsUcUPRY7JZGFbE3peoduYiqd29LW/BXoInn\\ntRV3Ks5H8QLH3/qS/zWwiE4x2yE8cOykWj/lPMlFCDYdWK4f55eS1LcxN+WtLiaL\\ndDQG84KICZznbqTxlvdizLwCvch9Y19dPszPuwrBJ2KbOsngPfHDARs2aU++J1d1\\n7MjIpBLJHTlYKRdutANtxEKCq+KX9/K8GnjZYLhmmecaVr6OoSp3Nq6zlvJe7qPb\\nc1IUJhA1oDyNVBAPs5ROKkM6qhDJmI9OpKoGVGWG7u3kDQ3Oo59wBoC65xTZNFy1\\nGKcQbCcegKsnxdchBO9nMK3wh8H6JUkpdXPrurysHqQ6JIAar0rXIlOvg8kD6yNU\\n7bYK6xetBzkYBGgz7vbgYq+k2ur3nQLvJmBnPVqY/7bjSGDIfbkJWOudD2LaqQUc\\nIUeBpTlOsqfVhXwfen+ynntPdSQU14ILmQAztFzZor2leNWAR6pYG6ZI3vEzAX3l\\nWPzmS7L13VC1w11IG0wdKuzhx1jHGJ32JrNyL4LoJ1O++8GWlJS0+ZC85gwIaFQC\\nLB+sGw4PruxLUGFe2ZLYWgYnN3Iw5JBPxfc+Kxrp1xhHCZNdC1B1ajtkOwvdZbIU\\nOP9Cp5MRt5AeGBZ9ujIMsAxOZrPeN574ewqnY+z431eC6rNFzdmlY1Av8wARAQAB\\ntHVTb3VyY2UgS2V5IDwzSjI1UlA3NlZUWUwzVktMV1haTENOUFI1WUZMQzJQMk9Q\\nRFRETE9IVVFQQkkzN0RZWE1CWkpORFRVSkxSWjNDUlc1RkdXVEJNSDY0UTNBN1BZ\\nTk9KTjY0T09YSFpRRE5STDNRWEVJPT6JAk4EEwEKADgWIQRwNKmbNZyi3T9X4lFD\\ne2w8aYQwLwUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBDe2w8\\naYQwL7osD/9nj9I/89AR2p+MDw+RqGjNrEMnIyCC1+IneGC5MJXLYb/9oz9JRMrb\\n/+Gk+MhPjkgHVbI9BzByIzNh0stYF1T17rJDIyehjfbejYQKFwJd1+QHCfSgLIQQ\\nNOtKKr9iX5fUdPzlLzkdaTRGMidUTqWuY77wgmZoKN1a1Q801NXjIkY3QJ5GpjSf\\ncdvgu77k7y/0juUu0eTeNpd1TXs/GBitETnfDEKcVUkk8x+OwvSFE7VrWJCNAH/x\\nOAQUeT9S7CczoUeFWCII880xFcpdynt+ogYyxVh22RV13HJ/HJlmUA+9cpQ6ntAW\\nXdhKS814mJjqfTk5j2ZzLwKekqQgUSjCB7ucbEPhaHdQHShfuNQg9EhtP2Qy+Ptg\\ntGFMF4f+s9anFobioeYnS9S3JuR73UHD6XOz4GDgGx/3kdlxwRfjOqnRWzC3oNmU\\nVmT2caEmXnjEqL3FP1wVOEcciBqOAgT0QsMB06eOHL+cJxMOE6j/Wo4Y2loF0+Bq\\nR0KMqbg0lpSyLHjTmOo15DgzohSALI44niM1SaVGGlzOawb5zOd8ownvfwcut1wG\\n0UxhwbyoiHblTySzzjhekJQGMGQOyRUIfbjbNtHKeVFVEosM5dUhXWRA+8n1uhc+\\npqdAhXSd9yEIjy8dIc7USlTTqEEOYYXetEWYJP6tolKuggSiiUB49A==\\n=9Hjj\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:09:01.659060Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/remove_star\", - \n \"replies_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies\", - \n \"submissions_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions\", - \n \"url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"uuid\": \"92b1914a-1b1c-4674-baad-1fb662aed682\"\n }\n ]\n}\n" - headers: - Content-Length: - - '13467' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/submissions - response: - body: - string: "{\n \"submissions\": [\n {\n \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7e2de803-ccc1-42d0-87f3-76972745d11c/download\", - \n \"filename\": \"1-consistent_synonym-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 623, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7e2de803-ccc1-42d0-87f3-76972745d11c\", - \n \"uuid\": \"7e2de803-ccc1-42d0-87f3-76972745d11c\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7064722a-8970-4fc0-b8df-8b8c05a95d81/download\", - \n \"filename\": \"2-consistent_synonym-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 692, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7064722a-8970-4fc0-b8df-8b8c05a95d81\", - \n \"uuid\": \"7064722a-8970-4fc0-b8df-8b8c05a95d81\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/d2aa85bc-28b7-40e4-bbc2-fb7fa588965b/download\", - \n \"filename\": \"3-consistent_synonym-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/d2aa85bc-28b7-40e4-bbc2-fb7fa588965b\", - \n \"uuid\": \"d2aa85bc-28b7-40e4-bbc2-fb7fa588965b\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/42f45442-ee20-4745-8518-c8a01bad5f46/download\", - \n \"filename\": \"4-consistent_synonym-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/42f45442-ee20-4745-8518-c8a01bad5f46\", - \n \"uuid\": \"42f45442-ee20-4745-8518-c8a01bad5f46\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/48bcb4a3-6f23-479e-a718-e0b93fd4b9c1/download\", - \n \"filename\": \"1-concrete_limerick-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 611, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/48bcb4a3-6f23-479e-a718-e0b93fd4b9c1\", - \n \"uuid\": \"48bcb4a3-6f23-479e-a718-e0b93fd4b9c1\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/d8db9ba7-4789-41c8-9f7b-3761a367816c/download\", - \n \"filename\": \"2-concrete_limerick-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 757, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/d8db9ba7-4789-41c8-9f7b-3761a367816c\", - \n \"uuid\": \"d8db9ba7-4789-41c8-9f7b-3761a367816c\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/648932a9-7e82-4fde-a65a-fee812b50ec0/download\", - \n \"filename\": \"3-concrete_limerick-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/648932a9-7e82-4fde-a65a-fee812b50ec0\", - \n \"uuid\": \"648932a9-7e82-4fde-a65a-fee812b50ec0\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/e0565187-d9ea-494b-8ea0-173befacb1f3/download\", - \n \"filename\": \"4-concrete_limerick-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/e0565187-d9ea-494b-8ea0-173befacb1f3\", - \n \"uuid\": \"e0565187-d9ea-494b-8ea0-173befacb1f3\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/ecc07e49-be88-40d5-8e99-bfb3b3812668/download\", - \n \"filename\": \"1-indecorous_creamery-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 593, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/ecc07e49-be88-40d5-8e99-bfb3b3812668\", - \n \"uuid\": \"ecc07e49-be88-40d5-8e99-bfb3b3812668\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/c60627e5-dfc6-42dc-8874-b290ef09a2d9/download\", - \n \"filename\": \"2-indecorous_creamery-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [], \n \"size\": 595, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/c60627e5-dfc6-42dc-8874-b290ef09a2d9\", - \n \"uuid\": \"c60627e5-dfc6-42dc-8874-b290ef09a2d9\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/0e734035-3193-4c94-a86a-41d04332d8c0/download\", - \n \"filename\": \"3-indecorous_creamery-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/0e734035-3193-4c94-a86a-41d04332d8c0\", - \n \"uuid\": \"0e734035-3193-4c94-a86a-41d04332d8c0\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/93d72061-a8f5-4166-9a7a-3beeea4989e2/download\", - \n \"filename\": \"4-indecorous_creamery-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/93d72061-a8f5-4166-9a7a-3beeea4989e2\", - \n \"uuid\": \"93d72061-a8f5-4166-9a7a-3beeea4989e2\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/f2fc98d1-8acb-405f-a4c3-c93bf23febba/download\", - \n \"filename\": \"1-conjunctive_lavage-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 638, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/f2fc98d1-8acb-405f-a4c3-c93bf23febba\", - \n \"uuid\": \"f2fc98d1-8acb-405f-a4c3-c93bf23febba\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/4abcd4b4-3922-4ae0-ad97-9186f51e172c/download\", - \n \"filename\": \"2-conjunctive_lavage-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 667, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/4abcd4b4-3922-4ae0-ad97-9186f51e172c\", - \n \"uuid\": \"4abcd4b4-3922-4ae0-ad97-9186f51e172c\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/2281fccc-4cae-4228-a837-e6f3a3e1e6d2/download\", - \n \"filename\": \"3-conjunctive_lavage-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/2281fccc-4cae-4228-a837-e6f3a3e1e6d2\", - \n \"uuid\": \"2281fccc-4cae-4228-a837-e6f3a3e1e6d2\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/098a7d90-0ae4-47cf-a7a2-2afc00094a3b/download\", - \n \"filename\": \"4-conjunctive_lavage-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 661, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/098a7d90-0ae4-47cf-a7a2-2afc00094a3b\", - \n \"uuid\": \"098a7d90-0ae4-47cf-a7a2-2afc00094a3b\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/546e7e6b-ac50-4ba7-b738-82f0d261feee/download\", - \n \"filename\": \"1-sixty-nine_alliance-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 591, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/546e7e6b-ac50-4ba7-b738-82f0d261feee\", - \n \"uuid\": \"546e7e6b-ac50-4ba7-b738-82f0d261feee\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/987ef070-4e9e-43e0-98e0-2c623607aae1/download\", - \n \"filename\": \"2-sixty-nine_alliance-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 591, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/987ef070-4e9e-43e0-98e0-2c623607aae1\", - \n \"uuid\": \"987ef070-4e9e-43e0-98e0-2c623607aae1\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/2df5a904-e89a-48f9-9e33-5b9759317f1b/download\", - \n \"filename\": \"3-sixty-nine_alliance-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 661, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/2df5a904-e89a-48f9-9e33-5b9759317f1b\", - \n \"uuid\": \"2df5a904-e89a-48f9-9e33-5b9759317f1b\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/03d1920d-d4d8-4580-9c42-6333c812383a/download\", - \n \"filename\": \"4-sixty-nine_alliance-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 661, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/03d1920d-d4d8-4580-9c42-6333c812383a\", - \n \"uuid\": \"03d1920d-d4d8-4580-9c42-6333c812383a\"\n }\n ]\n}\n" - headers: - Content-Length: - - '12367' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/replies - response: - body: - string: "{\n \"replies\": [\n {\n \"filename\": \"5-consistent_synonym-reply.gpg\", - \n \"is_deleted_by_source\": false, \n \"journalist_first_name\": - null, \n \"journalist_last_name\": null, \n \"journalist_username\": - \"dellsberg\", \n \"journalist_uuid\": \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", - \n \"reply_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/9df9083e-1ac1-4085-883d-8c9982b6ad79\", - \n \"seen_by\": [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ ], \n \"size\": 1150, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"uuid\": \"9df9083e-1ac1-4085-883d-8c9982b6ad79\"\n }, \n {\n - \ \"filename\": \"6-consistent_synonym-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/ba38afd6-aadf-48d1-a599-bd74601105d9\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1219, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"uuid\": \"ba38afd6-aadf-48d1-a599-bd74601105d9\"\n }, \n {\n - \ \"filename\": \"5-concrete_limerick-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/9bb8030a-8561-4a03-85dc-e921bd6a891c\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1138, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"uuid\": \"9bb8030a-8561-4a03-85dc-e921bd6a891c\"\n }, \n {\n - \ \"filename\": \"6-concrete_limerick-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": \"\", \n \"journalist_last_name\": - \"\", \n \"journalist_username\": \"deleted\", \n \"journalist_uuid\": - \"deleted\", \n \"reply_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/0a82f046-581c-49ef-9b51-ce5b73e45c1a\", - \n \"seen_by\": [], \n \"size\": 1284, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"uuid\": \"0a82f046-581c-49ef-9b51-ce5b73e45c1a\"\n }, \n {\n - \ \"filename\": \"5-indecorous_creamery-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": \"\", \n \"journalist_last_name\": - \"\", \n \"journalist_username\": \"deleted\", \n \"journalist_uuid\": - \"deleted\", \n \"reply_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/1c2ff7fa-252a-426a-83e9-5840cf657739\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n - \ ], \n \"size\": 1120, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"uuid\": \"1c2ff7fa-252a-426a-83e9-5840cf657739\"\n }, \n {\n - \ \"filename\": \"6-indecorous_creamery-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/5f5707b7-ee1d-410f-94be-1ba8c1929264\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1122, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"uuid\": \"5f5707b7-ee1d-410f-94be-1ba8c1929264\"\n }, \n {\n - \ \"filename\": \"5-conjunctive_lavage-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/158dfd73-3cb3-4a6e-85b3-f37ae54e0802\", - \n \"seen_by\": [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ ], \n \"size\": 1165, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"uuid\": \"158dfd73-3cb3-4a6e-85b3-f37ae54e0802\"\n }, \n {\n - \ \"filename\": \"6-conjunctive_lavage-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": \"\", \n \"journalist_last_name\": - \"\", \n \"journalist_username\": \"deleted\", \n \"journalist_uuid\": - \"deleted\", \n \"reply_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/24fbb6b4-504c-4fa7-9971-e6f2d1447a48\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n - \ ], \n \"size\": 1194, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"uuid\": \"24fbb6b4-504c-4fa7-9971-e6f2d1447a48\"\n }, \n {\n - \ \"filename\": \"5-sixty-nine_alliance-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/4dad63f1-dc12-4162-9c59-065c88b2a8b4\", - \n \"seen_by\": [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ ], \n \"size\": 1118, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"uuid\": \"4dad63f1-dc12-4162-9c59-065c88b2a8b4\"\n }, \n {\n - \ \"filename\": \"6-sixty-nine_alliance-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/d5b658be-aabd-4d7b-89c1-51de7fa246a0\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1118, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"uuid\": \"d5b658be-aabd-4d7b-89c1-51de7fa246a0\"\n }\n ]\n}\n" - headers: - Content-Length: - - '6430' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/546e7e6b-ac50-4ba7-b738-82f0d261feee/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqARAAmwUjOf3oGUcC5K7tSj2wxiaUdEVeNF4vF3dX1fehU6KBpQhv1Fq1RkRg - 1xM0d/QOpfw31CX3ZS2hPdA0YkFt8xCNHi2UYY2Klumo9clEx5TsyF2xQ0YKSZ5zNlqVJWKRpa1t - bhtG3nRC7KQfEsQNQyLgQM/l9EJtzrYoYJEgd6vj9m8kPYsPhNnX4xtV9I4CFam1fwKqdJvjRiHd - 2v48TXcqxYywEwUKyrPyeLUvhFaPfYX3d7QVKd94Wj9FUcccV3Sn1JNeggVKuyo2i4k4ISkGGRr5 - Dr+Z7WVOTzZ2A/Ec7X5onGDbi1XGlrK94PaOEe00ER8sSqGQKDmfTu/RgHp2vwi5hvBUtOy7171f - 5lf16EIXP9WzNq5svfBBcRSiqTAXIIZ7L1gT1XT78edb/1UTAzj8MWv7AjOCWX893AzSS0QT52qb - vtdFygfDSLjTlOLS5S5mSwXySnTMEWgxtr7MEMOiNOiYmL/DGlHHMBv+k0KwcCj7UAQ6Sxs5Ek2V - nUP12NtHqUv50LWhIx1sec4SlinNwRyUXlBz03ZKazij654snOziaTHIS5ColH1Dybymz04FjWsZ - 1g7J09SSwH2SFCX/ZC/F1+DrJf6aXvjBtS6K1jB0179vzLqtOc+g+IT4R7RGZoc5SJNcIwNzSAhR - Psvoid62jXUBMluHUGnSPgFsdF4s8vKoV+3hb12cuGou87Qthv62oGM2k5aX2KHk/AWAcQw4LeT+ - iYWJWWBwFLOt2WUfZcX+rKQUquZi - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-sixty-nine_alliance-msg.gpg - Content-Length: - - '591' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Etag: - - sha256:c2f54737913721bc1c2984e1d18ff6e7c21633f61d6e6cbd64d55367d4de1aee - Expires: - - Thu, 20 Jan 2022 11:09:09 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/987ef070-4e9e-43e0-98e0-2c623607aae1/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//aY9hxX2ogbaW32nmX01SSuMf0f9p/d916Nmkjcy19fl/FJYYuicgocKt - /sae44rGh/mrxSAPlujS9BA+kFAaKC1mHvIKwZNRIX95XjjOXj83ndEju5DEkWpS10j5fVQ6JsMy - HV71GP5RZpOvOd6h7MB84MtKsKwTNRiuafeRaBdYWsT+RfuAURTHnWY3PpyBFDYwqlh3UeRdJfXu - J2XSc6H/2071WCOFvJqD47fkdtD2ox+pWXjP4D4ZDjNRqx2apSYqdQWmDuPM9cxDbIMbELnoZZ8R - /e0hgHzbEq7bTwytpyZKnW5fdx0MWoE1GL5l9a6Yr8HdzzbOxYO3vYCf1+gQCDX+/4pRQePzS4+r - 7lJGkIQ8ioaX5ow/nDlllLqEXsHxybCI8du+a/DvlDJrpf7ZcfZRGpsOyU1w0+ZTizPfknMaDK9/ - xhhBt1JU4huxZKH3F1F6y9ws9tVIcfk6eVRkWRbvcVIf1W5yPb3hGPwZe8TpQmp4EgG9Ub6ExLjc - S1lyJ5IVBm+MUy12DRUIHKDU9ZEtkCcqZ9WdNj0FeUGiCUg6Q9ODrOVkuX53JVHwbOBMpOu7Az6h - Vb3CCImEt1VKsPRNNIMdJj4OiF0ycUwlIlZNtTvhP737zjX+FKx7fA8WhusxvrxN7bWj5YHaJ6ur - 89WzLagmFrEBFNvz7Y/SPgGyUwWol+H/UJhuwiMxQPzXQZFSMVaf8kNud+FEcsVwLlr+7RxltIUk - Cg8CSW0Qc7K0zX+aT1t1ybpjAxAU - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-sixty-nine_alliance-msg.gpg - Content-Length: - - '591' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Etag: - - sha256:c8d979c2a5ddbe1442b987bf52676c27952972e9b5cfc65e8725808aa0c00ece - Expires: - - Thu, 20 Jan 2022 11:09:09 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/f2fc98d1-8acb-405f-a4c3-c93bf23febba/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//ZGSn6Joprv5rISp7I9pxfmNwnQywlsFX1PCfQd9yWWVg0BBVIgEp1oe1 - 8d5CkW840whZxhT/+2RIqDIHZ/sLXJabXXDa1NIYBLCehXbkFvZDTBeyuxWRxk0QPFLlyB8MYN6c - 3MbLOsyjppgQS4wtcYSlcDva5tuYn0wnlWz1DEUAAgC1mfuNa4AjlfEDh6pN+52tq5ysl9vE3WHA - CHAuw5Wbql3NhJgjmWBCY+5OirTUWz+UBX+XhyPVD0g1HMD9mbpbgUFhuBOZt68YNPBdrtosLKp1 - c7PdajSwRqmE4hx2s568npRbFjL9l4GpGAcLef3+hjCfK4kTb1wcIsEcZX/dptfId9Ny4opzos3S - r/v3TckuSbzWkbO4sLgjFxR48vByIvB8DgDPTLF1wFn8KjmRI9uy3+lvjjhQ4FecRceYOkZRKf8E - DOzcGlbcxQMADYTUkikD48fEeVp7GrqCcamdT5xtVK1EC5BgrU411KNV9W98rWAJKiwc/ZM5TlRg - A7EaVllksthnB/R2nt7wYXB2yhi3iFOQXWOXvgyp+TEAtmMGXZXxhOCAPasxiiGk5lssxmckhgyJ - sEZY5vkrcUgEp6rw1afkDpzrcnKYxe/B5e3nxzB4HY8/VoNLuV0qCsyn7KF4QQgeFSblbbPrXGa3 - avOREyv2eBcOX9INYBPSbQFIF34xe1cmsu9LRxvJtNw+7L2jfbAt/p+K0uWbL2iWGAzWOdIz4ER3 - ZGE7ejn1FV5LkUiusfADKIvWh/Jcf4rRSY5noaaUdBkyT0JDXWLPvbANUZPIOysB/tO36MRBBdTF - fq8mcxnERAHf5Ok= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-conjunctive_lavage-msg.gpg - Content-Length: - - '638' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:369cbfc86fb18c430582307d6f64110de459504027b3132883d89ab50bd50ff4 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/4abcd4b4-3922-4ae0-ad97-9186f51e172c/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//bwoOwi3Zwszz1n7ylgcD++Vx2S1yUzLOqNi8KWe4xAJSCaJw39dcbkiT - 1OqJpJDWwIjWd1yRIoeLqH21SX4+PWt2Ra2j/MqjsnQdmXa4hEqdnTgaiLHXC8DvUF3Kk4YfJ2Ro - e32INfFkpT+AuXRSZFTmVlmzFYKTEvlnAGhOGubEbZPc0/pWZt2f9FlnVbHGTYeiD7mZfxmpwVTL - ilTxm0nAZMVsv+sD/f4yLoYn0f34e3zMwWgWFJ8n5G0Avnhkxq7NmzOLeAIcmY+jA3enYAUrhCNX - SXWgI+sUfNh9Fxyp+2DkXtW3hEctclLyIpSmRbMSfhGdUbGSDlRwyrZXZXvE2GkE20xiFbilnhjw - dIsgCwGWjIHduH5S84+l49bbAQ3lHnaQUzrIBM5CAipsubdp4UJQW5MH+QcEf6u6P4YS9PhRs6c3 - oFRoCAvY9mRSXe1iqjxE5jAXQeKZkZGzB3AJdoBrzM6ZsOFXPALJy+eKk1/k1NrR4md/MUtAxsej - V3CIH96BC8GUNMXAaEzHAd7aOEN4acdT9QY0uua9cq42bJ7Em3zpzxG7x30SLL9eHvYuGSqeJr5T - K1HF10GEjdQBzpR3PBl0eFwO0qjqW5YBQyHB4+exT+vVYJ1sSeOQor5yCFDDxjplYDonYeQLJOWl - fkg3UPOpFbMvB21nCRzSigH1RFaR4mU110vETzz+BSfNqDawJdGdtsvgo/qjszTVhRstgSSMRJkP - Oi5gpNSjAKP4oHwSf1YS8EPdA0lnR1/keAlNkIMfogWicxyzegEbkFFVdvxZDw++a3rdFanSEhqn - B/y6C4BhoY0kF3V3RbHUG4xB2voOTgdqbuB34EjiXqg13epvVzH5Ng== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-conjunctive_lavage-msg.gpg - Content-Length: - - '667' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:d92a7cb9901368d8ce9478c1ee67a9becf3789330648c801de9070b5d1c38232 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/ecc07e49-be88-40d5-8e99-bfb3b3812668/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqARAA0bqrq1QpF62ZAMgrtbCo/7kmm8IGB/7Lddclop2NH0P4BEOO0yCFruoE - oh/JvsHnA1aOiB+OWUba0jqytICNts/SmkUCMGawCvB4f0mCFTwqnPKZolol1juhi+v0Nj0I4No7 - FL8hYgu4OQnUJoUaHnk1G27QayFc8bpA/uIqD3Wc7vy1stVmjIbwRZibEUgUThiW05jvPST7bCcf - a91lPAOpIB7n3jY43omHBfCnwXlhCmkl5ruyKJK0a6buP0UlZJv0eMNjLJ8cIZmIabOsKYJT4JGD - fXGJ/NBOa0Nv++crzLYu8tL+8iApEdyegHsKpzKDoT0t97IemCABPjLi18ZRh1YRlrOPKSre0HeE - 94d8fylTU3gP/j0oWt9tDxhMuLyqAjqfB5OvwNyO4Q44UovqnLdiCQvkTKavmXlfIoQ+mex6jlbD - AbPj5zwPU1ms+fqZ5BMNWagpuvGpW4+uDG4yQCbwKq0OWtdqMC5Ml/NC7bTXdowAUTZxcK4L1UZv - 8BliQ0bS8jKsFLC26KEdO9kHYwhoUVhJI6sS8IFTUBRpfuw7sc3ucjGC9a9Vbfc2ytTnSA4thwcn - 7kE8ElvDn1lpOx74+EeoGAksYQTw77FAf0OwYALZ0MlahyzxcZeW0WUShR4nUFkfwSghPmMYwmOW - NgiAEgyTB9G474RoPLzSQAH3kq3MXakzuGOxoH0BJyCV7pjx3DdpQxlg/PddYwURy1JO/2aQlEcd - dDew0WaSU00mRSf187RA0izsOoPJZGg= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-indecorous_creamery-msg.gpg - Content-Length: - - '593' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:36167d9be8bd62598eecb1b8cc4f7cd2e6571141907ab2ff46a3add6c164fb96 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/c60627e5-dfc6-42dc-8874-b290ef09a2d9/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//QX+Kk2k7xQF0Izm5HeQ5s5yL46DBQOX3HFSVih7JkpcjVGWREQyAfnOa - UgsMZ/sxJzKxLK41rRDMNAX2tWevCXoJdULFntJ1RQkUhNcqs1h9MPnavQxij9qSmaLFiEcfaSnk - jBooeYToIKaZ2jw/krVqqa57wbQlEexGMc1wTlfstEdmN3sQ70bZesNXBN7Cqv6HpVLbwjhhYXnw - 3mBjrCNwajvKVTW8kZvW0w/bufTewd4HYjycS+LlL0vtm6gNS1L+6FrMHktMjKyv0v4Fb3W2OQVu - hCVWvXccSY7Bv6cBNhiQOu9TcsR9MYrCSEBx7PTB2elznj3rcGsI5NTTcbt3mDqMlqSzAFY6JggH - riUPbNiiVwbGuMq/1QCpuHm7fSuLfxcEJCWbMhWBiYGbx/q+0YuuVnAwq4ECpo9OU/pWawUS7MqC - E2FktiBBlWJNlU7l3uKA6NpF2Reo1tsdSBsSBxg9JuU8hmein+PQtDgiUfqxb/z5OynsbKgEErs7 - 9+2uWvTzZB4N/4D49RcJQC+SY9rR6a4+bY1acVXF6lSDwvgrmdhtYRLh206Kk7GLyWWlW38EPB1v - vG51N48usjrAIUZGwyftERf7eZyqQGQeGCEqxBkjnTwACDUuEwNFuHDcEPgE7wFlM4vUzqnvbJRL - y9Xh4po9fRm/aUGQ7QjSQgFGYkMyvxrOBNoz2u8GYFhQcOZsBwx0s/pNwWHzjtqTFWu5QYG6kHmz - NOMplDrqSg18sbLUra1CifTy2uGLP7+EUQ== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-indecorous_creamery-msg.gpg - Content-Length: - - '595' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:49c83b1c967bf7f87885f8a9e50e375c297ffe1a0f4b4369775f87a1d761d5a2 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/48bcb4a3-6f23-479e-a718-e0b93fd4b9c1/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ/+N0q7FdEEMxFB+ckGtjSjsKZpRrDlSd9P9hQNspERIrjrUL//aTlkRuyC - Bf+MtKJwjHl5QQvwSCmK9j+6tx2r1mtVBAvjB+Cd01Hr23buxhZ2nILVbCjq4lNwdWbbxYf1B2BN - VC38P3+hzfoToDaBYqPY8o98XeHxcn9ogqg7BXF73lHcum1A3Orq331qzrdnd5Hc0uk4euCytLIj - HvLLOt1fahV0sem0GwJjKgKcw3KNYElUipL82TUvVXmM4oxUSRbx7c1qvmMHE3RAvzMmevZUV502 - 5hzDJjtjbdSjwEQbsGTvmAy8Hu8nTIKmqZnLUNQAeNMLUiS9P/jy9eXn0EuOyNL7IcAeFZx1F5M5 - VekU1FiIFWM9ialJT9+muMkDUsgZqaCXQANzNyQDOvankAfDwLJYZtiUXCXAwI5QVzK9PmGotWii - 1DLqR1Rq26WKe/trbztyI+22Vkow4IJVvKSlzFftnDML3C2GnsAbo5+vwMBqkAX4F6m8VBeeMeem - ylDCh8bq2BoibodzbopQsKPZHcbsD14Okno7moKH3OFUReGqi+a7GDMJvN/XkEIRWjTDPnwWlfIU - rXZzpkZCwwDgrflLRVQoUBvQ3gu2+4T3/xE0J7kEFMR+qjlMUVCQFIQAfoTELFIlTOaN9a4T8Xg9 - l6XTUfpvJnz0PZ7s56PSUgGngyQhpYO4x6gHOBLO4+OLJSYshdB+qNB0iTLoJwjj6Spe3u7TTs34 - XQpIPp466dFfsJclGaqGodCghkn+6OYUt3pMeTxX61meRhYjpgdceLE= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-concrete_limerick-msg.gpg - Content-Length: - - '611' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:2d4f3f0281c2b3da41855bafc3c90d3c1509d7f7b2cff3456c55a2b7efec5e31 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/d8db9ba7-4789-41c8-9f7b-3761a367816c/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//d0r7U80dRHjHvMi5LkGOjtP+uHC46RsTkcshCNSH6++lYRWF8Y7USW4x - 66I8tWPIuuCIs9GcooUKO4b0kLz9NJlu0znbaIJN2OPeCJQ4GsQg49aPzTh6aRtOVt54sr9Lzlgu - d75mTqLtgMriTPKg8047lTxw1430feJdKSXIIPgce2S36CPPmS/yXYQOLMnsdvnpJ0lUkjSU27hb - PnF46hXehR0MKUArrSqeKAdOGUfkXHW13Kzss8tEvcfRlClz9gHePp2lVSvN7Urq8jEwt+EAQIJ8 - EKEGMVgdu+hQenjoKoubG0kP7trTg0gWdYP9jfprQEznCFIsDi7H71U3ek1o/eZz3Se1gkrxTDf4 - 3cTIHRjdw7szTjwO3jGIWe+PslKpMvPm7xxDI7LUk/7s4NIlMIPmHPEWOek/GrwCf5yp0L9554Ti - 4FF4LQwCposVIAmN9Haus6iJdAj3Br17tbkdW+SQmuZ9goRSotlA+mCMLDTIxnPKZItn53m5zHBy - InK+vOdre0gmCs40O+z5u2TPNw4SflxvJbk7v/jmoWMcRlURt+JajxpNPko6zluuRxJyNM3Qn4t7 - gLHmYIKMwjpr9RdHrPkSwxQLzAcW+DITCl6crxRTibi+QQIEz5bSf285lwby+66xdzgqX663KH5Y - p0dV99rZgiLwlpl0PHLSwCMBS9rTj0edt0rrwikTltaCqj5aOsOdCTYH8SQeSOzU9sreZbrLLAJu - ca+7tsvRFAQDl+YvIxN9UifQI2h7Kyma5F6EGOQ+OlAdpPFgtN2lKnX/5LLIaEf3M4uU+BPX+Rem - fPHbDPy/szIORpdcLA6z7AYk/a4i6ngzmBdqEGhXaBqkeVItHR5beyCcks++evNGECfcodK4SLDA - 14pFiLtnOAIa6GzJHpI7uiK4mPUQk+2ccMP2pdhpt76XVpShKkvAgjTexaZBZ3ELKwQDVZOOYf5d - 6FmaxpeN5Tx4/hQ2aN0oYA== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-concrete_limerick-msg.gpg - Content-Length: - - '757' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:9253415712bbff3a68beddda5f93781c81399d5639f7f14a93b49c8fd8539ea6 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7e2de803-ccc1-42d0-87f3-76972745d11c/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ/+LTnLpo/pLzl6tUqLxckEJCSe8zdn+H2XqP+NOQoZ5pcmzqtPjPDI31fv - ibuvBSE5IHzZfvg3X/wNkE1s1IFVRf0kjC8jcJD4MZX4bpyB2uQatoovA1X9J6OjLjoBRbEseRfW - 5ubE0nxFpdCX3XvFDT0371u6GLpi4Y0fsfC/Oom6XI3waop59NbYYqi614DF1GIcI/vXo9B4cOnl - bkuSJ/Sf5+uZnwEhDUkTuFSnfIHWfTP+ENeXCUYRqu/w6dEqnVTwVWWdwQL88Bgvpuif8wCVTA0w - SmX8LVnhudWxRCnPS7GDxhV1OiCRvvOBx80Isy+XXfoTf/UiJbP/zO0zF25FFS8jIWgHxiqzHFDd - QY1cGTwM8nPciaiW5PPj0ghlv1TDyqDIbl+QNd91dOPVqxFt0/EwT+RA74ukHmYbfFnE3BGA6ibJ - /brtdNcgwosfgeyN+9bI1rNUAPWMeMb2qnuQn3KwaYfLSv9hOxkVtE/xfocXdws6zqgiKCS84mHB - zfoeWSPKD+5pGxuR0VNNPezCWRoAuSSeZ7YUEK4PehfV1OrWo9/eAlvqzY/wDMEdGP3aaGLrGesH - cNGrfawhNQsndIlZTf/KTaFxSXIoc/BAP2l+GzwM3JyL1lTQp1d/nIdeVoq8Qfs7EWnhUiWy+03x - 2fEfBRADY8tKxtLoP8bSXgFoywsO2/jD8BHKxf3Bihb7bf8inYGjdVpG+uPyyo1gy9jg7LcNU764 - mU0m+ArM/b5cQa9jmplYDHL3fZ3xuCfUgldu2jvuErfhdkPxZ+F9qgPfYFrpjKbxsE/V7QY= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-consistent_synonym-msg.gpg - Content-Length: - - '623' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:1b629a42600affd777665af40e1324db3de989f51d0ec3943857461718fd7acc - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7064722a-8970-4fc0-b8df-8b8c05a95d81/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//f06OY7TdROea0h8wAPqHBVj4vLBLKY4e435urytRmWqQI1MnPC2Du5BR - Eb3OGYIsZeuYR27gnkxXQxAMUR8R7NWCn2/6eTEQHh7YuLxIHXFs2uyPLe219sdM/9vPhlWjbET/ - qEPsn42WKW2bFZFvv/Not5ouEfn1PuxyplGaFXKJ2i96pziQ+0rBFYU0Gc/psQ2qqUYT3fG4lCGy - Poi8Fnken38RMRYh0cM/hesB1XlXiIDrDBClGYhmcN6h61Daqgo6Z1k4HQfsDO9B6PR8AQ3y385p - QXhzMN10p5kp7aCRbFCqMgd+eYWWD63NnqyB3BI421tZcULIXow0/ddkZRErg4iUnRrqY0ZJKxm2 - PNAh3B/d6LMyeO5LAC1K1xE07ZAruGNCmTpdC1xXLSoSbnwLN1ORjtc+2ZR60voFWkmp4CgUA/mk - zfFwf9WYsXFPSHTIH007M7LzTFU4xsKLqjoD62z4HBQXDtpfLdXY3Hdb8ybdV68GrCKXY4GytQpj - ZSrHlpfTzaesyBKQpPDkSYjTlrhPdfeE5c41ny50zqwMYMrI3uyrVBdcFyaoRs8LrGqgrtigiUG2 - BgVGwPEOGbpjFQ120lnLS4mvG0M/3oWPOenUJayNhRAXesB9mJa0cLC+9xvXzJXs5ZKH2ZzxB7wk - wc9+9wJoUa3fYiGV/UbSowHMr6W3J365h8lkRpclFeQWogkO8wMaoRzuqFwe3DnMdcQjUG0rmBCi - QUbynFI54RiEinJNDDIVzDp1qx1TADMskGMLc6/vxT/JB5lGBK6ueXCdvCIoQrcUdkpOlvDaFomM - kLQCAih3421QTr055Hz0tAHvXn1nqZHYSh2Njstra1FzMDBlI8yaL28HtgpMr93hShTJwq8dzarq - SI4U99qiJHw= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-consistent_synonym-msg.gpg - Content-Length: - - '692' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:8df755c2ad5b82e4c47c0564176df0e406d33e444386fcafcd7c524b8b558467 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/4dad63f1-dc12-4162-9c59-065c88b2a8b4/download - response: - body: - string: !!binary | - hQIMA0N7bDxphDAvAQ//XuxXLagu7zv1n7lcws75pYK8tSmE5tH3eqDw9imBkXqOWtutqRX3E9YB - EKvcPoTSZwAxhU5vdHuWHtMbglo6no6eEyzVXnSUHUzr4Pdzv4uL+0prIX9q1u4b30qsZ6wmdIOt - KkGav+8P2ifFfGvleyCigFBV/ipIailap8mkDIKvxGRmAmCQqCJHiUpRNe6QkEddaBLwdcfOzubZ - 4XxsaGwYo0cYK30+NP8LCgnDtSv7la+mtd+qHVylkascHnGL1nHP9yFbGTxKZ7RvlPixo8qMc1Y3 - INLmgKyOca6iDyH5swWWEptE0AU7fPi8ghDhtXZv8jEknClZM71BHF8YkzieeNpYozvhJLayQvV+ - sDDwe6IDn/hDXJtYSNSa0XHo79hVQafknZAfiMXBSS1LTsCSRkcSHvb3KHoe1s7GqNprbx+p/49T - MFHo2HOJ8/UIcCFM0VoB0LhQlzcj7vORQSNrDpVS3AKgdZPsJ8qpsjLTeEKszU8B/GPbBNVpAuv2 - i0YwAtkNN3nzOQE2Mq8mpj+SYS/iTSgJFs5q6VKN0mwf6nu+d96BteocdQrA27aSMXo11adLbReS - NEUkBjRL9/sNl6d4qGCXesp3DZym6pA1Zf7numhJmqVdFHy+XgmfSOZaSGHBDMpt6csHtBa11mmB - 1w4S6WN5e2jKiVq+30WFAgwDw+fEwKIgGyoBD/4khdTGj/2wC01WQJ4CG53Z8e5mATqpPjBJdNKY - Y1OfJXRZLKdbNvAu9MLzVlQlHmVZkadmierHaDStK5prpxlQHZrrcuWrRjZZhRd72EujVSVwEHP0 - hEYleON7I0LQlc7Dac812iw+Qzfaqk9AEe+0GR9xrjsc13bfLdplVK5g3mc8rJMPbK21L9c/5JUu - tEwMQNN00sbdhqaQ38tAqcGCc4CiQK7t68PnGxpiD6WqGng0v0bjpr4m7l4M0RGix44QFuMh8fOg - ysNxdgikEjwcIqwYvuXYJOJKvl/B3NrLRgSjc08HBYbBS9731ic5UGrMXMb489Soey7Z1K/d8paK - fQI01En3bxq9Uu/px1+W61ckFVxnuJ8SkM7Dgb9GQiG4msoB1y3SoKqpbq9Ny7ZETfIcneG5eeJ0 - e6IvIwKXbEamUBAK91p0FPrNF0/x0bea7i+9topmiVt3N5FweHRx/l/iqWUkXI2Q3UHkd8Gelp3g - 4TEs67qGeM+BwIgIuy5PLMu0ajDjSiVjgZ2BQsPYzwWVjWW9igInW1RSaV48qe4bsgRDhreUllkV - i0qNnwZ3fj3XURBPYdU4W+dKaD9F6LGF6OqxG/M5tR2scjOlyCB5K4qnh0VS83+UyavLndBt7W0E - 8n4aqrGdVwmnSqzRC2WLqxwhlkPkwWRWvuJRvdI+AcBdl+2EImV99JPQeNxJtsoYIeBDmYSXKwQu - OJSgU9W+y7dIlve7qXljjmVYqZ+n789KN1w7J6Y1BxQfQyM= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-sixty-nine_alliance-reply.gpg - Content-Length: - - '1118' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:a37f717849486b9aee64abb4a643ddd68b1113b084b1877331662db5faf2d4b7 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/d5b658be-aabd-4d7b-89c1-51de7fa246a0/download - response: - body: - string: !!binary | - hQIMA0N7bDxphDAvAQ//TH6p5AOn1CrjxYM86z+RMEqJA3KAWtZRfG0DN+HrRi4U+4jqoRkNuScK - jGMANsbTgKVFe8ho6dS9Vx4YFxcAxrNSRnOAkKqCK6EzSMXQ9ndwhBGTfLQDsMM8UCQTHb05OSyn - MUxwFS90J+WcoeAXimrX++kseH2p5UQGxe1wDooQqSvDJtPuIjYCnpWaNvp72/z153ihGAZ/83Hh - vHC2huc43vtGLKNgYYH3ZualcGBoQVbCGSLxVukaouLC2sqh2gqhSinEUdf+A62p66QexT5SqYaB - AHt1FhOtUey+LKaMskLv3LZc6GVr6UEEsybveRMgMtYwLHIkrKYIB1NQde1W60nUEax9MwKA4ZqN - 1ArV78ssvbVzAFcqfvIuHlXfPXOmD7t8yuT96hTNAhe2Ih9fiYGVhHAbltP/d6lCzFEzvEve0BNj - xwH31OrmzsrGMKTD5xpjaQnJko8enAK9/V/s+SFevWJeGuzPUd7M3ymD4pGXzWAw03BGK2B0+YOZ - IpAoPKbh6Z8FlBL0tujL0PS77PM4s7kxKZ7pWAU2m/PTJv57GtBaw2t7GTpWdFNu+9zx2vygdQwe - SkJpaM7tgonvBvbwyqT2jYbzqCfRqj45AHntTzEw3UZlxLmUvHh+u/LLPvn7EDOPtl5UQANgj6Rl - XnTgw8k0znG3VRJ6vfGFAgwDw+fEwKIgGyoBD/9pw1xQzuUiV+uEuopup9unQa1XTkfL6X72Tqp5 - eCvRNOHHYmThZCp9QHnsJm2NBwHyZfrYgzl48quf86iekCoPgyW1RPTUEGDCJjK7XvtNULsMZB4m - sDzS32TgP5MKzxGmAwQWTj7o8s1QXv9gy2wr/GpVfF6mbHtWALY+fovm1TkQ8UGBv6j9LZcBjqn3 - MZfZnCqwOqa267ToB5AjxbL6X756TMaydpJ0MCHhh2JcGYEKzyp67BON2lqF3pYfaw/E5u+4N8pc - +H5N1E9T23xANWJhiydk+BE1I4moDVTR+iVn3SywDKFqO3VdblMVAEHS8ZS+sTSXi5KJw0k/+v5+ - Q9j6uKeMCSjCwGInby4AQnFhlKXL1hBaYFVAjHAaXZZhGrPZOErESOJAFCGW9WhJkedsi9HP0FUC - TuNZpJB31EDo34+LYIrfBmHXoefL1vwJKHSKR9KNROiEUT0hv6pK+psT3jXx/dCM1H3Ads6D6Rc5 - 1hcS1alsjXoWZJmgugON/U5WnMvDDWlKtgbHZQyelqEzcDvItemBqWNLqfrsDJ9wi4nQiEeih/xq - /uYB8dwYMhi1sW8R4Agn4hsQhchMtiu7sFqAm69KJR2c38x7njcZnym3mEn6KS11ttbv5Q1kBRLm - O8c7jLtVxyxdwUGIBsUfwFM+xQq3cGcZ6Dfdx9I+AbOZaVM3gHy0lp3wxwUiEVWuyRG/1/ys8jlG - Y7W8jTPskwLJRay6Z0wkCcGMYPKnvyIuMuv9gaU/FluEaAY= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-sixty-nine_alliance-reply.gpg - Content-Length: - - '1118' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:506b499968c47ee42d2aa758cf2043499810091417f99d4bca76a2aa239d5b52 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/158dfd73-3cb3-4a6e-85b3-f37ae54e0802/download - response: - body: - string: !!binary | - hQIMAy2m2NzuNpRrAQ/9F+gtuJpPO37A/NM6OacHAK+lBUvHM8icpiAz35EqSbr4OnnAQ8IRX0MU - v8Z0QpNB7+MCWlWY4QL59zaBuqHwIeg6GAu/szkpRxhD5eKAvRa1ukR9XrQ3pDmpNHU7k0l3x+jI - tmKqt7WtxqiZ2GjIDTitpgowd40k3Af/BeYQ7IEHqzv0xbpsVp06+RtLFc05Tg2mVlK9lt5mJmht - VcZQJJ3P+d3wcROuKuwmPqzi5FAlLQx3opOy1hbukgpH4E+lBSA1EwsYZ91/4AgnhB+VLgw1EAsh - SSCtNfhTE5AAaJ/a78zf30ukkZ1v+mIaoX+MFYCk0/eGVQpuElK2OO3MH8lrOylr9/388cP+aWC/ - iN7RpXdUDWi1iUtLLieBwyVYTNnw7yea7Mbpme9gwjk0Jg56dgs+npUWLp+BTChxWAR67nG4M6Fz - vNZbclyvwyrojcBWKDnP5zIGIxGFufNeJcAehapP1SVuxuOO5aCwaKSZLiZZeINDhn7qJ4rNNhja - 2fwQbVmMW8WMh8m0ofijC1mQEj6bh0ElUzkZlRcD1WgfExnlyHov8AWu107IidLqalAU/rAGksd1 - CxxCUZ8cixPktyV6jE+g2IMGD9iNKuXmlJy20ITvVpaq5OHT8lUPJIMyoZCcfbNJy4ys5YASATH0 - aDU5C7iLg3hWltKCUU6FAgwDw+fEwKIgGyoBEADbIZ0faKpZjWxU8Pu6ZGNEphU3jYPg6CT1j3M7 - 0Sc0kBu3WZZDbAH3wUPbMCD4xNnTWhxjBCUmqLorPXXXm2LpE7FaApUS/DXl/TjTdYlKml+MsXph - AhdQQs/P6w/WhiHI92UAOdWnAtKebjMqh23oaFVVuVdkdXEdz62aSOqkE5PLJ4EggzaAEo9hwc3H - m/zq6f5bxS2BdgnEUuL+4Q3iOiydQ80obTJZNIRDPL6cmC+XKDrDA3sXluviOA3ct8nnwtwtkSGH - /cq81wt9lNVxpVriOZfFIe74bxJ3PQvxaLGpcqFg8nT57bfVzkhfPuXYh5AlNO459RUkiaZa3vmZ - ZlltTq5iNIrlTPqX6GerzOCHYYu3CT64DgviXF9isKgukzDyZmeGJK/LKSG+uC/CuBSzF1opE6SY - F9B0sUTTqPJ7mBmJU9wpoNnQG9uGx9/qEqRJ43k5KNGLUs8LwtqBhKkBNUA9HnasfjYdDvhmNxxk - ENr+Vg3IWqwsCrSeaOI7BaYiokDpympu44q5NZ1f/akfXjdcdO3Z7fStB3lOJX/ZvVRcZyg5fkXd - wSg5jQ3Nqyq1ZxTkuUt+QHx+74VMQXJ7e/w/OLrJNoedgLM4eY+U2PqhsdNY0qXQAU10eu/yoK20 - IWQoqBEDNvKbs8T0zTELQ8Rw3527ujnro1cfoNJtAfJ5dQ7MOzrxUSNXW38Y/O2idXYllsvRNodt - kahbwINUfi3i91KBHXq7wAdQ8wODMmyLXZ5tJCbnpkHrH9wx0/Q+W8omR2zjdOgD298MjO0f0wAR - 27+cdwdzVlNEWsvI2nIr4bWhIq/iEq0ZCw== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-conjunctive_lavage-reply.gpg - Content-Length: - - '1165' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:6fc20abac42bbb6e36d08f16e84de997605c3caa88b1b6610cff5453f0a78bd2 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/24fbb6b4-504c-4fa7-9971-e6f2d1447a48/download - response: - body: - string: !!binary | - hQIMAy2m2NzuNpRrARAAv2fCgqOcLQn5BgYTSajwFM4sm++V+BFhV2RMZ0Ywc7yIGObndNc3H4v2 - 6CFo9OdMA2+uQrRzF3sNwoFn1tFLkRLZR4g2c0R8cynrB8XYgV2dR+T1/969ZEfOcCpFVOeAl8tD - Mld8VeC2HIiz7ttYMnRhO0LSuDEegI78z2idd/ugDgJa6oDCdtC1H4iFWiyES/arZQhlNBonZcJb - K9ujj6KWKysqB78+APhIUBF8DuAhRFv13raTqR+y5YZBJoGLqCt/K37Gkj9oV2Ty/juFBKKEZgiA - wEGgIYY5DmytKgErLRIZhKr/mfjeSpAgtMLFp3MLH6BXASzbGvUZoVmPcRcg0zujYARWu0cj4NFf - tfEHO2qqW0WQIUhzjEYvQEf9lbu4hZp9tNJ71hASCXJpVMJSkazq/5Xnh2ukFlSpvEaSOl1nX7jk - 7UMHQFd6ckTIssp7aIrZmBJB3kfcGxSWkCmu05fMFDr6LfxeyfJlt7kDv4PP0xHaY0A+aJ4Pce+s - WSlRkl6akI0+ZLsADxRNq1MwSVi9G9wqgoJ64CUJyjo9nMWZUyNISx7bYnZLG/0RzS57N6iXQkwf - pr9c08+zL360sJGnJOKSaAD05VCgduE+EbQ02fd/GN8sC7pJ7vc1bFoOssmIHjVtuJtCX1hxXuie - wzWk3g9HLU0Ge/P7wHiFAgwDw+fEwKIgGyoBEACzLkBPhzq0XbOkNrJ0mgsG6Te9AIHzZMmCpSJB - FBBaGUwkJP2njofVnMzUzGZEiloNlHU0JqU2h+OygDwKZWopcnAvjSf44nSXVLariWywWWtRrTUp - /qLymnpIEkK8LVrwGKwNhavEzg1xRM0FadGTGPOpHhm9WWU1cVU+zxy7JD/RJCqByXhZgwBnveK9 - 7o7/8MtANWmmEK/08zzfRKJAUDjReQFlbyTLtTzLhZ5qRapDPMHEc/5iE0FyArfUxmzgoC8abvuR - xXcE/rqj+jXpekfJGnh/b1KSa//3FU1KglcEN12aDT09hfYZLs5aNYLfhRGCsGUwI/sGhyr7fTEC - swj4DcYhbRdhcMn2LvaLLxHDzT/CYwniLzryFGN/yYFqoWH4VNK/k+fd63ovJoz2gvTOGtF85bKP - D/djVUB8ZHrwQPYhmVPAq9GgE83APidKDVpiV9o6CoGc8lNVnUNqMg1m8OQd70wxbSNQR1UscTsv - pafXWb8BGCv2Dh92nGgYDsVG4Q7kyxacH8/6b3Ej0NAxlmq7T4KEhtK4zWAxNW98fuXvU6x/xOed - GUyIJcC6LRy2nvHKpebo+x/m9c+z5kL2IkNszDrn6K+v6zRge5KjwB8ZVaQWviVOCO4XBevHyBM4 - QUTOhwvaKSO+Lfr/d6SUkFeXPW4DszXo4aPFPNKKAdRQneC2tRM/jHptBBJcUOh30yiyVZXtqyWJ - bUWisJsylbza/CcoxEe9YVWqq8LATiXuOiIovJw9Hl4PowDN/a/tzELxzkEvkSpliChiOETfCBtz - sUrPgThyINfRHpw1vW0URz4mXgArtxIVXf41HDU6Ks4Jk6dI2ZV9RIfHvP/D0pXi+cBES5kf - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-conjunctive_lavage-reply.gpg - Content-Length: - - '1194' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:3d7492dee4392a2c7180f236615ebd6c26d772529d502c5124258127ef40a391 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/1c2ff7fa-252a-426a-83e9-5840cf657739/download - response: - body: - string: !!binary | - hQIMAxGWEiPHDepYARAAu/TMRu3Ff5fRgQqO/E5Bv/94dfp2b5I+AyQ5+ejoEVp1xxS+IiQWM+Sn - YWnrgUSCRlPRZLzlgORkyg9hV+Hke6/ycie75w4z5C2yLMp4fS2/bsIsAfUpd4diUUjc/L++RWvw - GX91oQB9aFsEJxiD6LIb5DvXf4EeU34XmTGpTUNx2st1bcTvsw3ApzsW4isLgZipHKYekOnX8qvx - vpOjVjyWEeSdNNQg+hAgB9JK+vp4Ueykhyz5Xg6EaPbWciYV/pgP4kDa0yilHImH+eSABa/SKUar - ykt0ny6BbbyfvZJXCC16sHuCzmddXBuhoEm7Z9dn4cBbP/mWVbkw0aPTYEdTNYMi5pMdVvSWlLbt - u8A2wKiOCkzUkaguZjbsJVJPc+jm0XQuccVqTdQkUiXiKZWw/pFxgc0UgqiHF6cqO57xZS9I7OQs - yx2CrR20ITwb2rRUxsF5SiUvGN39aj/2ycIZ5PGZ3dweQHDOMo5kR47aOph2Ac2BztN/s3x7fqfJ - 8KVjxCW5xlv9yhl/lIr6CPgH+4NqJvxQu5M3zXVr3hTnoTnBoLX/g7w7oxEwAi43jI0FEzm2e2bn - W03ezM3b1P0uLeNx2nmqo2HDZPJPItU7BgN03A8GBmxPsDojeR0khzZtWPBPPxIyELg2I2gPvDo+ - hQI5s2Zwu3b1jYnq012FAgwDw+fEwKIgGyoBEADHptlGI/S5RTU8LAGF5COwuVWEIGieqkNRnIEi - +aq3ln+i9lDHpbUoqjtcxAGYaoC/AkmWwu8Zb08LPOw6yGj9Vq8HkvqYoF3PVfR44gr2g8MGBGhl - Y6NqAAXAe/SxqGJUsN22Ag9TDKWcMPxM/K5+7IqQixQy6FvqNrQ4EHwAJUxTZZH/8A9q0r7SWYtv - 65OSbkaIaO7ZNxzqvCP5f+Ut05BX7xpVlJ2JMxZFGZCy+s0/0uinhtPbUsL3XqFhYVVFFGlfMpSX - KZMNIo7I4e+NzMpm90gHQpfbCoR2zafhxgADsEaHi6LNrm+1kHbL/acPKctAXbFeWFrUbjceBYH7 - kVdEVMP5B2ycHD8ER3HRXAOD/UDHb0Udn7zHNXojsNFQ30A5PD15IbdyL10eGm5LaFndkxcQRonm - 7ALRJXXV6veXtE2glXCMmbtzIBycZpxipEG6T0046uBZs9XhQ27UoQ88d5ar0MsgoZkTK4WKQlUv - Sz78qafEIhiuqxSNT6NErjBmgxFCcMvu1OP5XgCMsSBYCIVB2VmbrFVv7mpH0apqc9doMmveYsAd - L20u7ejj82IipaNxJNvTcwuXpbWt5woSIgY/icD1v8ms7ugDbPxHgUpqDOUhC6PBtTq/so2bDxEA - peMxY5zLQgFxKFJW4pOa1104hg1TdZyClxU629JAAfXer+CmTbev26iQ375glBw+rNyWA9J+iX02 - nu/JwYp6Z/VLK8FY/5WWZicqRnG/4G96w6zHwkB5zD7rF44utw== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-indecorous_creamery-reply.gpg - Content-Length: - - '1120' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:8ea8d0a16663cc9b7d4f2e196ecd9d675ffef7f0f9d6b51294cfbba8ec99eb33 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/5f5707b7-ee1d-410f-94be-1ba8c1929264/download - response: - body: - string: !!binary | - hQIMAxGWEiPHDepYAQ//XWIBkXv/uh9NDRr86nlqZ2gJGssV96HBp10HVUbyyyEVcQkZ/evEOizB - 0gFUBwqBwuYAazPNgfgEVywXbAERoFR9Pul4AjVCkHqbrPOl62BSsKpLSAQIV7YP6AlJPiO3Pm0R - VeENWPAjmotBHfpjgVwiCkQeSyF8lCAveo7707ppJT2hCpxjJQhtt1WIyOQSE7dAaAnywUWbBkoB - HldRJiKQa1O8CsNm8ELPQaczYR41LZpPHZ+h1xLR15iGnXq630ZjDagxok3+aXqI20MemxYOvIc9 - nqxQLduSWS0HykifTX7wYcCGj0PCFqSSngNUaQTAjSQPOQjUurWV4T2/aT2ixT9waPaHut8jpQ6p - Lzc0Pe/C0+yB6YBJJxINovWHzGL0N7ZWuPVmkWgPWcOCPu5Cc3pn+cv5fuqoFuJs1/G1t6eIBfqI - BMw3FPbq1sa6QUPY6RDqpRE8+48cHpVHBHKl2wc1uL9WVUtZdFKAOz2bLu7CAnD1VmZvpszgq12/ - j5m2UwINjo4N78UbjClCMgbyzji2hM9q2B7qkTf5JFZtg3YbhNKgiJxDpTcYdMG92BQS3vYjxHiR - FzxGwbDuQofyqIhIVhlK/42NZUns3W6Sm+fkl03fR72xtBdGCdC6tGJn7lrys8B8JG/Dr3LsBqnr - A+KqQNu3Xr2ERHqrie+FAgwDw+fEwKIgGyoBD/4m3ypz6E9TqX/mm4Syc+KOtfb1XXWGlrI+YtxW - cZHPI+9iCA2XamMXFdYDYueb0CGB0bP5l1THXkYsN+kvuVzcdXZ2hY1U8tMhvU/UNirdau3Is2vJ - uOQ1cqvJHTkEXEZdTzZG66Q6ZjtQjr8OMH4XugC8TKIR0gq/xmxk+49fq3FT09d+bccvFJQil5SF - +OKqJQGVhKaUd3bF/ITS20psYNcR36QK/QDrCM1dp2s85wWe8j8cnVPscuJ0lbX0qey5tMUf74Yn - cT8qNeKhh+Z/6oOjBB+UtcN/c6okMAKbX0IoQjCskE/D6ldscXkYTx4wVp3CyzZMGwyh8sjDBPp2 - ilsccaHhybJkFx+qwx/R8AoLc9za7qlfU6BYvasGeY0LmU9DCsS+fMQjL/34rkq248h4mxee1rap - v9vipuGDkd8EJMZPkR6PDL8iIHW13xqDlTEAkhuD0fsFVxGxdXgUdvNrPHb3/X+c+BDCdP1OFO7S - 0SQOO7NijD9O3NhNuKBkW3FnCYHb6sbJ5XRsD6h4LbGe6KwH97xULC4jRVSIHssTl+Nozcv1Xml1 - 4AJbGtcpna3Fc3Arjjop8UNoDntuDfXEuRulX+Hckib/IrIGTqgoEHYCEd/RMhY0ZE2hT/7iQBaT - FMuOpyvlV+Mb6zjynz3qy63WKV/cIAT3LwrWOdJCAelNp8jPH79glm+vZoeaZwjztzVucJRxKxtN - CuvLf+ziRI9v1FiL5GT5LAPpr3jtZ+qi9j9rKKAtPKfINXiS/B7M - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-indecorous_creamery-reply.gpg - Content-Length: - - '1122' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:1342def77aa79e3babeb0b709cf3dce39e69a8e1e04ec0c6a41c8aca6a979600 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/9bb8030a-8561-4a03-85dc-e921bd6a891c/download - response: - body: - string: !!binary | - hQIMA8XplUsUkq4fAQ//dI3ZLYYvp5nURcYqnL+N6qkdzdZ8SV8zxGd6wI7Oc0pUQy6Ri/Ap4gVy - 0fQGy+gA6QI599dDmRA5IxNV2GJ7D1KGkwCw9149ZGx3s1Qk9xDLN1+2xr6wbC8WnO7aMb3XPnzK - hEOpX+GIxK+PShiwR0TedX/M1ZybM2YNxuJn14/yO3tbr9nhBSujuQkSitQ7xdccdYiO3laJNjD1 - h1/aSUPz5yz77as0ctRoSwjl1+JY9O0RhclEKF8T7lIBms+uZwZ9b5yfQIlaMHd7iGT4HdIUMPjn - QWn2JmJ51BEa5G1Gpu6wEu5xT3fl6Z3Il3T3ARY96z9Ps2sCQg5FrTJ7U+RRj7yt2Xw1PF13DNpm - Sd2y6Qhpu3lFkM8cIzI+4O5mYL9Qe+9vy6B83vCtDKUZ9jqcZuGa5HD6f4Gzcu0FW1WAbCT02MAY - YTM8p/tyAoEIKgKcQxFmEhMFbaPOQ20TRXKb+x5sJGh1i5M4CmMQsGvczZrh13Zm5QIw9cIqc4de - uh4WrMsSHlGpdR+glbzRq7kCoofi3QOSrsTGrnaIPqPp4M3VNNJnR47yipKLFSGMI4T6zqHSTelW - ClhS4svd0qSPVK+DWD+XJ5lHrUDIzBM7FyGEkWAQpbqHIaE2fAN9QloAkcTPSO3A3/MdnYKHFLPC - BT+m30B2N7D1S7HC5geFAgwDw+fEwKIgGyoBEADBNF5oX0O6LpdpJAYuZpoZkVbZ6ZK0uc9gTh5N - CaoJNS2gHZtwhzqfgFzZVu9hERuUFvELXaeebv8zxNSRohUtIr0uDOWA9ZFJ+IrzaSBEfns28jkX - b8GsGeJQ3FPnvdp0LtOGAsrUGj02e71lJOx8qCfVgo1d4ZKxHpCSdC9+CoZbCxPE6a4TRCiE3Khu - /DDi96t2C5jNRHCIsfgwbaBB7sLeibkiIMhiKYGMz994UmA82XAHPdkIgXUsgju1UwxKfxk84Kwe - F1hybOfkpjAJ7kH/E5l5Udy7eEk5kz1M9TWr2UHSpY48x/enEOWYoAcC7f3tBKWak3WrxOhCjPjk - /7y1vSMKCLnsVkdNAJ6DTMpjQsv5aKuk8UPMTqw5oEl7JuFKcwdztXOgtsVXbe8t0rYTLMJlDa7w - 5Q5erJ5PdkQexpccw68Xswa85GZSUCIwqywrW4v5T3oemN8ZdZWuACFLBPvv6/JeRg+wEN4lk7qk - 7Q9FnjbY8a2Wn6ydCh81gAm6XQn5s+HH5FEVrJBbVrshDXZgdFIfLer2yyVOBOi8HnzYylxBgOHH - IRRlW5zV3c8bcPsX4doyiHXg6Rq8xs0vJRghqVRJXLYzsp2KG9h8gUvvX4F6I1o3zTE9RRI7jUVQ - sg81ViU4toOfaLcKuwpXKfy8tZR37+FZqWQSy9JSAdJ2DdIiaFHAX636/MO3AYocVMKsfQHHMmES - zPxveAOVrYp9wctgh3dNe7tJqFJZgObxmyKWdeLTmC3LE0P9d73Py9yfqmlZ8ADishAQToTzkQ== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-concrete_limerick-reply.gpg - Content-Length: - - '1138' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:be7131a49df1b7a26d0610a96294198e1b27d7f13c18fc7b420132e9604e878a - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/0a82f046-581c-49ef-9b51-ce5b73e45c1a/download - response: - body: - string: !!binary | - hQIMA8XplUsUkq4fAQ/9HcK9M4c7Tks2GRPrAJgP7c3FGgz8Q/2HPBNoc73Fu1vsFUy19Zk2UfKX - 5LpKqFvMvo9T+HZPscKkoYpru68WahEAyIvdWRXl1OP072usBa/pOel4MdsX0l+ShrjK5H860zp3 - shnbNAhmpeEJ2TNQmDNj6UQsJWTS6hMoxAWIxBbuScqUk5T9oNEL7BSxZQnBfsMt50EPf3F4Fcn4 - aFRwWZtQZYlJjTodr1QiPykSaN88+ipqB2WatT+zxwBDVhjZTDWLZeprizvV+Ezxk4HwkGVm4C3C - lGquJCjAKt6t392zDVd1jEy83ctiu9DFZ/RBuVt6ath47JpXXKYu9Pm+hwYOZ5jOlE1C6z+B4xWd - sEDpocvIUxt+8VZx7DGACGRzHbJ5NapObt2eX6sQgxyMOwmg+bYqo7DHfbyMdPLY4SE+mytI0/Z2 - mm3/6yOOnAEOl3+5/M7aUPH3qUy/4S63iJKQ2banBSD0yDNQ6I/0MnU31AysERrRCSdxOExq/9u0 - IqHhb0In7hX+6EM3mQSg+z0AvX/xHWcn24TeSjMv/9WMFcasm85Xb305FVFrRyeMPUDcrbwepp8G - J/pj7mldMCe+5I17pxnQ8sImFt/GZG8DqoVrR6K2s5s2DCKywizUjifHg6L1sM8gY8d80y50U6mR - Tr8WNtdIdVuANcufU26FAgwDw+fEwKIgGyoBD/9+mmWhGDd48AshmcJ2SiqkgYuYUdp10ujWVZNx - IN2o5monN2AXkTyLUH6h0f/5HtJEGkoqXzQUs/DysOIRu27QqMS4BjW3fWXfqcKlBXItYHdd+BBw - czdqXrEMxdFv4MiP8q796+keQsJizPInpyApvFz4j7n9oLyshNLU2z+QoDkhKir6q+kSoDkuySug - JS0qzkdP0zp1QF/IzmmdyOLbApIZpYCY/wJMxVrqeBijl6cwHV5O+PMw0415WRxNXZ6PzEGzMeX0 - zSgputz0Jx4f7wpRjS/jgcP66VHTAl0dAKtEY7FHPUS68/0tBhsLOYGv8AJA4evAeVPCWhj4zJH9 - dpTTJd8PDOapoQH/xBEvt6AN2WKXeDH52tl0QFdtmVDPYjbqo5zh/qctHKv0QdsDjZZXmpnTCrfq - nnLRagcPeW6YKyn8yhrP44VR6Gzt9CSN3HGPmjfy72vqnyB0rEdkYoSEZZ0hxTsZ3QMT0bZ7sDPA - XK19LW9BRzjZtlKSFGONuciDN5lR4tQntGacjMcOj/xGe65PmuL484mak/900Cx9jwrw1hdq+a9e - gpKDsc4KG9suXkiJrzEHQE+18kgRBvoMZTAbumECKOuHUgZ919F1GgV3No6XjQZ+botyN4mgSwJm - VIV18ep7w0SQF8Qb+BCo8mbS64+nXd4cQfwtktLAIwHCxqT2yTvD2UXrLQXoCIvFP8xJ6T92oCgn - sSzyBciKz7C4EQ6N9dKQSo2ZXHSRO81/LuBGhreMQnhYiV90OceTFJ+U0nFWh7smggqjZSlqflg/ - W5wcqd945LAnGlQPky0AQcOYl5cFa2cHE6FZNhs/hQL5CAIir9AosMeOz7A+msaijWWsnkfc8KAF - HIrk8/qi7WDKd3ni++4dUBP9+xWijpy6jHzD3DJgP30sXFCDAjlvz+4Qopz4wXTncY03ypkcEGjP - sGsGWkGeBwyOvdxwk02XXTWyFv6aFenv6dNoJ/Mv - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-concrete_limerick-reply.gpg - Content-Length: - - '1284' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:df33b47b1b077cad3b8ab00a5eecef38faf353be83387ca4ddafe193a8ee81f6 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/2281fccc-4cae-4228-a837-e6f3a3e1e6d2/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//VfnuIjbnRUrH1WRMvSak2SMigZymPdL6hvNluiuGz4hYbNIZqKWVjuzy - 3BNnWhvWpljKFy86NsonbdF29kuOIPePWLdXVe8mL4a3Kc3IY8T5JBWsvnkSl3TEaRGrlWsG5/ag - 4NkyBH45p070Rr57RqVcUBGe/ckzVhuiIOzmj3ujImMGG+ozo2kWPY2RfovqDtocUzywbh4fxtRD - lZQ5lgercImj8uvOaR0vbGzl67zg8HN4tz9U7QMxd37M2+PEBQoNILaRx2OQwyXEAjP89zEbqQmB - +N+I8WcHfvnj5V95JQ9DJP3LjOBYDb9fcesY5mu7E3yDzrd7OJkUhAimik7ImjkeVTnJx3IkNiRp - GutO8DunsgomolaehXlZrJ5dRU/SIISKcEPZlXc4sXpls+zS0S6d0hhwF8sgOKmxv55hWWe0+2Nu - nkXNUR3rxxKYyYf4Pv2VPJVxnr9+4u0MAAV7q3ztemLJNSAS8T2eRX3pkhKo3tRfDLvovSpqCIqT - ZMTSODjs+whuLDoR8DZuW+rGllZDu9OZO2V+UnODrH8ilbZ3wxt6Ryo6MR7wZbocbrMYNewtJFML - SS7I9xVzHmLDSfRePHo+kXa2qsD2nH7TQJ2H9VIyA21SvHVtDuqTjiZPSuypsuHldnpJbnrGQtX3 - CChqw5bh+aBLR5K8t1TShAGS5bRN7WaLcnaEqZfWFHTduPGNEOtpHZtVnxWrI/Khxwlm/HDmmuRV - I+CC5eQeIv1dQ889/JZNOq8z8EuofNes2mnw+fkEWdyFfllb55HBxwrtRYphlujUDTVy82+FfY2a - ozhTgY58FyjhaY3t8Y48vMHJ8j4BfsXkTHGGXGDPuLBg+Q== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=3-conjunctive_lavage-doc.gz.gpg - Content-Length: - - '661' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:1ae2759fd28879da3d3ba964ce8dfc13280583a08219127997508118eed6b4a5 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/9df9083e-1ac1-4085-883d-8c9982b6ad79/download - response: - body: - string: !!binary | - hQIMA6YSKmjUcDXDAQ/7BrrIWGBja8P2KDQIoAT4IclJDo5po5P93oEFQpUnbOUGwkeLnZeY1EXP - DPthD6FUmgE4p+afTgeAJHa5p7aZ3cBunjGpx7CUwXXubZTEt6nl6xcENtfrgIzUG8SZDCDJcsZS - kXd0JMqxLswy0eCiQo+zDv5BOf5TT3P/RCCWI0MYWLSchTkdjyGeWJd5+SdPlSTHB54J+PGOipQ1 - 6FWWxpYA5/vYVWg+4vwFJt3RYqUITyWGi8RI5E5aXEhMs3bShrXZ1WLpjpJg34ybfNg+ZAYq9ava - Sxv/PR0NcZRaPAFz25DRZIB2IN0pbNOsr17nKEmOszuAfi65+VCBNGpuGtb1/B6VnBKZ2D1beUEJ - oVpYaSr/VU0eEv6YcsaqUfaGcNyzpipfqQ1aLYXyhdLzXYKlj2qUQpntMVvfa9tp/p+FX6CxyG2Z - vCyzC28sGaQfizjYeVqV1xxu2/Q2Yb087pQgq9R+JWNgy3uyDss3YrooACirO4/pYc8qWUda15Hp - xIqmgnuYUJ0/albmzwc1GGR2AFqYALnhmZodifqvhmfmICytmh8LQhEVVInVn3ma6EMcFd2p6z5K - a8Y8G0bN4c79iFK75bUg3sNvP7osGB427a6JicZu3uMGzl8zH+7UFtOVeV5zoPB/USoHBJVLwxbG - 3EphCCaYHpk6ER7DRz2FAgwDw+fEwKIgGyoBD/9rgU6OldLEAOLqLwSF1gq7bgBfFzYHiiJcsSyr - +XTWr58Po+7pbGwBwIbr7eOmqga+hvJEDUZxYRkd59fgrnKU0GB882ig0H95Uu3kdzYIG5g79KVA - UOsbHAjXPSpm+8w18OLxdaz/rYM6V1M+Td2+KnPPcdETMLRliFMOJvj1gAJmKXQNhStnkJ68nJNC - I21O3GcU0suoOXFTMtLSqpFZX6g0BXaK+WN3dw5RM68zZ+eFvanqfCCZwGUx4KjJCuxAVsUP9eaH - Jga5hBbRBdXNHcUlrMGJW7Ig0YMZW3Sao2Z75rObITzLimMdMWay9Qfgh91I8TKspFiOLYe3yvxW - oRemzwTeC6vQX8RjNzzHN1zqCS/7UDiHT8kMGfhldo0mmVAwf3Uwl9DHul+T8X0Ci2551E2KFUE7 - Muj9VXBs6+3Uo83RCKwo3HIHMlYIyvpoThmP/w2QFEYJc4wQfCDl3N2DjdLe1oRVwmi82oRn5/8O - 6HlJYoSG38NMgVXdGG3UMrlK5S4yZ+gWtXtXGpqCihc3pT1VzNs5wuZvmxlGkSDDWMKkHu74TaB4 - 7dwKsPhctAPlunVxgy0tjUUJvU86gkGy/Tk/DqKfPwDMwMbMuQD4MQuYuWgcoxp86TKKxkmjhZYq - b0uxys2dUyJqjaQ3SiPjRTM7PZrA9nl2S6cmENJeAYOuj+E7SpEkg0H98JvFb71VbMIMq7BWYGp4 - 8QwhQ2ljYD2T8K3TCBJ4Z0caYoI44kIFBmDBwva6DRjYEv5I8+SU8q/cXAcJkemGFs6ncohM4uuQ - eXikZTP4UDJRUg== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-consistent_synonym-reply.gpg - Content-Length: - - '1150' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:0065e475a3573a3aab789202cfec080705a8b07558bf68612591af3a10166942 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/ba38afd6-aadf-48d1-a599-bd74601105d9/download - response: - body: - string: !!binary | - hQIMA6YSKmjUcDXDAQ/9FNU33HR0bX5ci79Lq1YwYMPu9QUmS1qviasV5DFtV/YIFaog+Ip30R+a - DUEPVCMQuOTfJd/zuX15bFh6BbkJ+fVfo9GsGW6NrgDIDnt7GKDmbcm64CVvtAf0sa5KaU8405mk - LseOtJAuKXxBm9vNRBHjwgxdl5zZprIhjAa/biJh03jy+BihB5uEF5gGqLVVIRFIZQz4jA1MsCXQ - 4EpGjQYCsrBqPzdKWLRhmfWZ7h6GiWHzoz0LYMwqTxQcMfcOYe5kOZ7yvRO71u/MXXn5WK6u4CC9 - PA8oh45bbJVdC6I/fRxcYh8RYwIhxnDl5/EtW1CEknCkNPp/GIlEvu2jAQGO/bK/paGoOyY7wAgV - 0q3aRGHRoUs+DLG+WQ+YFt5jN2P4JAiW01Zr8HLPl+cQdkQUlprP19ODTepGQm1lwK37oPHvQwtg - PdpFpJDUWFkbg4q0hpGTVk5HCr3/DgNHUk10Hae2lQpf6Q9P40E87cOwsiJrWsPMpL+g6V4rebqU - 2BPj+CrWpMgHe/zuy0cwX2lYj0Put+kBDoXJsDQopn4/Wc8aISmnxLMfpAv4kXA6x5KhvApuGZ3L - uMU63cb/m+5NKeGqpo1kZOG0cim8lApnqzFqwXjkbaoDL6W2yGsX0VZcugjvU9IFifAAoiLCs/eU - 28r9t+HevU3+fhAT1KWFAgwDw+fEwKIgGyoBD/9etXKoKImkUN7va98DeaW4fE/pqDUw+2vU7CVm - DkcR6ay5okyHbR6zwtjdW8EHscStZR7WA04e8YxwqkVSlVecDr7Oey/WaEqT730+4HRUI2QuMJYk - 48sqf5BlGd+vz7+hv0jRB4eeVPwRZm22o1252jrrbzwgvOncNKW+F25rOQEMrGo2VrweOwzjsUQh - Bk1HZGrXfxnyikH/mFQe4qZEKNbf/zu2dYz+9z4lX+G/yIrdn/bACQMulnl6UNQKOF6curDaysPb - BX1xqFTHjeCzQ2lQ73bjX3Zhc70Sww6MR6NQuz4Z8cJ9c2LCpbAT2JCfCdhukedjrS6SpeULveP9 - a5g3vQJevdnwPITGAz59Qsx7Uw5jv/cN/pAGb7RbzvQERWPJFKg/MDz2cCIQb4gga1uDyJTXzY8j - xXZ2h+n9RXac9YvpzlstyDg+9H02cbJn5z8euQ30CGKwD/Ydls7X+Q4v6QFTdZxJrQiIw+dBjOaH - Y4c5AgqYwq7eYCDlWEromT+nBfz8xOFo7/0Ea7iU7eWzvPt1z7X3i2rUOU85+m2lmgNxm1bvr/oO - hJpttyj0k5yv1nSEnwzgjC/HNImQLawyZhAFGM1NCn66Sk005EVPFppu2zodz/rMRdjTm2JliEBa - X4VmnwUtaEE6CqdFAViOFum7s/CFNIS5xENngNKjAZlQSzKpVecUBuO3nndeVxrdmd4B8n4wLiTo - 0OqNHOhmS4r5sIFdYmnNfmeK6Ksg/yS094ri3D9MeHHEjAXwrw8FAh37cyN73kdXW0sKtkY4VGIf - TDDwwx2bS/muPCZ3VfgTLHLuZrwPX6KFpkRyJyAX8UPeZwN312yqX3mcrtB60rPlAMtibq05KKSd - rqK/U9A1vzBorijE8RNFXihbW41PvA== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-consistent_synonym-reply.gpg - Content-Length: - - '1219' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:3a1d257181881c338f2dae2618c62d53f72da2e93789d25b032bcd6a72cc0257 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: '{"files": ["2281fccc-4cae-4228-a837-e6f3a3e1e6d2", "098a7d90-0ae4-47cf-a7a2-2afc00094a3b"], - "messages": ["4abcd4b4-3922-4ae0-ad97-9186f51e172c"], "replies": ["158dfd73-3cb3-4a6e-85b3-f37ae54e0802"]}' - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Length: - - '198' - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: POST - uri: http://localhost:8081/api/v1/seen - response: - body: - string: "{\n \"message\": \"resources marked seen\"\n}\n" - headers: - Content-Length: - - '41' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -version: 1 diff --git a/client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml b/client/tests/functional/cassettes/test_export_wizard_device_already_unlocked.yaml similarity index 100% rename from client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml rename to client/tests/functional/cassettes/test_export_wizard_device_already_unlocked.yaml diff --git a/client/tests/functional/cassettes/test_export_wizard_error.yaml b/client/tests/functional/cassettes/test_export_wizard_error.yaml new file mode 100644 index 000000000..41f610534 --- /dev/null +++ b/client/tests/functional/cassettes/test_export_wizard_error.yaml @@ -0,0 +1,1517 @@ +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-07T23:04:49.173748Z","journalist_first_name":null,"journalist_last_name":null,"journalist_uuid":"9d2be057-360f-42d4-8145-26b31ea57d8a","token":"ImJHeTdHZEY5SEdHTmk1NnFFMzBhb1ZaWFYxdW1qam1ERjZTZVdCRnBPV0ki.ZXIzcQ.PxFGrGsa9ZW8AN4GcDpqmmI9oBY"} + + ' + headers: + Connection: + - close + Content-Length: + - '265' + Content-Type: + - application/json + Date: + - Thu, 07 Dec 2023 21:04:49 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 ImJHeTdHZEY5SEdHTmk1NnFFMzBhb1ZaWFYxdW1qam1ERjZTZVdCRnBPV0ki.ZXIzcQ.PxFGrGsa9ZW8AN4GcDpqmmI9oBY + 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":"9d2be057-360f-42d4-8145-26b31ea57d8a"},{"first_name":null,"last_name":null,"username":"dellsberg","uuid":"f3fbd8f2-7109-4beb-9387-b18b373d5409"},{"first_name":null,"last_name":null,"username":"deleted","uuid":"b32161b8-eb1f-40e3-b072-9ccc507dfd61"}]} + + ' + headers: + Connection: + - close + Content-Length: + - '329' + Content-Type: + - application/json + Date: + - Thu, 07 Dec 2023 21:04:49 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 ImJHeTdHZEY5SEdHTmk1NnFFMzBhb1ZaWFYxdW1qam1ERjZTZVdCRnBPV0ki.ZXIzcQ.PxFGrGsa9ZW8AN4GcDpqmmI9oBY + 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/745e22fd-1cd1-45ee-91cf-dbaada945716/add_star","interaction_count":6,"is_flagged":false,"is_starred":false,"journalist_designation":"tuberous + flutter","key":{"fingerprint":"447AC4EFBB460F5A347B2C606C6B11C41940F8C9","public":"-----BEGIN + PGP PUBLIC KEY BLOCK-----\nComment: 447A C4EF BB46 0F5A 347B 2C60 6C6B 11C4 + 1940 F8C9\nComment: Source Key Union[Volume, MountedVolume]: logger.info("No USB devices found") raise ExportException(sdstatus=Status.NO_DEVICE_DETECTED) elif len(targets) > 1: - logger.error( - "Too many possibilities! Detach a storage device before continuing." - ) + logger.error("Too many USB devices! Detach a device before continuing.") raise ExportException(sdstatus=Status.MULTI_DEVICE_DETECTED) # lsblk -o NAME,RM,RO,TYPE,MOUNTPOINT,FSTYPE --json @@ -213,8 +212,8 @@ def _get_supported_volume( def _is_it_veracrypt(self, volume: Volume) -> EncryptionScheme: """ Helper. Best-effort detection of unlocked VeraCrypt drives. - Udisks2 requires the flag file /etc/udisks2/tcrypt.conf to - enable VC detection, which we will ship with the `securedrop-export` package. + udisks2 requires the flag file /etc/udisks2/tcrypt.conf to + enable VeraCrypt drive detection, which we ship with this package. """ try: info = subprocess.check_output( @@ -222,7 +221,7 @@ def _is_it_veracrypt(self, volume: Volume) -> EncryptionScheme: "udisksctl", "info", "--block-device", - f"{volume.device_name}", + quote(volume.device_name), ] ).decode("utf-8") if "IdType: crypto_TCRYPT\n" in info: @@ -251,9 +250,10 @@ def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: in the list of results to check for. (See https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect) """ - logger.debug("Unlocking volume {}".format(volume.device_name)) + logger.debug("Unlocking volume {}".format(quote(volume.device_name))) - command = f"udisksctl unlock --block-device {volume.device_name}" + command = "udisksctl" + args = ["unlock", "--block-device", quote(volume.device_name)] # pexpect allows for a match list that contains pexpect.EOF and pexpect.TIMEOUT # as well as string/regex matches: @@ -274,7 +274,7 @@ def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: ] # type: PexpectList unlock_error = Status.ERROR_UNLOCK_GENERIC - child = pexpect.spawn(command) + child = pexpect.spawn(command, args) index = child.expect(prompt) if index != 0: logger.error("Did not receive disk unlock prompt") @@ -284,14 +284,14 @@ def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: child.sendline(encryption_key) index = child.expect(expected) if index == 0 or index == 1: - # We know what format the string is in. # Pexpect includes a re.Match object at `child.match`, but this freaks mypy out: # see https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect + # We know what format the results are in dm_name = child.match.group(1).decode("utf-8").strip() # type: ignore logger.debug(f"Device is unlocked as {dm_name}") child.close() - if (child.exitstatus) not in (0, 1): + if child.exitstatus is not None and child.exitstatus not in (0, 1): logger.warning(f"pexpect: child exited with {child.exitstatus}") # dm_name format is /dev/dm-X @@ -323,17 +323,21 @@ def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolum https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect) """ - info = f"udisksctl info --block-device {volume.device_name}" + info_cmd = "udisksctl" + info_args = ["info", "--block-device", quote(volume.device_name)] + # The terminal output has colours and other formatting. A match is anything + # that includes our device identified as PreferredDevice on one line # \x1b[37mPreferredDevice:\x1b[0m /dev/sdaX\r\n expected_info = [ - f"PreferredDevice:[\t+]{volume.device_name}", + f"PreferredDevice:.*[^\r\n]{volume.device_name}", "Error looking up object for device", pexpect.EOF, pexpect.TIMEOUT, ] # type: PexpectList max_retries = 3 - mount = f"udisksctl mount --block-device {full_unlocked_name}" + mount_cmd = "udisksctl" + mount_args = ["mount", "--block-device", quote(full_unlocked_name)] # We can't pass {full_unlocked_name} in the match statement since even if we # pass in /dev/mapper/xxx, udisks2 may refer to the disk as /dev/dm-X. @@ -352,47 +356,39 @@ def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolum f"(unlocked as {full_unlocked_name})" ) for _ in range(max_retries): - child = pexpect.spawn(info) + child = pexpect.spawn(info_cmd, info_args) index = child.expect(expected_info) - logger.debug( - f"Results from udisks info: {volume.device_name}, " - f"before: {child.before}, after: {child.after}" - ) child.close() if index != 0: - logger.debug( - f"udisks can't identify {volume.device_name}, retrying..." - ) + logger.debug(f"udisks can't identify {volume.device_name}, retrying...") time.sleep(0.5) else: logger.debug(f"udisks found {volume.device_name}") break logger.info(f"Mount {full_unlocked_name} using udisksctl") - child = pexpect.spawn(mount) + child = pexpect.spawn(mount_cmd, mount_args) index = child.expect(expected_mount) - logger.debug( - f"child: {str(child.match)}, before: {child.before}, after: {child.after}" - ) - if index == 0: # As above, we know the format. # Per https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect, # `child.match` is a re.Match object mountpoint = child.match.group(1).decode("utf-8").strip() # type: ignore - logger.debug(f"Successfully mounted device at {mountpoint}") + logger.info(f"Successfully mounted device at {mountpoint}") elif index == 1: # Use udisks unlocked name + logger.debug("Already mounted, get unlocked_name and mountpoint") full_unlocked_name = child.match.group(1).decode("utf-8").strip() # type: ignore mountpoint = child.match.group(2).decode("utf-8").strip() # type: ignore - logger.debug(f"Device already mounted at {mountpoint}") + logger.info(f"Device {full_unlocked_name} already mounted at {mountpoint}") elif index == 2: logger.debug("Device is not ready") + logger.debug("Close pexpect process") child.close() if mountpoint: @@ -409,8 +405,8 @@ def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolum def write_data_to_device( self, device: MountedVolume, - submission_tmpdir: str, - submission_target_dirname: str, + archive_tmpdir: str, + archive_target_dirname: str, ): """ Move files to drive (overwrites files with same filename) and unmount drive. @@ -423,16 +419,14 @@ def write_data_to_device( # Flag to pass to cleanup method is_error = False - target_path = os.path.join(device.mountpoint, submission_target_dirname) + target_path = os.path.join(device.mountpoint, archive_target_dirname) subprocess.check_call(["mkdir", target_path]) - export_data = os.path.join(submission_tmpdir, "export_data/") - logger.debug("Copying file to {}".format(submission_target_dirname)) + export_data = os.path.join(archive_tmpdir, "export_data/") + logger.debug("Copying file to {}".format(archive_target_dirname)) subprocess.check_call(["cp", "-r", export_data, target_path]) - logger.info( - "File copied successfully to {}".format(submission_target_dirname) - ) + logger.info("File copied successfully to {}".format(archive_target_dirname)) except (subprocess.CalledProcessError, OSError) as ex: logger.error(ex) @@ -442,12 +436,12 @@ def write_data_to_device( raise ExportException(sdstatus=Status.ERROR_EXPORT) from ex finally: - self.cleanup(device, submission_tmpdir, is_error) + self.cleanup(device, archive_tmpdir, is_error) def cleanup( self, volume: MountedVolume, - submission_tmpdir: str, + archive_tmpdir: str, is_error: bool = False, should_close_volume: bool = True, ): @@ -465,7 +459,7 @@ def cleanup( logger.debug("Syncing filesystems") try: subprocess.check_call(["sync"]) - self._remove_temp_directory(submission_tmpdir) + self._remove_temp_directory(archive_tmpdir) # Future configurable option if should_close_volume: @@ -479,43 +473,44 @@ def _close_volume(self, mv: MountedVolume) -> Volume: """ Unmount and close volume. """ - if os.path.exists(mv.mountpoint): - logger.debug(f"Unmounting drive {mv.unlocked_name} from {mv.mountpoint}") - try: - subprocess.check_call( - [ - "udisksctl", - "unmount", - "--block-device", - f"{mv.unlocked_name}", - ] - ) - - except subprocess.CalledProcessError as ex: - logger.error(ex) - logger.error("Error unmounting device") - - raise ExportException(sdstatus=Status.ERROR_UNMOUNT_VOLUME_BUSY) from ex - else: - logger.info("Mountpoint does not exist; volume was already unmounted") - - if os.path.exists(f"{mv.unlocked_name}"): - logger.debug(f"Closing drive {mv.device_name}") - try: - subprocess.check_call( - [ - "udisksctl", - "lock", - "--block-device", - f"{mv.device_name}", - ] - ) - - except subprocess.CalledProcessError as ex: - logger.error("Error closing device") - raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex - else: - logger.info("Mapped entry does not exist; volume was already closed") + logger.debug(f"Unmounting drive {mv.unlocked_name} from {mv.mountpoint}") + try: + subprocess.check_call( + [ + "udisksctl", + "unmount", + "--block-device", + quote(mv.unlocked_name), + ], + # Redirect stderr/stdout to avoid broken pipe when subprocess terminates, + # which results in qrexec attempting to parse error lines written to stderr + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + except subprocess.CalledProcessError as ex: + logger.error(ex) + logger.error("Error unmounting device") + + raise ExportException(sdstatus=Status.ERROR_UNMOUNT_VOLUME_BUSY) from ex + + logger.debug(f"Closing drive {mv.device_name}") + try: + subprocess.check_call( + [ + "udisksctl", + "lock", + "--block-device", + quote(mv.device_name), + ], + # Redirect stderr/stdout to avoid broken pipe when subprocess terminates + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + except subprocess.CalledProcessError as ex: + logger.error("Error closing device") + raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex return Volume( device_name=f"{_DEV_PREFIX}{mv.device_name}", diff --git a/export/securedrop_export/disk/service.py b/export/securedrop_export/disk/service.py index 6a7b7cdfd..9a8034ee5 100644 --- a/export/securedrop_export/disk/service.py +++ b/export/securedrop_export/disk/service.py @@ -1,10 +1,11 @@ import logging +from securedrop_export.archive import Archive +from securedrop_export.exceptions import ExportException + from .cli import CLI from .status import Status from .volume import MountedVolume, Volume -from securedrop_export.archive import Archive -from securedrop_export.exceptions import ExportException logger = logging.getLogger(__name__) diff --git a/export/securedrop_export/disk/status.py b/export/securedrop_export/disk/status.py index ca04ccaef..59f868672 100644 --- a/export/securedrop_export/disk/status.py +++ b/export/securedrop_export/disk/status.py @@ -8,15 +8,17 @@ class Status(BaseStatus): "INVALID_DEVICE_DETECTED" # Not encrypted, or partitions too many/too nested ) - MULTI_DEVICE_DETECTED = "MULTI_DEVICE_DETECTED" # Not currently supported + MULTI_DEVICE_DETECTED = ( + "MULTI_DEVICE_DETECTED" # Multiple devices are not currently supported + ) DEVICE_LOCKED = "DEVICE_LOCKED" # One valid device detected, and it's locked DEVICE_WRITABLE = ( "DEVICE_WRITABLE" # One valid device detected, and it's unlocked (and mounted) ) - ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" # Bad passphrase (LUKS) - ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" # May not be used + ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" # Bad LUKS passphrase + ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" # Other error during unlocking ERROR_MOUNT = "ERROR_MOUNT" # Unlocked but not mounted SUCCESS_EXPORT = "SUCCESS_EXPORT" diff --git a/export/securedrop_export/main.py b/export/securedrop_export/main.py index d04787c3b..812203f98 100755 --- a/export/securedrop_export/main.py +++ b/export/securedrop_export/main.py @@ -1,22 +1,20 @@ import contextlib import io +import logging import os -import shutil import platform -import logging +import shutil import sys +from logging.handlers import SysLogHandler, TimedRotatingFileHandler +from securedrop_export import __version__ from securedrop_export.archive import Archive, Metadata from securedrop_export.command import Command -from securedrop_export.status import BaseStatus from securedrop_export.directory import safe_mkdir -from securedrop_export.exceptions import ExportException - from securedrop_export.disk import Service as ExportService +from securedrop_export.exceptions import ExportException from securedrop_export.print import Service as PrintService - -from logging.handlers import TimedRotatingFileHandler, SysLogHandler -from securedrop_export import __version__ +from securedrop_export.status import BaseStatus DEFAULT_HOME = os.path.join(os.path.expanduser("~"), ".securedrop_export") LOG_DIR_NAME = "logs" @@ -46,7 +44,7 @@ def entrypoint(): The program is called with the archive name as the first argument. """ - status, submission = None, None + status, archive = None, None try: _configure_logging() @@ -62,20 +60,20 @@ def entrypoint(): else: logger.debug("Extract tarball") - submission = Archive(data_path).extract_tarball() + archive = Archive(data_path).extract_tarball() logger.debug("Validate metadata") - metadata = Metadata(submission.tmpdir).validate() + metadata = Metadata(archive.tmpdir).validate() logger.info("Archive extraction and metadata validation successful") # If all we're doing is starting the vm, we're done; otherwise, # run the appropriate print or export routine if metadata.command is not Command.START_VM: - submission.set_metadata(metadata) + archive.set_metadata(metadata) logger.info(f"Start {metadata.command.value} service") - status = _start_service(submission) + status = _start_service(archive) logger.info(f"Status: {status.value}") - # Gotta catch'em all. A nonzero exit status will cause other programs + # A nonzero exit status will cause other programs # to try to handle the files, which we don't want. except Exception as ex: logger.error(ex) @@ -87,7 +85,7 @@ def entrypoint(): status = Status.ERROR_GENERIC finally: - _exit_gracefully(submission, status) + _exit_gracefully(archive, status) def _configure_logging(): @@ -130,34 +128,33 @@ def _configure_logging(): raise ExportException(sdstatus=Status.ERROR_LOGGING) from ex -def _start_service(submission: Archive) -> BaseStatus: +def _start_service(archive: Archive) -> BaseStatus: """ Start print or export service. """ # Print Routines - if submission.command is Command.PRINT: - return PrintService(submission).print() - elif submission.command is Command.PRINTER_PREFLIGHT: - return PrintService(submission).printer_preflight() - elif submission.command is Command.PRINTER_TEST: - return PrintService(submission).printer_test() + if archive.command is Command.PRINT: + return PrintService(archive).print() + elif archive.command is Command.PRINTER_PREFLIGHT: + return PrintService(archive).printer_preflight() + elif archive.command is Command.PRINTER_TEST: + return PrintService(archive).printer_test() # Export routines - elif submission.command is Command.EXPORT: - return ExportService(submission).export() + elif archive.command is Command.EXPORT: + return ExportService(archive).export() elif ( - submission.command is Command.CHECK_USBS - or submission.command is Command.CHECK_VOLUME + archive.command is Command.CHECK_USBS or archive.command is Command.CHECK_VOLUME ): - return ExportService(submission).scan_all_devices() + return ExportService(archive).scan_all_devices() # Unreachable raise ExportException( - f"unreachable: unknown submission.command value: {submission.command}" + f"unreachable: unknown submission.command value: {archive.command}" ) -def _exit_gracefully(submission: Archive, status: BaseStatus): +def _exit_gracefully(archive: Archive, status: BaseStatus): """ Write status code, ensure file cleanup, and exit with return code 0. Non-zero exit values will cause the system to try alternative @@ -165,8 +162,8 @@ def _exit_gracefully(submission: Archive, status: BaseStatus): """ try: # If the file archive was extracted, delete before returning - if submission and os.path.isdir(submission.tmpdir): - shutil.rmtree(submission.tmpdir) + if archive and os.path.isdir(archive.tmpdir): + shutil.rmtree(archive.tmpdir) # Do this after deletion to avoid giving the client two error messages in case of the # block above failing _write_status(status) diff --git a/export/securedrop_export/print/service.py b/export/securedrop_export/print/service.py index dbff034bf..0583346c1 100644 --- a/export/securedrop_export/print/service.py +++ b/export/securedrop_export/print/service.py @@ -4,7 +4,8 @@ import subprocess import time -from securedrop_export.exceptions import handler, TimeoutException, ExportException +from securedrop_export.exceptions import ExportException, TimeoutException, handler + from .status import Status logger = logging.getLogger(__name__) @@ -32,7 +33,7 @@ def __init__(self, submission, printer_timeout_seconds=PRINTER_WAIT_TIMEOUT): self.printer_name = self.PRINTER_NAME self.printer_wait_timeout = printer_timeout_seconds # Override during testing - def print(self): + def print(self) -> Status: """ Routine to print all files. Throws ExportException if an error is encountered. @@ -42,9 +43,9 @@ def print(self): self._print_all_files() # When client can accept new print statuses, we will return # a success status here - # return Status.PRINT_SUCCESS + return Status.PRINT_SUCCESS - def printer_preflight(self): + def printer_preflight(self) -> Status: """ Routine to perform preflight printer testing. @@ -54,9 +55,9 @@ def printer_preflight(self): self._check_printer_setup() # When client can accept new print statuses, we will return # a success status here - # return Status.PREFLIGHT_SUCCESS + return Status.PREFLIGHT_SUCCESS - def printer_test(self): + def printer_test(self) -> Status: """ Routine to print a test page. @@ -67,7 +68,7 @@ def printer_test(self): self._print_test_page() # When client can accept new print statuses, we will return # a success status here - # return Status.TEST_SUCCESS + return Status.PRINT_TEST_PAGE_SUCCESS def _wait_for_print(self): """ diff --git a/export/securedrop_export/print/status.py b/export/securedrop_export/print/status.py index 116316a46..e96d813be 100644 --- a/export/securedrop_export/print/status.py +++ b/export/securedrop_export/print/status.py @@ -15,7 +15,7 @@ class Status(BaseStatus): # New PREFLIGHT_SUCCESS = "PRINTER_PREFLIGHT_SUCCESS" - TEST_SUCCESS = "PRINTER_TEST_SUCCESS" + PRINT_TEST_PAGE_SUCCESS = "PRINTER_TEST_SUCCESS" PRINT_SUCCESS = "PRINTER_SUCCESS" ERROR_UNKNOWN = "ERROR_GENERIC" # Unknown printer error, backwards-compatible diff --git a/export/tests/disk/test_cli.py b/export/tests/disk/test_cli.py index 9b6b3cb77..7d0950eb4 100644 --- a/export/tests/disk/test_cli.py +++ b/export/tests/disk/test_cli.py @@ -1,34 +1,33 @@ -import pytest +import re +import subprocess from unittest import mock -import subprocess -import re +import pytest +from securedrop_export.archive import Archive from securedrop_export.disk.cli import CLI -from securedrop_export.disk.volume import EncryptionScheme, Volume, MountedVolume -from securedrop_export.exceptions import ExportException from securedrop_export.disk.status import Status - -from securedrop_export.archive import Archive +from securedrop_export.disk.volume import EncryptionScheme, MountedVolume, Volume +from securedrop_export.exceptions import ExportException # Sample lsblk and udisk inputs for testing the parsing of different device conditions from ..lsblk_sample import ( - UDISKS_STATUS_MULTI_CONNECTED, - UDISKS_STATUS_ONE_DEVICE_CONNECTED, - UDISKS_STATUS_NOTHING_CONNECTED, - ONE_DEVICE_LUKS_UNMOUNTED, - ONE_DEVICE_VC_UNLOCKED, - ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE, + ERROR_DEVICE_MULTI_ENC_PARTITION, ERROR_NO_SUPPORTED_DEVICE, + ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE, ERROR_UNENCRYPTED_DEVICE_MOUNTED, - ERROR_DEVICE_MULTI_ENC_PARTITION, + ONE_DEVICE_LUKS_UNMOUNTED, + ONE_DEVICE_VC_UNLOCKED, + SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED, + SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED, SINGLE_DEVICE_LOCKED, - SINGLE_PART_LUKS_WRITABLE, SINGLE_PART_LUKS_UNLOCKED_UNMOUNTED, + SINGLE_PART_LUKS_WRITABLE, SINGLE_PART_UNLOCKED_VC_UNMOUNTED, - SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED, - SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED, SINGLE_PART_VC_WRITABLE, + UDISKS_STATUS_MULTI_CONNECTED, + UDISKS_STATUS_NOTHING_CONNECTED, + UDISKS_STATUS_ONE_DEVICE_CONNECTED, ) _PRETEND_LUKS_ID = "/dev/mapper/luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094" @@ -153,7 +152,12 @@ def test_get_volume_empty_udisks_does_not_keep_checking(self, mock_sp, mock_gsv) mock_gsv.assert_not_called() @pytest.mark.parametrize("input", supported_volumes_no_mount_required) - def test__get_supported_volume_success_no_mount(self, input): + @mock.patch("subprocess.check_output") + def test__get_supported_volume_success_no_mount(self, mock_sp, input): + # mock subprocess results on the _is_it_veracrypt method + mock_sp.return_value = "IdType: crypto_TCRYPT\n".encode( + "utf-8" + ) vol = self.cli._get_supported_volume(input) assert vol diff --git a/export/tests/disk/test_service.py b/export/tests/disk/test_service.py index 3b52fe985..66943f481 100644 --- a/export/tests/disk/test_service.py +++ b/export/tests/disk/test_service.py @@ -1,13 +1,13 @@ -from unittest import mock import os import tempfile +from unittest import mock -from securedrop_export.exceptions import ExportException -from securedrop_export.disk.status import Status -from securedrop_export.disk.volume import Volume, MountedVolume, EncryptionScheme from securedrop_export.archive import Archive, Metadata -from securedrop_export.disk.service import Service from securedrop_export.disk.cli import CLI +from securedrop_export.disk.service import Service +from securedrop_export.disk.status import Status +from securedrop_export.disk.volume import EncryptionScheme, MountedVolume, Volume +from securedrop_export.exceptions import ExportException SAMPLE_OUTPUT_USB = "/dev/sda" SAMPLE_OUTPUT_USB_PARTITIONED = "/dev/sda1" diff --git a/export/tests/print/test_service.py b/export/tests/print/test_service.py index cf5c6ca1a..46bf6dc82 100644 --- a/export/tests/print/test_service.py +++ b/export/tests/print/test_service.py @@ -1,14 +1,13 @@ -import pytest - -from unittest import mock import os import subprocess from subprocess import CalledProcessError +from unittest import mock -from securedrop_export.directory import safe_mkdir +import pytest -from securedrop_export.exceptions import ExportException from securedrop_export.archive import Archive +from securedrop_export.directory import safe_mkdir +from securedrop_export.exceptions import ExportException from securedrop_export.print.service import Service from securedrop_export.print.status import Status @@ -187,7 +186,7 @@ def test_setup_printer_error(self, mocker): def test_safe_check_call(self): # This works, since `ls` is a valid comand - self.service.safe_check_call(["ls"], Status.TEST_SUCCESS) + self.service.safe_check_call(["ls"], Status.PRINT_TEST_PAGE_SUCCESS) def test_safe_check_call_invalid_call(self): with pytest.raises(ExportException) as ex: @@ -198,7 +197,7 @@ def test_safe_check_call_invalid_call(self): def test_safe_check_call_write_to_stderr_and_ignore_error(self): self.service.safe_check_call( ["python3", "-c", "import sys;sys.stderr.write('hello')"], - error_status=Status.TEST_SUCCESS, + error_status=Status.PRINT_TEST_PAGE_SUCCESS, ignore_stderr_startswith=b"hello", ) @@ -390,9 +389,11 @@ def test_safe_check_call_has_error_in_stderr(self): mock.patch("subprocess.run") with mock.patch("subprocess.run"), pytest.raises(ExportException) as ex: - self.service.safe_check_call(command="ls", error_status=Status.TEST_SUCCESS) + self.service.safe_check_call( + command="ls", error_status=Status.PRINT_TEST_PAGE_SUCCESS + ) - assert ex.value.sdstatus is Status.TEST_SUCCESS + assert ex.value.sdstatus is Status.PRINT_TEST_PAGE_SUCCESS @mock.patch("securedrop_export.print.service.time.sleep", return_value=None) @mock.patch( diff --git a/export/tests/test_archive.py b/export/tests/test_archive.py index 37510f0c8..c1ae85fb2 100644 --- a/export/tests/test_archive.py +++ b/export/tests/test_archive.py @@ -1,16 +1,15 @@ +import json import os import subprocess # noqa: F401 +import tarfile import tempfile - +from io import BytesIO from unittest import mock -import json import pytest -import tarfile -from io import BytesIO -from securedrop_export.exceptions import ExportException from securedrop_export.archive import Archive, Metadata, Status +from securedrop_export.exceptions import ExportException def test_extract_tarball(): diff --git a/export/tests/test_directory.py b/export/tests/test_directory.py index b0857f59c..bd81a66d8 100644 --- a/export/tests/test_directory.py +++ b/export/tests/test_directory.py @@ -1,9 +1,10 @@ -import pytest import os -import tempfile import shutil - +import tempfile from pathlib import Path + +import pytest + from securedrop_export import directory diff --git a/export/tests/test_exceptions.py b/export/tests/test_exceptions.py index 71af41143..272f58645 100644 --- a/export/tests/test_exceptions.py +++ b/export/tests/test_exceptions.py @@ -1,7 +1,8 @@ -import pytest import signal -from securedrop_export.exceptions import handler, TimeoutException +import pytest + +from securedrop_export.exceptions import TimeoutException, handler def test_handler(): diff --git a/export/tests/test_main.py b/export/tests/test_main.py index ebad33eff..dd1306a85 100644 --- a/export/tests/test_main.py +++ b/export/tests/test_main.py @@ -1,22 +1,23 @@ -import pytest -from unittest import mock import shutil - from pathlib import Path -from securedrop_export.archive import Archive, Metadata, Status as ArchiveStatus -from securedrop_export.status import BaseStatus +from unittest import mock + +import pytest + +from securedrop_export.archive import Archive, Metadata +from securedrop_export.archive import Status as ArchiveStatus from securedrop_export.command import Command -from securedrop_export.exceptions import ExportException from securedrop_export.disk.status import Status as ExportStatus - +from securedrop_export.exceptions import ExportException from securedrop_export.main import ( Status, - entrypoint, + _configure_logging, _exit_gracefully, - _write_status, _start_service, - _configure_logging, + _write_status, + entrypoint, ) +from securedrop_export.status import BaseStatus _PRINT_SAMPLE_ARCHIVE = "sample_print.sd-export" _EXPORT_SAMPLE_ARCHIVE = "sample_export.sd-export" From 0367a08c66ab01619be951930d7e3db0ffb9768d Mon Sep 17 00:00:00 2001 From: Ro Date: Tue, 13 Feb 2024 21:03:21 -0500 Subject: [PATCH 10/10] Update debian rules to require exact replacement of /etc/udisks2/tcrypt.conf file. Update Export README to include new status values. Include VeraCrypt unlock instructions in InsertUSBPage. Extract new dialog strings for localization. --- .../export/export_wizard_constants.py | 1 + .../conversation/export/export_wizard_page.py | 2 + client/securedrop_client/locale/messages.pot | 7 +--- debian/rules | 5 ++- export/README.md | 42 ++++++++++--------- 5 files changed, 32 insertions(+), 25 deletions(-) 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 546563d50..2c92da9f3 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -30,6 +30,7 @@ class Pages(IntEnum): ), ExportStatus.INVALID_DEVICE_DETECTED: _( "Either the drive is not encrypted or there is something else wrong with it." + "
" "If this is a VeraCrypt drive, please unlock it from within `sd-devices`, then try again." ), ExportStatus.DEVICE_WRITABLE: _("The device is ready for 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 983bbd551..e83ecb1bd 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -334,6 +334,8 @@ def __init__(self, export: Export, summary: str) -> None: body = _( "Please insert one of the export drives provisioned specifically " "for the SecureDrop Workstation." + "
" + "If you're using a VeraCrypt drive, unlock it manually before proceeding." ) super().__init__(export, header=header, body=body) diff --git a/client/securedrop_client/locale/messages.pot b/client/securedrop_client/locale/messages.pot index d7d99c378..0155f5cb1 100644 --- a/client/securedrop_client/locale/messages.pot +++ b/client/securedrop_client/locale/messages.pot @@ -276,16 +276,13 @@ msgstr "" msgid "BACK" msgstr "" -msgid "Export {}" -msgstr "" - msgid "No device detected" msgstr "" msgid "Too many USB devices detected; please insert one supported device." msgstr "" -msgid "Either the drive is not encrypted or there is something else wrong with it.If this is a VeraCrypt drive, please unlock it from within `sd-devices`, then try again." +msgid "Either the drive is not encrypted or there is something else wrong with it.
If this is a VeraCrypt drive, please unlock it from within `sd-devices`, then try again." msgstr "" msgid "The device is ready for export." @@ -333,7 +330,7 @@ msgstr "" msgid "Export Failed" msgstr "" -msgid "Please insert one of the export drives provisioned specifically for the SecureDrop Workstation." +msgid "Please insert one of the export drives provisioned specifically for the SecureDrop Workstation.
If you're using a VeraCrypt drive, unlock it manually before proceeding." msgstr "" msgid "Remember to be careful when working with files outside of your Workstation machine." diff --git a/debian/rules b/debian/rules index 88d2cead3..535ba6833 100755 --- a/debian/rules +++ b/debian/rules @@ -19,7 +19,10 @@ override_dh_strip_nondeterminism: # Override debhelper's auto-generated files in `/etc/` # to force an exact replacement of the files we are modifying -# there (specifically, `/etc/apt/trusted.gpg.d/securedrop-keyring.gpg`). +# there (specifically, `/etc/apt/trusted.gpg.d/securedrop-keyring.gpg` +# for the keyring package and `/etc/udisks2/tcrypt.conf` for the +# securedrop-export package). override_dh_installdeb: dh_installdeb cat /dev/null > ${CURDIR}/debian/securedrop-keyring/DEBIAN/conffiles + cat /dev/null > ${CURDIR}/debian/securedrop-export/DEBIAN/conffiles diff --git a/export/README.md b/export/README.md index 796bcd068..7c66cc72f 100644 --- a/export/README.md +++ b/export/README.md @@ -86,34 +86,38 @@ For all device types (described in detail below), the following standard error t The supported device types for export are as follows, including the possible errors specific to that device type: -1. `disk-test` : Preflight check that probes for USB connected devices, that returns: - - `DEVICE_WRITABLE` if a supported USB device is attached and unlocked - - `DEVICE_LOCKED` if a supported drive is inserted but locked (a LUKS drive, since locked Veracrypt detection is not supported) +1. `disk-test`: Preflight check that probes for USB connected devices, that returns: - `NO_DEVICE_DETECTED`, `MULTI_DEVICE_DETECTED`: wrong number of inserted USB drives - - `INVALID_DEVICE_DETECTED`: Wrong number of partitions, unsupported encryption scheme, etc - - `UNKNOWN_DEVICE_DETECTED`: (Future use) this is what a locked drive that could be Veracrypt would return + - `INVALID_DEVICE_DETECTED`: Wrong number of partitions, unsupported encryption scheme, etc. + Note: locked VeraCrypt drives also return this status, and a hint is shown to the user that they must + manually unlock such drives before proceeding. + - `DEVICE_LOCKED` if a supported drive is inserted but locked (a LUKS drive, since locked Veracrypt detection is not supported) + - `DEVICE_WRITABLE` if a supported USB device is attached and unlocked. (Only used for Preflight check) - `DEVICE_ERROR`: A problem was encountered and device state cannot be reported. -2. `printer-test`: prints a test page that returns: - - `ERROR_PRINTER_NOT_FOUND` if no printer is connected - - `ERROR_PRINTER_NOT_SUPPORTED` if the printer is not currently supported by the export script - - `ERROR_PRINTER_DRIVER_UNAVAILABLE` if the printer driver is not available - - `ERROR_PRINTER_INSTALL` If there is an error installing the printer - - `ERROR_PRINT` if there is an error printing - -3. `printer`: sends files to printer that returns: +2. `disk`: Attempts to send files to disk. Can return any Preflight status except `DEVICE_WRITABLE`, as well as + the following status results below, which replace `DEVICE_WRITABLE` since they attempt the export action. + Because export is a linear process, a status such as `ERROR_EXPORT_CLEANUP` indicates that the file export + succeeded and the problem occurred after that point in the process. + - `ERROR_UNLOCK_LUKS` if LUKS decryption failed due to bad passphrase + - `ERROR_UNLOCK_GENERIC` if unlocking failed due to some other reason + - `ERROR_MOUNT` if there was an error mounting the volume + - `ERROR_UNMOUT_VOLUME_BUSY` if there was an error unmounting the drive after export + - `ERROR_EXPORT_CLEANUP` if there was an error removing temporary directories after export + - `SUCCESS_EXPORT`: Entire routine, including export and cleanup, was successful + +3. `printer-preflight`, `printer-test`: test the printer and ensure it is ready. - `ERROR_PRINTER_NOT_FOUND` if no printer is connected - `ERROR_PRINTER_NOT_SUPPORTED` if the printer is not currently supported by the export script - `ERROR_PRINTER_DRIVER_UNAVAILABLE` if the printer driver is not available + - `ERROR_PRINTER_URI` if `lpinfo` fails to retrieve printer information - `ERROR_PRINTER_INSTALL` If there is an error installing the printer - `ERROR_PRINT` if there is an error printing + - `PRINT_PREFLIGHT_SUCCESS` if preflight checks were successful (Preflight only) -4. `disk`: sends files to disk that returns: - - `SUCCESS_EXPORT`: Successful - - `ERROR_CLEANUP`: Export was successful but files could not be cleaned up or drive was not properly unmounted - - `ERROR_UNLOCK_LUKS` if the luks decryption failed (likely due to bad passphrase) - - `ERROR_MOUNT` if there was an error mounting the volume (after unlocking the luks volume) - - `ERROR_WRITE` if there was an error writing to disk (e.g., no space left on device) +4. `printer`: sends files to printer that returns any of the `printer-preflight` statuses except + `PRINT_PREFLIGHT_SUCCESS`, as well as: + - `PRINT_SUCCESS` if the job is dispatched successfully ### Export Folder Structure