diff --git a/client/securedrop_client/app.py b/client/securedrop_client/app.py index 8e9fe9e9ca..8a6a990c42 100644 --- a/client/securedrop_client/app.py +++ b/client/securedrop_client/app.py @@ -34,7 +34,7 @@ from PyQt5.QtCore import Qt, QThread, QTimer from PyQt5.QtWidgets import QApplication, QMessageBox -from securedrop_client import __version__, export, state +from securedrop_client import __version__, state from securedrop_client.database import Database from securedrop_client.db import make_session_maker from securedrop_client.gui.main import Window @@ -240,16 +240,11 @@ def start_app(args, qt_args) -> NoReturn: # type: ignore[no-untyped-def] database = Database(session) app_state = state.State(database) - with threads(4) as [ - export_service_thread, + with threads(3) as [ sync_thread, main_queue_thread, file_download_queue_thread, ]: - export_service = export.getService() - export_service.moveToThread(export_service_thread) - export_service_thread.start() - gui = Window(app_state) controller = Controller( diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index 366f68eab6..8ab0a61de5 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -1,153 +1,292 @@ import json import logging import os -import subprocess import tarfile -import threading +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, pyqtBoundSignal, pyqtSignal, pyqtSlot +from PyQt5.QtCore import QProcess, 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). + + A list of valid filepaths must be supplied. """ - METADATA_FN = "metadata.json" + _METADATA_FN = "metadata.json" - USB_TEST_FN = "usb-test.sd-export" - USB_TEST_METADATA = {"device": "usb-test"} + _USB_TEST_FN = "usb-test.sd-export" + _USB_TEST_METADATA = {"device": "usb-test"} - PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" - PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} + _PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" + _PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} - DISK_TEST_FN = "disk-test.sd-export" - DISK_TEST_METADATA = {"device": "disk-test"} + _PRINT_FN = "print_archive.sd-export" + _PRINT_METADATA = {"device": "printer"} - PRINT_FN = "print_archive.sd-export" - PRINT_METADATA = {"device": "printer"} + _DISK_FN = "archive.sd-export" + _DISK_METADATA = {"device": "disk"} + _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" + # Emit export states + export_state_changed = pyqtSignal(object) - # Set up signals for communication with the controller # - # Emit ExportStatus - preflight_check_call_success = pyqtSignal(object) - export_usb_call_success = pyqtSignal(object) - printer_preflight_success = pyqtSignal(object) - print_call_success = pyqtSignal(object) + # Emit print states + print_preflight_check_succeeded = pyqtSignal(object) + print_succeeded = 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) + export_completed = pyqtSignal(object) - # Emit List[str] of filepaths - export_completed = pyqtSignal(list) + print_preflight_check_failed = pyqtSignal(object) + print_failed = pyqtSignal(object) - 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__() + 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() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._PRINTER_PREFLIGHT_FN, + metadata=self._PRINTER_PREFLIGHT_METADATA, + ) + self._run_qrexec_export( + archive_path, self._on_print_preflight_success, self._on_print_prefight_error + ) + + def run_export_preflight_checks(self) -> None: + """ + Run preflight check to verify that a valid USB device is connected. + """ + logger.debug("Beginning export preflight check") + + self.tmpdir = mkdtemp() - self.connect_signals( - export_preflight_check_requested, - export_requested, - print_preflight_check_requested, - print_requested, + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._USB_TEST_FN, + metadata=self._USB_TEST_METADATA, + ) + # Emits status via on_process_completed() + self._run_qrexec_export( + archive_path, self._on_export_process_finished, self._on_export_process_error ) - def 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, + 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 + + self.tmpdir = mkdtemp() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._DISK_FN, + metadata=metadata, + filepaths=filepaths, + ) + + # Emits status through callbacks + self._run_qrexec_export( + archive_path, self._on_export_process_finished, self._on_export_process_error + ) + + except IOError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) + + def _run_qrexec_export( + self, archive_path: str, success_callback: Callable, error_callback: Callable ) -> None: - # This instance can optionally react to events to prevent - # coupling it to dependent code. - if export_preflight_check_requested is not None: - export_preflight_check_requested.connect(self.run_preflight_checks) - if export_requested is not None: - export_requested.connect(self.send_file_to_usb_device) - if print_requested is not None: - print_requested.connect(self.print) - if print_preflight_check_requested is not None: - print_preflight_check_requested.connect(self.run_printer_preflight) - - def _run_qrexec_export(cls, archive_path: str) -> ExportStatus: """ - Make the subprocess call to send the archive to the Export VM, where the archive will be - processed. + Send the archive to the Export VM, where the archive will be processed. + Uses qrexec-client-vm (via QProcess). Results are emitted via the + `on_process_finished` callback; errors are reported via `on_process_error`. Args: archive_path (str): The path to the archive to be processed. + success_callback, err_callback: Callback functions to connect to the success and + error signals of QProcess. They are included to accommodate the print functions, + which still use separate signals for print preflight, print, and error states, but + can be removed in favour of a generic success callback and error callback when the + print code is updated. + 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) - Returns: - str: The export status returned from the Export VM processing script. + def _on_export_process_finished(self): + """ + Callback, handle and emit QProcess result. As with all such callbacks, + the method signature cannot change. + """ + self._cleanup_tmpdir() + # securedrop-export writes status to stderr + 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)) + + else: + logger.error("Export subprocess did not return a value we could parse") + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) + + except ValueError as e: + logger.debug(f"Export subprocess returned unexpected value: {e}") + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) + + 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() + 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) - 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 _on_print_preflight_success(self): """ + Print preflight success callback. + """ + self._cleanup_tmpdir() + output = self.process.readAllStandardError().data().decode("utf-8").strip() 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, + status = ExportStatus(output) + self.print_preflight_check_succeeded.emit(status) + logger.debug("Print preflight success") + + except ValueError as error: + logger.debug(f"Print preflight check failed: {error}") + logger.error("Print preflight check failed") + self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) + + def _on_print_prefight_error(self): + """ + Print Preflight error callback. + """ + self._cleanup_tmpdir() + err = self.process.readAllStandardError().data().decode("utf-8").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)) + + def print(self, filepaths: List[str]) -> None: + """ + Bundle files at filepaths into tarball and send for + printing via qrexec. + """ + try: + 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) - return ExportStatus(result) + except IOError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + 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( - 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 +303,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 +349,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 +360,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 +370,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 2c2a199246..da475c3fa3 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 c4dfd6a704..ff189f086a 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -12,18 +12,15 @@ 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 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 @@ -160,8 +157,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 +184,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 +208,12 @@ 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. + (Re-)generates the conversation transcript and opens export wizard. """ file_path = ( Path(self.controller.data_dir) @@ -241,10 +234,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: - dialog = ExportConversationTranscriptDialog( - self._export_device, TRANSCRIPT_FILENAME, str(file_path) - ) - dialog.exec() + export_device = ExportDevice() + wizard = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) + wizard.exec() class ExportConversationAction(QAction): # pragma: nocover @@ -267,16 +259,13 @@ def __init__( self._source = source self._state = app_state - 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 - alongside all the (attached) files that are downloaded, in the manner - of the existing ExportFileDialog. + (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 @@ -302,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 ExportFileDialog. + of the existing ExportWizard. """ transcript_location = ( Path(self.controller.data_dir) @@ -331,6 +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() files = [ stack.enter_context(open(file_location, "r")) for file_location in file_locations ] @@ -341,12 +331,12 @@ def _prepare_to_export(self) -> None: else: summary = _("all files and transcript") - dialog = ExportConversationDialog( - self._export_device, + 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 29142e98dc..219c004655 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 ExportWizard as ExportWizard # 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 7da54e94cc..328c19e436 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_wizard import ExportWizard # 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 9cf61dd06b..0000000000 --- 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 c71ebe2d84..0000000000 --- a/client/securedrop_client/gui/conversation/export/dialog.py +++ /dev/null @@ -1,55 +0,0 @@ -from gettext import gettext as _ -from typing import List - -from PyQt5.QtCore import pyqtSlot - -from .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/dialog_button.css b/client/securedrop_client/gui/conversation/export/dialog_button.css new file mode 100644 index 0000000000..132952a4bd --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/dialog_button.css @@ -0,0 +1,29 @@ +#ModalDialog_button_box QPushButton#ModalDialog_primary_button { + background-color: #2a319d; + color: #fff; +} + +#ModalDialog.dangerous #ModalDialog_button_box QPushButton { + border-color: #ff3366; + color: #ff3366; +} + +#ModalDialog.dangerous #ModalDialog_button_box QPushButton#ModalDialog_primary_button { + background-color: #ff3366; + border-color: #ff3366; + color: #ffffff; +} + +#ModalDialog_button_box QPushButton#ModalDialog_primary_button::disabled { + border: 2px solid #c2c4e3; + background-color: #c2c4e3; + color: #e1e2f1; +} + +#ModalDialog_button_box QPushButton#ModalDialog_primary_button_active { + background-color: #f1f1f6; + color: #fff; + border: 2px solid #f1f1f6; + margin: 0; + height: 40px; +} diff --git a/client/securedrop_client/gui/conversation/export/dialog_message.css b/client/securedrop_client/gui/conversation/export/dialog_message.css new file mode 100644 index 0000000000..20415fe9b9 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/dialog_message.css @@ -0,0 +1,13 @@ +#ModalDialog_error_details { + margin: 0px 40px 0px 36px; + font-family: 'Montserrat'; + font-size: 16px; + color: #ff0064; +} + +#ModalDialog_error_details_active { + margin: 0px 40px 0px 36px; + font-family: 'Montserrat'; + font-size: 16px; + color: #ff66c4; +} diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py new file mode 100644 index 0000000000..2630b3b981 --- /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 0000000000..eccb767a00 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -0,0 +1,45 @@ +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 0000000000..a3b2cf93a2 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -0,0 +1,459 @@ +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/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py deleted file mode 100644 index 414d2c8b15..0000000000 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ /dev/null @@ -1,288 +0,0 @@ -""" -A dialog that allows journalists to export sensitive files to a USB drive. -""" -from gettext import gettext as _ -from typing import 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.gui.base import ModalDialog, PasswordEdit, SecureQLabel -from securedrop_client.gui.base.checkbox import SDCheckBox - -from .device import Device - - -class FileDialog(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: - 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 - ).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.file_name) - self.ready_header = _( - "Ready to export:
" '{}' - ).format(self.file_name) - self.insert_usb_header = _("Insert encrypted USB drive") - self.passphrase_header = _("Enter passphrase for USB drive") - self.success_header = _("Export successful") - self.error_header = _("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.file_name) - 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_file) - self.header.setText(self.passphrase_header) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.error_details.hide() - self.body.hide() - self.passphrase_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_file) - self.header.setText(self.passphrase_header) - self.error_details.setText(self.passphrase_error_message) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.body.hide() - self.error_details.show() - self.passphrase_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_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(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_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 - - # 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: - 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/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index 32e160bd1c..40eaa7c887 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 9f47735ce3..b6508fa06f 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 3318197076..0000000000 --- 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 a03f5b905d..19b2f789ca 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, @@ -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 @@ -2255,8 +2256,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,13 +2454,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 - ) - self.export_dialog.show() + export_device = conversation.ExportDevice() + + self.export_wizard = ExportWizard(export_device, self.file.filename, [file_location]) + self.export_wizard.show() @pyqtSlot() def _on_print_clicked(self) -> None: @@ -2469,9 +2471,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 a7918bbc1a..8607ce8269 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -4,14 +4,14 @@ import tempfile from configparser import ConfigParser from datetime import datetime -from typing import List from uuid import uuid4 +from unittest import mock 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 +23,7 @@ Source, make_session_maker, ) -from securedrop_client.export import ExportStatus +from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -49,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 @@ -80,7 +80,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,46 +92,46 @@ 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_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_file_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.ExportFileDialog(export_device, "file_UUID", "file123.jpg") + 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.ExportTranscriptDialog( - export_device, "transcript.txt", "/some/path/transcript.txt" + dialog = conversation.ExportWizard( + export_device, "transcript.txt", ["/some/path/transcript.txt"] ) yield dialog @@ -169,39 +169,108 @@ def homedir(i18n): yield tmpdir -class MockExportService(export.Service): - """An export service that assumes the Qubes RPC calls are successful and skips them.""" +@pytest.fixture(scope="function") +def mock_export_locked(): + """ + Represents the following scenario: + * Locked USB already inserted + * "Export" clicked, export wizard launched + * Passphrase successfully entered on first attempt (and export suceeeds) + """ + device = conversation.ExportDevice() + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.DEVICE_LOCKED + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = mock.MagicMock() + device.export.side_effect = [ + lambda filepaths, passphrase: device.export_state_changed.emit( + ExportStatus.DEVICE_WRITABLE + ), + lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.SUCCESS_EXPORT), + ] - def __init__(self, unlocked: bool): - super().__init__() - if unlocked: - self.preflight_response = ExportStatus.DEVICE_WRITABLE - else: - self.preflight_response = ExportStatus.DEVICE_LOCKED + return device - 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) +@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() - def run_printer_preflight(self) -> None: - self.printer_preflight_success.emit(ExportStatus.PRINT_PREFLIGHT_SUCCESS) + 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 + ) - def print(self, filepaths: List[str]) -> None: - self.print_call_success.emit(ExportStatus.PRINT_SUCCESS) - self.export_completed.emit(filepaths) + return device @pytest.fixture(scope="function") -def mock_export_service(): - return MockExportService(unlocked=False) +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_service_unlocked_device(): - return MockExportService(unlocked=True) +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 @pytest.fixture(scope="function") diff --git a/client/tests/functional/cassettes/test_export_file_dialog_locked.yaml b/client/tests/functional/cassettes/test_export_wizard_device_locked.yaml similarity index 100% rename from client/tests/functional/cassettes/test_export_file_dialog_locked.yaml rename to client/tests/functional/cassettes/test_export_wizard_device_locked.yaml diff --git a/client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml b/client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml similarity index 100% rename from client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml rename to client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml diff --git a/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml b/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml new file mode 100644 index 0000000000..d59c25ebbe --- /dev/null +++ b/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml @@ -0,0 +1,1518 @@ +interactions: +- request: + body: '{"username": "journalist", "passphrase": "correct horse battery staple + profanity oil chewy", "one_time_code": "123456"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '119' + User-Agent: + - python-requests/2.31.0 + method: POST + uri: http://localhost:8081/api/v1/token + response: + body: + string: '{"expiration":"2023-12-08T21:31:36.503560Z","journalist_first_name":null,"journalist_last_name":null,"journalist_uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9","token":"IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM"} + + ' + headers: + Connection: + - close + Content-Length: + - '265' + Content-Type: + - application/json + Date: + - Fri, 08 Dec 2023 19:31:36 GMT + Server: + - Werkzeug/2.2.3 Python/3.8.10 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8081/api/v1/users + response: + body: + string: '{"users":[{"first_name":null,"last_name":null,"username":"journalist","uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9"},{"first_name":null,"last_name":null,"username":"dellsberg","uuid":"ac647c21-82f5-4d19-8350-6657a7d32f6b"},{"first_name":null,"last_name":null,"username":"deleted","uuid":"200a587e-b40c-48eb-b18a-0d1263f8af2e"}]} + + ' + headers: + Connection: + - close + Content-Length: + - '329' + Content-Type: + - application/json + Date: + - Fri, 08 Dec 2023 19:31:36 GMT + Server: + - Werkzeug/2.2.3 Python/3.8.10 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8081/api/v1/sources + response: + body: + string: '{"sources":[{"add_star_url":"/api/v1/sources/1924d581-a3af-45c6-a3c9-0ec2f1205bc1/add_star","interaction_count":6,"is_flagged":false,"is_starred":false,"journalist_designation":"oriental + hutch","key":{"fingerprint":"DF4DC2E19F0A6A304C8C3188AEF8C5E2BD8AE199","public":"-----BEGIN + PGP PUBLIC KEY BLOCK-----\nComment: DF4D C2E1 9F0A 6A30 4C8C 3188 AEF8 C5E2 + BD8A E199\nComment: Source Key " - '3 files' - ) - 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): - export_dialog._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() - - -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog): - export_dialog._show_passphrase_request_message_again() - - assert export_dialog.header.text() == "Enter passphrase for USB drive" - assert ( - export_dialog.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() - - -def test_ExportDialog__show_success_message(mocker, export_dialog): - export_dialog._show_success_message() - - assert export_dialog.header.text() == "Export successful" - assert ( - export_dialog.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() - - -def test_ExportDialog__show_insert_usb_message(mocker, export_dialog): - export_dialog._show_insert_usb_message() - - assert export_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog.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() - - -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog): - export_dialog._show_insert_encrypted_usb_message() - - assert export_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_dialog.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() - - -def test_ExportDialog__show_generic_error_message(mocker, export_dialog): - export_dialog.error_status = "mock_error_status" - - export_dialog._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() - - -def test_ExportDialog__export_files(mocker, export_dialog): - 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") - - export_dialog._export_files() - - device.export_files.assert_called_once_with( - ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"], - "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) - - export_dialog._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 - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_dialog -): - export_dialog._show_passphrase_request_message = mocker.MagicMock() - export_dialog.continue_button.setEnabled(True) - - export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - - export_dialog._show_passphrase_request_message.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_continue_enabled_and_device_unlocked( - mocker, export_dialog -): - export_dialog._export_file = mocker.MagicMock() - export_dialog.continue_button.setEnabled(True) - - export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - - export_dialog._export_file.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_dialog -): - assert not export_dialog.continue_button.isEnabled() - export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - assert export_dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_dialog -): - assert not export_dialog.continue_button.isEnabled() - export_dialog._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog): - export_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog._on_export_preflight_check_failed(error) - - export_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__on_export_succeeded(mocker, export_dialog): - export_dialog._show_success_message = mocker.MagicMock() - - export_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) - - export_dialog._show_success_message.assert_called_once_with() - - -def test_ExportDialog__on_export_failed(mocker, export_dialog): - export_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog._on_export_failed(error) - - export_dialog._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) - - # 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 - ) - - # 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() - - -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) - - # 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 - ) - - # 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() - - -def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_dialog -): - 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) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._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 - ) - - # 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() - - -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) - - # 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 - ) - assert export_dialog.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 - - -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) - - # 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 - ) - assert export_dialog.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" diff --git a/client/tests/gui/conversation/export/test_export_wizard.py b/client/tests/gui/conversation/export/test_export_wizard.py new file mode 100644 index 0000000000..24124e72d7 --- /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 e0b6101550..0000000000 --- a/client/tests/gui/conversation/export/test_file_dialog.py +++ /dev/null @@ -1,368 +0,0 @@ -from securedrop_client.export import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportFileDialog -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" - ) - - export_file_dialog = ExportFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") - - _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.file_dialog.SecureQLabel" - ) - mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") - filename = '' - - ExportFileDialog(mocker.MagicMock(), "mock_uuid", filename) - - 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() - - # file123.jpg comes from the export_file_dialog fixture - assert ( - export_file_dialog.header.text() == "Preparing to export:" - "
" - 'file123.jpg' - ) - assert ( - export_file_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_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() - - -def test_ExportDialog___show_passphrase_request_message(mocker, export_file_dialog): - export_file_dialog._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() - - -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message_again() - - assert export_file_dialog.header.text() == "Enter passphrase for USB drive" - assert ( - export_file_dialog.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() - - -def test_ExportDialog__show_success_message(mocker, export_file_dialog): - export_file_dialog._show_success_message() - - assert export_file_dialog.header.text() == "Export successful" - assert ( - export_file_dialog.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() - - -def test_ExportDialog__show_insert_usb_message(mocker, export_file_dialog): - export_file_dialog._show_insert_usb_message() - - assert export_file_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_file_dialog.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() - - -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_file_dialog): - export_file_dialog._show_insert_encrypted_usb_message() - - assert export_file_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_file_dialog.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_file_dialog.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() - - -def test_ExportDialog__show_generic_error_message(mocker, export_file_dialog): - export_file_dialog.error_status = "mock_error_status" - - export_file_dialog._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() - - -def test_ExportDialog__export_file(mocker, export_file_dialog): - 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") - - export_file_dialog._export_file() - - device.export_file_to_usb_drive.assert_called_once_with( - export_file_dialog.file_uuid, "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) - - export_file_dialog._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 - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_device_unlocked( - mocker, export_file_dialog -): - 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_file_dialog._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 - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_file_dialog -): - export_file_dialog._show_passphrase_request_message = mocker.MagicMock() - export_file_dialog.continue_button.setEnabled(True) - - export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED) - - export_file_dialog._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 -): - export_file_dialog._export_file = mocker.MagicMock() - export_file_dialog.continue_button.setEnabled(True) - - export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE) - - export_file_dialog._export_file.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_file_dialog -): - 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() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_file_dialog -): - 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() - - -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_file_dialog): - export_file_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_file_dialog._on_export_preflight_check_failed(error) - - export_file_dialog._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() - - export_file_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT) - - export_file_dialog._show_success_message.assert_called_once_with() - - -def test_ExportDialog__on_export_failed(mocker, export_file_dialog): - export_file_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_file_dialog._on_export_failed(error) - - export_file_dialog._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) - - # 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 - ) - - # 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() - - -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) - - # 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 - ) - - # 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() - - -def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_file_dialog -): - 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) - - # 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 - ) - - # 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() - - -def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( - 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) - - # 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 - ) - assert export_file_dialog.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 - - -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) - - # 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 - ) - assert export_file_dialog.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" diff --git a/client/tests/gui/conversation/export/test_print_dialog.py b/client/tests/gui/conversation/export/test_print_dialog.py index d21765fdbc..0bd4836f8e 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 a59a8e4410..af86797842 100644 --- a/client/tests/gui/conversation/export/test_print_transcript_dialog.py +++ b/client/tests/gui/conversation/export/test_print_transcript_dialog.py @@ -1,4 +1,4 @@ -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation import PrintTranscriptDialog from tests.helper import app # noqa: F401 diff --git a/client/tests/gui/conversation/export/test_transcript_dialog.py b/client/tests/gui/conversation/export/test_transcript_dialog.py deleted file mode 100644 index f0abfa859d..0000000000 --- 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 8f3bde2cb0..82f4a9f387 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.ExportWizard") 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.ExportWizard") 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 11b4234bce..0c3d49b8cc 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,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.ExportFileDialog") + wizard = mocker.patch("securedrop_client.gui.conversation.export.ExportWizard") fw._on_export_clicked() - dialog.assert_called_once_with(export_device(), file.uuid, file.filename) + 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): @@ -3627,17 +3632,17 @@ 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") + 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): """ - 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 +3651,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 +3671,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 80d3a4911d..c00880041f 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 3fab23e9eb..fa5d0484ab 100644 --- a/client/tests/integration/test_styles_sdclient.py +++ b/client/tests/integration/test_styles_sdclient.py @@ -130,8 +130,8 @@ def test_class_name_matches_css_object_name_for_print_dialog(print_dialog): def test_class_name_matches_css_object_name_for_export_file_dialog(export_file_dialog): - assert "FileDialog" == export_file_dialog.__class__.__name__ - assert "FileDialog" in export_file_dialog.passphrase_form.objectName() + assert "ExportDialog" == export_file_dialog.__class__.__name__ + assert "ExportDialog" in export_file_dialog.passphrase_form.objectName() def test_class_name_matches_css_object_name_for_modal_dialog(modal_dialog): diff --git a/client/tests/test_export.py b/client/tests/test_export.py deleted file mode 100644 index d5e43b4f3e..0000000000 --- a/client/tests/test_export.py +++ /dev/null @@ -1,453 +0,0 @@ -import os -import subprocess -import unittest -from tempfile import NamedTemporaryFile, TemporaryDirectory - -import pytest - -from securedrop_client import export -from securedrop_client.export import Export, ExportError, ExportStatus - - -class TestService(unittest.TestCase): - def tearDown(self): - # ensure any changes to the export.Service instance are reset - # export.resetService() - pass - - def test_service_is_unique(self): - service = export.getService() - same_service = export.getService() # Act. - - self.assertTrue( - service is same_service, - "expected successive calls to getService to return the same service, got different services", # noqa: E501 - ) - - def test_service_can_be_reset(self): - service = export.getService() - export.resetService() # Act. - different_service = export.getService() - - self.assertTrue( - different_service is not service, - "expected resetService to reset the service, got same service after reset", - ) - - -def test_run_printer_preflight(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archives during the preflight - checks and that the success signal is emitted by Export. - """ - - export = Export() - mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.PRINT_PREFLIGHT_SUCCESS - ) - export.printer_preflight_success = mocker.MagicMock() - export.printer_preflight_success.emit = mocker.MagicMock() - - export.run_printer_preflight() - export.printer_preflight_success.emit.assert_called_once_with( - ExportStatus.PRINT_PREFLIGHT_SUCCESS - ) - - -def test_run_printer_preflight_error(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archives during the preflight - checks and that the failure signal is emitted by Export. - """ - - export = Export() - error = ExportError("bang!") - mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - - export.printer_preflight_failure = mocker.MagicMock() - export.printer_preflight_failure.emit = mocker.MagicMock() - - export.run_printer_preflight() - - export.printer_preflight_failure.emit.assert_called_once_with(error) - - -def test_print(mocker): - export = Export() - - mock_qrexec_call = mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.PRINT_SUCCESS - ) - - export.print_call_success = mocker.MagicMock() - export.print_call_success.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - - export.print(["path1", "path2"]) - - mock_qrexec_call.assert_called_once_with( - metadata=export.PRINT_METADATA, filename=export.PRINT_FN, filepaths=["path1", "path2"] - ) - export.print_call_success.emit.assert_called_once_with(ExportStatus.PRINT_SUCCESS) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_print_error(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archive containing the file to - print and that the failure signal is emitted. - """ - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - - export = Export() - export.print_call_failure = mocker.MagicMock() - export.print_call_failure.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - error = ExportError("oh no!") - _run_print = mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - mocker.patch("os.path.exists", return_value=True) - - export.print(["path1", "path2"]) - - _run_print.assert_called_once_with( - metadata=export.PRINT_METADATA, filename=export.PRINT_FN, filepaths=["path1", "path2"] - ) - export.print_call_failure.emit.assert_called_once_with(error) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_send_file_to_usb_device(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archive containing the export - file and that the success signal is emitted. - """ - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - - export = Export() - export.export_usb_call_success = mocker.MagicMock() - export.export_usb_call_success.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - _run_disk_export = mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.SUCCESS_EXPORT - ) - mocker.patch("os.path.exists", return_value=True) - - metadata = export.DISK_METADATA - metadata[export.DISK_ENCRYPTION_KEY_NAME] = "mock passphrase" - - export.send_file_to_usb_device(["path1", "path2"], "mock passphrase") - - _run_disk_export.assert_called_once_with( - metadata=metadata, filename=export.DISK_FN, filepaths=["path1", "path2"] - ) - export.export_usb_call_success.emit.assert_called_once_with(ExportStatus.SUCCESS_EXPORT) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_send_file_to_usb_device_error(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archive containing the export - file and that the failure signal is emitted. - """ - export = Export() - - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - - export.export_usb_call_failure = mocker.MagicMock() - export.export_usb_call_failure.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - error = ExportError("ohno") - _run_disk_export = mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - - metadata = export.DISK_METADATA - metadata[export.DISK_ENCRYPTION_KEY_NAME] = "mock passphrase" - - export.send_file_to_usb_device(["path1", "path2"], "mock passphrase") - - _run_disk_export.assert_called_once_with( - metadata=metadata, filename=export.DISK_FN, filepaths=["path1", "path2"] - ) - export.export_usb_call_failure.emit.assert_called_once_with(error) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_run_usb_preflight_checks(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archives during the preflight - checks and that the success signal is emitted by Export. - """ - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - export = Export() - - export.preflight_check_call_success = mocker.MagicMock() - export.preflight_check_call_success.emit = mocker.MagicMock() - _run_export = mocker.patch.object( - export, "_build_archive_and_export", return_value=ExportStatus.DEVICE_LOCKED - ) - - export.run_preflight_checks() - - _run_export.assert_called_once_with( - metadata=export.USB_TEST_METADATA, filename=export.USB_TEST_FN - ) - export.preflight_check_call_success.emit.assert_called_once_with(ExportStatus.DEVICE_LOCKED) - - -def test_run_usb_preflight_checks_error(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archives during the preflight - checks and that the failure signal is emitted by Export. - """ - - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - - export = Export() - export.preflight_check_call_failure = mocker.MagicMock() - export.preflight_check_call_failure.emit = mocker.MagicMock() - error = ExportError("bang!") - _run_export = mocker.patch.object(export, "_build_archive_and_export", side_effect=error) - - export.run_preflight_checks() - - _run_export.assert_called_once_with( - metadata=export.USB_TEST_METADATA, filename=export.USB_TEST_FN - ) - export.preflight_check_call_failure.emit.assert_called_once_with(error) - - -@pytest.mark.parametrize("success_qrexec", [e.value for e in ExportStatus]) -def test__build_archive_and_export_success(mocker, success_qrexec): - """ - Test the command that calls out to underlying qrexec service. - """ - export = Export() - - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - - mock_qrexec_call = mocker.patch.object( - export, "_run_qrexec_export", return_value=bytes(success_qrexec, "utf-8") - ) - mocker.patch.object(export, "_create_archive", return_value="mock_archive_path") - - metadata = {"device": "pretend", "encryption_method": "transparent"} - - result = export._build_archive_and_export( - metadata=metadata, filename="mock_filename", filepaths=["mock_filepath"] - ) - mock_qrexec_call.assert_called_once() - - assert result == bytes(success_qrexec, "utf-8") - - -def test__build_archive_and_export_error(mocker): - """ - Test the command that calls out to underlying qrexec service. - """ - export = Export() - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - - mocker.patch.object(export, "_create_archive", return_value="mock_archive_path") - - mock_qrexec_call = mocker.patch.object( - export, "_run_qrexec_export", side_effect=ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) - ) - - metadata = {"device": "pretend", "encryption_method": "transparent"} - - with pytest.raises(ExportError): - result = export._build_archive_and_export( - metadata=metadata, filename="mock_filename", filepaths=["mock_filepath"] - ) - assert result == ExportStatus.UNEXPECTED_RETURN_STATUS - - mock_qrexec_call.assert_called_once() - - -def test__create_archive(mocker): - """ - Ensure _create_archive creates an archive in the supplied directory. - """ - export = Export() - archive_path = None - with TemporaryDirectory() as temp_dir: - archive_path = export._create_archive( - archive_dir=temp_dir, archive_fn="mock.sd-export", metadata={}, filepaths=[] - ) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") - assert os.path.exists(archive_path) # sanity check - - assert not os.path.exists(archive_path) - - -def test__create_archive_with_an_export_file(mocker): - export = Export() - archive_path = None - with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: - archive_path = export._create_archive( - archive_dir=temp_dir, - archive_fn="mock.sd-export", - metadata={}, - filepaths=[export_file.name], - ) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") - assert os.path.exists(archive_path) # sanity check - - assert not os.path.exists(archive_path) - - -def test__create_archive_with_multiple_export_files(mocker): - """ - Ensure an archive - """ - export = Export() - archive_path = None - with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file_one, NamedTemporaryFile() as export_file_two: # noqa - transcript_path = os.path.join(temp_dir, "transcript.txt") - with open(transcript_path, "a+") as transcript: - archive_path = export._create_archive( - temp_dir, - "mock.sd-export", - {}, - [export_file_one.name, export_file_two.name, transcript.name], - ) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") - assert os.path.exists(archive_path) # sanity check - - assert not os.path.exists(archive_path) - - -@pytest.mark.parametrize("qrexec_return_value_success", [e.value for e in ExportStatus]) -def test__run_qrexec_export(mocker, qrexec_return_value_success): - """ - Ensure the subprocess call returns the expected output. - """ - export = Export() - qrexec_mocker = mocker.patch( - "subprocess.check_output", return_value=bytes(qrexec_return_value_success, "utf-8") - ) - result = export._run_qrexec_export("mock.sd-export") - - qrexec_mocker.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "mock.sd-export", - ], - stderr=-2, - ) - - assert ExportStatus(result) - - -@pytest.mark.parametrize( - "qrexec_return_value_error", [b"", b"qrexec not connected", b"DEVICE_UNLOCKED\nERROR_WRITE"] -) -def test__run_qrexec_export_returns_bad_data(mocker, qrexec_return_value_error): - """ - Ensure the subprocess call returns the expected output. - """ - export = Export() - qrexec_mocker = mocker.patch("subprocess.check_output", return_value=qrexec_return_value_error) - - with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"): - export._run_qrexec_export("mock.sd-export") - - qrexec_mocker.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "mock.sd-export", - ], - stderr=-2, - ) - - -def test__run_qrexec_export_does_not_raise_ExportError_when_CalledProcessError(mocker): - """ - Ensure ExportError is raised if a CalledProcessError is encountered. - """ - mock_error = subprocess.CalledProcessError(cmd=["mock_cmd"], returncode=123) - mocker.patch("subprocess.check_output", side_effect=mock_error) - - export = Export() - - with pytest.raises(ExportError, match="CALLED_PROCESS_ERROR"): - export._run_qrexec_export("mock.sd-export") - - -def test__run_qrexec_export_with_evil_command(mocker): - """ - Ensure shell command is shell-escaped. - """ - export = Export() - check_output = mocker.patch("subprocess.check_output", return_value=b"ERROR_FILE_NOT_FOUND") - - with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"): - export._run_qrexec_export("somefile; ls -la ~") - - check_output.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "'somefile; ls -la ~'", - ], - stderr=-2, - ) - - -def test__run_qrexec_export_error_on_empty_return_value(mocker): - """ - Ensure an error is raised when qrexec call returns empty string, - """ - export = Export() - check_output = mocker.patch("subprocess.check_output", return_value=b"") - - with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"): - export._run_qrexec_export("somefile.sd-export") - - check_output.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "somefile.sd-export", - ], - stderr=-2, - ) diff --git a/export/tests/disk/test_service.py b/export/tests/disk/test_service.py index 13a729507f..73dc0210ac 100644 --- a/export/tests/disk/test_service.py +++ b/export/tests/disk/test_service.py @@ -60,8 +60,7 @@ def _setup_submission(cls) -> Archive: metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: f.write( - '{"device": "disk", "encryption_method":' - ' "luks", "encryption_key": "hunter1"}' + '{"device": "disk", "encryption_key": "hunter1"}' ) return submission.set_metadata(Metadata(temp_folder).validate()) diff --git a/export/tests/test_archive.py b/export/tests/test_archive.py index 7c09b83d67..37510f0c84 100644 --- a/export/tests/test_archive.py +++ b/export/tests/test_archive.py @@ -436,7 +436,7 @@ def test_invalid_config(capsys): temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write('{"device": "asdf", "encryption_method": "OHNO"}') + f.write('{"device": "asdf"}') with pytest.raises(ExportException) as ex: Metadata(temp_folder).validate() @@ -450,7 +450,7 @@ def test_malformed_config(capsys): temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write('{"device": "asdf", "encryption_method": {"OHNO", "MALFORMED"}') + f.write('{"device": {"OHNO", "MALFORMED"}') with pytest.raises(ExportException) as ex: Metadata(temp_folder).validate()