diff --git a/securedrop_client/app.py b/securedrop_client/app.py index d4165f0bc..2bd84075c 100644 --- a/securedrop_client/app.py +++ b/securedrop_client/app.py @@ -41,6 +41,8 @@ from securedrop_client.logic import Controller from securedrop_client.utils import safe_mkdir +export_service = export.getService() + LanguageCode = NewType("LanguageCode", str) DEFAULT_LANGUAGE = LanguageCode("en") @@ -246,11 +248,10 @@ def start_app(args, qt_args) -> NoReturn: # type: ignore [no-untyped-def] main_queue_thread, file_download_queue_thread, ]: - export_service = export.Service() export_service.moveToThread(export_service_thread) export_service_thread.start() - gui = Window(app_state, export_service) + gui = Window(app_state) controller = Controller( "http://localhost:8081/", diff --git a/securedrop_client/export.py b/securedrop_client/export.py deleted file mode 100644 index 62abdbd84..000000000 --- a/securedrop_client/export.py +++ /dev/null @@ -1,380 +0,0 @@ -import json -import logging -import os -import subprocess -import tarfile -import threading -from enum import Enum -from io import BytesIO -from shlex import quote -from tempfile import TemporaryDirectory -from typing import List, Optional - -from PyQt5.QtCore import QObject, pyqtBoundSignal, pyqtSignal, pyqtSlot - -logger = logging.getLogger(__name__) - - -class ExportError(Exception): - def __init__(self, status: "ExportStatus"): - self.status: "ExportStatus" = status - - -class ExportStatus(Enum): - # On the way to success - USB_CONNECTED = "USB_CONNECTED" - DISK_ENCRYPTED = "USB_ENCRYPTED" - - # Not too far from success - USB_NOT_CONNECTED = "USB_NOT_CONNECTED" - BAD_PASSPHRASE = "USB_BAD_PASSPHRASE" - - # Failure - CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR" - DISK_ENCRYPTION_NOT_SUPPORTED_ERROR = "USB_ENCRYPTION_NOT_SUPPORTED" - ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION" - UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS" - PRINTER_NOT_FOUND = "ERROR_PRINTER_NOT_FOUND" - MISSING_PRINTER_URI = "ERROR_MISSING_PRINTER_URI" - - -class Export(QObject): - """ - This class sends files over to the Export VM so that they can be copied to a luks-encrypted USB - disk drive or printed by a USB-connected printer. - - Files are archived in a specified format, which you can learn more about in the README for the - securedrop-export repository. - """ - - METADATA_FN = "metadata.json" - - USB_TEST_FN = "usb-test.sd-export" - USB_TEST_METADATA = {"device": "usb-test"} - - PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" - PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} - - DISK_TEST_FN = "disk-test.sd-export" - DISK_TEST_METADATA = {"device": "disk-test"} - - PRINT_FN = "print_archive.sd-export" - PRINT_METADATA = {"device": "printer"} - - DISK_FN = "archive.sd-export" - DISK_METADATA = {"device": "disk", "encryption_method": "luks"} - DISK_ENCRYPTION_KEY_NAME = "encryption_key" - DISK_EXPORT_DIR = "export_data" - - # Set up signals for communication with the controller - preflight_check_call_failure = pyqtSignal(object) - preflight_check_call_success = pyqtSignal() - export_usb_call_failure = pyqtSignal(object) - export_usb_call_success = pyqtSignal() - export_completed = pyqtSignal(list) - - printer_preflight_success = pyqtSignal() - printer_preflight_failure = pyqtSignal(object) - print_call_failure = pyqtSignal(object) - print_call_success = pyqtSignal() - - def __init__( - self, - export_preflight_check_requested: Optional[pyqtBoundSignal] = None, - export_requested: Optional[pyqtBoundSignal] = None, - print_preflight_check_requested: Optional[pyqtBoundSignal] = None, - print_requested: Optional[pyqtBoundSignal] = None, - ) -> None: - super().__init__() - - self.connect_signals( - export_preflight_check_requested, - export_requested, - print_preflight_check_requested, - print_requested, - ) - - def connect_signals( - self, - export_preflight_check_requested: Optional[pyqtBoundSignal] = None, - export_requested: Optional[pyqtBoundSignal] = None, - print_preflight_check_requested: Optional[pyqtBoundSignal] = None, - print_requested: Optional[pyqtBoundSignal] = None, - ) -> None: - - # This instance can optionally react to events to prevent - # coupling it to dependent code. - if export_preflight_check_requested is not None: - export_preflight_check_requested.connect(self.run_preflight_checks) - if export_requested is not None: - export_requested.connect(self.send_file_to_usb_device) - if print_requested is not None: - print_requested.connect(self.print) - if print_preflight_check_requested is not None: - print_preflight_check_requested.connect(self.run_printer_preflight) - - def _export_archive(cls, archive_path: str) -> Optional[ExportStatus]: - """ - Make the subprocess call to send the archive to the Export VM, where the archive will be - processed. - - Args: - archive_path (str): The path to the archive to be processed. - - Returns: - str: The export status returned from the Export VM processing script. - - Raises: - ExportError: Raised if (1) CalledProcessError is encountered, which can occur when - trying to start the Export VM when the USB device is not attached, or (2) when - the return code from `check_output` is not 0. - """ - try: - # There are already talks of switching to a QVM-RPC implementation for unlocking devices - # and exporting files, so it's important to remember to shell-escape what we pass to the - # shell, even if for the time being we're already protected against shell injection via - # Python's implementation of subprocess, see - # https://docs.python.org/3/library/subprocess.html#security-considerations - output = subprocess.check_output( - [ - quote("qrexec-client-vm"), - quote("--"), - quote("sd-devices"), - quote("qubes.OpenInVM"), - quote("/usr/lib/qubes/qopen-in-vm"), - quote("--view-only"), - quote("--"), - quote(archive_path), - ], - stderr=subprocess.STDOUT, - ) - result = output.decode("utf-8").strip() - - # No status is returned for successful `disk`, `printer-test`, and `print` calls. - # This will change in a future release of sd-export. - if result: - return ExportStatus(result) - else: - return None - except ValueError as e: - logger.debug(f"Export subprocess returned unexpected value: {e}") - raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS) - except subprocess.CalledProcessError as e: - logger.error("Subprocess failed") - logger.debug(f"Subprocess failed: {e}") - raise ExportError(ExportStatus.CALLED_PROCESS_ERROR) - - def _create_archive( - cls, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] - ) -> str: - """ - Create the archive to be sent to the Export VM. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - archive_fn (str): The name of the archive file. - metadata (dict): The dictionary containing metadata to add to the archive. - filepaths (List[str]): The list of files to add to the archive. - - Returns: - str: The path to newly-created archive file. - """ - archive_path = os.path.join(archive_dir, archive_fn) - - with tarfile.open(archive_path, "w:gz") as archive: - cls._add_virtual_file_to_archive(archive, cls.METADATA_FN, metadata) - - for filepath in filepaths: - cls._add_file_to_archive(archive, filepath) - - return archive_path - - def _add_virtual_file_to_archive( - cls, archive: tarfile.TarFile, filename: str, filedata: dict - ) -> None: - """ - Add filedata to a stream of in-memory bytes and add these bytes to the archive. - - Args: - archive (TarFile): The archive object to add the virtual file to. - filename (str): The name of the virtual file. - filedata (dict): The data to add to the bytes stream. - - """ - filedata_string = json.dumps(filedata) - filedata_bytes = BytesIO(filedata_string.encode("utf-8")) - tarinfo = tarfile.TarInfo(filename) - tarinfo.size = len(filedata_string) - archive.addfile(tarinfo, filedata_bytes) - - def _add_file_to_archive(cls, archive: tarfile.TarFile, filepath: str) -> None: - """ - Add the file to the archive. When the archive is extracted, the file should exist in a - directory called "export_data". - - Args: - archive: The archive object ot add the file to. - filepath: The path to the file that will be added to the supplied archive. - """ - filename = os.path.basename(filepath) - arcname = os.path.join(cls.DISK_EXPORT_DIR, filename) - archive.add(filepath, arcname=arcname, recursive=False) - - def _run_printer_preflight(self, archive_dir: str) -> None: - """ - Make sure printer is ready. - """ - archive_path = self._create_archive( - archive_dir, self.PRINTER_PREFLIGHT_FN, self.PRINTER_PREFLIGHT_METADATA - ) - - status = self._export_archive(archive_path) - if status: - raise ExportError(status) - - def _run_usb_test(self, archive_dir: str) -> None: - """ - Run usb-test. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - - Raises: - ExportError: Raised if the usb-test does not return a USB_CONNECTED status. - """ - archive_path = self._create_archive(archive_dir, self.USB_TEST_FN, self.USB_TEST_METADATA) - status = self._export_archive(archive_path) - if status and status != ExportStatus.USB_CONNECTED: - raise ExportError(status) - - def _run_disk_test(self, archive_dir: str) -> None: - """ - Run disk-test. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - - Raises: - ExportError: Raised if the usb-test does not return a DISK_ENCRYPTED status. - """ - archive_path = self._create_archive(archive_dir, self.DISK_TEST_FN, self.DISK_TEST_METADATA) - - status = self._export_archive(archive_path) - if status and status != ExportStatus.DISK_ENCRYPTED: - raise ExportError(status) - - def _run_disk_export(self, archive_dir: str, filepaths: List[str], passphrase: str) -> None: - """ - Run disk-test. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - - Raises: - ExportError: Raised if the usb-test does not return a DISK_ENCRYPTED status. - """ - metadata = self.DISK_METADATA.copy() - metadata[self.DISK_ENCRYPTION_KEY_NAME] = passphrase - archive_path = self._create_archive(archive_dir, self.DISK_FN, metadata, filepaths) - - status = self._export_archive(archive_path) - if status: - raise ExportError(status) - - def _run_print(self, archive_dir: str, filepaths: List[str]) -> None: - """ - Create "printer" archive to send to Export VM. - - Args: - archive_dir (str): The path to the directory in which to create the archive. - - """ - metadata = self.PRINT_METADATA.copy() - archive_path = self._create_archive(archive_dir, self.PRINT_FN, metadata, filepaths) - status = self._export_archive(archive_path) - if status: - raise ExportError(status) - - @pyqtSlot() - def run_preflight_checks(self) -> None: - """ - Run preflight checks to verify that the usb device is connected and luks-encrypted. - """ - with TemporaryDirectory() as temp_dir: - try: - logger.debug( - "beginning preflight checks in thread {}".format( - threading.current_thread().ident - ) - ) - self._run_usb_test(temp_dir) - self._run_disk_test(temp_dir) - logger.debug("completed preflight checks: success") - self.preflight_check_call_success.emit() - except ExportError as e: - logger.debug("completed preflight checks: failure") - self.preflight_check_call_failure.emit(e) - - @pyqtSlot() - def run_printer_preflight(self) -> None: - """ - Make sure the Export VM is started. - """ - with TemporaryDirectory() as temp_dir: - try: - self._run_printer_preflight(temp_dir) - self.printer_preflight_success.emit() - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.printer_preflight_failure.emit(e) - - @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. - """ - with TemporaryDirectory() as temp_dir: - try: - logger.debug( - "beginning export from thread {}".format(threading.current_thread().ident) - ) - self._run_disk_export(temp_dir, filepaths, passphrase) - self.export_usb_call_success.emit() - logger.debug("Export successful") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.export_usb_call_failure.emit(e) - - 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. - """ - with TemporaryDirectory() as temp_dir: - try: - logger.debug( - "beginning printer from thread {}".format(threading.current_thread().ident) - ) - self._run_print(temp_dir, filepaths) - self.print_call_success.emit() - logger.debug("Print successful") - except ExportError as e: - logger.error("Export failed") - logger.debug(f"Export failed: {e}") - self.print_call_failure.emit(e) - - self.export_completed.emit(filepaths) - - -Service = Export diff --git a/securedrop_client/export/__init__.py b/securedrop_client/export/__init__.py new file mode 100644 index 000000000..710071ff5 --- /dev/null +++ b/securedrop_client/export/__init__.py @@ -0,0 +1,5 @@ +from .cli import Error as ExportError # noqa: F401 +from .cli import Status as ExportStatus # noqa: F401 +from .disk import Disk, getDisk # noqa: F401 +from .printer import Printer, getPrinter # noqa: F401 +from .service import Service, getService # noqa: F401 diff --git a/securedrop_client/export/archive.py b/securedrop_client/export/archive.py new file mode 100644 index 000000000..1e75be2e0 --- /dev/null +++ b/securedrop_client/export/archive.py @@ -0,0 +1,69 @@ +import json +import os +import tarfile +from io import BytesIO +from typing import List + + +class Archive: + METADATA_FN = "metadata.json" + DISK_EXPORT_DIR = "export_data" + + @staticmethod + def create_archive( + archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] + ) -> str: + """ + Create the archive to be sent to the Export VM. + + Args: + archive_dir (str): The path to the directory in which to create the archive. + archive_fn (str): The name of the archive file. + metadata (dict): The dictionary containing metadata to add to the archive. + filepaths (List[str]): The list of files to add to the archive. + + Returns: + str: The path to newly-created archive file. + """ + archive_path = os.path.join(archive_dir, archive_fn) + + with tarfile.open(archive_path, "w:gz") as archive: + Archive._add_virtual_file_to_archive(archive, Archive.METADATA_FN, metadata) + + for filepath in filepaths: + Archive._add_file_to_archive(archive, filepath) + + return archive_path + + @staticmethod + def _add_virtual_file_to_archive( + archive: tarfile.TarFile, filename: str, filedata: dict + ) -> None: + """ + Add filedata to a stream of in-memory bytes and add these bytes to the archive. + + Args: + archive (TarFile): The archive object to add the virtual file to. + filename (str): The name of the virtual file. + filedata (dict): The data to add to the bytes stream. + + """ + filedata_string = json.dumps(filedata) + filedata_bytes = BytesIO(filedata_string.encode("utf-8")) + tarinfo = tarfile.TarInfo(filename) + tarinfo.size = len(filedata_string) + archive.addfile(tarinfo, filedata_bytes) + + @staticmethod + def _add_file_to_archive(archive: tarfile.TarFile, filepath: str) -> None: + """ + Add the file to the archive. When the archive is extracted, the file should exist in a + directory called "export_data". + + Args: + archive: The archive object ot add the file to. + filepath: The path to the file that will be added to the supplied archive. + """ + filename = os.path.basename(filepath) + arcname = os.path.join(Archive.DISK_EXPORT_DIR, filename) + archive.add(filepath, arcname=arcname, recursive=False) diff --git a/securedrop_client/export/cli.py b/securedrop_client/export/cli.py new file mode 100644 index 000000000..25a20e455 --- /dev/null +++ b/securedrop_client/export/cli.py @@ -0,0 +1,184 @@ +import logging +import subprocess +from enum import Enum +from shlex import quote +from typing import List, Optional + +from .archive import Archive + +logger = logging.getLogger(__name__) + + +class Status(Enum): + # On the way to success + USB_CONNECTED = "USB_CONNECTED" + DISK_ENCRYPTED = "USB_ENCRYPTED" + + # Not too far from success + USB_NOT_CONNECTED = "USB_NOT_CONNECTED" + BAD_PASSPHRASE = "USB_BAD_PASSPHRASE" + + # Failure + CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR" + DISK_ENCRYPTION_NOT_SUPPORTED_ERROR = "USB_ENCRYPTION_NOT_SUPPORTED" + ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION" + UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS" + PRINTER_NOT_FOUND = "ERROR_PRINTER_NOT_FOUND" + MISSING_PRINTER_URI = "ERROR_MISSING_PRINTER_URI" + + +class Error(Exception): + def __init__(self, status: Status): + self.status = status + + +class CLI: + DISK_PRESENCE_CHECK_FN = "usb-test.sd-export" + DISK_PRESENCE_CHECK_METADATA = {"device": "usb-test"} + + PRINTER_CHECK_FN = "printer-preflight.sd-export" + PRINTER_CHECK_METADATA = {"device": "printer-preflight"} + + DISK_ENCRYPTION_CHECK_FN = "disk-test.sd-export" + DISK_ENCRYPTION_CHECK_METADATA = {"device": "disk-test"} + + PRINT_FN = "print_archive.sd-export" + PRINT_METADATA = {"device": "printer"} + + EXPORT_FN = "archive.sd-export" + EXPORT_METADATA = {"device": "disk", "encryption_method": "luks"} + DISK_ENCRYPTION_KEY_NAME = "encryption_key" + + def __init__(self) -> None: + pass + + def check_printer_status(self, archive_dir: str) -> None: + """ + Make sure printer is ready. + """ + archive_path = Archive.create_archive( + archive_dir, self.PRINTER_CHECK_FN, self.PRINTER_CHECK_METADATA + ) + + status = self._export_archive(archive_path) + if status: + raise Error(status) + + def export(self, archive_dir: str, filepaths: List[str], passphrase: str) -> None: + """ + Run disk-test. + + Args: + archive_dir (str): The path to the directory in which to create the archive. + + Raises: + Error: Raised if the usb-test does not return a DISK_ENCRYPTED status. + """ + metadata = self.EXPORT_METADATA.copy() + metadata[self.DISK_ENCRYPTION_KEY_NAME] = passphrase + archive_path = Archive.create_archive(archive_dir, self.EXPORT_FN, metadata, filepaths) + + status = self._export_archive(archive_path) + if status: + raise Error(status) # pragma: no cover + + def check_disk_encryption(self, archive_dir: str) -> None: + """ + Run disk-test. + + Args: + archive_dir (str): The path to the directory in which to create the archive. + + Raises: + Error: Raised if the usb-test does not return a DISK_ENCRYPTED status. + """ + archive_path = Archive.create_archive( + archive_dir, self.DISK_ENCRYPTION_CHECK_FN, self.DISK_ENCRYPTION_CHECK_METADATA + ) + + status = self._export_archive(archive_path) + if status and status != Status.DISK_ENCRYPTED: + raise Error(status) + + def print(self, archive_dir: str, filepaths: List[str]) -> None: + """ + Create "printer" archive to send to Export VM. + + Args: + archive_dir (str): The path to the directory in which to create the archive. + + """ + metadata = self.PRINT_METADATA.copy() + archive_path = Archive.create_archive(archive_dir, self.PRINT_FN, metadata, filepaths) + status = self._export_archive(archive_path) + if status: + raise Error(status) + + def check_disk_presence(self, archive_dir: str) -> None: + """ + Run usb-test. + + Args: + archive_dir (str): The path to the directory in which to create the archive. + + Raises: + Error: Raised if the usb-test does not return a USB_CONNECTED status. + """ + archive_path = Archive.create_archive( + archive_dir, self.DISK_PRESENCE_CHECK_FN, self.DISK_PRESENCE_CHECK_METADATA + ) + status = self._export_archive(archive_path) + if status and status != Status.USB_CONNECTED: + raise Error(status) + + @staticmethod + def _export_archive(archive_path: str) -> Optional[Status]: + """ + Make the subprocess call to send the archive to the Export VM, where the archive will be + processed. + + Args: + archive_path (str): The path to the archive to be processed. + + Returns: + str: The export status returned from the Export VM processing script. + + Raises: + Error: Raised if (1) CalledProcessError is encountered, which can occur when + trying to start the Export VM when the USB device is not attached, or (2) when + the return code from `check_output` is not 0. + """ + try: + # There are already talks of switching to a QVM-RPC implementation for unlocking devices + # and exporting files, so it's important to remember to shell-escape what we pass to the + # shell, even if for the time being we're already protected against shell injection via + # Python's implementation of subprocess, see + # https://docs.python.org/3/library/subprocess.html#security-considerations + output = subprocess.check_output( + [ + "qrexec-client-vm", + "--", + "sd-devices", + "qubes.OpenInVM", + "/usr/lib/qubes/qopen-in-vm", + "--view-only", + "--", + quote(archive_path), + ], + stderr=subprocess.STDOUT, + ) + result = output.decode("utf-8").strip() + + # No status is returned for successful `disk`, `printer-test`, and `print` calls. + # This will change in a future release of sd-export. + if result: + return Status(result) + else: + return None + except ValueError as e: + logger.debug(f"Export subprocess returned unexpected value: {e}") + raise Error(Status.UNEXPECTED_RETURN_STATUS) + except subprocess.CalledProcessError as e: + logger.error("Subprocess failed") + logger.debug(f"Subprocess failed: {e}") + raise Error(Status.CALLED_PROCESS_ERROR) diff --git a/securedrop_client/export/disk.py b/securedrop_client/export/disk.py new file mode 100644 index 000000000..5fd61916a --- /dev/null +++ b/securedrop_client/export/disk.py @@ -0,0 +1,312 @@ +import warnings +from typing import Callable, NewType, Optional + +from PyQt5.QtCore import ( + QObject, + QState, + QStateMachine, + QTimer, + pyqtBoundSignal, + pyqtSignal, + pyqtSlot, +) + +from .cli import Error as CLIError +from .service import Service + +DEFAULT_POLLING_INTERVAL_IN_MILLISECONDS = 2000 + + +class Disk(QObject): + """Allows to export files to an ecrypted disk and track its availability.""" + + status_changed = pyqtSignal() + export_done = pyqtSignal() + export_failed = pyqtSignal() + + client_started_watching = pyqtSignal() + last_client_stopped_watching = pyqtSignal() + + Status = NewType("Status", str) + StatusUnknown = Status("unknown-isw32") + StatusReachable = Status("luks-encrypted-8563d") + StatusUnreachable = Status("unreachable-ofbu4") + + def __init__( + self, + export_service: Service, + polling_interval_in_milliseconds: int = DEFAULT_POLLING_INTERVAL_IN_MILLISECONDS, + ) -> None: + super().__init__() + + self._connected_clients = 0 + self._export_service = export_service + self._poller = _Poller(polling_interval_in_milliseconds) + self._cache = _StatusCache(self._export_service) + self._last_error: Optional[CLIError] = None + + # Accept that the status is unknown if we don't watch the disk for a bit. + self._cache.clear_on(self._poller.paused.entered) + self._cache.on_change_emit(self.status_changed) + + self._export_service.connect_signals(disk_check_requested=self._poller.polling.entered) + + self._poller.wait_on(self._export_service.luks_encrypted_disk_found) + self._poller.wait_on(self._export_service.luks_encrypted_disk_not_found) + + self._poller.start_on(self.client_started_watching) + self._poller.pause_on(self.last_client_stopped_watching) + + self._export_service.luks_encrypted_disk_not_found.connect( + lambda error: self._on_luks_encrypted_disk_not_found(error) + ) + self._export_service.export_failed.connect(self._on_export_failed) + self._export_service.export_succeeded.connect(self._on_export_succeeded) + + @property + def status(self) -> Status: + return self._cache.status + + @property + def last_error(self) -> Optional[CLIError]: + return self._last_error # FIXME Returning the CLIError type is an abstraction leak. + + @pyqtSlot() + def watch(self) -> None: + self._connected_clients += 1 + self.client_started_watching.emit() + + @pyqtSlot() + def stop_watching(self) -> None: + self._connected_clients -= 1 + if self._connected_clients < 1: + self.last_client_stopped_watching.emit() + + def export_on(self, signal: pyqtBoundSignal) -> None: + """Allow to export files, in a thread-safe manner.""" + self._export_service.connect_signals(export_requested=signal) + + def check_status_once_on(self, signal: pyqtBoundSignal) -> None: + warnings.warn( + "check_status_once_on must not be used for new features, use the connect method instead", # noqa: E501 + DeprecationWarning, + stacklevel=2, + ) + + self._export_service.connect_signals(disk_check_requested=signal) + + @pyqtSlot() + def _on_luks_encrypted_disk_not_found(self, error: CLIError) -> None: + self._last_error = error + + @pyqtSlot() + def _on_export_succeeded(self) -> None: + self.export_done.emit() + + @pyqtSlot(object) + def _on_export_failed(self, error: CLIError) -> None: + self._last_error = error + self.export_failed.emit() + + +class _StatusCache(QStateMachine): + """A cache that holds the information available about the status of the export disk. + + Paste the following state chart in https://mermaid.live for + a visual representation of the behavior implemented by this class! + stateDiagram-v2 + [*] --> unknown + unknown --> luks_encrypted: export_service.luks_encrypted_disk_found + unknown --> unreachable: export_service.luks_encrypted_disk_not_found + + luks_encrypted --> luks_unreachable: export_service.luks_encrypted_disk_not_found + luks_encrypted --> unknown: registered_clearing_signal_emitted + + unreachable --> luks_encrypted: export_service.luks_encrypted_disk_found + unreachable --> unknown: registered_clearing_signal_emitted + """ + + def __init__(self, export_service: Service) -> None: + super().__init__() + + self._service = export_service + self._status = Disk.StatusUnknown + + # Declare the state chart described in the docstring. + # See https://doc.qt.io/qt-5/statemachine-api.html + # + # This is a very declarative exercise. + + self._unknown = QState() + self._luks_encrypted = QState() + self._unreachable = QState() + + self._unknown.addTransition(self._service.luks_encrypted_disk_found, self._luks_encrypted) + self._unknown.addTransition(self._service.luks_encrypted_disk_not_found, self._unreachable) + + self._luks_encrypted.addTransition( + self._service.luks_encrypted_disk_not_found, self._unreachable + ) + self._unreachable.addTransition( + self._service.luks_encrypted_disk_found, self._luks_encrypted + ) + + # The transitions to the unknown state are created + # when clearing signals are connected. + + self.addState(self._unknown) + self.addState(self._luks_encrypted) + self.addState(self._unreachable) + + self.setInitialState(self._unknown) + + self._unknown.entered.connect(self._on_unknown_state_entered) + self._luks_encrypted.entered.connect(self._on_luks_encrypted_state_entered) + self._unreachable.entered.connect(self._on_unreachable_state_entered) + + self.start() + + def clear_on(self, signal: pyqtBoundSignal) -> None: + """Allow the cache to be cleared (status == Disk.UnknownStatus) when signal is emitted. + + Register a clearing signal.""" + self._luks_encrypted.addTransition(signal, self._unknown) + self._unreachable.addTransition(signal, self._unknown) + + def on_change_emit(self, signal: pyqtBoundSignal) -> None: + """Allow a signal to be emitted when the value of the cache changes.""" + self._unknown.entered.connect(signal) + self._luks_encrypted.entered.connect(signal) + self._unreachable.entered.connect(signal) + + @property + def status(self) -> Disk.Status: + return self._status + + @pyqtSlot() + def _on_unknown_state_entered(self) -> None: + self._status = Disk.StatusUnknown + + @pyqtSlot() + def _on_luks_encrypted_state_entered(self) -> None: + self._status = Disk.StatusReachable + + @pyqtSlot() + def _on_unreachable_state_entered(self) -> None: + self._status = Disk.StatusUnreachable + + +class _Poller(QStateMachine): + """Allow a function to ge called repeatedly, with a delay between calls. + + Paste the following state chart in https://mermaid.live for + a visual representation of the behavior implemented by this class! + stateDiagram-v2 + [*] --> paused + + state started { + [*] --> polling + polling --> waiting: registered_waiting_signal_emitted + waiting --> polling: polling_delay_timer.timeout + } + + paused --> started: registered_starting_signal_emitted + started --> paused: registered_pausing_signal_emitted + """ + + def __init__(self, polling_interval_in_milliseconds: int) -> None: + super().__init__() + + self._polling_delay_timer = QTimer() + self._polling_delay_timer.setInterval(polling_interval_in_milliseconds) + + # Declare the state chart described in the docstring. + # See https://doc.qt.io/qt-5/statemachine-api.html + # + # This is a very declarative exercise. + # The public state names are part of the API of this class. + + self.paused = QState() + self._started = QState() + self.polling = QState(self._started) + self._waiting = QState(self._started) + + # The transition from polling to waiting are created + # when waiting signals are registered. + + self._waiting.addTransition(self._polling_delay_timer.timeout, self.polling) + + # The transitions from paused to started (resp. started to paused) + # are created when starting (resp. pausing) signals are registered. + + self.addState(self.paused) + self.addState(self._started) + self.setInitialState(self.paused) + self._started.setInitialState(self.polling) + + self._waiting.entered.connect(self._polling_delay_timer.start) + self.polling.entered.connect(self._polling_delay_timer.stop) + + self.start() # start the state machine, not polling! + + def start_on(self, signal: pyqtBoundSignal) -> None: + """Allow polling to be started, in a thread-safe manner. + + Register a starting signal.""" + self.paused.addTransition(signal, self._started) + + def pause_on(self, signal: pyqtBoundSignal) -> None: + """Allow polling to be paused, in a thread-safe manner. + + Register a pausing signal.""" + self._started.addTransition(signal, self.paused) + + def wait_on(self, signal: pyqtBoundSignal) -> None: + """Allow to signal that a given polling event is done, in a thread-safe manner. + + Register a waiting signal.""" + self.polling.addTransition(signal, self._waiting) + signal.connect(self._polling_delay_timer.start) + + def poll_by(self, callback: Callable[[], None]) -> None: + """Allow a function to ge called repeatedly, with a delay between calls.""" + # It would be nice to connect a signal, but I didn't find how + # to allow registering a signal that requires arguments without + # the closure. + self.polling.entered.connect(lambda: callback()) # pragma: nocover + + +# Store disks to prevent concurrent access to the export service. See getDisk. +_disks: dict[int, Disk] = {} + + +def getDisk( + export_service: Service, + polling_interval_in_milliseconds: int = DEFAULT_POLLING_INTERVAL_IN_MILLISECONDS, +) -> Disk: + """Return a disk with a specific configuration. + + All calls to this function with the same configuration return the same disk instance.""" + global _disks + + # Only create one disk by export service, + # to prevent unnecessary concurrent access to the export service. + disk_id = id(export_service) + + disk = _disks.get(disk_id, None) + if not disk: + disk = Disk(export_service, polling_interval_in_milliseconds) + _disks[disk_id] = disk + + return disk + + +def clearDisk( + export_service: Service, +) -> None: + global _disks + + # See getDisk + disk_id = id(export_service) + if disk_id in _disks: + del _disks[disk_id] diff --git a/securedrop_client/export/printer.py b/securedrop_client/export/printer.py new file mode 100644 index 000000000..19c2f5658 --- /dev/null +++ b/securedrop_client/export/printer.py @@ -0,0 +1,313 @@ +import warnings +from typing import Callable, NewType, Optional + +from PyQt5.QtCore import ( + QObject, + QState, + QStateMachine, + QTimer, + pyqtBoundSignal, + pyqtSignal, + pyqtSlot, +) + +from .cli import Error as CLIError +from .service import Service + +DEFAULT_POLLING_INTERVAL_IN_MILLISECONDS = 2000 + + +class Printer(QObject): + """Allows to enqueue printing jobs and track the status of the printing queue. + + It can be treated like a printer, but technically speaking it only interfaces with + a printing queue, not an actual printer. Success or failure enqueing jobs can be tracked, + and is the best proxy we currently have to tracking the outcome of printing operations.""" + + status_changed = pyqtSignal() + job_done = pyqtSignal() + job_failed = pyqtSignal() + + client_started_watching = pyqtSignal() + last_client_stopped_watching = pyqtSignal() + + Status = NewType("Status", str) + StatusUnknown = Status("unknown-sf5fd") + StatusReady = Status("ready-83jf3") + StatusUnreachable = Status("unreachable-120a0") + + def __init__( + self, + printing_service: Service, + polling_interval_in_milliseconds: int = DEFAULT_POLLING_INTERVAL_IN_MILLISECONDS, + ) -> None: + super().__init__() + + self._connected_clients = 0 + self._printing_service = printing_service + self._poller = _Poller(polling_interval_in_milliseconds) + self._cache = _StatusCache(self._printing_service) + self._last_error: Optional[CLIError] = None + + # Accept that the status is unknown if we don't watch the printer for a bit. + self._cache.clear_on(self._poller.paused.entered) + self._cache.on_change_emit(self.status_changed) + + self._printing_service.connect_signals(printer_check_requested=self._poller.polling.entered) + + self._poller.wait_on(self._printing_service.printer_not_found_ready) + self._poller.wait_on(self._printing_service.printer_found_ready) + + self._poller.start_on(self.client_started_watching) + self._poller.pause_on(self.last_client_stopped_watching) + + # The printing service is not up-to-date on the printing queue terminology. + self._printing_service.printer_not_found_ready.connect( + lambda error: self._on_printer_not_found_ready(error) + ) + self._printing_service.print_failed.connect(self._on_job_enqueuing_failed) + self._printing_service.print_succeeded.connect(self._on_job_enqueued) + + @property + def status(self) -> Status: + return self._cache.status + + @property + def last_error(self) -> Optional[CLIError]: + return self._last_error # FIXME Returning the CLIError type is an abstraction leak. + + @pyqtSlot() + def watch(self) -> None: + self._connected_clients += 1 + self.client_started_watching.emit() + + @pyqtSlot() + def stop_watching(self) -> None: + self._connected_clients -= 1 + if self._connected_clients < 1: + self.last_client_stopped_watching.emit() + + def enqueue_job_on(self, signal: pyqtBoundSignal) -> None: + """Allow to enqueue printing jobs, in a thread-safe manner.""" + self._printing_service.connect_signals(print_requested=signal) + + def check_status_once_on(self, signal: pyqtBoundSignal) -> None: + warnings.warn( + "check_status_once_on must not be used for new features, use the connect method instead", # noqa: E501 + DeprecationWarning, + stacklevel=2, + ) + + self._printing_service.connect_signals(printer_check_requested=signal) + + @pyqtSlot() + def _on_printer_not_found_ready(self, error: CLIError) -> None: + self._last_error = error + + @pyqtSlot() + def _on_job_enqueued(self) -> None: + self.job_done.emit() + + @pyqtSlot(object) + def _on_job_enqueuing_failed(self, error: CLIError) -> None: + self._last_error = error + self.job_failed.emit() + + +class _StatusCache(QStateMachine): + """A cache that holds the information available about the status of the printing pipeline. + + Paste the following state chart in https://mermaid.live for + a visual representation of the behavior implemented by this class! + stateDiagram-v2 + [*] --> unknown + unknown --> ready: printing_service.printer_found_ready + unknown --> unreachable: printing_service.printer_not_found_ready + + ready --> unreachable: printing_service.printer_not_found_ready + ready --> unknown: registered_clearing_signal_emitted + + unreachable --> ready: printing_service.printer_found_ready + unreachable --> unknown: registered_clearing_signal_emitted + """ + + def __init__(self, printing_service: Service) -> None: + super().__init__() + + self._service = printing_service + self._status = Printer.StatusUnknown + + # Declare the state chart described in the docstring. + # See https://doc.qt.io/qt-5/statemachine-api.html + # + # This is a very declarative exercise. + + self._unknown = QState() + self._ready = QState() + self._unreachable = QState() + + self._unknown.addTransition(self._service.printer_found_ready, self._ready) + self._unknown.addTransition(self._service.printer_not_found_ready, self._unreachable) + + self._ready.addTransition(self._service.printer_not_found_ready, self._unreachable) + self._unreachable.addTransition(self._service.printer_found_ready, self._ready) + + # The transitions to the unknown state are created + # when clearing signals are connected. + + self.addState(self._unknown) + self.addState(self._ready) + self.addState(self._unreachable) + + self.setInitialState(self._unknown) + + self._unknown.entered.connect(self._on_unknown_state_entered) + self._ready.entered.connect(self._on_ready_state_entered) + self._unreachable.entered.connect(self._on_unreachable_state_entered) + + self.start() + + def clear_on(self, signal: pyqtBoundSignal) -> None: + """Allow the cache to be cleared (status == Printer.UnknownStatus) when signal is emitted. + + Register a clearing signal.""" + self._ready.addTransition(signal, self._unknown) + self._unreachable.addTransition(signal, self._unknown) + + def on_change_emit(self, signal: pyqtBoundSignal) -> None: + """Allow a signal to be emitted whe the value of the cache changes.""" + self._unknown.entered.connect(signal) + self._ready.entered.connect(signal) + self._unreachable.entered.connect(signal) + + @property + def status(self) -> Printer.Status: + return self._status + + @pyqtSlot() + def _on_unknown_state_entered(self) -> None: + self._status = Printer.StatusUnknown + + @pyqtSlot() + def _on_ready_state_entered(self) -> None: + self._status = Printer.StatusReady + + @pyqtSlot() + def _on_unreachable_state_entered(self) -> None: + self._status = Printer.StatusUnreachable + + +class _Poller(QStateMachine): + """Allow a function to ge called repeatedly, with a delay between calls. + + Paste the following state chart in https://mermaid.live for + a visual representation of the behavior implemented by this class! + stateDiagram-v2 + [*] --> paused + + state started { + [*] --> polling + polling --> waiting: registered_waiting_signal_emitted + waiting --> polling: polling_delay_timer.timeout + } + + paused --> started: registered_starting_signal_emitted + started --> paused: registered_pausing_signal_emitted + """ + + def __init__(self, polling_interval_in_milliseconds: int) -> None: + super().__init__() + + self._polling_delay_timer = QTimer() + self._polling_delay_timer.setInterval(polling_interval_in_milliseconds) + + # Declare the state chart described in the docstring. + # See https://doc.qt.io/qt-5/statemachine-api.html + # + # This is a very declarative exercise. + # The public state names are part of the API of this class. + + self.paused = QState() + self._started = QState() + self.polling = QState(self._started) + self._waiting = QState(self._started) + + # The transition from polling to waiting are created + # when waiting signals are registered. + + self._waiting.addTransition(self._polling_delay_timer.timeout, self.polling) + + # The transitions from paused to started (resp. started to paused) + # are created when starting (resp. pausing) signals are registered. + + self.addState(self.paused) + self.addState(self._started) + self.setInitialState(self.paused) + self._started.setInitialState(self.polling) + + self._waiting.entered.connect(self._polling_delay_timer.start) + self.polling.entered.connect(self._polling_delay_timer.stop) + + self.start() # start the state machine, not polling! + + def start_on(self, signal: pyqtBoundSignal) -> None: + """Allow polling to be started, in a thread-safe manner. + + Register a starting signal.""" + self.paused.addTransition(signal, self._started) + + def pause_on(self, signal: pyqtBoundSignal) -> None: + """Allow polling to be paused, in a thread-safe manner. + + Register a pausing signal.""" + self._started.addTransition(signal, self.paused) + + def wait_on(self, signal: pyqtBoundSignal) -> None: + """Allow to signal that a given polling event is done, in a thread-safe manner. + + Register a waiting signal.""" + self.polling.addTransition(signal, self._waiting) + signal.connect(self._polling_delay_timer.start) + + def poll_by(self, callback: Callable[[], None]) -> None: + """Allow a function to ge called repeatedly, with a delay between calls.""" + # It would be nice to connect a signal, but I didn't find how + # to allow registering a signal that requires arguments without + # the closure. + self.polling.entered.connect(lambda: callback()) # pragma: nocover + + +# Store printers to prevent concurrent access to the printing service. See getPrinter. +_printers: dict[int, Printer] = {} + + +def getPrinter( + printing_service: Service, + polling_interval_in_milliseconds: int = DEFAULT_POLLING_INTERVAL_IN_MILLISECONDS, +) -> Printer: + """Return a printer with a specific configuration. + + All calls to this function with the same configuration return the same printer instance.""" + global _printers + + # Only create one printer by printing service, + # to prevent unnecessary concurrent access to the printing service. + printer_id = id(printing_service) + + printer = _printers.get(printer_id, None) + if not printer: + printer = Printer(printing_service, polling_interval_in_milliseconds) + _printers[printer_id] = printer + + return printer + + +def clearPrinter( + export_service: Service, +) -> None: + global _printers + + # See getPrinter + printer_id = id(export_service) + if printer_id in _printers: + del _printers[printer_id] diff --git a/securedrop_client/export/service.py b/securedrop_client/export/service.py new file mode 100644 index 000000000..77f92646e --- /dev/null +++ b/securedrop_client/export/service.py @@ -0,0 +1,192 @@ +import logging +import threading +from tempfile import TemporaryDirectory +from typing import List, Optional + +from PyQt5.QtCore import QObject, pyqtBoundSignal, pyqtSignal, pyqtSlot + +from .cli import CLI +from .cli import Error as CLIError + +logger = logging.getLogger(__name__) + + +class Service(QObject): + """ + This class sends files over to the Export VM so that they can be copied to a luks-encrypted USB + disk drive or printed by a USB-connected printer. + + Files are archived in a specified format, which you can learn more about in the README for the + securedrop-export repository. + """ + + # Set up signals for communication with the export device + preflight_check_call_failure = pyqtSignal(object) # DEPRECATED + preflight_check_call_success = pyqtSignal() # DEPRECATED + export_usb_call_failure = pyqtSignal(object) # DEPRECATED + export_usb_call_success = pyqtSignal() # DEPRECATED + export_completed = pyqtSignal(list) # DEPRECATED + + printer_preflight_success = pyqtSignal() # DEPRECATED + printer_preflight_failure = pyqtSignal(object) # DEPRECATED + print_call_failure = pyqtSignal(object) # DEPRECATED + print_call_success = pyqtSignal() # DEPRECATED + + luks_encrypted_disk_not_found = pyqtSignal(object) + luks_encrypted_disk_found = pyqtSignal() + export_failed = pyqtSignal(object) + export_succeeded = pyqtSignal() + export_finished = pyqtSignal(list) + + printer_found_ready = pyqtSignal() + printer_not_found_ready = pyqtSignal(object) + print_failed = pyqtSignal(object) + print_succeeded = pyqtSignal() + print_finished = pyqtSignal(list) + + def __init__( + self, + disk_check_requested: Optional[pyqtBoundSignal] = None, + export_requested: Optional[pyqtBoundSignal] = None, + printer_check_requested: Optional[pyqtBoundSignal] = None, + print_requested: Optional[pyqtBoundSignal] = None, + ) -> None: + super().__init__() + + self._cli = CLI() + + self.connect_signals( + disk_check_requested, + export_requested, + printer_check_requested, + print_requested, + ) + + # Ensure backwards compatibility with deprecated API + self.printer_found_ready.connect(self.printer_preflight_success) + self.printer_not_found_ready.connect(self.printer_preflight_failure) + self.print_succeeded.connect(self.print_call_success) + self.print_failed.connect(self.print_call_failure) + + self.luks_encrypted_disk_found.connect(self.preflight_check_call_success) + self.luks_encrypted_disk_not_found.connect(self.preflight_check_call_failure) + self.export_succeeded.connect(self.export_usb_call_success) + self.export_failed.connect(self.export_usb_call_failure) + self.export_finished.connect(self.export_completed) + self.print_finished.connect(self.export_completed) + + def connect_signals( + self, + disk_check_requested: Optional[pyqtBoundSignal] = None, + export_requested: Optional[pyqtBoundSignal] = None, + printer_check_requested: Optional[pyqtBoundSignal] = None, + print_requested: Optional[pyqtBoundSignal] = None, + ) -> None: + + # This instance can optionally react to events to prevent + # coupling it to dependent code. + if disk_check_requested is not None: + disk_check_requested.connect(self.check_disk) + if export_requested is not None: + export_requested.connect(self.export) + if print_requested is not None: + print_requested.connect(self.print) + if printer_check_requested is not None: + printer_check_requested.connect(self.check_printer_status) + + @pyqtSlot() + def check_disk(self) -> None: + """ + Checks that the USB disk is connected and LUKS-encrypted. + """ + with TemporaryDirectory() as temp_dir: + try: + logger.debug( + "beginning preflight checks in thread {}".format( + threading.current_thread().ident + ) + ) + self._cli.check_disk_presence(temp_dir) + self._cli.check_disk_encryption(temp_dir) + logger.debug("completed preflight checks: success") + self.luks_encrypted_disk_found.emit() + except CLIError as e: + logger.debug("completed preflight checks: failure") + # FIXME Emitting the CLIError type is an abstraction leak. + self.luks_encrypted_disk_not_found.emit(e) + + @pyqtSlot() + def check_printer_status(self) -> None: + """ + Make sure the Export VM is started. + """ + with TemporaryDirectory() as temp_dir: + try: + self._cli.check_printer_status(temp_dir) + self.printer_found_ready.emit() + except CLIError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + # FIXME Emitting the CLIError type is an abstraction leak. + self.printer_not_found_ready.emit(e) + + @pyqtSlot(list, str) + def export(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. + """ + with TemporaryDirectory() as temp_dir: + try: + logger.debug( + "beginning export from thread {}".format(threading.current_thread().ident) + ) + self._cli.export(temp_dir, filepaths, passphrase) + self.export_succeeded.emit() + logger.debug("Export successful") + except CLIError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.export_failed.emit(e) + + self.export_finished.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. + """ + with TemporaryDirectory() as temp_dir: + try: + logger.debug( + "beginning printer from thread {}".format(threading.current_thread().ident) + ) + self._cli.print(temp_dir, filepaths) + self.print_succeeded.emit() + logger.debug("Print successful") + except CLIError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.print_failed.emit(e) + + self.print_finished.emit(filepaths) + + +# Store the service instance to prevent unnecessary concurrent access to the CLI. See getService. +_service = Service() + + +def getService() -> Service: + """All calls to this function return the same service instance.""" + return _service + + +def resetService() -> None: + global _service + _service = Service() diff --git a/securedrop_client/gui/actions.py b/securedrop_client/gui/actions.py index 37a5082b8..e436a59db 100644 --- a/securedrop_client/gui/actions.py +++ b/securedrop_client/gui/actions.py @@ -62,7 +62,7 @@ def _set_enabled_initial_value(self) -> None: self._on_selected_conversation_files_changed() -class DeleteSourceAction(QAction): +class DeleteSource(QAction): """Use this action to delete the source record.""" def __init__( @@ -91,7 +91,7 @@ def trigger(self) -> None: self._confirmation_dialog.exec() -class DeleteConversationAction(QAction): +class DeleteConversation(QAction): """Use this action to delete a source's submissions and replies.""" def __init__( diff --git a/securedrop_client/gui/conversation/__init__.py b/securedrop_client/gui/conversation/__init__.py index 0c63f40c7..a01adc3fb 100644 --- a/securedrop_client/gui/conversation/__init__.py +++ b/securedrop_client/gui/conversation/__init__.py @@ -3,6 +3,5 @@ """ # 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 ExportFileDialog # noqa: F401 from .export import PrintDialog as PrintFileDialog # noqa: F401 diff --git a/securedrop_client/gui/conversation/export/__init__.py b/securedrop_client/gui/conversation/export/__init__.py index 58465f7df..bd5292e8c 100644 --- a/securedrop_client/gui/conversation/export/__init__.py +++ b/securedrop_client/gui/conversation/export/__init__.py @@ -1,3 +1,2 @@ -from .device import Device # noqa: F401 from .dialog import ExportDialog as Dialog # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 diff --git a/securedrop_client/gui/conversation/export/device.py b/securedrop_client/gui/conversation/export/device.py deleted file mode 100644 index 8f0250fee..000000000 --- a/securedrop_client/gui/conversation/export/device.py +++ /dev/null @@ -1,109 +0,0 @@ -import logging -import os - -from PyQt5.QtCore import QObject, pyqtSignal - -from securedrop_client 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() - export_preflight_check_succeeded = pyqtSignal() - export_preflight_check_failed = pyqtSignal(object) - - export_requested = pyqtSignal(list, str) - export_succeeded = pyqtSignal() - export_failed = pyqtSignal(object) - export_completed = pyqtSignal(list) - - print_preflight_check_requested = pyqtSignal() - print_preflight_check_succeeded = pyqtSignal() - print_preflight_check_failed = pyqtSignal(object) - - print_requested = pyqtSignal(list) - print_succeeded = pyqtSignal() - print_failed = pyqtSignal(object) - - def __init__(self, controller: Controller, export_service: export.Service) -> 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_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): - return - - self.export_requested.emit([file_location], passphrase) - - 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): - return - - self.print_requested.emit([file_location]) diff --git a/securedrop_client/gui/conversation/export/dialog.py b/securedrop_client/gui/conversation/export/dialog.py index 52f4f03a2..f4a2d3392 100644 --- a/securedrop_client/gui/conversation/export/dialog.py +++ b/securedrop_client/gui/conversation/export/dialog.py @@ -5,48 +5,46 @@ from typing import Optional from pkg_resources import resource_string -from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtGui import QColor, QFont from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QLineEdit, QVBoxLayout, QWidget -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export import Disk, ExportStatus from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel from securedrop_client.gui.base.checkbox import SDCheckBox -from .device import Device - class ExportDialog(ModalDialog): + disk_status_check_requested = pyqtSignal() + file_export_requested = pyqtSignal(list, str) + DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8") PASSPHRASE_LABEL_SPACING = 0.5 NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: + def __init__(self, export_disk: Disk, file_location: str, file_name: str) -> None: super().__init__() self.setStyleSheet(self.DIALOG_CSS) - self._device = device - self.file_uuid = file_uuid + self._export_disk = export_disk + self.file_location = file_location 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 export_disk signals to slots + self._export_disk.status_changed.connect(self._on_disk_status_changed) + self._export_disk.export_done.connect(self._on_export_succeeded) + self._export_disk.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) + self.continue_button.clicked.connect(self.disk_status_check_requested) # Dialog content self.starting_header = _( @@ -119,7 +117,12 @@ def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: self._show_starting_instructions() self.start_animate_header() - self._run_preflight() + self._export_disk.export_on(self.file_export_requested) + self._export_disk.check_status_once_on(self.disk_status_check_requested) + self.disk_status_check_requested.emit() + + def text(self) -> str: + return self.header.text() + super().text() def _show_starting_instructions(self) -> None: self.header.setText(self.starting_header) @@ -166,7 +169,7 @@ def _show_success_message(self) -> None: def _show_insert_usb_message(self) -> None: self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._run_preflight) + self.continue_button.clicked.connect(self.disk_status_check_requested) self.header.setText(self.insert_usb_header) self.continue_button.setText(_("CONTINUE")) self.body.setText(self.insert_usb_message) @@ -178,7 +181,7 @@ def _show_insert_usb_message(self) -> None: def _show_insert_encrypted_usb_message(self) -> None: self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._run_preflight) + self.continue_button.clicked.connect(self.disk_status_check_requested) self.header.setText(self.insert_usb_header) self.error_details.setText(self.usb_error_message) self.continue_button.setText(_("CONTINUE")) @@ -204,15 +207,22 @@ def _show_generic_error_message(self) -> None: self.adjustSize() @pyqtSlot() - def _run_preflight(self) -> None: - self._device.run_export_preflight_checks() + def _on_disk_status_changed(self) -> None: + disk_status = self._export_disk.status + if disk_status == Disk.StatusReachable: + self._on_export_preflight_check_succeeded() + elif disk_status == Disk.StatusUnreachable: + self._on_export_preflight_check_failed() + else: + # Disk.StatusUnknown is not supprted by this dialog. + pass @pyqtSlot() def _export_file(self, checked: bool = False) -> None: self.start_animate_activestate() self.cancel_button.setEnabled(False) self.passphrase_field.setDisabled(True) - self._device.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) + self.file_export_requested.emit([self.file_location], self.passphrase_field.text()) @pyqtSlot() def _on_export_preflight_check_succeeded(self) -> None: @@ -230,22 +240,34 @@ def _on_export_preflight_check_succeeded(self) -> None: self._show_passphrase_request_message() @pyqtSlot(object) - def _on_export_preflight_check_failed(self, error: ExportError) -> None: + def _on_export_preflight_check_failed(self) -> None: + error = self._export_disk.last_error + if error: + status = error.status + else: + status = ExportStatus.UNEXPECTED_RETURN_STATUS + self.stop_animate_header() self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) - self._update_dialog(error.status) + self._update_dialog(status) @pyqtSlot() def _on_export_succeeded(self) -> None: self.stop_animate_activestate() self._show_success_message() - @pyqtSlot(object) - def _on_export_failed(self, error: ExportError) -> None: + @pyqtSlot() + def _on_export_failed(self) -> None: + error = self._export_disk.last_error + if error: + status = error.status + else: + status = ExportStatus.UNEXPECTED_RETURN_STATUS + self.stop_animate_activestate() self.cancel_button.setEnabled(True) self.passphrase_field.setDisabled(False) - self._update_dialog(error.status) + self._update_dialog(status) def _update_dialog(self, error_status: ExportStatus) -> None: self.error_status = error_status diff --git a/securedrop_client/gui/conversation/export/print_dialog.py b/securedrop_client/gui/conversation/export/print_dialog.py index 320c14386..4f7a7b0e6 100644 --- a/securedrop_client/gui/conversation/export/print_dialog.py +++ b/securedrop_client/gui/conversation/export/print_dialog.py @@ -1,38 +1,36 @@ from gettext import gettext as _ from typing import Optional -from PyQt5.QtCore import QSize, pyqtSlot +from PyQt5.QtCore import QSize, pyqtSignal, pyqtSlot -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export import ExportStatus, Printer from securedrop_client.gui.base import ModalDialog, SecureQLabel -from .device import Device - class PrintDialog(ModalDialog): + printer_status_check_requested = pyqtSignal() + file_printing_requested = pyqtSignal(list) + FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: + def __init__(self, printer: Printer, file_location: str, file_name: str) -> None: super().__init__() - self._device = device - self.file_uuid = file_uuid + self._printer = printer + self.file_location = file_location 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.print_preflight_check_succeeded.connect( - self._on_print_preflight_check_succeeded - ) - self._device.print_preflight_check_failed.connect(self._on_print_preflight_check_failed) + # Connect printer signals to slots + self._printer.status_changed.connect(self._on_printer_status_changed) # Connect parent signals to slots self.continue_button.setEnabled(False) - self.continue_button.clicked.connect(self._run_preflight) + self.continue_button.clicked.connect(self.printer_status_check_requested) # Dialog content self.starting_header = _( @@ -62,7 +60,12 @@ def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: self._show_starting_instructions() self.start_animate_header() - self._run_preflight() + self._printer.enqueue_job_on(self.file_printing_requested) + self._printer.check_status_once_on(self.printer_status_check_requested) + self.printer_status_check_requested.emit() + + def text(self) -> str: + return self.header.text() + super().text() def _show_starting_instructions(self) -> None: self.header.setText(self.starting_header) @@ -72,7 +75,7 @@ def _show_starting_instructions(self) -> None: def _show_insert_usb_message(self) -> None: self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._run_preflight) + self.continue_button.clicked.connect(self.printer_status_check_requested) self.header.setText(self.insert_usb_header) self.body.setText(self.insert_usb_message) self.error_details.hide() @@ -90,12 +93,19 @@ def _show_generic_error_message(self) -> None: self.adjustSize() @pyqtSlot() - def _run_preflight(self) -> None: - self._device.run_printer_preflight_checks() + def _on_printer_status_changed(self) -> None: + printer_status = self._printer.status + if printer_status == Printer.StatusReady: + self._on_print_preflight_check_succeeded() + elif printer_status == Printer.StatusUnreachable: + self._on_print_preflight_check_failed() + else: + # Printer.StatusUnknown is not supprted by this dialog. + pass @pyqtSlot() def _print_file(self) -> None: - self._device.print_file(self.file_uuid) + self.file_printing_requested.emit([self.file_location]) self.close() @pyqtSlot() @@ -114,14 +124,20 @@ def _on_print_preflight_check_succeeded(self) -> None: self._print_file() @pyqtSlot(object) - def _on_print_preflight_check_failed(self, error: ExportError) -> None: + def _on_print_preflight_check_failed(self) -> None: + error = self._printer.last_error + if error: + status = error.status + else: + status = ExportStatus.UNEXPECTED_RETURN_STATUS + self.stop_animate_header() self.header_icon.update_image("printer.svg", svg_size=QSize(64, 64)) - self.error_status = error.status + self.error_status = status # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.continue_button.clicked.disconnect() - if error.status == ExportStatus.PRINTER_NOT_FOUND: + if status == ExportStatus.PRINTER_NOT_FOUND: self.continue_button.clicked.connect(self._show_insert_usb_message) else: self.continue_button.clicked.connect(self._show_generic_error_message) @@ -129,7 +145,7 @@ def _on_print_preflight_check_failed(self, error: ExportError) -> None: self.continue_button.setEnabled(True) self.continue_button.setFocus() else: - if error.status == ExportStatus.PRINTER_NOT_FOUND: + if status == ExportStatus.PRINTER_NOT_FOUND: self._show_insert_usb_message() else: self._show_generic_error_message() diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index 17d75efa4..5a4091bfe 100644 --- a/securedrop_client/gui/main.py +++ b/securedrop_client/gui/main.py @@ -27,7 +27,7 @@ from PyQt5.QtGui import QClipboard, QGuiApplication, QIcon, QKeySequence from PyQt5.QtWidgets import QAction, QApplication, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget -from securedrop_client import __version__, export, state +from securedrop_client import __version__, state from securedrop_client.db import Source, User from securedrop_client.gui.auth import LoginDialog from securedrop_client.gui.widgets import LeftPane, MainView, TopPane @@ -48,7 +48,6 @@ class Window(QMainWindow): def __init__( self, app_state: Optional[state.State] = None, - export_service: Optional[export.Service] = None, ) -> None: """ Create the default start state. The window contains a root widget into @@ -77,7 +76,7 @@ def __init__( layout.setSpacing(0) self.main_pane.setLayout(layout) self.left_pane = LeftPane() - self.main_view = MainView(self.main_pane, app_state, export_service) + self.main_view = MainView(self.main_pane, app_state) layout.addWidget(self.left_pane) layout.addWidget(self.main_view) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 22c279ee3..26e2bba4c 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -70,12 +70,7 @@ Source, User, ) -from securedrop_client.gui import conversation -from securedrop_client.gui.actions import ( - DeleteConversationAction, - DeleteSourceAction, - DownloadConversation, -) +from securedrop_client.gui import actions, conversation from securedrop_client.gui.base import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton from securedrop_client.gui.conversation import DeleteConversationDialog from securedrop_client.gui.source import DeleteSourceDialog @@ -570,12 +565,10 @@ def __init__( self, parent: Optional[QWidget], app_state: Optional[state.State] = None, - export_service: Optional[export.Service] = None, ) -> None: super().__init__(parent) self._state = app_state - self._export_service = export_service # Set id and styles self.setObjectName("MainView") @@ -675,7 +668,7 @@ def on_source_changed(self) -> None: ) else: conversation_wrapper = SourceConversationWrapper( - source, self.controller, self._state, self._export_service + source, self.controller, self._state ) self.source_conversations[source.uuid] = conversation_wrapper @@ -2211,7 +2204,6 @@ def __init__( file_missing: pyqtBoundSignal, index: int, container_width: int, - export_service: Optional[export.Service] = None, ) -> None: """ Given some text and a reference to the controller, make something to display a file. @@ -2220,13 +2212,9 @@ def __init__( self.controller = controller - if export_service is None: - # Note that injecting an export service that runs in a separate - # thread is greatly encouraged! But it is optional because strictly - # speaking it is not a dependency of this FileWidget. - export_service = export.Service() - - self._export_device = conversation.ExportDevice(controller, export_service) + export_service = export.getService() + self._export_disk = export.getDisk(export_service) + self._printer = export.getPrinter(export_service) self.file = self.controller.get_file(file_uuid) self.uuid = file_uuid @@ -2429,8 +2417,9 @@ def _on_export_clicked(self) -> None: if not self.controller.downloaded_file_exists(self.file): return + file_location = self.file.location(self.controller.data_dir) self.export_dialog = conversation.ExportFileDialog( - self._export_device, self.uuid, self.file.filename + self._export_disk, file_location, self.file.filename ) self.export_dialog.show() @@ -2442,7 +2431,8 @@ def _on_print_clicked(self) -> None: if not self.controller.downloaded_file_exists(self.file): return - dialog = conversation.PrintFileDialog(self._export_device, self.uuid, self.file.filename) + file_location = self.file.location(self.controller.data_dir) + dialog = conversation.PrintFileDialog(self._printer, file_location, self.file.filename) dialog.exec() def _on_left_click(self) -> None: @@ -2638,12 +2628,9 @@ def __init__( self, source_db_object: Source, controller: Controller, - export_service: Optional[export.Service] = None, ) -> None: super().__init__() - self._export_service = export_service - self.source = source_db_object self.source_uuid = source_db_object.uuid self.controller = controller @@ -2832,7 +2819,6 @@ def add_file(self, file: File, index: int) -> None: self.controller.file_missing, index, self._scroll.widget().width(), - self._export_service, ) self._scroll.add_widget_to_conversation(index, conversation_item, Qt.AlignLeft) self.current_messages[file.uuid] = conversation_item @@ -2940,7 +2926,6 @@ def __init__( source: Source, controller: Controller, app_state: Optional[state.State] = None, - export_service: Optional[export.Service] = None, ) -> None: super().__init__() @@ -2966,7 +2951,7 @@ def __init__( # Create widgets self.conversation_title_bar = SourceProfileShortWidget(source, controller, app_state) - self.conversation_view = ConversationView(source, controller, export_service) + self.conversation_view = ConversationView(source, controller) self.reply_box = ReplyBoxWidget(source, controller) self.deletion_indicator = SourceDeletionIndicator() self.conversation_deletion_indicator = ConversationDeletionIndicator() @@ -3392,13 +3377,13 @@ def __init__( self.setStyleSheet(self.SOURCE_MENU_CSS) - self.addAction(DownloadConversation(self, self.controller, app_state)) + self.addAction(actions.DownloadConversation(self, self.controller, app_state)) self.addAction( - DeleteConversationAction( + actions.DeleteConversation( self.source, self, self.controller, DeleteConversationDialog, app_state ) ) - self.addAction(DeleteSourceAction(self.source, self, self.controller, DeleteSourceDialog)) + self.addAction(actions.DeleteSource(self.source, self, self.controller, DeleteSourceDialog)) class SourceMenuButton(QToolButton): diff --git a/tests/conftest.py b/tests/conftest.py index f6dbc4b98..7390be569 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ Source, make_session_maker, ) +from securedrop_client.export.cli import CLI from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -73,23 +74,23 @@ def lang(request): @pytest.fixture(scope="function") -def print_dialog(mocker, homedir): +def print_dialog(mocker, homedir, export_service): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) - - dialog = conversation.PrintFileDialog(export_device, "file_UUID", "file123.jpg") + dialog = conversation.PrintFileDialog( + export.getPrinter(export_service), "file_location", "file123.jpg" + ) yield dialog @pytest.fixture(scope="function") -def export_dialog(mocker, homedir): +def export_dialog(mocker, homedir, export_service): 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.ExportFileDialog( + export.getDisk(export_service), "file_location", "file123.jpg" + ) yield dialog @@ -127,29 +128,26 @@ def homedir(i18n): @pytest.fixture(scope="function") -def export_service(): +def export_service(mocker): """An export service that assumes the Qubes RPC calls are successful and skips them.""" export_service = export.Service() # Ensure the export_service doesn't rely on Qubes OS: - export_service._run_disk_test = lambda dir: None - export_service._run_usb_test = lambda dir: None - export_service._run_disk_export = lambda dir, paths, passphrase: None - export_service._run_printer_preflight = lambda dir: None - export_service._run_print = lambda dir, paths: None + export_service._cli = mocker.MagicMock(spec=CLI) return export_service @pytest.fixture(scope="function") def functional_test_app_started_context( - homedir, reply_status_codes, session, config, qtbot, export_service + mocker, homedir, reply_status_codes, session, config, qtbot, export_service ): """ Returns a tuple containing the gui window and controller of a configured client. This should be used to for tests that need to start from the login dialog before the main application window is visible. """ + mocker.patch("securedrop_client.export.getService", return_value=export_service) app_state = state.State() - gui = Window(app_state, export_service) + gui = Window(app_state) create_gpg_test_context(homedir) # Configure test keys session_maker = make_session_maker(homedir) # Configure and create the database controller = Controller(HOSTNAME, gui, session_maker, homedir, app_state, False, False) diff --git a/tests/export/__init__.py b/tests/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/export/test_archive.py b/tests/export/test_archive.py new file mode 100644 index 000000000..a2c50d4dd --- /dev/null +++ b/tests/export/test_archive.py @@ -0,0 +1,34 @@ +import os +import tarfile +import unittest +from tempfile import NamedTemporaryFile, TemporaryDirectory +from unittest.mock import patch + +from securedrop_client.export.archive import Archive + + +class TestExportServiceArchive(unittest.TestCase): + def test_create_archive_creates_an_archive_with_files(self): + + with TemporaryDirectory() as temp_dir: + with NamedTemporaryFile() as example_file: + example_file.write(b"Hello, world!") + + print(example_file.name) + archive_path = Archive.create_archive( + temp_dir, "archive_function", "metadata", [example_file.name] + ) + print(archive_path) + + with tarfile.open(archive_path, "r:gz") as archive: + example_file_name = os.path.basename(example_file.name) + expected_archive_content = ["metadata.json", f"export_data/{example_file_name}"] + + self.assertEqual(expected_archive_content, archive.getnames()) + + @patch.object(tarfile, "open") + def test_create_archive_returns_the_archive_path(self, _): + expected_archive_path = "directory/archive_function" + + return_value = Archive.create_archive("directory", "archive_function", "metadata") + self.assertEqual(expected_archive_path, return_value) diff --git a/tests/export/test_cli.py b/tests/export/test_cli.py new file mode 100644 index 000000000..af918c899 --- /dev/null +++ b/tests/export/test_cli.py @@ -0,0 +1,335 @@ +import subprocess +import unittest +from unittest.mock import patch + +import pytest + +from securedrop_client import export +from securedrop_client.export.archive import Archive +from securedrop_client.export.cli import CLI, Error, Status + + +class TestExportServiceCLIInterfaceForDisk(unittest.TestCase): + SUCCESS_STATUS = "" # sd-devices API + not_SUCCESS_STATUS = "whatever" + not_USB_CONNECTED = "whatever" + not_DISK_ENCRYPTED = "whatever" + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_disk_presence_check_returns_without_errors_when_empty_response( + self, create_archive, export_archive + ): + export_service = export.getService() + valid_archive_path = "archive_path_0fh437" + + export_service._cli.check_disk_presence(valid_archive_path) + + assert True # no exception was raised + + @patch.object(CLI, "_export_archive", return_value=Status.USB_CONNECTED) + @patch.object(Archive, "create_archive") + def test_disk_presence_check_returns_without_errors_when_response_is_USB_CONNECTED( + self, create_archive, export_archive + ): + export_service = export.getService() + valid_archive_path = "archive_path_0fh437" + + export_service._cli.check_disk_presence(valid_archive_path) + + assert True # no exception was raised + + @patch.object(CLI, "_export_archive", return_value=Status.USB_CONNECTED) + @patch.object(Archive, "create_archive", return_value="archive_path_03234t") + def test_disk_presence_check_exports_a_specifically_created_archive( + self, create_archive, export_archive + ): + expected_archive_path = "archive_path_03234t" + expected_archive_dir = "archive_dir_ly4421" + export_service = export.getService() + + export_service._cli.check_disk_presence(expected_archive_dir) + + export_archive.assert_called_once_with(expected_archive_path) + create_archive.assert_called_once_with( + expected_archive_dir, "usb-test.sd-export", {"device": "usb-test"} + ) + + @patch.object(CLI, "_export_archive", return_value=not_USB_CONNECTED) + @patch.object(Archive, "create_archive") + def test_disk_presence_check_raises_export_error_when_response_not_empty_or_USB_CONNECTED( + self, create_archive, export_archive + ): + valid_archive_path = "archive_path_1dsd63" + export_service = export.getService() + + with pytest.raises(export.ExportError): + export_service._cli.check_disk_presence(valid_archive_path) + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_disk_encryption_check_returns_without_errors_when_empty_response( + self, create_archive, export_archive + ): + export_service = export.getService() + valid_archive_path = "archive_path_0kdy3" + + export_service._cli.check_disk_encryption(valid_archive_path) + + assert True # no exception was raised + + @patch.object(CLI, "_export_archive", return_value=Status.DISK_ENCRYPTED) + @patch.object(Archive, "create_archive") + def test_disk_encryption_check_returns_without_errors_when_response_is_DISK_ENCRYPTED( + self, create_archive, export_archive + ): + export_service = export.getService() + valid_archive_path = "archive_path_0kdy3" + + export_service._cli.check_disk_encryption(valid_archive_path) + + assert True # no exception was raised + + @patch.object(CLI, "_export_archive", return_value=Status.DISK_ENCRYPTED) + @patch.object(Archive, "create_archive", return_value="archive_path_243kjg") + def test_disk_encryption_check_exports_a_specifically_created_archive( + self, create_archive, export_archive + ): + expected_archive_path = "archive_path_243kjg" + expected_archive_dir = "archive_dir_zsoeq" + export_service = export.getService() + + export_service._cli.check_disk_encryption(expected_archive_dir) + + export_archive.assert_called_once_with(expected_archive_path) + create_archive.assert_called_once_with( + expected_archive_dir, "disk-test.sd-export", {"device": "disk-test"} + ) + + @patch.object(CLI, "_export_archive", return_value=not_DISK_ENCRYPTED) + @patch.object(Archive, "create_archive") + def test_disk_encryption_check_raises_export_error_when_response_not_empty_or_DISK_EMCRYPTED( + self, create_archive, export_archive + ): + valid_archive_path = "archive_path_034d3" + export_service = export.getService() + + with pytest.raises(export.ExportError): + export_service._cli.check_disk_encryption(valid_archive_path) + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_export_returns_without_errors_when_empty_response( + self, create_archive, export_archive + ): + export_service = export.getService() + valid_archive_dir = "archive_dir_sfutqe" + valid_file_path = "memo.txt" + valid_passphrase = "battery horse etcetera" + + export_service._cli.export(valid_archive_dir, [valid_file_path], valid_passphrase) + + assert True # no exception was raised + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive", return_value="archive_path_3sa47") + def test_export_exports_a_specifically_created_archive(self, create_archive, export_archive): + expected_file_path = "memo-df434.txt" + expected_archive_path = "archive_path_3sa47" + expected_archive_dir = "archive_dir_3209n4" + valid_passphrase = "battery horse etcetera" + export_service = export.getService() + + export_service._cli.export(expected_archive_dir, [expected_file_path], valid_passphrase) + + export_archive.assert_called_once_with(expected_archive_path) + create_archive.assert_called_once_with( + expected_archive_dir, + "archive.sd-export", + { + "device": "disk", + "encryption_method": "luks", + "encryption_key": "battery horse etcetera", + }, + [expected_file_path], + ) + + @patch.object(CLI, "_export_archive", return_value=not_SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_export_raises_export_error_when_response_not_empty( + self, create_archive, export_archive + ): + valid_archive_dir = "archive_dir_elriu3" + valid_file_path = "memo.txt" + valid_passphrase = "battery horse etcetera" + export_service = export.getService() + + with pytest.raises(export.ExportError): + export_service._cli.export(valid_archive_dir, [valid_file_path], valid_passphrase) + + +class TestExportServiceCLIInterfaceForPrinter(unittest.TestCase): + SUCCESS_STATUS = "" # sd-devices API + not_SUCCESS_STATUS = "whatever" + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_printer_status_check_returns_without_errors_when_empty_response( + self, create_archive, export_archive + ): + export_service = export.getService() + valid_archive_path = "archive_path_13kn3" + + export_service._cli.check_printer_status(valid_archive_path) + + assert True # no exception was raised + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive", return_value="archive_path_9f483f") + def test_printer_status_check_exports_a_specifically_created_archive( + self, create_archive, export_archive + ): + expected_archive_path = "archive_path_9f483f" + expected_archive_dir = "archive_dir_2i19c" + export_service = export.getService() + + export_service._cli.check_printer_status(expected_archive_dir) + + export_archive.assert_called_once_with(expected_archive_path) + create_archive.assert_called_once_with( + expected_archive_dir, "printer-preflight.sd-export", {"device": "printer-preflight"} + ) + + @patch.object(CLI, "_export_archive", return_value=not_SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_printer_status_check_raises_export_error_when_response_not_empty( + self, create_archive, export_archive + ): + valid_archive_path = "archive_path_034d3" + export_service = export.getService() + + with pytest.raises(export.ExportError): + export_service._cli.check_printer_status(valid_archive_path) + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_print_returns_without_errors_when_empty_response(self, create_archive, export_archive): + export_service = export.getService() + valid_archive_dir = "archive_dir_84jde" + valid_file_path = "memo.txt" + + export_service._cli.print(valid_archive_dir, [valid_file_path]) + + assert True # no exception was raised + + @patch.object(CLI, "_export_archive", return_value=SUCCESS_STATUS) + @patch.object(Archive, "create_archive", return_value="archive_path_2jr723") + def test_print_exports_a_specifically_created_archive(self, create_archive, export_archive): + expected_file_path = "memo-3497j.txt" + expected_archive_path = "archive_path_2jr723" + expected_archive_dir = "archive_dir_d3aowj2" + export_service = export.getService() + + export_service._cli.print(expected_archive_dir, [expected_file_path]) + + export_archive.assert_called_once_with(expected_archive_path) + create_archive.assert_called_once_with( + expected_archive_dir, + "print_archive.sd-export", + {"device": "printer"}, + [expected_file_path], + ) + + @patch.object(CLI, "_export_archive", return_value=not_SUCCESS_STATUS) + @patch.object(Archive, "create_archive") + def test_print_raises_export_error_when_not_repsonse_not_empty( + self, create_archive, export_archive + ): + valid_archive_dir = "archive_dir_0ngyw" + valid_file_path = "memo.txt" + export_service = export.getService() + + with pytest.raises(export.ExportError): + export_service._cli.print(valid_archive_dir, [valid_file_path]) + + +class TestExportServiceCLIInterfaceWithQubesOS(unittest.TestCase): + @patch.object(subprocess, "check_output", return_value=b"") + def test_path_of_exported_archive_is_shell_sanitized(self, subprocess_check_output): + unsafe_archive_path = "archive_$(path)_../.._4587fn" + sanitized_archive_path = "'archive_$(path)_../.._4587fn'" + export_service = export.getService() + export_service._cli._export_archive(unsafe_archive_path) + + assert subprocess_check_output.called_once() + subprocess_check_output_arguments = subprocess_check_output.call_args.args[0] + assert unsafe_archive_path not in subprocess_check_output_arguments + assert sanitized_archive_path in subprocess_check_output_arguments + + @patch.object(subprocess, "check_output", return_value=b"") + def test_issues_expected_qrexec_command(self, subprocess_check_output): + unsafe_archive_path = "archive_$(path)_../.._3wqrc" + sanitized_archive_path = "'archive_$(path)_../.._3wqrc'" + export_service = export.getService() + + expected_qrexec_command = [ + "qrexec-client-vm", + "--", + "sd-devices", + "qubes.OpenInVM", + "/usr/lib/qubes/qopen-in-vm", + "--view-only", + "--", + sanitized_archive_path, + ] + expected_execution_options = {"stderr": subprocess.STDOUT} + + export_service._cli._export_archive(unsafe_archive_path) + + assert subprocess_check_output.called_once_with(expected_qrexec_command) + + subprocess_check_output_positional_arguments = subprocess_check_output.call_args.args[0] + assert subprocess_check_output_positional_arguments == expected_qrexec_command + + subprocess_check_output_keyword_arguments = subprocess_check_output.call_args.kwargs + assert subprocess_check_output_keyword_arguments == expected_execution_options + + @patch.object(subprocess, "check_output", return_value=b"") + def test_returns_no_status_when_response_is_empty(self, subprocess_check_output): + valid_archive_path = "archive_path_sdr3k" + export_service = export.getService() + + status = export_service._cli._export_archive(valid_archive_path) + assert status is None + + @patch.object(subprocess, "check_output", return_value=b"whatever") + def test_returns_status_when_response_is_a_valid_status(self, subprocess_check_output): + valid_archive_path = "archive_path_32arci" + export_service = export.getService() + + with pytest.raises(Error) as error: + export_service._cli._export_archive(valid_archive_path) + assert error.value.status == Status.UNEXPECTED_RETURN_STATUS + + @patch.object(subprocess, "check_output", return_value=b"USB_CONNECTED") + def test_raises_UNEXPECTED_RETURN_STATUS_when_response_is_not_a_valid_status( + self, subprocess_check_output + ): + valid_archive_path = "archive_path_pdsa49" + export_service = export.getService() + + status = export_service._cli._export_archive(valid_archive_path) + assert status == Status.USB_CONNECTED + + @patch.object( + subprocess, + "check_output", + side_effect=subprocess.CalledProcessError(returncode=1, cmd="..."), + ) + def test_raises_CALLED_PROCESS_ERROR_when_command_fails(self, subprocess_check_output): + valid_archive_path = "archive_path_scm3I3" + export_service = export.getService() + + with pytest.raises(Error) as error: + export_service._cli._export_archive(valid_archive_path) + assert error.value.status == Status.CALLED_PROCESS_ERROR diff --git a/tests/export/test_disk.py b/tests/export/test_disk.py new file mode 100644 index 000000000..1cfd15d40 --- /dev/null +++ b/tests/export/test_disk.py @@ -0,0 +1,564 @@ +import unittest +from unittest.mock import MagicMock + +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot +from PyQt5.QtTest import QSignalSpy + +from securedrop_client.export import Disk, ExportError, ExportStatus, Service, getDisk +from tests.helper import assertEmissions, emitsSignals + + +class ExportService(QObject): + """A dummy export service which responses can be configured for testing purposes.""" + + luks_encrypted_disk_found = pyqtSignal() + luks_encrypted_disk_not_found = pyqtSignal(object) + + # allow to wait for response without assuming any response in particular + response_emitted = pyqtSignal() + + export_failed = pyqtSignal(object) + export_succeeded = pyqtSignal(object) + + def __init__(self, responses=[Disk.StatusReachable]): + super().__init__() + + self.responses = responses[:] + + def connect_signals( + self, + disk_check_requested=None, + export_requested=None, + ): + + if export_requested is not None: + export_requested.connect(self.export) + if disk_check_requested is not None: + disk_check_requested.connect(self.check_disk) + + @pyqtSlot() + def check_disk(self): + try: + response = self.responses.pop(0) + # The disk is unreachable unless it's LUKS-encrypted. + # Note that using the Disk.Status type is merely for convenience and readability. + if response == Disk.StatusReachable: + self.luks_encrypted_disk_found.emit() + else: + reason = object() # to comply with the Service API + self.luks_encrypted_disk_not_found.emit(reason) + except IndexError: + reason = object() + self.luks_encrypted_disk_not_found.emit(reason) + + self.response_emitted.emit() + + +class ExportServiceClient(QObject): + """A dummy export service client to test the dummy export service. + + Let's make sure our tests rely on reliable tooling!""" + + query_export_service = pyqtSignal() + + def __init__(self): + super().__init__() + + +class Portfolio(QObject): + """A dummy portfolio that can send files to be exported for testing purposes.""" + + file_sent = pyqtSignal(list) + + def __init__(self): + super().__init__() + + +class TestExportService(unittest.TestCase): + def test_export_service_responds_with_luks_encrypted_disk_found_by_default(self): + client = ExportServiceClient() + export_service = ExportService() # default responses + + luks_encrypted_disk_found_emissions = QSignalSpy(export_service.luks_encrypted_disk_found) + luks_encrypted_disk_not_found_emissions = QSignalSpy( + export_service.luks_encrypted_disk_not_found + ) + response_emitted_emissions = QSignalSpy(export_service.response_emitted) + self.assertTrue(luks_encrypted_disk_found_emissions.isValid()) + self.assertTrue(luks_encrypted_disk_not_found_emissions.isValid()) + self.assertTrue(response_emitted_emissions.isValid()) + + export_service.connect_signals(disk_check_requested=client.query_export_service) + + emitsSignals(client.query_export_service.emit) # Act. + assertEmissions(self, response_emitted_emissions, 1) + + assertEmissions(self, luks_encrypted_disk_found_emissions, 1) + assertEmissions(self, luks_encrypted_disk_not_found_emissions, 0) + + def test_export_service_responds_as_configured(self): + client = ExportServiceClient() + responses = [ + "not LUKS-encrypted", # whatever + Disk.StatusReachable, + Disk.StatusReachable, + Disk.StatusUnreachable, # not Disk.StatusReachable + Disk.StatusReachable, + # nothing else + ] + export_service = ExportService(responses) # override default responses + luks_encrypted_disk_found_emissions = QSignalSpy(export_service.luks_encrypted_disk_found) + luks_encrypted_disk_not_found_emissions = QSignalSpy( + export_service.luks_encrypted_disk_not_found + ) + self.assertTrue(luks_encrypted_disk_found_emissions.isValid()) + self.assertTrue(luks_encrypted_disk_not_found_emissions.isValid()) + + export_service.connect_signals(disk_check_requested=client.query_export_service) + + client.query_export_service.emit() # Act. + self.assertEqual(0, len(luks_encrypted_disk_found_emissions)) + self.assertEqual(1, len(luks_encrypted_disk_not_found_emissions)) + + client.query_export_service.emit() # Act again, because we care about the sequence, etc. + self.assertEqual(1, len(luks_encrypted_disk_found_emissions)) + self.assertEqual(1, len(luks_encrypted_disk_not_found_emissions)) + + client.query_export_service.emit() + self.assertEqual(2, len(luks_encrypted_disk_found_emissions)) + self.assertEqual(1, len(luks_encrypted_disk_not_found_emissions)) + + client.query_export_service.emit() + self.assertEqual(2, len(luks_encrypted_disk_found_emissions)) + self.assertEqual(2, len(luks_encrypted_disk_not_found_emissions)) + + client.query_export_service.emit() + self.assertEqual(3, len(luks_encrypted_disk_found_emissions)) + self.assertEqual(2, len(luks_encrypted_disk_not_found_emissions)) + + # After all configured responses are consumed, defaults to LUKS-encrypted disk not found. + client.query_export_service.emit() + self.assertEqual(3, len(luks_encrypted_disk_found_emissions)) + self.assertEqual(3, len(luks_encrypted_disk_not_found_emissions)) + + client.query_export_service.emit() + self.assertEqual(3, len(luks_encrypted_disk_found_emissions)) + self.assertEqual(4, len(luks_encrypted_disk_not_found_emissions)) + + +class TestDisk(unittest.TestCase): + def test_disk_is_unique_for_any_given_export_service(self): + export_service = Service() + disk = getDisk(export_service) + same_disk = getDisk(export_service) + + self.assertTrue( + disk is same_disk, + "expected successive calls to getDisk to return the same disk, got different disks", # noqa: E501 + ) + + def test_disk_status_is_unknown_by_default(self): + export_service = Service() + disk = getDisk(export_service) + + self.assertEqual(Disk.StatusUnknown, disk.status) + + def test_disk_status_tracks_export_service_responses_when_watched(self): + responses = [ + Disk.StatusReachable, + Disk.StatusUnreachable, + Disk.StatusReachable, + ] + export_service = ExportService(responses) + + POLLING_INTERVAL = 200 # milliseconds + # There is a limit to the precision of the timer, but 50ms is plenty to play with. + FRACTION_OF_POLLING_INTERVAL = 50 # milliseconds + CHECK_EXECUTION_TIME = 20 # milliseconds + + disk = getDisk(export_service, POLLING_INTERVAL) + export_service_response = QSignalSpy(export_service.response_emitted) + self.assertTrue(export_service_response.isValid()) + + self.ensure_that_disk_internals_are_ready_to_process_events(disk, 400) + + # Warming up... + self.assertEqual( + 0, + len(export_service_response), + "Expected export service to receive no queries before the disk is being watched, and emit no responses.", # noqa: E501 + ) + self.assertEqual( + Disk.StatusUnknown, + disk.status, + "Expected default disk status to be Disk.StatusUnknown, was not.", + ) + + disk.watch() # Action! + + # The first query should be issued immediately. + # After that, the dummy export service responds blazing fast. + export_service_response.wait(CHECK_EXECUTION_TIME) # milliseconds + self.assertEqual( + 1, + len(export_service_response), + "Expected exactly 1 query to the export service, and 1 response immediately after the disk started being watched.", # noqa: E501 + ) + self.assertEqual( + Disk.StatusReachable, + disk.status, + "Expected disk status to track the last response, did not.", + ) + + # No new queries are issued before the polling interval has elapsed. + export_service_response.wait(POLLING_INTERVAL - FRACTION_OF_POLLING_INTERVAL) + self.assertEqual( + 1, + len(export_service_response), + "Expected no new query, nor response (unchanged total of 1) before the polling interval elapsed.", # noqa: E501 + ) + + # A new query is issued after the polling interval has elapsed, + # the dummy export service response is almost instantaneous. + export_service_response.wait(FRACTION_OF_POLLING_INTERVAL + CHECK_EXECUTION_TIME) + self.assertEqual( + 2, + len(export_service_response), + "Expected exactly a total of 2 queries, and 2 responses after the polling interval elapsed.", # noqa: E501 + ) + self.assertEqual( + Disk.StatusUnreachable, + disk.status, + "Expected disk status to track the last response, did not.", + ) + + export_service_response.wait(POLLING_INTERVAL + CHECK_EXECUTION_TIME) + self.assertEqual( + 3, + len(export_service_response), + "Expected exactly a total of 3 queries, and 3 responses after the polling interval elapsed.", # noqa: E501 + ) + self.assertEqual( + Disk.StatusReachable, + disk.status, + "Expected disk status to track the last response, did not.", + ) + + export_service_response.wait(POLLING_INTERVAL + CHECK_EXECUTION_TIME) + self.assertEqual( + 4, + len(export_service_response), + "Expected exactly a total of 4 queries, and 4 responses after the polling interval elapsed.", # noqa: E501 + ) + self.assertEqual( + Disk.StatusUnreachable, + disk.status, + "Expected disk status to track the last response, did not.", + ) + + def test_disk_status_stops_tracking_export_service_responses_when_not_watched(self): + responses = [ + Disk.StatusReachable, + Disk.StatusUnreachable, + Disk.StatusReachable, + ] + export_service = ExportService(responses) + POLLING_INTERVAL = 100 # milliseconds + CHECK_EXECUTION_TIME = 20 # milliseconds + disk = getDisk(export_service, POLLING_INTERVAL) + + export_service_response = QSignalSpy(export_service.response_emitted) + self.assertTrue(export_service_response.isValid()) + + self.ensure_that_disk_internals_are_ready_to_process_events(disk, 400) + + # Warming up... + self.assertEqual( + 0, + len(export_service_response), + "Expected export service to receive no queries before the disk is being watched, and emit no responses.", # noqa: E501 + ) + self.assertEqual( + Disk.StatusUnknown, + disk.status, + "Expected default disk status to be Disk.StatusUnknown, was not.", + ) + + disk.watch() # Action! + + # The first query should be issued immediately. + # After that, the dummy export service responds blazing fast. + export_service_response.wait(CHECK_EXECUTION_TIME) # milliseconds + self.assertEqual( + 1, + len(export_service_response), + "Expected exactly 1 query to the export service, and 1 response immediately after the disk started being watched.", # noqa: E501 + ) + self.assertEqual( + Disk.StatusReachable, + disk.status, + "Expected disk status to track the last response, did not.", + ) + + disk.stop_watching() + + self.assertEqual( + Disk.StatusUnknown, + disk.status, + "Expected disk status to become unknown as soon as stopped being watched.", + ) + + export_service_response.wait(POLLING_INTERVAL + CHECK_EXECUTION_TIME) # will time out + self.assertEqual( + 1, + len(export_service_response), + "Expected no new query to the export service after the disk stopped being watched (total 1 query and 1 response)", # noqa: E501 + ) + self.assertEqual( + Disk.StatusUnknown, + disk.status, + "Expected disk status to remain unknown until being watched again.", + ) + + def test_disk_signals_changes_in_disk_status(self): + export_service = ExportService(["not LUKS-encrypted"]) + + POLLING_INTERVAL = 200 # milliseconds + disk = getDisk(export_service, POLLING_INTERVAL) + + disk_status_changed_emissions = QSignalSpy(disk.status_changed) + self.assertTrue(disk_status_changed_emissions.isValid()) + + self.ensure_that_disk_internals_are_ready_to_process_events(disk, 400) + + # Warming up... + self.assertEqual( + Disk.StatusUnknown, + disk.status, + "Expected default disk status to be Disk.StatusUnknown, was not.", + ) + self.assertEqual( + 1, + len(disk_status_changed_emissions), + "Expected disk_status_changed to be emitted when disk is initialized.", + ) + + export_service.luks_encrypted_disk_found.emit() + self.assertEqual( + Disk.StatusReachable, + disk.status, + "Expected disk status to become Disk.StatusReachable, did not.", + ) + self.assertEqual( + 2, + len(disk_status_changed_emissions), + "Expected disk_status_changed to be emitted, was not.", + ) + + export_service.luks_encrypted_disk_found.emit() + self.assertEqual( + Disk.StatusReachable, + disk.status, + "Didn't expect any change in disk status.", + ) + self.assertEqual( + 2, + len(disk_status_changed_emissions), + "Expected disk_status_changed not to be emitted (total count 2), but it was.", + ) + + reason = object() + export_service.luks_encrypted_disk_not_found.emit(reason) + self.assertEqual( + Disk.StatusUnreachable, + disk.status, + "Expected disk status to become Disk.StatusUnreachable, did not.", + ) + self.assertEqual( + 3, + len(disk_status_changed_emissions), + "Expected disk_status_changed to be emitted, was not.", + ) + + # This last segment is admittedly awkward. We want to make sure that + # the signal is emitted when pausing the disk. Even though the disk watching + # is not the point of this test, we do have to watch in order to be able + # to stop watching it. + # + # Watching triggers a query to the disk_service. To ensure that + # watching the disk doesn't have any visible side-effects, the dummy service + # is configured to respond that the disk wasn't found LUKS-encrypted. + disk.watch() + disk.stop_watching() + disk_status_changed_emissions.wait(POLLING_INTERVAL) + self.assertEqual( + Disk.StatusUnknown, + disk.status, + "Expected disk status to become Disk.StatusUnknown as soon as not being watched, was not.", # noqa: E501 + ) + self.assertEqual( + 4, + len(disk_status_changed_emissions), + "Expected disk_status_changed to be emitted, was not.", + ) + + def test_disk_last_error_returns_none_by_default(self): + export_service = ExportService() + disk = getDisk(export_service) + + assert disk.last_error is None + + def test_disk_last_error_returns_the_last_service_error_when_luks_encrypted_disk_not_found( + self, + ): + export_service = ExportService() + luks_encrypted_disk_not_found_emissions = QSignalSpy( + export_service.luks_encrypted_disk_not_found + ) + assert luks_encrypted_disk_not_found_emissions.isValid() + + disk = getDisk(export_service) + error = ExportError(ExportStatus.USB_NOT_CONNECTED) + expected_error = error + + export_service.luks_encrypted_disk_not_found.emit(error) + luks_encrypted_disk_not_found_emissions.wait(50) + + self.assertEqual(expected_error, disk.last_error) + + # another round + + error = ExportError(ExportStatus.CALLED_PROCESS_ERROR) + expected_error = error + + export_service.luks_encrypted_disk_not_found.emit(error) + luks_encrypted_disk_not_found_emissions.wait(50) + + self.assertEqual(expected_error, disk.last_error) + + def test_disk_last_error_returns_the_last_service_error_when_export_fails(self): + export_service = ExportService() + export_failed_emissions = QSignalSpy(export_service.export_failed) + assert export_failed_emissions.isValid() + + disk = getDisk(export_service) + error = ExportError(ExportStatus.BAD_PASSPHRASE) + expected_error = error + + export_service.export_failed.emit(error) + export_failed_emissions.wait(50) + + self.assertEqual(expected_error, disk.last_error) + + # another round + + error = ExportError(ExportStatus.CALLED_PROCESS_ERROR) + expected_error = error + + export_service.export_failed.emit(error) + export_failed_emissions.wait(50) + + self.assertEqual(expected_error, disk.last_error) + + def test_disk_allows_to_export(self): + portfolio = Portfolio() + + export_service = Service() + export_method = MagicMock() + export_service.export = export_method + + disk = getDisk(export_service) + disk.export_on(portfolio.file_sent) + + portfolio.file_sent.emit(["my_file"]) + + export_method.assert_called_once_with(["my_file"]) + + def test_disk_signals_when_export_is_successful(self): + export_service = Service() + disk = getDisk(export_service) + + export_done_emissions = QSignalSpy(disk.export_done) + export_failed_emissions = QSignalSpy(disk.export_failed) + self.assertTrue(export_done_emissions.isValid()) + self.assertTrue(export_failed_emissions.isValid()) + + export_service.export_succeeded.emit() + + self.assertEqual( + 1, + len(export_done_emissions), + "Expected disk to a emit export_done signal when the export service reports success.", + ) + self.assertEqual( + 0, + len(export_failed_emissions), + f"Expected disk to emit no export_failed signal when the export service reports success, got {len(export_failed_emissions)}.", # noqa: E501 + ) + + def test_disk_signals_when_export_fails(self): + export_service = Service() + disk = getDisk(export_service) + + export_done_emissions = QSignalSpy(disk.export_done) + export_failed_emissions = QSignalSpy(disk.export_failed) + self.assertTrue(export_done_emissions.isValid()) + self.assertTrue(export_failed_emissions.isValid()) + + export_service.export_failed.emit("a good reason") + + self.assertEqual( + 1, + len(export_failed_emissions), + "Expected disk to emit a job_failed signal when the export service reports a failure.", + ) + self.assertEqual( + 0, + len(export_done_emissions), + f"Expected disk to emit no export_done signal when the export service reports a failure, got {len(export_done_emissions)}.", # noqa: E501 + ) + + def ensure_that_disk_internals_are_ready_to_process_events( + self, disk, max_waiting_time_in_milliseconds + ): + """Give a little bit of time to the state machines to start. + + All QStateMachine instances require Qt's event loop to be processing + events before being considered to be fully started and running. + + This is a bit sad and an implementation detail, but testing + components that depend on Qt's event loop is like that.""" + poller_started = QSignalSpy(disk._poller.started) + cache_started = QSignalSpy(disk._cache.started) + + # these are max waiting times, not required pauses + poller_started.wait(int(max_waiting_time_in_milliseconds / 2)) + cache_started.wait(int(max_waiting_time_in_milliseconds / 2)) + + self.assertTrue( + disk._poller.isRunning(), + f"Expected the disk poller to be running after {max_waiting_time_in_milliseconds}ms. Abort the test.", # noqa: E501 + ) + self.assertTrue( + disk._cache.isRunning(), + f"Expected the disk cache to be running after {max_waiting_time_in_milliseconds}ms. Abort the test.", # noqa: E501 + ) + + +class TestDiskDeprecatedInterface(unittest.TestCase): + def test_performs_one_check_when_registered_signal_is_emitted(self): + client = ExportServiceClient() + export_service = ExportService() + disk = getDisk(export_service) + + response_emitted_emissions = QSignalSpy(export_service.response_emitted) + self.assertTrue(response_emitted_emissions.isValid()) + + disk.check_status_once_on(client.query_export_service) + client.query_export_service.emit() # Act. + + self.assertEqual( + 1, + len(response_emitted_emissions), + "Expected service to emit a response as a result of being queried.", + ) diff --git a/tests/export/test_printer.py b/tests/export/test_printer.py new file mode 100644 index 000000000..11fa69eb2 --- /dev/null +++ b/tests/export/test_printer.py @@ -0,0 +1,559 @@ +import unittest +from unittest.mock import MagicMock + +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot +from PyQt5.QtTest import QSignalSpy + +from securedrop_client.export import ExportError, ExportStatus, Printer, Service, getPrinter +from tests.helper import app # noqa: F401 + + +class PrintingService(QObject): + """A dummy printing service which responses can be configured for testing purposes.""" + + printer_found_ready = pyqtSignal() + printer_not_found_ready = pyqtSignal(object) + + # allow to wait for response without assuming any response in particular + response_emitted = pyqtSignal() + + print_failed = pyqtSignal(object) + print_succeeded = pyqtSignal(object) + + def __init__(self, responses=[Printer.StatusReady]): + super().__init__() + + self.responses = responses[:] + + def connect_signals( + self, + printer_check_requested=None, + print_requested=None, + ): + + if print_requested is not None: + print_requested.connect(self.print) + if printer_check_requested is not None: + printer_check_requested.connect(self.check_printer) + + @pyqtSlot() + def check_printer(self): + try: + response = self.responses.pop(0) + # The printer is unreachable unless it's ready. + # Note that using the Printer.Status type is merely for convenience and readability. + if response == Printer.StatusReady: + self.printer_found_ready.emit() + else: + reason = object() # to comply with the Service API + self.printer_not_found_ready.emit(reason) + except IndexError: + reason = object() + self.printer_not_found_ready.emit(reason) + + self.response_emitted.emit() + + +class PrintingServiceClient(QObject): + """A dummy printing service client to test the dummy printing service. + + Let's make sure our tests rely on reliable tooling!""" + + query_printing_service = pyqtSignal() + + def __init__(self): + super().__init__() + + +class Portfolio(QObject): + """A dummy portfolio that can send documents to be printed for testing purposes.""" + + document_sent = pyqtSignal(list) + + def __init__(self): + super().__init__() + + +class TestPrintingService(unittest.TestCase): + def test_printing_service_responds_with_printer_found_ready_by_default(self): + client = PrintingServiceClient() + printing_service = PrintingService() # default responses + + printer_found_ready_emissions = QSignalSpy(printing_service.printer_found_ready) + printer_not_found_ready_emissions = QSignalSpy(printing_service.printer_not_found_ready) + self.assertTrue(printer_found_ready_emissions.isValid()) + self.assertTrue(printer_not_found_ready_emissions.isValid()) + + printing_service.connect_signals(printer_check_requested=client.query_printing_service) + + client.query_printing_service.emit() # Act. + self.assertEqual(1, len(printer_found_ready_emissions)) + self.assertEqual(0, len(printer_not_found_ready_emissions)) + + def test_printing_service_responds_as_configured(self): + client = PrintingServiceClient() + responses = [ + "not ready", # whatever + Printer.StatusReady, + Printer.StatusReady, + Printer.StatusUnreachable, # not Printer.StatusReady + Printer.StatusReady, + # nothing else + ] + printing_service = PrintingService(responses) # override default responses + printer_found_ready_emissions = QSignalSpy(printing_service.printer_found_ready) + printer_not_found_ready_emissions = QSignalSpy(printing_service.printer_not_found_ready) + self.assertTrue(printer_found_ready_emissions.isValid()) + self.assertTrue(printer_not_found_ready_emissions.isValid()) + + printing_service.connect_signals(printer_check_requested=client.query_printing_service) + + client.query_printing_service.emit() # Act. + self.assertEqual(0, len(printer_found_ready_emissions)) + self.assertEqual(1, len(printer_not_found_ready_emissions)) + + client.query_printing_service.emit() # Act again, because we care about the sequence, etc. + self.assertEqual(1, len(printer_found_ready_emissions)) + self.assertEqual(1, len(printer_not_found_ready_emissions)) + + client.query_printing_service.emit() + self.assertEqual(2, len(printer_found_ready_emissions)) + self.assertEqual(1, len(printer_not_found_ready_emissions)) + + client.query_printing_service.emit() + self.assertEqual(2, len(printer_found_ready_emissions)) + self.assertEqual(2, len(printer_not_found_ready_emissions)) + + client.query_printing_service.emit() + self.assertEqual(3, len(printer_found_ready_emissions)) + self.assertEqual(2, len(printer_not_found_ready_emissions)) + + # After all configured responses are consumed, defaults to printer not found ready. + client.query_printing_service.emit() + self.assertEqual(3, len(printer_found_ready_emissions)) + self.assertEqual(3, len(printer_not_found_ready_emissions)) + + client.query_printing_service.emit() + self.assertEqual(3, len(printer_found_ready_emissions)) + self.assertEqual(4, len(printer_not_found_ready_emissions)) + + +class TestPrinter(unittest.TestCase): + def test_printer_is_unique_for_any_given_printing_service(self): + printing_service = Service() + printer = getPrinter(printing_service) + same_printer = getPrinter(printing_service) + + self.assertTrue( + printer is same_printer, + "expected successive calls to getPrinter to return the same printer, got different printers", # noqa: E501 + ) + + def test_printer_status_is_unknown_by_default(self): + printing_service = Service() + printer = getPrinter(printing_service) + + self.assertEqual(Printer.StatusUnknown, printer.status) + + def test_printer_status_tracks_printing_service_responses_when_watched(self): + responses = [ + Printer.StatusReady, + Printer.StatusUnreachable, + Printer.StatusReady, + ] + printing_service = PrintingService(responses) + printing_service_response = QSignalSpy(printing_service.response_emitted) + self.assertTrue(printing_service_response.isValid()) + + POLLING_INTERVAL = 200 # milliseconds + # There is a limit to the precision of the timer, but 50ms is plenty to play with. + FRACTION_OF_POLLING_INTERVAL = 50 # milliseconds + CHECK_EXECUTION_TIME = 20 # milliseconds + + printer = getPrinter(printing_service, POLLING_INTERVAL) + + self.ensure_that_printer_internals_are_ready_to_process_events(printer, 400) + + # Warming up... + self.assertEqual( + Printer.StatusUnknown, + printer.status, + "Expected default printer status to be Printer.StatusUnknown, was not.", + ) + + self.assertEqual( + 0, + len(printing_service_response), + "Expected printing service to receive no queries before the printer is being watched, and emit no responses.", # noqa: E501 + ) + + printer.watch() # Action! + + # The first query should be issued immediately. + # After that, the dummy printing service responds blazing fast. + printing_service_response.wait(CHECK_EXECUTION_TIME) # milliseconds + self.assertEqual( + 1, + len(printing_service_response), + "Expected exactly 1 query to the printing service, and 1 response immediately after the printer started being watched.", # noqa: E501 + ) + self.assertEqual( + Printer.StatusReady, + printer.status, + "Expected printer status to track the last response, did not.", + ) + + # No new queries are issued before the polling interval has elapsed. + printing_service_response.wait(POLLING_INTERVAL - FRACTION_OF_POLLING_INTERVAL) + self.assertEqual( + 1, + len(printing_service_response), + "Expected no new query, nor response (unchanged total of 1) before the polling interval elapsed.", # noqa: E501 + ) + + # A new query is issued after the polling interval has elapsed, + # the dummy printing service response is almost instantaneous. + printing_service_response.wait(FRACTION_OF_POLLING_INTERVAL + CHECK_EXECUTION_TIME) + self.assertEqual( + 2, + len(printing_service_response), + "Expected exactly a total of 2 queries, and 2 responses after the polling interval elapsed.", # noqa: E501 + ) + self.assertEqual( + Printer.StatusUnreachable, + printer.status, + "Expected printer status to track the last response, did not.", + ) + + printing_service_response.wait(POLLING_INTERVAL + CHECK_EXECUTION_TIME) + self.assertEqual( + 3, + len(printing_service_response), + "Expected exactly a total of 3 queries, and 3 responses after the polling interval elapsed.", # noqa: E501 + ) + self.assertEqual( + Printer.StatusReady, + printer.status, + "Expected printer status to track the last response, did not.", + ) + + printing_service_response.wait(POLLING_INTERVAL + CHECK_EXECUTION_TIME) + self.assertEqual( + 4, + len(printing_service_response), + "Expected exactly a total of 4 queries, and 4 responses after the polling interval elapsed.", # noqa: E501 + ) + self.assertEqual( + Printer.StatusUnreachable, + printer.status, + "Expected printer status to track the last response, did not.", + ) + + def test_printer_status_stops_tracking_printing_service_responses_when_not_watched(self): + responses = [ + Printer.StatusReady, + Printer.StatusUnreachable, + Printer.StatusReady, + ] + printing_service = PrintingService(responses) + printing_service_response = QSignalSpy(printing_service.response_emitted) + self.assertTrue(printing_service_response.isValid()) + + POLLING_INTERVAL = 100 # milliseconds + CHECK_EXECUTION_TIME = 20 # milliseconds + printer = getPrinter(printing_service, POLLING_INTERVAL) + + self.ensure_that_printer_internals_are_ready_to_process_events(printer, 400) + + # Warming up... + self.assertEqual( + Printer.StatusUnknown, + printer.status, + "Expected default printer status to be Printer.StatusUnknown, was not.", + ) + + self.assertEqual( + 0, + len(printing_service_response), + "Expected printing service to receive no queries before the printer is watched, and emit no responses.", # noqa: E501 + ) + + printer.watch() # Action! + + # The first query should be issued immediately. + # After that, the dummy printing service responds blazing fast. + printing_service_response.wait(CHECK_EXECUTION_TIME) # milliseconds + self.assertEqual( + 1, + len(printing_service_response), + "Expected exactly 1 query to the printing service, and 1 response immediately after the printer started being watched.", # noqa: E501 + ) + self.assertEqual( + Printer.StatusReady, + printer.status, + "Expected printer status to track the last response, did not.", + ) + + printer.stop_watching() + + self.assertEqual( + Printer.StatusUnknown, + printer.status, + "Expected printer status to become unknown as soon as watching stopped.", + ) + + printing_service_response.wait(POLLING_INTERVAL + CHECK_EXECUTION_TIME) # will time out + self.assertEqual( + 1, + len(printing_service_response), + "Expected no new query to the printing service after watching has stopped (total 1 query and 1 response)", # noqa: E501 + ) + self.assertEqual( + Printer.StatusUnknown, + printer.status, + "Expected printer status to remain unknown until being watched again.", + ) + + def test_printer_signals_changes_in_printer_status(self): + printing_service = PrintingService(["not ready"]) + + POLLING_INTERVAL = 200 # milliseconds + printer = getPrinter(printing_service, POLLING_INTERVAL) + + printer_status_changed_emissions = QSignalSpy(printer.status_changed) + self.assertTrue(printer_status_changed_emissions.isValid()) + + self.ensure_that_printer_internals_are_ready_to_process_events(printer, 400) + + # Warming up... + self.assertEqual( + Printer.StatusUnknown, + printer.status, + "Expected default printer status to be Printer.StatusUnknown, was not.", + ) + self.assertEqual( + 1, + len(printer_status_changed_emissions), + "Expected printer_status_changed to be emitted when printer is initialized.", + ) + + printing_service.printer_found_ready.emit() + self.assertEqual( + Printer.StatusReady, + printer.status, + "Expected printer status to become Printer.StatusReady, did not.", + ) + self.assertEqual( + 2, + len(printer_status_changed_emissions), + "Expected printer_status_changed to be emitted, was not.", + ) + + printing_service.printer_found_ready.emit() + self.assertEqual( + Printer.StatusReady, + printer.status, + "Didn't expect any change in printer status.", + ) + self.assertEqual( + 2, + len(printer_status_changed_emissions), + "Expected printer_status_changed not to be emitted (total count 2), but it was.", + ) + + reason = object() + printing_service.printer_not_found_ready.emit(reason) + self.assertEqual( + Printer.StatusUnreachable, + printer.status, + "Expected printer status to become Printer.StatusUnreachable, did not.", + ) + self.assertEqual( + 3, + len(printer_status_changed_emissions), + "Expected printer_status_changed to be emitted, was not.", + ) + + # This last segment is admittedly awkward. We want to make sure that + # the signal is emitted when pausing the printer. Even though the printer watching + # is not the point of this test, we do have to watch in order to be able + # to stop watching it. + # + # Watching triggers a query to the printer_service. To ensure that + # watching the printer doesn't have any visible side-effects, the dummy service + # is configured to respond that the printer wasn't found ready. + printer.watch() + printer.stop_watching() + printer_status_changed_emissions.wait(POLLING_INTERVAL) + self.assertEqual( + Printer.StatusUnknown, + printer.status, + "Expected printer status to become Printer.StatusUnknown as soon as watching stopped, was not.", # noqa: E501 + ) + self.assertEqual( + 4, + len(printer_status_changed_emissions), + "Expected printer_status_changed to be emitted, was not.", + ) + + def test_printer_last_error_returns_none_by_default(self): + printing_service = PrintingService() + printer = getPrinter(printing_service) + + assert printer.last_error is None + + def test_printer_last_error_returns_the_last_service_error_when_printer_not_found_ready( + self, + ): + printing_service = PrintingService() + printer_not_found_ready_emissions = QSignalSpy(printing_service.printer_not_found_ready) + assert printer_not_found_ready_emissions.isValid() + + printer = getPrinter(printing_service) + error = ExportError(ExportStatus.PRINTER_NOT_FOUND) + expected_error = error + + printing_service.printer_not_found_ready.emit(error) + printer_not_found_ready_emissions.wait(50) + + self.assertEqual(expected_error, printer.last_error) + + # another round + + error = ExportError(ExportStatus.CALLED_PROCESS_ERROR) + expected_error = error + + printing_service.printer_not_found_ready.emit(error) + printer_not_found_ready_emissions.wait(50) + + self.assertEqual(expected_error, printer.last_error) + + def test_printer_last_error_returns_the_last_service_error_when_print_fails(self): + printing_service = PrintingService() + print_failed_emissions = QSignalSpy(printing_service.print_failed) + assert print_failed_emissions.isValid() + + printer = getPrinter(printing_service) + error = ExportError(ExportStatus.PRINTER_NOT_FOUND) + expected_error = error + + printing_service.print_failed.emit(error) + print_failed_emissions.wait(50) + + self.assertEqual(expected_error, printer.last_error) + + # another round + + error = ExportError(ExportStatus.CALLED_PROCESS_ERROR) + expected_error = error + + printing_service.print_failed.emit(error) + print_failed_emissions.wait(50) + + self.assertEqual(expected_error, printer.last_error) + + def test_printer_allows_to_enqueue_printing_job(self): + portfolio = Portfolio() + + printing_service = Service() + print_method = MagicMock() + printing_service.print = print_method + + printer = getPrinter(printing_service) + printer.enqueue_job_on(portfolio.document_sent) + + portfolio.document_sent.emit(["my_document"]) + + # The printing service is not up-to-date on the printing queue terminology. + print_method.assert_called_once_with(["my_document"]) + + def test_printer_signals_when_printing_jobs_are_enqueued_successfully(self): + printing_service = Service() + printer = getPrinter(printing_service) + + job_done_emissions = QSignalSpy(printer.job_done) + job_failed_emissions = QSignalSpy(printer.job_failed) + self.assertTrue(job_done_emissions.isValid()) + self.assertTrue(job_failed_emissions.isValid()) + + # The printing service is not up-to-date on the printing queue terminology. + printing_service.print_succeeded.emit() + + self.assertEqual( + 1, + len(job_done_emissions), + "Expected printer to a emit job_done signal when the printing service reports success.", + ) + self.assertEqual( + 0, + len(job_failed_emissions), + f"Expected printer to emit no job_failed signal when the printing service reports success, got {len(job_failed_emissions)}.", # noqa: E501 + ) + + def test_printer_signals_when_printing_jobs_enqueueing_fails(self): + printing_service = Service() + printer = getPrinter(printing_service) + + job_done_emissions = QSignalSpy(printer.job_done) + job_failed_emissions = QSignalSpy(printer.job_failed) + self.assertTrue(job_done_emissions.isValid()) + self.assertTrue(job_failed_emissions.isValid()) + + # The printing service is not up-to-date on the printing queue terminology. + printing_service.print_failed.emit("a good reason") + + self.assertEqual( + 1, + len(job_failed_emissions), + "Expected printer to emit a job_failed signal when the printing service reports a failure.", # noqa: E501 + ) + self.assertEqual( + 0, + len(job_done_emissions), + f"Expected printer to emit no job_done signal when the printing service reports a failure, got {len(job_done_emissions)}.", # noqa: E501 + ) + + def ensure_that_printer_internals_are_ready_to_process_events( + self, printer, max_waiting_time_in_milliseconds + ): + """Give a little bit of time to the state machines to start. + + All QStateMachine instances require Qt's event loop to be processing + events before being considered to be fully started and running. + + This is a bit sad and an implementation detail, but testing + components that depend on Qt's event loop is like that.""" + poller_started = QSignalSpy(printer._poller.started) + cache_started = QSignalSpy(printer._cache.started) + + # these are max waiting times, not required pauses + poller_started.wait(int(max_waiting_time_in_milliseconds / 2)) + cache_started.wait(int(max_waiting_time_in_milliseconds / 2)) + + self.assertTrue( + printer._poller.isRunning(), + f"Expected the printer poller to be running after {max_waiting_time_in_milliseconds}ms. Abort the test.", # noqa: E501 + ) + self.assertTrue( + printer._cache.isRunning(), + f"Expected the printer cache to be running after {max_waiting_time_in_milliseconds}ms. Abort the test.", # noqa: E501 + ) + + +class TestPrinterDeprecatedInterface(unittest.TestCase): + def test_performs_one_check_when_registered_signal_is_emitted(self): + client = PrintingServiceClient() + printing_service = PrintingService() + printer = getPrinter(printing_service) + + response_emitted_emissions = QSignalSpy(printing_service.response_emitted) + self.assertTrue(response_emitted_emissions.isValid()) + + printer.check_status_once_on(client.query_printing_service) + client.query_printing_service.emit() # Act. + + self.assertEqual( + 1, + len(response_emitted_emissions), + "Expected service to emit a response as a result of being queried.", + ) diff --git a/tests/export/test_service.py b/tests/export/test_service.py new file mode 100644 index 000000000..9d955eafd --- /dev/null +++ b/tests/export/test_service.py @@ -0,0 +1,265 @@ +import unittest +from unittest.mock import patch + +from PyQt5.QtTest import QSignalSpy + +from securedrop_client import export +from securedrop_client.export.cli import CLI +from securedrop_client.export.cli import Error as CLIError + + +class TestExportServiceDiskInterface(unittest.TestCase): + @patch( + "securedrop_client.export.service.TemporaryDirectory.__enter__", + return_value="tmpdir-48vj6", + ) + @patch.object(CLI, "check_disk_encryption") + @patch.object(CLI, "check_disk_presence") + def test_uses_temporary_directory_for_disk_check( + self, check_disk_presence, check_disk_encryption, temporary_directory + ): + export_service = export.getService() + + export_service.check_disk() + + check_disk_presence.assert_called_once_with("tmpdir-48vj6") + check_disk_encryption.assert_called_once_with("tmpdir-48vj6") + + @patch.object(CLI, "check_disk_encryption") + @patch.object(CLI, "check_disk_presence") + def test_emits_luks_encrypted_disk_found_when_disk_check_succeeds( + self, check_disk_presence, check_disk_encryption + ): + export_service = export.getService() + luks_encrypted_disk_found_emissions = QSignalSpy(export_service.luks_encrypted_disk_found) + luks_encrypted_disk_not_found_emissions = QSignalSpy( + export_service.luks_encrypted_disk_not_found + ) + assert luks_encrypted_disk_found_emissions.isValid() + assert luks_encrypted_disk_not_found_emissions.isValid() + + export_service.check_disk() + + assert len(luks_encrypted_disk_found_emissions) == 1 + assert len(luks_encrypted_disk_not_found_emissions) == 0 + + @patch.object(CLI, "check_disk_encryption") + @patch.object(CLI, "check_disk_presence") + def test_emits_luks_encrypted_disk_not_found_when_disk_is_not_present( + self, check_disk_presence, check_disk_encryption + ): + check_error = CLIError(status="disk is missing") + check_disk_presence.side_effect = check_error + export_service = export.getService() + luks_encrypted_disk_found_emissions = QSignalSpy(export_service.luks_encrypted_disk_found) + luks_encrypted_disk_not_found_emissions = QSignalSpy( + export_service.luks_encrypted_disk_not_found + ) + assert luks_encrypted_disk_found_emissions.isValid() + assert luks_encrypted_disk_not_found_emissions.isValid() + + export_service.check_disk() + + assert len(luks_encrypted_disk_found_emissions) == 0 + assert len(luks_encrypted_disk_not_found_emissions) == 1 + + @patch.object(CLI, "check_disk_encryption") + @patch.object(CLI, "check_disk_presence") + def test_emits_luks_encrypted_disk_not_found_when_disk_is_not_luks_encrypted( + self, check_disk_presence, check_disk_encryption + ): + check_error = CLIError(status="disk is not LUKS-encrypted") + check_disk_encryption.side_effect = check_error + export_service = export.getService() + luks_encrypted_disk_found_emissions = QSignalSpy(export_service.luks_encrypted_disk_found) + luks_encrypted_disk_not_found_emissions = QSignalSpy( + export_service.luks_encrypted_disk_not_found + ) + assert luks_encrypted_disk_found_emissions.isValid() + assert luks_encrypted_disk_not_found_emissions.isValid() + + export_service.check_disk() + + assert len(luks_encrypted_disk_found_emissions) == 0 + assert len(luks_encrypted_disk_not_found_emissions) == 1 + + @patch( + "securedrop_client.export.service.TemporaryDirectory.__enter__", + return_value="tmpdir-5b8wq", + ) + @patch.object(CLI, "export") + def test_uses_temporary_directory_for_exporting(self, cli_export, temporary_directory): + valid_passphrase = "staple incorrect etcetera" + valid_file_path = "file_path_328f42" + expected_file_paths = [valid_file_path] + export_service = export.getService() + + export_service.export([valid_file_path], valid_passphrase) + + cli_export.assert_called_once_with("tmpdir-5b8wq", expected_file_paths, valid_passphrase) + + @patch.object(CLI, "export") + def test_emits_export_succeeded_and_export_finished_on_success(self, cli_export): + export_service = export.getService() + export_succeeded_emissions = QSignalSpy(export_service.export_succeeded) + export_finished_emissions = QSignalSpy(export_service.export_finished) + export_failed_emissions = QSignalSpy(export_service.export_failed) + assert export_succeeded_emissions.isValid() + assert export_finished_emissions.isValid() + assert export_failed_emissions.isValid() + + valid_passphrase = "staple incorrect etcetera" + valid_file_path = "file_path_328f42" + expected_file_paths = [valid_file_path] + + export_service.export([valid_file_path], valid_passphrase) + + assert len(export_failed_emissions) == 0 + assert len(export_succeeded_emissions) == 1 + assert len(export_finished_emissions) == 1 + self.assertEqual( + export_finished_emissions[0], + [expected_file_paths], + "Expected list of one exported file.", + ) + + @patch.object(CLI, "export") + def test_emits_export_failed_and_export_finished_on_failure(self, cli_export): + export_error = CLIError(status="some error happened") + cli_export.side_effect = export_error + export_service = export.getService() + export_failed_emissions = QSignalSpy(export_service.export_failed) + export_finished_emissions = QSignalSpy(export_service.export_finished) + export_succeeded_emissions = QSignalSpy(export_service.export_succeeded) + assert export_failed_emissions.isValid() + assert export_finished_emissions.isValid() + assert export_succeeded_emissions.isValid() + + valid_passphrase = "staple incorrect etcetera" + valid_file_path = "file_path_243f4f" + expected_file_paths = [valid_file_path] + + export_service.export([valid_file_path], valid_passphrase) + + assert len(export_succeeded_emissions) == 0 + assert len(export_failed_emissions) == 1 + assert len(export_finished_emissions) == 1 + self.assertEqual( + export_failed_emissions[0], + [export_error], + "Expected error object ot be emitted on failure.", + ) + self.assertEqual( + export_finished_emissions[0], + [expected_file_paths], + "Expected list of one exported file.", + ) + + +class TestExportServicePrinterInterface(unittest.TestCase): + @patch( + "securedrop_client.export.service.TemporaryDirectory.__enter__", + return_value="tmpdir-sq324f", + ) + @patch.object(CLI, "check_printer_status") + def test_uses_temporary_directory_for_printer_status_check( + self, check_printer_status, temporary_directory + ): + export_service = export.getService() + + export_service.check_printer_status() + + check_printer_status.assert_called_once_with("tmpdir-sq324f") + + @patch.object(CLI, "check_printer_status") + def test_emits_printer_found_ready_when_printer_status_check_succeeds( + self, check_printer_status + ): + export_service = export.getService() + printer_found_ready_emissions = QSignalSpy(export_service.printer_found_ready) + assert printer_found_ready_emissions.isValid() + + export_service.check_printer_status() + + assert len(printer_found_ready_emissions) == 1 + assert printer_found_ready_emissions[0] == [] + + @patch.object(CLI, "check_printer_status") + def test_emits_printer_not_found_ready_when_printer_status_check_fails( + self, check_printer_status + ): + expected_error = export.ExportError("bang!") + check_printer_status.side_effect = expected_error + export_service = export.getService() + printer_not_found_ready_emissions = QSignalSpy(export_service.printer_not_found_ready) + assert printer_not_found_ready_emissions.isValid() + + export_service.check_printer_status() + + assert len(printer_not_found_ready_emissions) == 1 + assert printer_not_found_ready_emissions[0] == [expected_error] + + @patch( + "securedrop_client.export.service.TemporaryDirectory.__enter__", + return_value="tmpdir-94jf3", + ) + @patch.object(CLI, "print") + def test_uses_temporary_directory_for_printing(self, print, temporary_directory): + valid_file_path = "file_path_328f42" + expected_file_paths = [valid_file_path] + export_service = export.getService() + + export_service.print([valid_file_path]) + + print.assert_called_once_with("tmpdir-94jf3", expected_file_paths) + + @patch.object(CLI, "print") + def test_emits_print_succeeded_and_print_finished_on_success(self, print): + export_service = export.getService() + print_succeeded_emissions = QSignalSpy(export_service.print_succeeded) + print_finished_emissions = QSignalSpy(export_service.print_finished) + print_failed_emissions = QSignalSpy(export_service.print_failed) + assert print_succeeded_emissions.isValid() + assert print_finished_emissions.isValid() + assert print_failed_emissions.isValid() + + valid_file_path = "file_path_328f42" + expected_file_paths = [valid_file_path] + + export_service.print([valid_file_path]) + + assert len(print_failed_emissions) == 0 + assert len(print_succeeded_emissions) == 1 + assert len(print_finished_emissions) == 1 + self.assertEqual( + print_finished_emissions[0], [expected_file_paths], "Expected list of one printed file." + ) + + @patch.object(CLI, "print") + def test_emits_print_failed_and_print_finished_on_failure(self, print): + print_error = CLIError(status="some error happened") + print.side_effect = print_error + export_service = export.getService() + print_failed_emissions = QSignalSpy(export_service.print_failed) + print_finished_emissions = QSignalSpy(export_service.print_finished) + print_succeeded_emissions = QSignalSpy(export_service.print_succeeded) + assert print_failed_emissions.isValid() + assert print_finished_emissions.isValid() + assert print_succeeded_emissions.isValid() + + valid_file_path = "file_path_243f4f" + expected_file_paths = [valid_file_path] + + export_service.print([valid_file_path]) + + assert len(print_succeeded_emissions) == 0 + assert len(print_failed_emissions) == 1 + assert len(print_finished_emissions) == 1 + self.assertEqual( + print_failed_emissions[0], + [print_error], + "Expected error object ot be emitted on failure.", + ) + self.assertEqual( + print_finished_emissions[0], [expected_file_paths], "Expected list of one printed file." + ) diff --git a/tests/gui/conversation/export/test_device.py b/tests/gui/conversation/export/test_device.py deleted file mode 100644 index 710ce59af..000000000 --- a/tests/gui/conversation/export/test_device.py +++ /dev/null @@ -1,240 +0,0 @@ -import os - -from PyQt5.QtTest import QSignalSpy - -from securedrop_client.app import threads -from securedrop_client.gui.conversation.export import Device -from securedrop_client.gui.main import Window -from securedrop_client.logic import Controller -from tests import factory - - -def no_session(): - pass - - -def test_Device_run_printer_preflight_checks(homedir, mocker, source, export_service): - gui = mocker.MagicMock(spec=Window) - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - no_session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - print_preflight_check_requested_emissions = QSignalSpy( - device.print_preflight_check_requested - ) - - device.run_printer_preflight_checks() - - assert len(print_preflight_check_requested_emissions) == 1 - - -def test_Device_run_print_file(mocker, homedir, export_service): - gui = mocker.MagicMock(spec=Window) - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - no_session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - print_requested_emissions = QSignalSpy(device.print_requested) - file = factory.File(source=factory.Source()) - mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) - - filepath = file.location(controller.data_dir) - os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, "w"): - pass - - device.print_file(file.uuid) - - assert len(print_requested_emissions) == 1 - - -def test_Device_print_file_file_missing(homedir, mocker, session, export_service): - """ - If the file is missing from the data dir, is_downloaded should be set to False and the failure - should be communicated to the user. - """ - gui = mocker.MagicMock() - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - file = factory.File(source=factory.Source()) - mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) - warning_logger = mocker.patch("securedrop_client.logic.logger.warning") - - device.print_file(file.uuid) - - log_msg = "Cannot find file in {}. File does not exist.".format( - os.path.dirname(file.filename) - ) - warning_logger.assert_called_once_with(log_msg) - - -def test_Device_print_file_when_orig_file_already_exists( - homedir, config, mocker, source, export_service -): - """ - The signal `print_requested` should still be emitted if the original file already exists. - """ - gui = mocker.MagicMock(spec=Window) - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - no_session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - file = factory.File(source=factory.Source()) - print_requested_emissions = QSignalSpy(device.print_requested) - mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) - mocker.patch("os.path.exists", return_value=True) - - device.print_file(file.uuid) - - assert len(print_requested_emissions) == 1 - controller.get_file.assert_called_with(file.uuid) - - -def test_Device_run_export_preflight_checks(homedir, mocker, source, export_service): - gui = mocker.MagicMock(spec=Window) - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - no_session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - export_preflight_check_requested_emissions = QSignalSpy( - device.export_preflight_check_requested - ) - file = factory.File(source=source["source"]) - mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) - - device.run_export_preflight_checks() - - assert len(export_preflight_check_requested_emissions) == 1 - - -def test_Device_export_file_to_usb_drive(homedir, mocker, export_service): - """ - The signal `export_requested` should be emitted during export_file_to_usb_drive. - """ - gui = mocker.MagicMock(spec=Window) - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - no_session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - export_requested_emissions = QSignalSpy(device.export_requested) - file = factory.File(source=factory.Source()) - mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) - - filepath = file.location(controller.data_dir) - os.makedirs(os.path.dirname(filepath), mode=0o700, exist_ok=True) - with open(filepath, "w"): - pass - - device.export_file_to_usb_drive(file.uuid, "mock passphrase") - - assert len(export_requested_emissions) == 1 - - -def test_Device_export_file_to_usb_drive_file_missing(homedir, mocker, session, export_service): - """ - If the file is missing from the data dir, is_downloaded should be set to False and the failure - should be communicated to the user. - """ - gui = mocker.MagicMock(spec=Window) - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - file = factory.File(source=factory.Source()) - mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) - warning_logger = mocker.patch("securedrop_client.logic.logger.warning") - - device.export_file_to_usb_drive(file.uuid, "mock passphrase") - - log_msg = "Cannot find file in {}. File does not exist.".format( - os.path.dirname(file.filename) - ) - warning_logger.assert_called_once_with(log_msg) - - -def test_Device_export_file_to_usb_drive_when_orig_file_already_exists( - homedir, config, mocker, source, export_service -): - """ - The signal `export_requested` should still be emitted if the original file already exists. - """ - gui = mocker.MagicMock(spec=Window) - with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: - controller = Controller( - "http://localhost", - gui, - no_session, - homedir, - None, - sync_thread=sync_thread, - main_queue_thread=main_queue_thread, - file_download_queue_thread=file_download_queue_thread, - ) - device = Device(controller, export_service) - export_requested_emissions = QSignalSpy(device.export_requested) - file = factory.File(source=factory.Source()) - mocker.patch("securedrop_client.logic.Controller.get_file", return_value=file) - mocker.patch("os.path.exists", return_value=True) - - device.export_file_to_usb_drive(file.uuid, "mock passphrase") - - assert len(export_requested_emissions) == 1 - controller.get_file.assert_called_with(file.uuid) diff --git a/tests/gui/conversation/export/test_dialog.py b/tests/gui/conversation/export/test_dialog.py index e052636c2..2f0b74272 100644 --- a/tests/gui/conversation/export/test_dialog.py +++ b/tests/gui/conversation/export/test_dialog.py @@ -1,6 +1,159 @@ -from securedrop_client.export import ExportError, ExportStatus +import unittest +from unittest.mock import MagicMock, patch + +from PyQt5.QtTest import QSignalSpy + +from securedrop_client import export +from securedrop_client.export import ExportError, ExportStatus, getDisk +from securedrop_client.export.cli import CLI +from securedrop_client.export.disk import clearDisk +from securedrop_client.export.service import resetService from securedrop_client.gui.conversation import ExportFileDialog from tests.helper import app # noqa: F401 +from tests.helper import assertEmissions, emitsSignals, tearDownQtObjects + + +class TestExportFileDialog(unittest.TestCase): + def setUp(self): + resetService() + _export_service = export.getService() + _export_service._cli = MagicMock(spec=CLI) + _disk = export.getDisk(_export_service) + self.dialog = ExportFileDialog( + _disk, file_location="file_location_fd3r4", file_name="file_name_lk4oi" + ) + self._disk = _disk + self._export_service = _export_service + + def tearDown(self): + resetService() + clearDisk(self._export_service) + tearDownQtObjects() + pass + + def test_dialog_text_includes_header_and_body(self): + # There is a race condition when initializing the dialog, + # that's why it is created after disabling the service check_disk. + # If the service responds quick enough, the successful disk check + # causes the dialog header to be updated. + # self._export_service.check_disk = MagicMock() + # Or not? + self.dialog = ExportFileDialog( + self._disk, file_location="file_location_fd3r4", file_name="file_name_lk4oi" + ) + + default_header = "Preparing to export" + default_body = "Understand the risks before exporting files" + + dialog_text = self.dialog.text() + + self.assertTrue( + default_header in dialog_text, f'Expected "{default_header}" in "{dialog_text}"' + ) + self.assertTrue( + default_body in dialog_text, f'Expected "{default_body}" in "{dialog_text}"' + ) + + @patch("securedrop_client.export.disk.Disk.status", export.Disk.StatusReachable) + def test_requests_disk_passphrase_when_LUKS_encrypted_disk_found(self): + passphrase_prompt = "Enter passphrase for USB drive" + status_changed_emissions = QSignalSpy(self._disk.status_changed) + assert status_changed_emissions.isValid() + + # Sanity check + self.assertFalse( + passphrase_prompt in self.dialog.text(), + f'Did not expect "{passphrase_prompt}" in "{self.dialog.text()}".', + ) + + # Act. + emitsSignals(self._disk.status_changed.emit) + assertEmissions(self, status_changed_emissions, 1) + self.dialog.continue_button.click() # Click "OK". This is how the dialog currently works. + + self.assertTrue( + passphrase_prompt in self.dialog.text(), + f'Expected "{passphrase_prompt}" in "{self.dialog.text()}".', + ) + + @patch("securedrop_client.export.disk.Disk.status", export.Disk.StatusUnreachable) + @patch( + "securedrop_client.export.disk.Disk.last_error", ExportError(ExportStatus.USB_NOT_CONNECTED) + ) + def test_prompts_for_disk_when_disk_unreachable(self): + expected_message = "Please insert one of the export drives" + status_changed_emissions = QSignalSpy(self._disk.status_changed) + assert status_changed_emissions.isValid() + + # Sanity check + self.assertFalse( + expected_message in self.dialog.text(), + f'Did not expect "{expected_message}" in "{self.dialog.text()}".', + ) + + # Act. + emitsSignals(self._disk.status_changed.emit) + assertEmissions(self, status_changed_emissions, 1) + self.dialog.continue_button.click() # Click "OK". This is how the dialog currently works. + + self.assertTrue( + expected_message in self.dialog.text(), + f'Expected "{expected_message}" in "{self.dialog.text()}".', + ) + + @patch("securedrop_client.export.disk.Disk.status", export.Disk.StatusUnreachable) + @patch("securedrop_client.export.disk.Disk.last_error", None) + def test_displays_generic_error_message_when_disk_unreachanble_and_specific_error_is_missing( + self, + ): + expected_message = "See your administrator for help." + status_changed_emissions = QSignalSpy(self._disk.status_changed) + assert status_changed_emissions.isValid() + + # Sanity check + self.assertFalse( + expected_message in self.dialog.text(), + f'Did not expect "{expected_message}" in "{self.dialog.text()}".', + ) + + # Act. + emitsSignals(self._disk.status_changed.emit) + assertEmissions(self, status_changed_emissions, 1) + self.dialog.continue_button.click() # Click "OK". This is how the dialog currently works. + + self.assertTrue( + expected_message in self.dialog.text(), + f'Expected "{expected_message}" in "{self.dialog.text()}".', + ) + + @patch("securedrop_client.export.disk.Disk.last_error", None) + def test_displays_generic_error_message_when_export_fails_and_specific_error_is_missing( + self, + ): + expected_message = "See your administrator for help." + export_failed_emissions = QSignalSpy(self._disk.export_failed) + assert export_failed_emissions.isValid() + + # This doesn't quite make sense to me, but thats's how the dialog + # currently works. It is the state of the button that determines + # whether error messages are displayed immediately. + self.dialog.continue_button.setEnabled(True) + + # Sanity check + self.assertFalse( + expected_message in self.dialog.text(), + f'Did not expect "{expected_message}" in "{self.dialog.text()}".', + ) + + # Act. + emitsSignals(self._disk.export_failed.emit) + assertEmissions(self, export_failed_emissions, 1) + self.dialog.continue_button.click() # Click "OK". This is how the dialog currently works. + + self.assertTrue( + expected_message in self.dialog.text(), + f'Expected "{expected_message}" in "{self.dialog.text()}".', + ) def test_ExportDialog_init(mocker): @@ -161,17 +314,27 @@ def test_ExportDialog__show_generic_error_message(mocker, export_dialog): assert not export_dialog.cancel_button.isHidden() -def test_ExportDialog__export_file(mocker, export_dialog): - device = mocker.MagicMock() - device.export_file_to_usb_drive = mocker.MagicMock() - export_dialog._device = device +def test_ExportDialog__export_file(mocker, export_dialog, export_service): + # Remember the export disk is a singleton, + # so this actually modifies the disk associated + # with the dialog. + disk = getDisk(export_service) + file_export_requested_emissions = QSignalSpy(export_dialog.file_export_requested) + export_done_emissions = QSignalSpy(disk.export_done) + assert file_export_requested_emissions.isValid() + assert export_done_emissions.isValid() + export_dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") export_dialog._export_file() - device.export_file_to_usb_drive.assert_called_once_with( - export_dialog.file_uuid, "mock_passphrase" - ) + file_export_requested_emissions.wait(50) + export_done_emissions.wait(50) + + assert len(file_export_requested_emissions) == 1 + assert file_export_requested_emissions[0] == [[export_dialog.file_location], "mock_passphrase"] + + assert len(export_done_emissions) == 1 def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog): @@ -211,15 +374,21 @@ def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflig mocker, export_dialog ): assert not export_dialog.continue_button.isEnabled() - export_dialog._on_export_preflight_check_failed(mocker.MagicMock()) + export_dialog._on_export_preflight_check_failed() assert export_dialog.continue_button.isEnabled() -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog): +# FIXME This test mocks the object under test. +# FIXME This test asserts on an implementaiton detail, not on behavior. +def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog, export_service): + # Remember the export disk is a singleton, + # so this actually modifies the disk associated + # with the dialog. + disk = getDisk(export_service) + disk._last_error = ExportError("mock_error_status") export_dialog._update_dialog = mocker.MagicMock() - error = ExportError("mock_error_status") - export_dialog._on_export_preflight_check_failed(error) + export_dialog._on_export_preflight_check_failed() export_dialog._update_dialog.assert_called_with("mock_error_status") @@ -232,11 +401,17 @@ def test_ExportDialog__on_export_succeeded(mocker, export_dialog): export_dialog._show_success_message.assert_called_once_with() -def test_ExportDialog__on_export_failed(mocker, export_dialog): +# FIXME This test mocks the object under test. +# FIXME This test asserts on an implementaiton detail, not on behavior. +def test_ExportDialog__on_export_failed(mocker, export_dialog, export_service): + # Remember the export disk is a singleton, + # so this actually modifies the disk associated + # with the dialog. + disk = getDisk(export_service) + disk._last_error = ExportError("mock_error_status") export_dialog._update_dialog = mocker.MagicMock() - error = ExportError("mock_error_status") - export_dialog._on_export_failed(error) + export_dialog._on_export_failed() export_dialog._update_dialog.assert_called_with("mock_error_status") diff --git a/tests/gui/conversation/export/test_print_dialog.py b/tests/gui/conversation/export/test_print_dialog.py index ff46bee0f..5e46587cb 100644 --- a/tests/gui/conversation/export/test_print_dialog.py +++ b/tests/gui/conversation/export/test_print_dialog.py @@ -1,6 +1,181 @@ +import unittest +from unittest.mock import MagicMock, patch + +from PyQt5.QtTest import QSignalSpy + +from securedrop_client import export from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export.cli import CLI +from securedrop_client.export.printer import clearPrinter +from securedrop_client.export.service import resetService from securedrop_client.gui.conversation import PrintFileDialog from tests.helper import app # noqa: F401 +from tests.helper import assertEmissions, emitsSignals, tearDownQtObjects + + +class TestPrintFileDialog(unittest.TestCase): + def setUp(self): + resetService() + _export_service = export.getService() + _export_service._cli = MagicMock(spec=CLI) + _printer = export.getPrinter(_export_service) + self.dialog = PrintFileDialog( + _printer, file_location="file_location_fd3r4", file_name="file_name_lk4oi" + ) + self._printer = _printer + self._export_service = _export_service + + def tearDown(self): + resetService() + clearPrinter(self._export_service) + tearDownQtObjects() + + def test_dialog_text_includes_header_and_body(self): + default_header = "Preparing to print" + default_body = "Managing printout risks" + + dialog_text = self.dialog.text() + + assert default_header in dialog_text, f'Expected "{default_header}" in "{dialog_text}"' + assert default_body in dialog_text, f'Expected "{default_body}" in "{dialog_text}"' + + def test_continue_button_is_initially_diabled(self): + assert ( + not self.dialog.continue_button.isEnabled() + ), "Expected CONTINUE button to be disabled until the printer status is known, was enabled." # noqa: E501 + + @patch("securedrop_client.export.printer.Printer.status", export.Printer.StatusReady) + def test_becomes_ready_to_print_when_printer_found(self): + expected_text = "Ready to print" + status_changed_emissions = QSignalSpy(self._printer.status_changed) + assert status_changed_emissions.isValid() + + # Sanity check + assert ( + expected_text not in self.dialog.text() + ), f'Did not expect "{expected_text}" in "{self.dialog.text()}".' + + # Act. + # There isn't anything to do beyond waiting for the status to be checked. + assertEmissions(self, status_changed_emissions, 1) + + assert ( + expected_text in self.dialog.text() + ), f'Expected "{expected_text}" in "{self.dialog.text()}".' + assert ( + self.dialog.continue_button.isEnabled() + ), "Expected CONTINUE button to be enabled when the printer was found, was disabled." + + @patch("securedrop_client.export.printer.Printer.status", export.Printer.StatusUnreachable) + @patch( + "securedrop_client.export.printer.Printer.last_error", + ExportError(ExportStatus.PRINTER_NOT_FOUND), + ) + def test_requests_printer_when_printer_not_found(self): + printer_prompt = "Connect USB printer" + status_changed_emissions = QSignalSpy(self._printer.status_changed) + assert status_changed_emissions.isValid() + + # Sanity check + assert ( + printer_prompt not in self.dialog.text() + ), f'Did not expect "{printer_prompt}" in "{self.dialog.text()}".' + + # Act. + emitsSignals(self._printer.status_changed.emit) + assertEmissions(self, status_changed_emissions, 1) + self.dialog.continue_button.click() # Click "OK". This is how the dialog currently works. + assertEmissions(self, status_changed_emissions, 2) + + assert ( + printer_prompt in self.dialog.text() + ), f'Expected "{printer_prompt}" in "{self.dialog.text()}".' + assert ( + self.dialog.continue_button.isEnabled() + ), "Expected CONTINUE button to be enabled when the printer was not found, was disabled." + + # Subsequent printer status checks have the same effect + # + # The current implementation makes the dialog text depend + # on the enablement of the button, which doesn't quite + # make sense to me, but changing that is out of scope for now. + emitsSignals(self._printer.status_changed.emit) + assertEmissions(self, status_changed_emissions, 3) + + assert ( + printer_prompt in self.dialog.text() + ), f'Expected "{printer_prompt}" in "{self.dialog.text()}".' + assert ( + self.dialog.continue_button.isEnabled() + ), "Expected CONTINUE button to be enabled when the printer was not found, was disabled." + + @patch("securedrop_client.export.printer.Printer.status", export.Printer.StatusUnreachable) + @patch( + "securedrop_client.export.printer.Printer.last_error", + ExportError(ExportStatus.CALLED_PROCESS_ERROR), + ) + def test_requests_printer_when_printer_otherwise_unreachable(self): + + expected_message = "See your administrator for help." + status_changed_emissions = QSignalSpy(self._printer.status_changed) + assert status_changed_emissions.isValid() + + # Sanity check + assert ( + expected_message not in self.dialog.text() + ), f'Did not expect "{expected_message}" in "{self.dialog.text()}".' + + # Act. + emitsSignals(self._printer.status_changed.emit) + assertEmissions(self, status_changed_emissions, 1) + self.dialog.continue_button.click() # Click "OK". This is how the dialog currently works. + + assert ( + expected_message in self.dialog.text() + ), f'Expected "{expected_message}" in "{self.dialog.text()}".' + assert ( + self.dialog.continue_button.isEnabled() + ), "Expected CONTINUE button to be enabled when the printer is unreachable, was disabled." + + @patch("securedrop_client.export.printer.Printer.status", export.Printer.StatusUnreachable) + @patch("securedrop_client.export.printer.Printer.last_error", None) + def test_requests_printer_when_printer_unreachable_and_specific_error_is_missing(self): + expected_message = "See your administrator for help." + status_changed_emissions = QSignalSpy(self._printer.status_changed) + assert status_changed_emissions.isValid() + + # Sanity check + assert ( + expected_message not in self.dialog.text() + ), f'Did not expect "{expected_message}" in "{self.dialog.text()}".' + + # Act. + emitsSignals(self._printer.status_changed.emit) + assertEmissions(self, status_changed_emissions, 1) + self.dialog.continue_button.click() # Click "OK". This is how the dialog currently works. + assertEmissions(self, status_changed_emissions, 2) + + assert ( + expected_message in self.dialog.text() + ), f'Expected "{expected_message}" in "{self.dialog.text()}".' + assert ( + self.dialog.continue_button.isEnabled() + ), "Expected CONTINUE button to be enabled when the printer is unreachable, was disabled." + + # Subsequent printer status checks have the same effect + # + # The current implementation makes the dialog text depend + # on the enablement of the button, which doesn't quite + # make sense to me, but changing that is out of scope for now. + emitsSignals(self._printer.status_changed.emit) + assertEmissions(self, status_changed_emissions, 3) + + assert ( + expected_message in self.dialog.text() + ), f'Expected "{expected_message}" in "{self.dialog.text()}".' + assert ( + self.dialog.continue_button.isEnabled() + ), "Expected CONTINUE button to be enabled when the printer is unreachable, was disabled." def test_PrintFileDialog_init(mocker): @@ -120,97 +295,3 @@ def test_PrintFileDialog__on_print_preflight_check_succeeded_enabled_after_prefl assert not print_dialog.continue_button.isEnabled() print_dialog._on_print_preflight_check_succeeded() assert print_dialog.continue_button.isEnabled() - - -def test_PrintFileDialog__on_print_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, print_dialog -): - assert not print_dialog.continue_button.isEnabled() - print_dialog._on_print_preflight_check_failed(mocker.MagicMock()) - assert print_dialog.continue_button.isEnabled() - - -def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_PRINTER_NOT_FOUND( - mocker, print_dialog -): - print_dialog._show_insert_usb_message = mocker.MagicMock() - print_dialog.continue_button = mocker.MagicMock() - print_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - print_dialog._on_print_preflight_check_failed(ExportError(ExportStatus.PRINTER_NOT_FOUND)) - print_dialog.continue_button.clicked.connect.assert_called_once_with( - print_dialog._show_insert_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=True) - print_dialog._on_print_preflight_check_failed(ExportError(ExportStatus.PRINTER_NOT_FOUND)) - print_dialog._show_insert_usb_message.assert_called_once_with() - - -def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_MISSING_PRINTER_URI( - mocker, print_dialog -): - print_dialog._show_generic_error_message = mocker.MagicMock() - print_dialog.continue_button = mocker.MagicMock() - print_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - print_dialog._on_print_preflight_check_failed(ExportError(ExportStatus.MISSING_PRINTER_URI)) - print_dialog.continue_button.clicked.connect.assert_called_once_with( - print_dialog._show_generic_error_message - ) - assert print_dialog.error_status == ExportStatus.MISSING_PRINTER_URI - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=True) - print_dialog._on_print_preflight_check_failed(ExportError(ExportStatus.MISSING_PRINTER_URI)) - print_dialog._show_generic_error_message.assert_called_once_with() - assert print_dialog.error_status == ExportStatus.MISSING_PRINTER_URI - - -def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_CALLED_PROCESS_ERROR( - mocker, print_dialog -): - print_dialog._show_generic_error_message = mocker.MagicMock() - print_dialog.continue_button = mocker.MagicMock() - print_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - print_dialog._on_print_preflight_check_failed(ExportError(ExportStatus.CALLED_PROCESS_ERROR)) - print_dialog.continue_button.clicked.connect.assert_called_once_with( - print_dialog._show_generic_error_message - ) - assert print_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=True) - print_dialog._on_print_preflight_check_failed(ExportError(ExportStatus.CALLED_PROCESS_ERROR)) - print_dialog._show_generic_error_message.assert_called_once_with() - assert print_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - -def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_unknown( - mocker, print_dialog -): - print_dialog._show_generic_error_message = mocker.MagicMock() - print_dialog.continue_button = mocker.MagicMock() - print_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - print_dialog._on_print_preflight_check_failed(ExportError("Some Unknown Error Status")) - print_dialog.continue_button.clicked.connect.assert_called_once_with( - print_dialog._show_generic_error_message - ) - assert print_dialog.error_status == "Some Unknown Error Status" - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(print_dialog.continue_button, "isEnabled", return_value=True) - print_dialog._on_print_preflight_check_failed(ExportError("Some Unknown Error Status")) - print_dialog._show_generic_error_message.assert_called_once_with() - assert print_dialog.error_status == "Some Unknown Error Status" diff --git a/tests/gui/test_actions.py b/tests/gui/test_actions.py index 6c045233e..31bb1bf0f 100644 --- a/tests/gui/test_actions.py +++ b/tests/gui/test_actions.py @@ -7,17 +7,13 @@ from securedrop_client import state from securedrop_client.db import Source -from securedrop_client.gui.actions import ( - DeleteConversationAction, - DeleteSourceAction, - DownloadConversation, -) +from securedrop_client.gui import actions from securedrop_client.logic import Controller from tests import factory from tests.helper import app # noqa: F401 -class DeleteConversationActionTest(unittest.TestCase): +class DeleteConversationTest(unittest.TestCase): def setUp(self): self._source = factory.Source() _menu = QMenu() @@ -30,7 +26,7 @@ def setUp(self): def _dialog_constructor(source: Source) -> QDialog: return self._dialog - self.action = DeleteConversationAction( + self.action = actions.DeleteConversation( self._source, _menu, self._controller, _dialog_constructor, self._app_state ) @@ -79,7 +75,7 @@ def test_deletes_nothing_if_no_conversation_is_selected(self): assert not self._app_state.remove_conversation_files.called -class DeleteSourceActionTest(unittest.TestCase): +class DeleteSourceTest(unittest.TestCase): def setUp(self): self._source = factory.Source() _menu = QMenu() @@ -89,7 +85,9 @@ def setUp(self): def _dialog_constructor(source: Source) -> QDialog: return self._dialog - self.action = DeleteSourceAction(self._source, _menu, self._controller, _dialog_constructor) + self.action = actions.DeleteSource( + self._source, _menu, self._controller, _dialog_constructor + ) def test_deletes_source_when_dialog_accepted(self): # Accept the confirmation dialog from a separate thread. @@ -126,7 +124,7 @@ def test_trigger(self): menu = QMenu() controller = MagicMock(Controller, api=True) app_state = state.State() - action = DownloadConversation(menu, controller, app_state) + action = actions.DownloadConversation(menu, controller, app_state) conversation_id = state.ConversationId("some_conversation") app_state.selected_conversation = conversation_id @@ -139,7 +137,7 @@ def test_requires_authenticated_journalist(self): menu = QMenu() controller = mock.MagicMock(Controller, api=None) # no authenticated user app_state = state.State() - action = DownloadConversation(menu, controller, app_state) + action = actions.DownloadConversation(menu, controller, app_state) conversation_id = state.ConversationId("some_conversation") app_state.selected_conversation = conversation_id @@ -153,7 +151,7 @@ def test_trigger_downloads_nothing_if_no_conversation_is_selected(self): menu = QMenu() controller = MagicMock(Controller, api=True) app_state = state.State() - action = DownloadConversation(menu, controller, app_state) + action = actions.DownloadConversation(menu, controller, app_state) action.trigger() assert controller.download_conversation.not_called @@ -162,7 +160,7 @@ def test_gets_disabled_when_no_files_to_download_remain(self): menu = QMenu() controller = MagicMock(Controller, api=True) app_state = state.State() - action = DownloadConversation(menu, controller, app_state) + action = actions.DownloadConversation(menu, controller, app_state) conversation_id = state.ConversationId(3) app_state.selected_conversation = conversation_id @@ -178,7 +176,7 @@ def test_gets_enabled_when_files_are_available_to_download(self): menu = QMenu() controller = MagicMock(Controller, api=True) app_state = state.State() - action = DownloadConversation(menu, controller, app_state) + action = actions.DownloadConversation(menu, controller, app_state) conversation_id = state.ConversationId(3) app_state.selected_conversation = conversation_id @@ -200,7 +198,7 @@ def test_gets_initially_disabled_when_file_information_is_available(self): app_state.add_file(conversation_id, 5) app_state.file(5).is_downloaded = True - action = DownloadConversation(menu, controller, app_state) + action = actions.DownloadConversation(menu, controller, app_state) assert not action.isEnabled() @@ -214,14 +212,14 @@ def test_gets_initially_enabled_when_file_information_is_available(self): app_state.add_file(conversation_id, 5) app_state.file(5).is_downloaded = False - action = DownloadConversation(menu, controller, app_state) + action = actions.DownloadConversation(menu, controller, app_state) assert action.isEnabled() def test_does_not_require_state_to_be_defined(self): menu = QMenu() controller = MagicMock(Controller, api=True) - action = DownloadConversation(menu, controller, app_state=None) + action = actions.DownloadConversation(menu, controller, app_state=None) action.setEnabled(False) assert not action.isEnabled() @@ -234,7 +232,7 @@ def test_on_selected_conversation_files_changed_handles_missing_state_gracefully ): menu = QMenu() controller = MagicMock(Controller, api=True) - action = DownloadConversation(menu, controller, None) + action = actions.DownloadConversation(menu, controller, None) action.setEnabled(True) action._on_selected_conversation_files_changed() diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index 6db781b93..affbdb8c8 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -11,6 +11,8 @@ from securedrop_client.resources import load_icon from tests.helper import app # noqa: F401 +export_service = export.getService() + class WindowTest(unittest.TestCase): def test_clear_clipboard(self): @@ -38,12 +40,11 @@ def test_init(mocker): load_css = mocker.patch("securedrop_client.gui.main.load_css") app_state = state.State() - export_service = export.Service() - w = Window(app_state, export_service) + w = Window(app_state) mock_li.assert_called_once_with(w.icon) mock_lp.assert_called_once_with() - mock_mv.assert_called_once_with(w.main_pane, app_state, export_service) + mock_mv.assert_called_once_with(w.main_pane, app_state) assert mock_lo().addWidget.call_count == 2 load_css.assert_called_once_with("sdclient.css") diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 0148ce3a6..b8761839a 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -16,7 +16,7 @@ from PyQt5.QtWidgets import QVBoxLayout, QWidget from sqlalchemy.orm import attributes, scoped_session, sessionmaker -from securedrop_client import db, logic, storage +from securedrop_client import db, export, logic, storage from securedrop_client.app import threads from securedrop_client.gui.source import DeleteSourceDialog from securedrop_client.gui.widgets import ( @@ -3577,17 +3577,16 @@ def test_FileWidget_on_file_missing_does_not_show_download_button_when_uuid_does fw.download_button.show.assert_not_called() -def test_FileWidget__on_export_clicked(mocker, session, source): +def test_FileWidget__on_export_clicked(mocker, session, source, export_service): """ Ensure preflight checks start when the EXPORT button is clicked and that password is requested """ + mocker.patch("securedrop_client.export.getService", return_value=export_service) file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() - get_file = mocker.MagicMock(return_value=file) - controller = mocker.MagicMock(get_file=get_file) - export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice") + controller = mocker.MagicMock(get_file=get_file, data_dir="data_dir_3vf23") fw = FileWidget( file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), 0, 123 @@ -3600,7 +3599,8 @@ def test_FileWidget__on_export_clicked(mocker, session, source): dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog") fw._on_export_clicked() - dialog.assert_called_once_with(export_device(), file.uuid, file.filename) + export_disk = export.getDisk(export_service) + dialog.assert_called_once_with(export_disk, file.location("data_dir_3vf23"), file.filename) def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): @@ -3635,18 +3635,17 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): dialog.assert_not_called() -def test_FileWidget__on_print_clicked(mocker, session, source): +def test_FileWidget__on_print_clicked(mocker, session, source, export_service): """ Ensure print_file is called when the PRINT button is clicked """ + mocker.patch("securedrop_client.export.getService", return_value=export_service) file = factory.File(source=source["source"], is_downloaded=True) session.add(file) session.commit() get_file = mocker.MagicMock(return_value=file) - controller = mocker.MagicMock(get_file=get_file) - export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice") - + controller = mocker.MagicMock(get_file=get_file, data_dir="data_dir_4f4dsf") fw = FileWidget( file.uuid, controller, @@ -3665,7 +3664,8 @@ def test_FileWidget__on_print_clicked(mocker, session, source): fw._on_print_clicked() - dialog.assert_called_once_with(export_device(), file.uuid, file.filename) + printer = export.getPrinter(export_service) + dialog.assert_called_once_with(printer, file.location("data_dir_4f4dsf"), file.filename) def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): diff --git a/tests/helper.py b/tests/helper.py index ba49d4378..d1fc864ca 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -1,3 +1,30 @@ +import time + +from PyQt5.QtCore import QTimer from PyQt5.QtWidgets import QApplication app = QApplication([]) + + +DEFAULT_WAIT_TIMEOUT = 5000 +ACTION_DELAY = 100 # << DEFAULT_WAIT_TIMEOUT + +FLAKY_MAX_RUNS = 10 +FLAKY_MIN_PASSES = 9 + + +def assertEmissions(testcase, signal_emissions, count, timeout=5000) -> bool: + if len(signal_emissions) < count: + signal_emissions.wait(timeout) + + testcase.assertEqual(len(signal_emissions), count) + + +def emitsSignals(act): + """Delays the action just enough for wait statements to be executed.""" + QTimer.singleShot(100, act) + + +def tearDownQtObjects(): + """Introduces a short delay to give time to Qt objects to be torn down.""" + time.sleep(0.1) # Really. Prevents segfaults... :'( diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3a2411345..1a9287938 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,6 +3,7 @@ from securedrop_client import export from securedrop_client.app import threads +from securedrop_client.export.cli import CLI from securedrop_client.gui import conversation from securedrop_client.gui.base import ModalDialog from securedrop_client.gui.main import Window @@ -11,7 +12,8 @@ @pytest.fixture(scope="function") -def main_window(mocker, homedir): +def main_window(mocker, homedir, export_service): + mocker.patch("securedrop_client.export.getService", return_value=export_service) # Setup app = QApplication([]) gui = Window() @@ -63,7 +65,8 @@ def main_window(mocker, homedir): @pytest.fixture(scope="function") -def main_window_no_key(mocker, homedir): +def main_window_no_key(mocker, homedir, export_service): + mocker.patch("securedrop_client.export.getService", return_value=export_service) # Setup app = QApplication([]) gui = Window() @@ -146,22 +149,18 @@ def modal_dialog(mocker, homedir): @pytest.fixture(scope="function") -def export_service(): +def export_service(mocker): """An export service that assumes the Qubes RPC calls are successful and skips them.""" export_service = export.Service() # Ensure the export_service doesn't rely on Qubes OS: - export_service._run_disk_test = lambda dir: None - export_service._run_usb_test = lambda dir: None - export_service._run_disk_export = lambda dir, paths, passphrase: None - export_service._run_printer_preflight = lambda dir: None - export_service._run_print = lambda dir, paths: None + export_service._cli = mocker.MagicMock(spec=CLI) return export_service @pytest.fixture(scope="function") def print_dialog(mocker, homedir, export_service): app = QApplication([]) - gui = Window(export_service=export_service) + gui = Window() app.setActiveWindow(gui) gui.show() with threads(3) as [sync_thread, main_queue_thread, file_download_thread]: @@ -181,10 +180,11 @@ def print_dialog(mocker, homedir, export_service): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller, export_service) gui.setup(controller) gui.login_dialog.close() - dialog = conversation.PrintFileDialog(export_device, "file_uuid", "file_name") + dialog = conversation.PrintFileDialog( + export.getPrinter(export_service), "file_location", "file_name" + ) yield dialog @@ -196,7 +196,7 @@ def print_dialog(mocker, homedir, export_service): @pytest.fixture(scope="function") def export_dialog(mocker, homedir, export_service): app = QApplication([]) - gui = Window(export_service=export_service) + gui = Window() app.setActiveWindow(gui) gui.show() with threads(3) as [sync_thread, main_queue_thread, file_download_thread]: @@ -213,10 +213,11 @@ def export_dialog(mocker, homedir, export_service): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller, export_service) gui.setup(controller) gui.login_dialog.close() - dialog = conversation.ExportFileDialog(export_device, "file_uuid", "file_name") + dialog = conversation.ExportFileDialog( + export.getDisk(export_service), "file_location", "file_name" + ) dialog.show() yield dialog diff --git a/tests/test_app.py b/tests/test_app.py index a02e00768..62f30649e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -7,7 +7,7 @@ import pytest -from securedrop_client import export, state +from securedrop_client import state from securedrop_client.app import ( DEFAULT_SDC_HOME, ENCODING, @@ -139,8 +139,6 @@ def test_start_app(homedir, mocker): mock_args.proxy = False app_state = state.State() mocker.patch("securedrop_client.state.State", return_value=app_state) - export_service = export.Service() - mocker.patch("securedrop_client.export.Service", return_value=export_service) mocker.patch("securedrop_client.app.configure_logging") mock_app = mocker.patch("securedrop_client.app.QApplication") @@ -154,7 +152,7 @@ def test_start_app(homedir, mocker): start_app(mock_args, mock_qt_args) mock_app.assert_called_once_with(mock_qt_args) - mock_win.assert_called_once_with(app_state, export_service) + mock_win.assert_called_once_with(app_state) mock_controller.assert_called_once_with( "http://localhost:8081/", mock_win(), diff --git a/tests/test_export.py b/tests/test_export.py deleted file mode 100644 index 0e9c8e252..000000000 --- a/tests/test_export.py +++ /dev/null @@ -1,458 +0,0 @@ -import os -import subprocess -from tempfile import NamedTemporaryFile, TemporaryDirectory - -import pytest - -from securedrop_client.export import Export, ExportError, ExportStatus - - -def test_run_printer_preflight(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archives during the preflight - checks and that the success signal is emitted by Export. - """ - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - export = Export() - export.printer_preflight_success = mocker.MagicMock() - export.printer_preflight_success.emit = mocker.MagicMock() - _run_printer_preflight = mocker.patch.object(export, "_run_printer_preflight") - - export.run_printer_preflight() - - _run_printer_preflight.assert_called_once_with("mock_temp_dir") - export.printer_preflight_success.emit.assert_called_once_with() - - -def test_run_printer_preflight_error(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archives during the preflight - checks and that the failure signal is emitted by Export. - """ - mock_temp_dir = mocker.MagicMock() - mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir") - mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir) - export = Export() - export.printer_preflight_failure = mocker.MagicMock() - export.printer_preflight_failure.emit = mocker.MagicMock() - error = ExportError("bang!") - _run_print_preflight = mocker.patch.object(export, "_run_printer_preflight", side_effect=error) - - export.run_printer_preflight() - - _run_print_preflight.assert_called_once_with("mock_temp_dir") - export.printer_preflight_failure.emit.assert_called_once_with(error) - - -def test__run_printer_preflight(mocker): - """ - Ensure _export_archive and _create_archive are called with the expected parameters, - _export_archive is called with the return value of _create_archive, and - _run_disk_test returns without error if 'USB_CONNECTED' is the return value of _export_archive. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="") - - export._run_printer_preflight("mock_archive_dir") - - export._export_archive.assert_called_once_with("mock_archive_path") - export._create_archive.assert_called_once_with( - "mock_archive_dir", "printer-preflight.sd-export", {"device": "printer-preflight"} - ) - - -def test__run_printer_preflight_raises_ExportError_if_not_empty_string(mocker): - """ - Ensure ExportError is raised if _run_disk_test returns anything other than 'USB_CONNECTED'. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="SOMETHING_OTHER_THAN_EMPTY_STRING") - - with pytest.raises(ExportError): - export._run_printer_preflight("mock_archive_dir") - - -def test_print(mocker): - """ - Ensure TemporaryDirectory is used when creating and sending the archive containing the file to - print 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.print_call_success = mocker.MagicMock() - export.print_call_success.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - _run_print = mocker.patch.object(export, "_run_print") - mocker.patch("os.path.exists", return_value=True) - - export.print(["path1", "path2"]) - - _run_print.assert_called_once_with("mock_temp_dir", ["path1", "path2"]) - export.print_call_success.emit.assert_called_once_with() - 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("[mock_filepath]") - _run_print = mocker.patch.object(export, "_run_print", side_effect=error) - mocker.patch("os.path.exists", return_value=True) - - export.print(["path1", "path2"]) - - _run_print.assert_called_once_with("mock_temp_dir", ["path1", "path2"]) - export.print_call_failure.emit.assert_called_once_with(error) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test__run_print(mocker): - """ - Ensure _export_archive and _create_archive are called with the expected parameters and - _export_archive is called with the return value of _create_archive. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="") - - export._run_print("mock_archive_dir", ["mock_filepath"]) - - export._export_archive.assert_called_once_with("mock_archive_path") - export._create_archive.assert_called_once_with( - "mock_archive_dir", "print_archive.sd-export", {"device": "printer"}, ["mock_filepath"] - ) - - -def test__run_print_raises_ExportError_if_not_empty_string(mocker): - """ - Ensure ExportError is raised if _run_print returns anything other than ''. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="SOMETHING_OTHER_THAN_EMPTY_STRING") - - with pytest.raises(ExportError): - export._run_print("mock_archive_dir", ["mock_filepath"]) - - -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, "_run_disk_export") - mocker.patch("os.path.exists", return_value=True) - - export.send_file_to_usb_device(["path1", "path2"], "mock passphrase") - - _run_disk_export.assert_called_once_with("mock_temp_dir", ["path1", "path2"], "mock passphrase") - export.export_usb_call_success.emit.assert_called_once_with() - 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. - """ - 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_failure = mocker.MagicMock() - export.export_usb_call_failure.emit = mocker.MagicMock() - export.export_completed = mocker.MagicMock() - export.export_completed.emit = mocker.MagicMock() - error = ExportError("[mock_filepath]") - _run_disk_export = mocker.patch.object(export, "_run_disk_export", side_effect=error) - mocker.patch("os.path.exists", return_value=True) - - export.send_file_to_usb_device(["path1", "path2"], "mock passphrase") - - _run_disk_export.assert_called_once_with("mock_temp_dir", ["path1", "path2"], "mock passphrase") - export.export_usb_call_failure.emit.assert_called_once_with(error) - export.export_completed.emit.assert_called_once_with(["path1", "path2"]) - - -def test_run_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_usb_export = mocker.patch.object(export, "_run_usb_test") - _run_disk_export = mocker.patch.object(export, "_run_disk_test") - - export.run_preflight_checks() - - _run_usb_export.assert_called_once_with("mock_temp_dir") - _run_disk_export.assert_called_once_with("mock_temp_dir") - export.preflight_check_call_success.emit.assert_called_once_with() - - -def test_run_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_usb_export = mocker.patch.object(export, "_run_usb_test") - _run_disk_export = mocker.patch.object(export, "_run_disk_test", side_effect=error) - - export.run_preflight_checks() - - _run_usb_export.assert_called_once_with("mock_temp_dir") - _run_disk_export.assert_called_once_with("mock_temp_dir") - export.preflight_check_call_failure.emit.assert_called_once_with(error) - - -def test__run_disk_export(mocker): - """ - Ensure _export_archive and _create_archive are called with the expected parameters, - _export_archive is called with the return value of _create_archive, and - _run_disk_test returns without error if '' is the output status of _export_archive. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="") - - export._run_disk_export("mock_archive_dir", ["mock_filepath"], "mock_passphrase") - - export._export_archive.assert_called_once_with("mock_archive_path") - export._create_archive.assert_called_once_with( - "mock_archive_dir", - "archive.sd-export", - {"encryption_key": "mock_passphrase", "device": "disk", "encryption_method": "luks"}, - ["mock_filepath"], - ) - - -def test__run_disk_export_raises_ExportError_if_not_empty_string(mocker): - """ - Ensure ExportError is raised if _run_disk_test returns anything other than ''. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="SOMETHING_OTHER_THAN_EMPTY_STRING") - - with pytest.raises(ExportError): - export._run_disk_export("mock_archive_dir", ["mock_filepath"], "mock_passphrase") - - -def test__run_disk_test(mocker): - """ - Ensure _export_archive and _create_archive are called with the expected parameters, - _export_archive is called with the return value of _create_archive, and - _run_disk_test returns without error if 'USB_ENCRYPTED' is the output status of _export_archive. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value=ExportStatus("USB_ENCRYPTED")) - - export._run_disk_test("mock_archive_dir") - - export._export_archive.assert_called_once_with("mock_archive_path") - export._create_archive.assert_called_once_with( - "mock_archive_dir", "disk-test.sd-export", {"device": "disk-test"} - ) - - -def test__run_disk_test_raises_ExportError_if_not_USB_ENCRYPTED(mocker): - """ - Ensure ExportError is raised if _run_disk_test returns anything other than 'USB_ENCRYPTED'. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="SOMETHING_OTHER_THAN_USB_ENCRYPTED") - - with pytest.raises(ExportError): - export._run_disk_test("mock_archive_dir") - - -def test__run_usb_test(mocker): - """ - Ensure _export_archive and _create_archive are called with the expected parameters, - _export_archive is called with the return value of _create_archive, and - _run_disk_test returns without error if 'USB_CONNECTED' is the return value of _export_archive. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value=ExportStatus("USB_CONNECTED")) - - export._run_usb_test("mock_archive_dir") - - export._export_archive.assert_called_once_with("mock_archive_path") - export._create_archive.assert_called_once_with( - "mock_archive_dir", "usb-test.sd-export", {"device": "usb-test"} - ) - - -def test__run_usb_test_raises_ExportError_if_not_USB_CONNECTED(mocker): - """ - Ensure ExportError is raised if _run_disk_test returns anything other than 'USB_CONNECTED'. - """ - export = Export() - export._create_archive = mocker.MagicMock(return_value="mock_archive_path") - export._export_archive = mocker.MagicMock(return_value="SOMETHING_OTHER_THAN_USB_CONNECTED") - - with pytest.raises(ExportError): - export._run_usb_test("mock_archive_dir") - - -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(temp_dir, "mock.sd-export", {}) - 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(temp_dir, "mock.sd-export", {}, [export_file.name]) - assert archive_path == os.path.join(temp_dir, "mock.sd-export") - assert os.path.exists(archive_path) # sanity check - - assert not os.path.exists(archive_path) - - -def test__create_archive_with_multiple_export_files(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 - archive_path = export._create_archive( - temp_dir, "mock.sd-export", {}, [export_file_one.name, export_file_two.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__export_archive(mocker): - """ - Ensure the subprocess call returns the expected output. - """ - export = Export() - mocker.patch("subprocess.check_output", return_value=b"USB_CONNECTED") - status = export._export_archive("mock.sd-export") - assert status == ExportStatus.USB_CONNECTED - - mocker.patch("subprocess.check_output", return_value=b"mock") - with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"): - export._export_archive("mock.sd-export") - - -def test__export_archive_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._export_archive("mock.sd-export") - - -def test__export_archive_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._export_archive("somefile; rm -rf ~") - - check_output.assert_called_once_with( - [ - "qrexec-client-vm", - "--", - "sd-devices", - "qubes.OpenInVM", - "/usr/lib/qubes/qopen-in-vm", - "--view-only", - "--", - "'somefile; rm -rf ~'", - ], - stderr=-2, - ) - - -def test__export_archive_success_on_empty_return_value(mocker): - """ - Ensure an error is not raised when qrexec call returns empty string, - (success state for `disk`, `print`, `printer-test`). - - When export behaviour changes so that all success states return a status - string, this test will no longer pass and should be rewritten. - """ - export = Export() - check_output = mocker.patch("subprocess.check_output", return_value=b"") - - result = export._export_archive("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, - ) - - assert result is None