diff --git a/client/securedrop_client/app.py b/client/securedrop_client/app.py index 8e9fe9e9c..8a6a990c4 100644 --- a/client/securedrop_client/app.py +++ b/client/securedrop_client/app.py @@ -34,7 +34,7 @@ from PyQt5.QtCore import Qt, QThread, QTimer from PyQt5.QtWidgets import QApplication, QMessageBox -from securedrop_client import __version__, export, state +from securedrop_client import __version__, state from securedrop_client.database import Database from securedrop_client.db import make_session_maker from securedrop_client.gui.main import Window @@ -240,16 +240,11 @@ def start_app(args, qt_args) -> NoReturn: # type: ignore[no-untyped-def] database = Database(session) app_state = state.State(database) - with threads(4) as [ - export_service_thread, + with threads(3) as [ sync_thread, main_queue_thread, file_download_queue_thread, ]: - export_service = export.getService() - export_service.moveToThread(export_service_thread) - export_service_thread.start() - gui = Window(app_state) controller = Controller( diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py index a99fdf48d..1a768bcf4 100644 --- a/client/securedrop_client/export.py +++ b/client/securedrop_client/export.py @@ -1,170 +1,334 @@ import json import logging import os -import subprocess +import shutil 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 tempfile import mkdtemp +from typing import Callable, List, Optional -from PyQt5.QtCore import QObject, pyqtBoundSignal, pyqtSignal, pyqtSlot +from PyQt5.QtCore import QObject, QProcess, pyqtSignal + +from securedrop_client.export_status import ExportError, ExportStatus logger = logging.getLogger(__name__) -class ExportError(Exception): - def __init__(self, status: "ExportStatus"): - self.status: "ExportStatus" = status +class Export(QObject): + """ + Interface for sending files to Export VM for transfer to a + disk drive or printed by a USB-connected printer. + Files are archived in a specified format, (see `export` README). -class ExportStatus(Enum): - # On the way to success - USB_CONNECTED = "USB_CONNECTED" - DISK_ENCRYPTED = "USB_ENCRYPTED" + A list of valid filepaths must be supplied. + """ - # Not too far from success - USB_NOT_CONNECTED = "USB_NOT_CONNECTED" - BAD_PASSPHRASE = "USB_BAD_PASSPHRASE" + _METADATA_FN = "metadata.json" - # 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" + _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"} -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. + _PRINT_FN = "print_archive.sd-export" + _PRINT_METADATA = {"device": "printer"} - Files are archived in a specified format, which you can learn more about in the README for the - securedrop-export repository. - """ + _DISK_FN = "archive.sd-export" + _DISK_METADATA = {"device": "disk"} + _DISK_ENCRYPTION_KEY_NAME = "encryption_key" + _DISK_EXPORT_DIR = "export_data" + + # Emit export states + export_state_changed = pyqtSignal(object) + + # Emit print states + print_preflight_check_succeeded = pyqtSignal(object) + print_succeeded = pyqtSignal(object) + + export_completed = pyqtSignal(object) + + print_preflight_check_failed = pyqtSignal(object) + print_failed = pyqtSignal(object) + + process = None # Optional[QProcess] + tmpdir = None # mkdtemp directory must be cleaned up when QProcess completes + + def run_printer_preflight_checks(self) -> None: + """ + Make sure the Export VM is started. + """ + logger.info("Beginning printer preflight check") + self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) - METADATA_FN = "metadata.json" + try: + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._PRINTER_PREFLIGHT_FN, + metadata=self._PRINTER_PREFLIGHT_METADATA, + ) + self._run_qrexec_export( + archive_path, self._on_print_preflight_complete, self._on_print_prefight_error + ) + except ExportError as e: + logger.error(f"Error creating archive: {e}") + self._on_print_prefight_error() - USB_TEST_FN = "usb-test.sd-export" - USB_TEST_METADATA = {"device": "usb-test"} + def run_export_preflight_checks(self) -> None: + """ + Run preflight check to verify that a valid USB device is connected. + """ + logger.debug("Beginning export preflight check") - PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export" - PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"} + try: + self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) - DISK_TEST_FN = "disk-test.sd-export" - DISK_TEST_METADATA = {"device": "disk-test"} + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._USB_TEST_FN, + metadata=self._USB_TEST_METADATA, + ) + # Emits status via on_process_completed() + self._run_qrexec_export( + archive_path, self._on_export_process_complete, self._on_export_process_error + ) + except ExportError: + logger.error("Export preflight check failed during archive creation") + self._on_export_process_error() - PRINT_FN = "print_archive.sd-export" - PRINT_METADATA = {"device": "printer"} + def export(self, filepaths: List[str], passphrase: Optional[str]) -> None: + """ + Bundle filepaths into a tarball and send to encrypted USB via qrexec, + optionally supplying a passphrase to unlock encrypted drives. + """ + try: + logger.debug(f"Begin exporting {len(filepaths)} item(s)") - DISK_FN = "archive.sd-export" - DISK_METADATA = {"device": "disk", "encryption_method": "luks"} - DISK_ENCRYPTION_KEY_NAME = "encryption_key" - DISK_EXPORT_DIR = "export_data" + # Edit metadata template to include passphrase + metadata = self._DISK_METADATA.copy() + if passphrase: + metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase - # 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) + self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) - printer_preflight_success = pyqtSignal() - printer_preflight_failure = pyqtSignal(object) - print_call_failure = pyqtSignal(object) - print_call_success = pyqtSignal() + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._DISK_FN, + metadata=metadata, + filepaths=filepaths, + ) - 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, + # Emits status through callbacks + self._run_qrexec_export( + archive_path, self._on_export_process_complete, self._on_export_process_error + ) + + except IOError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.export_state_changed.emit(ExportStatus.ERROR_EXPORT) + + # ExportStatus.ERROR_MISSING_FILES + except ExportError as err: + if err.status: + logger.error("Export failed while creating archive") + self.export_state_changed.emit(ExportError(err.status)) + else: + logger.error("Export failed while creating archive (no status supplied)") + self.export_state_changed.emit(ExportError(ExportStatus.ERROR_EXPORT)) + + def _run_qrexec_export( + self, archive_path: str, success_callback: Callable, error_callback: Callable ) -> None: - # 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. + Send the archive to the Export VM, where the archive will be processed. + Uses qrexec-client-vm (via QProcess). Results are emitted via the + `finished` signal; errors are reported via `onError`. User-defined callback + functions must be connected to those signals. Args: archive_path (str): The path to the archive to be processed. + success_callback, err_callback: Callback functions to connect to the success and + error signals of QProcess. They are included to accommodate the print functions, + which still use separate signals for print preflight, print, and error states, but + can be removed in favour of a generic success callback and error callback when the + print code is updated. + Any callbacks must call _cleanup_tmpdir() to remove the temporary directory that held + the files to be exported. + """ + # There are already talks of switching to a QVM-RPC implementation for unlocking devices + # and exporting files, so it's important to remember to shell-escape what we pass to the + # shell, even if for the time being we're already protected against shell injection via + # Python's implementation of subprocess, see + # https://docs.python.org/3/library/subprocess.html#security-considerations + qrexec = "/usr/bin/qrexec-client-vm" + args = [ + quote("--"), + quote("sd-devices"), + quote("qubes.OpenInVM"), + quote("/usr/lib/qubes/qopen-in-vm"), + quote("--view-only"), + quote("--"), + quote(archive_path), + ] + + self.process = QProcess() + + self.process.finished.connect(success_callback) + self.process.errorOccurred.connect(error_callback) + + self.process.start(qrexec, args) + + def _cleanup_tmpdir(self) -> None: + """ + Should be called in all qrexec completion callbacks. + """ + if self.tmpdir and os.path.exists(self.tmpdir): + shutil.rmtree(self.tmpdir) - Returns: - str: The export status returned from the Export VM processing script. + def _on_export_process_complete(self) -> None: + """ + Callback, handle and emit results from QProcess. Information + can be read from stdout/err. This callback will be triggered + if the QProcess exits with return code 0. + """ + self._cleanup_tmpdir() + # securedrop-export writes status to stderr + if self.process: + err = self.process.readAllStandardError() + + logger.debug(f"stderr: {err}") + + try: + result = err.data().decode("utf-8").strip() + if result: + logger.debug(f"Result is {result}") + # This is a bit messy, but make sure we are just taking the last line + # (no-op if no newline, since we already stripped whitespace above) + status_string = result.split("\n")[-1] + self.export_state_changed.emit(ExportStatus(status_string)) + + else: + logger.error("Export preflight returned empty result") + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) + + except ValueError as e: + logger.debug(f"Export preflight returned unexpected value: {e}") + logger.error("Export preflight returned unexpected value") + self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) + + def _on_export_process_error(self) -> None: + """ + Callback, called if QProcess cannot complete export. As with all such, the method + signature cannot change. + """ + self._cleanup_tmpdir() + if self.process: + err = self.process.readAllStandardError().data().decode("utf-8").strip() + + logger.error(f"Export process error: {err}") + self.export_state_changed.emit(ExportStatus.CALLED_PROCESS_ERROR) - Raises: - ExportError: Raised if (1) CalledProcessError is encountered, which can occur when - trying to start the Export VM when the USB device is not attached, or (2) when - the return code from `check_output` is not 0. + def _on_print_preflight_complete(self) -> None: + """ + Print preflight completion callback. + """ + self._cleanup_tmpdir() + if self.process: + output = self.process.readAllStandardError().data().decode("utf-8").strip() + try: + status = ExportStatus(output) + if status == ExportStatus.PRINT_PREFLIGHT_SUCCESS: + self.print_preflight_check_succeeded.emit(status) + logger.debug("Print preflight success") + else: + logger.debug(f"Print preflight failure ({status.value})") + self.print_preflight_check_failed.emit(ExportError(status)) + + except ValueError as error: + logger.debug(f"Print preflight check failed: {error}") + logger.error("Print preflight check failed") + self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) + + def _on_print_prefight_error(self) -> None: + """ + Print Preflight error callback. Occurs when the QProcess itself fails. + """ + self._cleanup_tmpdir() + if self.process: + err = self.process.readAllStandardError().data().decode("utf-8").strip() + logger.debug(f"Print preflight error: {err}") + self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) + + # Todo: not sure if we need to connect here, since the print dialog is managed by sd-devices. + # We can probably use the export callback. + def _on_print_success(self) -> None: + self._cleanup_tmpdir() + logger.debug("Print success") + self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS) + + def end_process(self) -> None: + """ + Tell QProcess to quit if it hasn't already. + Connected to the ExportWizard's `finished` signal, which fires + when the dialog is closed, cancelled, or finished. + """ + self._cleanup_tmpdir() + logger.debug("Terminate process") + if self.process is not None and not self.process.waitForFinished(50): + self.process.terminate() + + def _on_print_error(self) -> None: + """ + Error callback for print qrexec. + """ + self._cleanup_tmpdir() + if self.process: + err = self.process.readAllStandardError() + logger.debug(f"Print error: {err}") + else: + logger.error("Print error (stderr unavailable)") + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) + + def print(self, filepaths: List[str]) -> None: + """ + Bundle files at filepaths into tarball and send for + printing via qrexec. """ try: - # There are already talks of switching to a QVM-RPC implementation for unlocking devices - # and exporting files, so it's important to remember to shell-escape what we pass to the - # shell, even if for the time being we're already protected against shell injection via - # Python's implementation of subprocess, see - # https://docs.python.org/3/library/subprocess.html#security-considerations - output = subprocess.check_output( - [ - quote("qrexec-client-vm"), - quote("--"), - quote("sd-devices"), - quote("qubes.OpenInVM"), - quote("/usr/lib/qubes/qopen-in-vm"), - quote("--view-only"), - quote("--"), - quote(archive_path), - ], - stderr=subprocess.STDOUT, + logger.debug("Beginning print") + + self.tmpdir = mkdtemp() + os.chmod(self.tmpdir, 0o700) + archive_path = self._create_archive( + archive_dir=self.tmpdir, + archive_fn=self._PRINT_FN, + metadata=self._PRINT_METADATA, + filepaths=filepaths, ) - result = output.decode("utf-8").strip() - - # 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) + self._run_qrexec_export(archive_path, self._on_print_success, self._on_print_error) + + except IOError as e: + logger.error("Export failed") + logger.debug(f"Export failed: {e}") + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) + + # ExportStatus.ERROR_MISSING_FILES + except ExportError as err: + if err.status: + logger.error("Print failed while creating archive") + self.print_failed.emit(ExportError(err.status)) else: - 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) + logger.error("Print failed while creating archive (no status supplied)") + self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT)) def _create_archive( - cls, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] + self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = [] ) -> str: """ Create the archive to be sent to the Export VM. @@ -181,20 +345,35 @@ def _create_archive( archive_path = os.path.join(archive_dir, archive_fn) with tarfile.open(archive_path, "w:gz") as archive: - cls._add_virtual_file_to_archive(archive, cls.METADATA_FN, metadata) + self._add_virtual_file_to_archive(archive, self._METADATA_FN, metadata) # When more than one file is added to the archive, # extra care must be taken to prevent name collisions. is_one_of_multiple_files = len(filepaths) > 1 + missing_count = 0 for filepath in filepaths: - cls._add_file_to_archive( - archive, filepath, prevent_name_collisions=is_one_of_multiple_files - ) + if not (os.path.exists(filepath)): + missing_count += 1 + logger.debug( + f"'{filepath}' does not exist, and will not be included in archive" + ) + # Controller checks files and keeps a reference open during export, + # so this shouldn't be reachable + logger.warning("File not found at specified filepath, skipping") + else: + self._add_file_to_archive( + archive, filepath, prevent_name_collisions=is_one_of_multiple_files + ) + if missing_count == len(filepaths) and missing_count > 0: + # Context manager will delete archive even if an exception occurs + # since the archive is in a TemporaryDirectory + logger.error("Files were moved or missing") + raise ExportError(ExportStatus.ERROR_MISSING_FILES) return archive_path def _add_virtual_file_to_archive( - cls, archive: tarfile.TarFile, filename: str, filedata: dict + self, archive: tarfile.TarFile, filename: str, filedata: dict ) -> None: """ Add filedata to a stream of in-memory bytes and add these bytes to the archive. @@ -212,7 +391,7 @@ def _add_virtual_file_to_archive( archive.addfile(tarinfo, filedata_bytes) def _add_file_to_archive( - cls, archive: tarfile.TarFile, filepath: str, prevent_name_collisions: bool = False + self, archive: tarfile.TarFile, filepath: str, prevent_name_collisions: bool = False ) -> None: """ Add the file to the archive. When the archive is extracted, the file should exist in a @@ -223,7 +402,7 @@ def _add_file_to_archive( filepath: The path to the file that will be added to the supplied archive. """ filename = os.path.basename(filepath) - arcname = os.path.join(cls.DISK_EXPORT_DIR, filename) + arcname = os.path.join(self._DISK_EXPORT_DIR, filename) if prevent_name_collisions: (parent_path, _) = os.path.split(filepath) grand_parent_path, parent_name = os.path.split(parent_path) @@ -233,181 +412,3 @@ def _add_file_to_archive( arcname = os.path.join("export_data", parent_name, 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 - -# Store a singleton service instance. -_service = Service() - - -def resetService() -> None: - """Replaces the existing sngleton service instance by a new one. - - Get the instance by using getService(). - """ - global _service - _service = Service() - - -def getService() -> Service: - """All calls to this function return the same singleton service instance. - - Use resetService() to replace it by a new one.""" - return _service diff --git a/client/securedrop_client/export_status.py b/client/securedrop_client/export_status.py new file mode 100644 index 000000000..65a0c43e0 --- /dev/null +++ b/client/securedrop_client/export_status.py @@ -0,0 +1,64 @@ +from enum import Enum + + +class ExportError(Exception): + def __init__(self, status: "ExportStatus"): + self.status: "ExportStatus" = status + + +class ExportStatus(Enum): + """ + All possible strings returned by the qrexec calls to sd-devices. These values come from + `print/status.py` and `disk/status.py` in `securedrop-export` + and must only be changed in coordination with changes released in that component. + """ + + # Export + NO_DEVICE_DETECTED = "NO_DEVICE_DETECTED" + INVALID_DEVICE_DETECTED = "INVALID_DEVICE_DETECTED" # Multi partitioned, not encrypted, etc + MULTI_DEVICE_DETECTED = "MULTI_DEVICE_DETECTED" # Not currently supported + UKNOWN_DEVICE_DETECTED = "UNKNOWN_DEVICE_DETECTED" # Badly-formatted USB or VeraCrypt/TC + + DEVICE_LOCKED = "DEVICE_LOCKED" # One valid device detected, and it's locked + DEVICE_WRITABLE = ( + "DEVICE_WRITABLE" # One valid device detected, and it's unlocked (and mounted) + ) + + ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" + ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" + ERROR_MOUNT = "ERROR_MOUNT" # Unlocked but not mounted + + SUCCESS_EXPORT = "SUCCESS_EXPORT" + ERROR_EXPORT = "ERROR_EXPORT" # Could not write to disk + + # Export succeeds but drives were not properly closed + ERROR_EXPORT_CLEANUP = "ERROR_EXPORT_CLEANUP" + ERROR_UNMOUNT_VOLUME_BUSY = "ERROR_UNMOUNT_VOLUME_BUSY" + + DEVICE_ERROR = "DEVICE_ERROR" # Something went wrong while trying to check the device + + # Print + ERROR_MULTIPLE_PRINTERS_FOUND = "ERROR_MULTIPLE_PRINTERS_FOUND" + ERROR_PRINTER_NOT_FOUND = "ERROR_PRINTER_NOT_FOUND" + ERROR_PRINTER_NOT_SUPPORTED = "ERROR_PRINTER_NOT_SUPPORTED" + ERROR_PRINTER_DRIVER_UNAVAILABLE = "ERROR_PRINTER_DRIVER_UNAVAILABLE" + ERROR_PRINTER_INSTALL = "ERROR_PRINTER_INSTALL" + ERROR_PRINTER_URI = "ERROR_PRINTER_URI" # new + + # Print error + ERROR_PRINT = "ERROR_PRINT" + + # New + PRINT_PREFLIGHT_SUCCESS = "PRINTER_PREFLIGHT_SUCCESS" + TEST_SUCCESS = "PRINTER_TEST_SUCCESS" + PRINT_SUCCESS = "PRINTER_SUCCESS" + + ERROR_UNKNOWN = "ERROR_GENERIC" # Unknown printer error, backwards-compatible + + # Misc + CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR" + ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION" + UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS" + + # Client-side error only + ERROR_MISSING_FILES = "ERROR_MISSING_FILES" # All files meant for export are missing diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 5d8420b86..1fdd66003 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -10,20 +10,17 @@ from typing import Callable, Optional from PyQt5.QtCore import Qt, pyqtSlot -from PyQt5.QtWidgets import QAction, QDialog, QMenu +from PyQt5.QtWidgets import QAction, QApplication, QDialog, QMenu from securedrop_client import state from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source +from securedrop_client.export import Export from securedrop_client.gui.base import ModalDialog -from securedrop_client.gui.conversation import ExportDevice as ConversationExportDevice -from securedrop_client.gui.conversation import ExportDialog as ExportConversationDialog -from securedrop_client.gui.conversation import ( - ExportTranscriptDialog as ExportConversationTranscriptDialog, -) from securedrop_client.gui.conversation import ( PrintTranscriptDialog as PrintConversationTranscriptDialog, ) +from securedrop_client.gui.conversation.export import ExportWizard from securedrop_client.logic import Controller from securedrop_client.utils import safe_mkdir @@ -160,8 +157,6 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller) - self.triggered.connect(self._on_triggered) @pyqtSlot() @@ -189,8 +184,9 @@ def _on_triggered(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with open(file_path, "r") as f: + export = Export() dialog = PrintConversationTranscriptDialog( - self._export_device, TRANSCRIPT_FILENAME, str(file_path) + export, TRANSCRIPT_FILENAME, [str(file_path)] ) dialog.exec() @@ -212,15 +208,12 @@ def __init__( self.controller = controller self._source = source - self._export_device = ConversationExportDevice(controller) - self.triggered.connect(self._on_triggered) @pyqtSlot() def _on_triggered(self) -> None: """ - (Re-)generates the conversation transcript and opens a confirmation dialog to export it, - in the manner of the existing ExportFileDialog. + (Re-)generates the conversation transcript and opens export wizard. """ file_path = ( Path(self.controller.data_dir) @@ -241,10 +234,9 @@ def _on_triggered(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with open(file_path, "r") as f: - dialog = ExportConversationTranscriptDialog( - self._export_device, TRANSCRIPT_FILENAME, str(file_path) - ) - dialog.exec() + export_device = Export() + wizard = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)]) + wizard.exec() class ExportConversationAction(QAction): # pragma: nocover @@ -267,16 +259,13 @@ def __init__( self._source = source self._state = app_state - self._export_device = ConversationExportDevice(controller) - self.triggered.connect(self._on_triggered) @pyqtSlot() def _on_triggered(self) -> None: """ - (Re-)generates the conversation transcript and opens a confirmation dialog to export it - alongside all the (attached) files that are downloaded, in the manner - of the existing ExportFileDialog. + (Re-)generates the conversation transcript and opens export wizard to export it + alongside all the (attached) files that are downloaded. """ if self._state is not None: id = self._state.selected_conversation @@ -302,7 +291,7 @@ def _prepare_to_export(self) -> None: """ (Re-)generates the conversation transcript and opens a confirmation dialog to export it alongside all the (attached) files that are downloaded, in the manner - of the existing ExportFileDialog. + of the existing ExportWizard. """ transcript_location = ( Path(self.controller.data_dir) @@ -331,6 +320,7 @@ def _prepare_to_export(self) -> None: # out of scope, any pending file removal will be performed # by the operating system. with ExitStack() as stack: + export_device = Export() files = [ stack.enter_context(open(file_location, "r")) for file_location in file_locations ] @@ -341,12 +331,13 @@ def _prepare_to_export(self) -> None: else: summary = _("all files and transcript") - dialog = ExportConversationDialog( - self._export_device, + wizard = ExportWizard( + export_device, summary, [str(file_location) for file_location in file_locations], + QApplication.activeWindow(), ) - dialog.exec() + wizard.exec() def _on_confirmation_dialog_accepted(self) -> None: self._prepare_to_export() diff --git a/client/securedrop_client/gui/base/inputs.py b/client/securedrop_client/gui/base/inputs.py index 2fbbc49d5..3a5473e8a 100644 --- a/client/securedrop_client/gui/base/inputs.py +++ b/client/securedrop_client/gui/base/inputs.py @@ -16,7 +16,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -from PyQt5.QtWidgets import QDialog, QLineEdit +from PyQt5.QtWidgets import QLineEdit, QWidget class PasswordEdit(QLineEdit): @@ -24,7 +24,7 @@ class PasswordEdit(QLineEdit): A LineEdit with icons to show/hide password entries """ - def __init__(self, parent: QDialog) -> None: + def __init__(self, parent: QWidget) -> None: super().__init__(parent) self.setEchoMode(QLineEdit.Password) diff --git a/client/securedrop_client/gui/conversation/__init__.py b/client/securedrop_client/gui/conversation/__init__.py index 29142e98d..c9db19eec 100644 --- a/client/securedrop_client/gui/conversation/__init__.py +++ b/client/securedrop_client/gui/conversation/__init__.py @@ -3,9 +3,6 @@ """ # Import classes here to make possible to import them from securedrop_client.gui.conversation from .delete import DeleteConversationDialog # noqa: F401 -from .export import Device as ExportDevice # noqa: F401 -from .export import Dialog as ExportDialog # noqa: F401 -from .export import FileDialog as ExportFileDialog # noqa: F401 -from .export import PrintDialog as PrintFileDialog # noqa: F401 +from .export import ExportWizard as ExportWizard # noqa: F401 +from .export import PrintDialog # noqa: F401 from .export import PrintTranscriptDialog # noqa: F401 -from .export import TranscriptDialog as ExportTranscriptDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/__init__.py b/client/securedrop_client/gui/conversation/export/__init__.py index 7da54e94c..29f7a78c2 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,6 +1,3 @@ -from .device import Device # noqa: F401 -from .dialog import Dialog # noqa: F401 -from .file_dialog import FileDialog # noqa: F401 +from .export_wizard import ExportWizard # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 from .print_transcript_dialog import PrintTranscriptDialog # noqa: F401 -from .transcript_dialog import TranscriptDialog # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/device.py b/client/securedrop_client/gui/conversation/export/device.py deleted file mode 100644 index dfa3c69f9..000000000 --- a/client/securedrop_client/gui/conversation/export/device.py +++ /dev/null @@ -1,128 +0,0 @@ -import logging -import os -from typing import List - -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) -> None: - super().__init__() - - self._controller = controller - self._export_service = export.getService() - - self._export_service.connect_signals( - self.export_preflight_check_requested, - self.export_requested, - self.print_preflight_check_requested, - self.print_requested, - ) - - # Abstract the Export instance away from the GUI - self._export_service.preflight_check_call_success.connect( - self.export_preflight_check_succeeded - ) - self._export_service.preflight_check_call_failure.connect( - self.export_preflight_check_failed - ) - - self._export_service.export_usb_call_success.connect(self.export_succeeded) - self._export_service.export_usb_call_failure.connect(self.export_failed) - self._export_service.export_completed.connect(self.export_completed) - - self._export_service.printer_preflight_success.connect(self.print_preflight_check_succeeded) - self._export_service.printer_preflight_failure.connect(self.print_preflight_check_failed) - - self._export_service.print_call_failure.connect(self.print_failed) - self._export_service.print_call_success.connect(self.print_succeeded) - - def run_printer_preflight_checks(self) -> None: - """ - Run preflight checks to make sure the Export VM is configured correctly. - """ - logger.info("Running printer preflight check") - self.print_preflight_check_requested.emit() - - def run_export_preflight_checks(self) -> None: - """ - Run preflight checks to make sure the Export VM is configured correctly. - """ - logger.info("Running export preflight check") - self.export_preflight_check_requested.emit() - - def export_transcript(self, file_location: str, passphrase: str) -> None: - """ - Send the transcript specified by file_location to the Export VM. - """ - self.export_requested.emit([file_location], passphrase) - - def export_files(self, file_locations: List[str], passphrase: str) -> None: - """ - Send the files specified by file_locations to the Export VM. - """ - self.export_requested.emit(file_locations, passphrase) - - def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: - """ - Send the file specified by file_uuid to the Export VM with the user-provided passphrase for - unlocking the attached transfer device. If the file is missing, update the db so that - is_downloaded is set to False. - """ - file = self._controller.get_file(file_uuid) - file_location = file.location(self._controller.data_dir) - logger.info("Exporting file in: {}".format(os.path.dirname(file_location))) - - if not self._controller.downloaded_file_exists(file): - return - - self.export_requested.emit([file_location], passphrase) - - def print_transcript(self, file_location: str) -> None: - """ - Send the transcript specified by file_location to the Export VM. - """ - self.print_requested.emit([file_location]) - - def print_file(self, file_uuid: str) -> None: - """ - Send the file specified by file_uuid to the Export VM. If the file is missing, update the db - so that is_downloaded is set to False. - """ - file = self._controller.get_file(file_uuid) - file_location = file.location(self._controller.data_dir) - logger.info("Printing file in: {}".format(os.path.dirname(file_location))) - - if not self._controller.downloaded_file_exists(file): - return - - self.print_requested.emit([file_location]) diff --git a/client/securedrop_client/gui/conversation/export/dialog.py b/client/securedrop_client/gui/conversation/export/dialog.py deleted file mode 100644 index c71ebe2d8..000000000 --- a/client/securedrop_client/gui/conversation/export/dialog.py +++ /dev/null @@ -1,55 +0,0 @@ -from gettext import gettext as _ -from typing import List - -from PyQt5.QtCore import pyqtSlot - -from .device import Device -from .file_dialog import FileDialog - - -class Dialog(FileDialog): - """Adapts the dialog used to export files to allow exporting a conversation. - - - Adjust the init arguments to export multiple files. - - Adds a method to allow all those files to be exported. - - Overrides the two slots that handles the export action to call said method. - """ - - def __init__(self, device: Device, summary: str, file_locations: List[str]) -> None: - super().__init__(device, "", summary) - - self.file_locations = file_locations - - @pyqtSlot(bool) - def _export_files(self, checked: bool = False) -> None: - self.start_animate_activestate() - self.cancel_button.setEnabled(False) - self.passphrase_field.setDisabled(True) - self._device.export_files(self.file_locations, self.passphrase_field.text()) - - @pyqtSlot() - def _show_passphrase_request_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_files) - self.header.setText(self.passphrase_header) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.error_details.hide() - self.body.hide() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - @pyqtSlot() - def _show_passphrase_request_message_again(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_files) - self.header.setText(self.passphrase_header) - self.error_details.setText(self.passphrase_error_message) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.body.hide() - self.error_details.show() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py new file mode 100644 index 000000000..2043a5bd8 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -0,0 +1,229 @@ +import logging +from gettext import gettext as _ +from typing import List, Optional + +from pkg_resources import resource_string +from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtGui import QIcon, QKeyEvent +from PyQt5.QtWidgets import QAbstractButton # noqa: F401 +from PyQt5.QtWidgets import QApplication, QWidget, QWizard, QWizardPage + +from securedrop_client.export import Export +from securedrop_client.export_status import ExportStatus +from securedrop_client.gui.base import SecureQLabel +from securedrop_client.gui.conversation.export.export_wizard_constants import Pages +from securedrop_client.gui.conversation.export.export_wizard_page import ( + ErrorPage, + ExportWizardPage, + FinalPage, + InsertUSBPage, + PassphraseWizardPage, + PreflightPage, +) +from securedrop_client.resources import load_movie + +logger = logging.getLogger(__name__) + + +class ExportWizard(QWizard): + """ + Guide user through the steps of exporting to a USB. + """ + + PASSPHRASE_LABEL_SPACING = 0.5 + NO_MARGIN = 0 + FILENAME_WIDTH_PX = 260 + FILE_OPTIONS_FONT_SPACING = 1.6 + BUTTON_CSS = resource_string(__name__, "wizard_button.css").decode("utf-8") + WIZARD_CSS = resource_string(__name__, "wizard.css").decode("utf-8") + + # If the drive is unlocked, we don't need a passphrase; if we do need one, + # it's populated later. + PASS_PLACEHOLDER_FIELD = "" + + def __init__( + self, + export: Export, + summary_text: str, + filepaths: List[str], + parent: Optional[QWidget] = None, + ) -> None: + # Normally, the active window is the right parent, but if the wizard is launched + # via another element (a modal dialog, such as the "Some files may not be exported" + # modal), the parent will be the modal dialog and the wizard layout will be affected. + # In those cases we want to be able to specify a different parent. + if not parent: + parent = QApplication.activeWindow() + super().__init__(parent) + self.export = export + self.summary_text = SecureQLabel( + summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX + ).text() + self.filepaths = filepaths + self.current_status: Optional[ExportStatus] = None + + # Signal from qrexec command runner + self.export.export_state_changed.connect(self.on_status_received) + + # Sends cleanup signal to export if wizard is closed or completed. + # (Avoid orphaned QProcess) + self.finished.connect(self.export.end_process) + + self._style_buttons() + self._set_layout() + self._set_pages() + self.adjustSize() + + def keyPressEvent(self, event: QKeyEvent) -> None: + """ + Allow for keyboard navigation of wizard buttons. + """ + if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return: + if self.cancel_button.hasFocus(): + self.cancel_button.click() + elif self.back_button.hasFocus(): + self.back_button.click() + else: + self.next_button.click() + else: + super().keyPressEvent(event) + + def _style_buttons(self) -> None: + """ + Style QWizard buttons and connect "Next" button click event to + request_export slot. + """ + # Activestate animation + self.button_animation = load_movie("activestate-wide.gif") + self.button_animation.setScaledSize(QSize(32, 32)) + self.button_animation.frameChanged.connect(self._animate_activestate) + + # Buttons + self.next_button = self.button(QWizard.WizardButton.NextButton) # type: QAbstractButton + self.cancel_button = self.button(QWizard.WizardButton.CancelButton) # type: QAbstractButton + self.back_button = self.button(QWizard.WizardButton.BackButton) # type: QAbstractButton + self.finish_button = self.button(QWizard.WizardButton.FinishButton) # type: QAbstractButton + + self.next_button.setObjectName("QWizardButton_PrimaryButton") + self.next_button.setStyleSheet(self.BUTTON_CSS) + self.next_button.setMinimumSize(QSize(142, 40)) + self.next_button.setMaximumHeight(40) + self.next_button.setIconSize(QSize(21, 21)) + self.next_button.clicked.connect(self.request_export) + self.next_button.setFixedSize(QSize(142, 40)) + + self.cancel_button.setObjectName("QWizardButton_GenericButton") + self.cancel_button.setStyleSheet(self.BUTTON_CSS) + self.cancel_button.setMinimumSize(QSize(142, 40)) + self.cancel_button.setMaximumHeight(40) + self.cancel_button.setFixedSize(QSize(142, 40)) + + self.back_button.setObjectName("QWizardButton_GenericButton") + self.back_button.setStyleSheet(self.BUTTON_CSS) + self.back_button.setMinimumSize(QSize(142, 40)) + self.back_button.setMaximumHeight(40) + self.back_button.setFixedSize(QSize(142, 40)) + + self.finish_button.setObjectName("QWizardButton_GenericButton") + self.finish_button.setStyleSheet(self.BUTTON_CSS) + self.finish_button.setMinimumSize(QSize(142, 40)) + self.finish_button.setMaximumHeight(40) + self.finish_button.setFixedSize(QSize(142, 40)) + + self.setButtonText(QWizard.WizardButton.NextButton, _("CONTINUE")) + self.setButtonText(QWizard.WizardButton.CancelButton, _("CANCEL")) + self.setButtonText(QWizard.WizardButton.FinishButton, _("DONE")) + self.setButtonText(QWizard.WizardButton.BackButton, _("BACK")) + + def _animate_activestate(self) -> None: + self.next_button.setIcon(QIcon(self.button_animation.currentPixmap())) + + def _start_animate_activestate(self) -> None: + self.button_animation.start() + + def _stop_animate_activestate(self) -> None: + self.next_button.setIcon(QIcon()) + self.button_animation.stop() + + def _set_layout(self) -> None: + title = ("Export %(summary)s") % {"summary": self.summary_text} + self.setWindowTitle(title) + self.setObjectName("QWizard_export") + self.setStyleSheet(self.WIZARD_CSS) + self.setModal(False) + self.setOptions( + QWizard.NoBackButtonOnLastPage + | QWizard.NoCancelButtonOnLastPage + | QWizard.NoBackButtonOnStartPage + ) + + def _set_pages(self) -> None: + for id, page in [ + (Pages.PREFLIGHT, self._create_preflight()), + (Pages.ERROR, self._create_errorpage()), + (Pages.INSERT_USB, self._create_insert_usb()), + (Pages.UNLOCK_USB, self._create_passphrase_prompt()), + (Pages.EXPORT_DONE, self._create_done()), + ]: + self.setPage(id, page) + self.adjustSize() + + @pyqtSlot() + def request_export(self) -> None: + """ + Handler for "next" button clicks. Start animation and request export. + (The export proceeds only as far as it's able, which is why it's + possible to trigger the same method on every dialog page). + + The Preflight QWizardPage triggers the preflight check itself when + it is created, so there is no corresponding `request_export_preflight` + method. + """ + logger.debug("Request export") + # While we're waiting for the results to come back, stay on the same page. + # This prevents the dialog from briefly flashing one page and then + # advancing to a subsequent page (for example, flashing the "Insert a USB" + # page before detecting the USB and advancing to the "Unlock USB" page) + page = self.currentPage() + if isinstance(page, ExportWizardPage): + page.set_complete(False) + self._start_animate_activestate() + + # Registered fields let us access the passphrase field + # of the PassphraseRequestPage from the wizard parent + passphrase_untrusted = self.field("passphrase") + if str(passphrase_untrusted) is not None: + self.export.export(self.filepaths, str(passphrase_untrusted)) + else: + self.export.export(self.filepaths, self.PASS_PLACEHOLDER_FIELD) + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + """ + Receive status update from export process in order to update the animation. + Child QWizardPages also implement this listener in order to update their own UI and store + a reference to the current status. + + Adjusting the QWizard control flow based on ExportStatus is handled by each child page. + """ + # Release the page (page was held during "next" button click event) + page = self.currentPage() + if isinstance(page, ExportWizardPage): + page.set_complete(True) + self._stop_animate_activestate() + self.current_status = status + + def _create_preflight(self) -> QWizardPage: + return PreflightPage(self.export, self.summary_text) + + def _create_errorpage(self) -> QWizardPage: + return ErrorPage(self.export) + + def _create_insert_usb(self) -> QWizardPage: + return InsertUSBPage(self.export, self.summary_text) + + def _create_passphrase_prompt(self) -> QWizardPage: + return PassphraseWizardPage(self.export) + + def _create_done(self) -> QWizardPage: + return FinalPage(self.export) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py new file mode 100644 index 000000000..2c92da9f3 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -0,0 +1,55 @@ +from enum import IntEnum +from gettext import gettext as _ + +from securedrop_client.export_status import ExportStatus + +""" +Export wizard page ordering, human-readable status messages +""" + + +# Sequential list of pages (the enum value matters as a ranked ordering.) +# The reason the 'error' page is second is because the other pages have +# validation logic that means they can't be bypassed by QWizard::next. +# When we need to show an error, it's easier to go 'back' to the error +# page and set it to be a FinalPage than it is to try to skip the conditional +# pages. PyQt6 introduces behaviour that may deprecate this requirement. +class Pages(IntEnum): + PREFLIGHT = 0 + ERROR = 1 + INSERT_USB = 2 + UNLOCK_USB = 3 + EXPORT_DONE = 4 + + +# Human-readable status info +STATUS_MESSAGES = { + ExportStatus.NO_DEVICE_DETECTED: _("No device detected"), + ExportStatus.MULTI_DEVICE_DETECTED: _( + "Too many USB devices detected; please insert one supported device." + ), + ExportStatus.INVALID_DEVICE_DETECTED: _( + "Either the drive is not encrypted or there is something else wrong with it." + "
" + "If this is a VeraCrypt drive, please unlock it from within `sd-devices`, then try again." + ), + ExportStatus.DEVICE_WRITABLE: _("The device is ready for export."), + ExportStatus.DEVICE_LOCKED: _("The device is locked."), + ExportStatus.ERROR_UNLOCK_LUKS: _("The passphrase provided did not work. Please try again."), + ExportStatus.ERROR_MOUNT: _("Error mounting drive"), + ExportStatus.ERROR_EXPORT: _("Error during export"), + ExportStatus.ERROR_UNMOUNT_VOLUME_BUSY: _( + "Files were exported succesfully, but the USB device could not be unmounted." + ), + ExportStatus.ERROR_EXPORT_CLEANUP: _( + "Files were exported succesfully, but some temporary files remain on disk." + "Reboot to remove them." + ), + ExportStatus.SUCCESS_EXPORT: _("Export successful"), + ExportStatus.DEVICE_ERROR: _( + "Error encountered with this device. See your administrator for help." + ), + ExportStatus.ERROR_MISSING_FILES: _("Files were moved or missing and could not be exported."), + ExportStatus.CALLED_PROCESS_ERROR: _("Error encountered. Please contact support."), + ExportStatus.UNEXPECTED_RETURN_STATUS: _("Error encountered. Please contact support."), +} diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py new file mode 100644 index 000000000..e83ecb1bd --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -0,0 +1,501 @@ +import logging +from gettext import gettext as _ +from typing import Optional + +from pkg_resources import resource_string +from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtGui import QColor, QFont, QPixmap +from PyQt5.QtWidgets import ( + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, + QWizardPage, +) + +from securedrop_client.export import Export +from securedrop_client.export_status import ExportStatus +from securedrop_client.gui.base import PasswordEdit, SecureQLabel +from securedrop_client.gui.base.checkbox import SDCheckBox +from securedrop_client.gui.base.misc import SvgLabel +from securedrop_client.gui.conversation.export.export_wizard_constants import STATUS_MESSAGES, Pages +from securedrop_client.resources import load_movie + +logger = logging.getLogger(__name__) + + +class ExportWizardPage(QWizardPage): + """ + Base class for all export wizard pages. Individual pages must inherit + from this class to: + * Implement `on_status_received`, a listener that receives export + statuses in order to update the UI and store a reference to the + current state. + * Include additional layout items + * Implement dynamic ordering (i.e., if the next window varies + depending on the result of the previous action, in which case the + `nextId()` method must be overwritten) + * Implement custom validation (logic that prevents a user + from skipping to the next page until conditions are met) + + Every wizard page has: + * A header (page title) + * Body (instructions) + * Optional error_instructions (Additional text that is hidden but + appears on recoverable error to help the user advance to the next stage) + """ + + WIZARD_CSS = resource_string(__name__, "wizard.css").decode("utf-8") + ERROR_DETAILS_CSS = resource_string(__name__, "wizard_message.css").decode("utf-8") + + MARGIN = 40 + PASSPHRASE_LABEL_SPACING = 0.5 + NO_MARGIN = 0 + FILENAME_WIDTH_PX = 260 + + # All pages should show the error page if these errors are encountered + UNRECOVERABLE_ERRORS = [ + ExportStatus.ERROR_MOUNT, + ExportStatus.ERROR_EXPORT, + ExportStatus.ERROR_MISSING_FILES, + ExportStatus.DEVICE_ERROR, + ExportStatus.CALLED_PROCESS_ERROR, + ExportStatus.UNEXPECTED_RETURN_STATUS, + ] + + def __init__(self, export: Export, header: str, body: Optional[str]) -> None: + super().__init__() + self.export = export + self.header_text = header + self.body_text = body + self.status: Optional[ExportStatus] = None + self._is_complete = True # Won't override parent method unless explicitly set to False + + self.setLayout(self._build_layout()) + + # Listen for export updates from export. + # Pages will receive signals even if they are not the current active page. + self.export.export_state_changed.connect(self.on_status_received) + + def set_complete(self, is_complete: bool) -> None: + """ + Flag a page as being incomplete. (Disables Next button and prevents + user from advancing to next page) + """ + self._is_complete = is_complete + + def isComplete(self) -> bool: + return self._is_complete and super().isComplete() + + def _build_layout(self) -> QVBoxLayout: + """ + Create parent layout, draw elements, return parent layout + """ + self.setObjectName("QWizard_export_page") + self.setStyleSheet(self.WIZARD_CSS) + parent_layout = QVBoxLayout(self) + parent_layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) + + # Header for icon and task title + header_container = QWidget() + header_container_layout = QHBoxLayout() + header_container.setLayout(header_container_layout) + header_container.setContentsMargins( + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) + self.header_icon = SvgLabel("savetodisk.svg", svg_size=QSize(64, 64)) + self.header_icon.setObjectName("QWizard_header_icon") + self.header_spinner = QPixmap() + self.header_spinner_label = QLabel() + self.header_spinner_label.setObjectName("QWizard_header_spinner") + self.header_spinner_label.setMinimumSize(64, 64) + self.header_spinner_label.setVisible(False) + self.header_spinner_label.setPixmap(self.header_spinner) + self.header = QLabel() + self.header.setObjectName("QWizard_header") + header_container_layout.addWidget(self.header_icon) + header_container_layout.addWidget(self.header_spinner_label) + header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter) + header_container_layout.addStretch() + self.header_line = QWidget() + self.header_line.setObjectName("QWizard_header_line") + + # Body to display instructions and forms + self.body = QLabel() + self.body.setObjectName("QWizard_body") + self.body.setWordWrap(True) + self.body.setScaledContents(True) + + body_container = QWidget() + self.body_layout = QVBoxLayout() + self.body_layout.setContentsMargins( + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) + body_container.setLayout(self.body_layout) + self.body_layout.addWidget(self.body) + + # Widget for displaying error messages (hidden by default) + self.error_details = QLabel() + self.error_details.setObjectName("QWizard_error_details") + self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS) + self.error_details.setContentsMargins( + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) + self.error_details.setWordWrap(True) + self.error_details.hide() + + # Header animation + self.header_animation = load_movie("header_animation.gif") + self.header_animation.setScaledSize(QSize(64, 64)) + self.header_animation.frameChanged.connect(self.animate_header) + + # Populate text content + self.header.setText(self.header_text) + if self.body_text: + self.body.setText(self.body_text) + + # Add all the layout elements + parent_layout.addWidget(header_container) + parent_layout.addWidget(self.header_line) + parent_layout.addWidget(body_container) + parent_layout.addWidget(self.error_details) + parent_layout.addStretch() + + return parent_layout + + def animate_header(self) -> None: + self.header_spinner_label.setPixmap(self.header_animation.currentPixmap()) + + def start_animate_header(self) -> None: + self.header_icon.setVisible(False) + self.header_spinner_label.setVisible(True) + self.header_animation.start() + + def stop_animate_header(self) -> None: + self.header_icon.setVisible(True) + self.header_spinner_label.setVisible(False) + self.header_animation.stop() + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + raise NotImplementedError("Children must implement") + + def nextId(self) -> int: + """ + Override builtin QWizardPage nextId() method to create custom control flow. + """ + if self.status is not None: + if self.status in ( + ExportStatus.DEVICE_WRITABLE, + ExportStatus.SUCCESS_EXPORT, + ExportStatus.ERROR_UNMOUNT_VOLUME_BUSY, + ExportStatus.ERROR_EXPORT_CLEANUP, + ): + return Pages.EXPORT_DONE + elif self.status in ( + ExportStatus.DEVICE_LOCKED, + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.ERROR_UNLOCK_GENERIC, + ): + return Pages.UNLOCK_USB + elif self.status in ( + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ): + return Pages.INSERT_USB + elif self.status in self.UNRECOVERABLE_ERRORS: + return Pages.ERROR + + return super().nextId() + + def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None: + """ + Update page's content based on new status. + """ + if not status: + logger.error("Empty status value given to update_content") + status = ExportStatus.UNEXPECTED_RETURN_STATUS + + if should_show_hint: + message = STATUS_MESSAGES.get(status) + if message: + self.error_details.setText(message) + self.error_details.show() + else: + self.error_details.hide() + + +class PreflightPage(ExportWizardPage): + def __init__(self, export: Export, summary: str) -> None: + self._should_autoskip_preflight = False + self.summary = summary + header = _( + "Preparing to export:
" '{}' + ).format(summary) + body = _( + "

Understand the risks before exporting files

" + "Malware" + "
" + "This workstation lets you open files securely. If you open files on another " + "computer, any embedded malware may spread to your computer or network. If you are " + "unsure how to manage this risk, please print the file, or contact your " + "administrator." + "

" + "Anonymity" + "
" + "Files submitted by sources may contain information or hidden metadata that " + "identifies who they are. To protect your sources, please consider redacting files " + "before working with them on network-connected computers." + ) + + super().__init__(export, header=header, body=body) + self.start_animate_header() + + # Don't need preflight check every time, just when the wizard is initialized + if self.status is None: + self.set_complete(False) + self.completeChanged.emit() + self.export.run_export_preflight_checks() + + def set_should_autoskip_preflight(self, should_autoskip: bool) -> None: + """ + Provide setter for auto-advancing wizard past the Preflight page. + If True, as soon as a Status is available, the wizard will advance + to the appropriate page. + """ + self._should_autoskip_preflight = should_autoskip + + def should_autoskip_preflight(self) -> bool: + """ + Return True if Preflight page should be advanced automatically as soon as + a given status is available. + + This workaround exists to let users skip past the preflight page if they are + returned to it from a later page. This is required because in PyQt5, + QWizard cannot navigate to a specific page, meaning users who insert an + unlocked drive, then start the wizard, then encounter a problem are sent + "back" to this page rather than to the InsertUSBPage, since it wasn't in + their call stack. + + The autoskip combined with custom nextId logic in ExporWizardPage allows us + to emulate the desired behaviour. + """ + return self._should_autoskip_preflight + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + self.status = status + self.stop_animate_header() + header = _("Ready to export:
" '{}').format( + self.summary + ) + self.header.setText(header) + self.set_complete(True) + self.completeChanged.emit() + + if self.wizard() and isinstance(self.wizard().currentPage(), PreflightPage): + # Let users skip preflight screen if they have already seen it. The first time a status + # is received, autoskip is False, and a user has to manually click "Continue"; + # after that, it's True. + if self.should_autoskip_preflight(): + self.wizard().next() + else: + self.set_should_autoskip_preflight(True) + + +class ErrorPage(ExportWizardPage): + def __init__(self, export: Export): + header = _("Export Failed") + super().__init__(export, header=header, body=None) + + def isComplete(self) -> bool: + """ + Override isComplete() to always return False. This disables + the 'next' button on the error page and means users can + only go back to a previous page or exit the wizard. + """ + return False + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + self.status = status + + +class InsertUSBPage(ExportWizardPage): + def __init__(self, export: Export, summary: str) -> None: + self.no_device_hint = 0 + self.summary = summary + header = _("Ready to export:
" '{}').format( + summary + ) + body = _( + "Please insert one of the export drives provisioned specifically " + "for the SecureDrop Workstation." + "
" + "If you're using a VeraCrypt drive, unlock it manually before proceeding." + ) + super().__init__(export, header=header, body=body) + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + self.status = status + if self.wizard() and isinstance(self.wizard().currentPage(), InsertUSBPage): + logger.debug(f"InsertUSB received {status.value}") + if status in ( + ExportStatus.MULTI_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ExportStatus.DEVICE_WRITABLE, + ): + self.update_content(status, should_show_hint=True) + elif status == ExportStatus.NO_DEVICE_DETECTED: + if self.no_device_hint > 0: + self.update_content(status, should_show_hint=True) + self.no_device_hint += 1 + else: + # Hide the error hint, it visible, so that if the user navigates + # forward then back they don't see an unneeded hint + self.error_details.hide() + self.wizard().next() + + def validatePage(self) -> bool: + """ + Implement custom validation logic. + """ + if self.status is not None: + return self.status not in ( + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.INVALID_DEVICE_DETECTED, + ExportStatus.MULTI_DEVICE_DETECTED, + ) + else: + return super().isComplete() + + +class FinalPage(ExportWizardPage): + def __init__(self, export: Export) -> None: + header = _("Export successful") + body = _( + "Remember to be careful when working with files outside of your Workstation machine." + ) + super().__init__(export, header, body) + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + self.status = status + self.update_content(status) + + # The completeChanged signal alerts the page to recheck its completion status, + # which we need to signal since we have custom isComplete() logic + if self.wizard() and isinstance(self.wizard().currentPage(), FinalPage): + self.completeChanged.emit() + + def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None: + header = None + body = None + if status == ExportStatus.SUCCESS_EXPORT: + header = _("Export successful") + body = _( + "Remember to be careful when working with files " + "outside of your Workstation machine." + ) + elif status in (ExportStatus.ERROR_EXPORT_CLEANUP, ExportStatus.ERROR_UNMOUNT_VOLUME_BUSY): + header = _("Export sucessful, but drive was not locked") + body = STATUS_MESSAGES.get(status) + + else: + header = _("Working...") + + self.header.setText(header) + if body: + self.body.setText(body) + + def isComplete(self) -> bool: + """ + Override the default isComplete() implementation in order to disable the "Finish" + button while an export is taking place. (If the "Working...." header is being shown, + the export is still in progress and "Finish" should not be clickable.) + """ + if self.status: + return self.status not in ( + ExportStatus.DEVICE_WRITABLE, + ExportStatus.DEVICE_LOCKED, + ) + else: + return True + + def nextId(self) -> int: + """ + The final page should not have any custom nextId() logic. + Disable it to ensure the Finished button ("Done") is shown. + """ + return -1 + + +class PassphraseWizardPage(ExportWizardPage): + """ + Wizard page that includes a passphrase prompt field + """ + + def __init__(self, export: Export) -> None: + header = _("Enter passphrase for USB drive") + super().__init__(export, header, body=None) + + def _build_layout(self) -> QVBoxLayout: + layout = super()._build_layout() + + # Passphrase Form + self.passphrase_form = QWidget() + self.passphrase_form.setObjectName("QWizard_passphrase_form") + passphrase_form_layout = QVBoxLayout() + passphrase_form_layout.setContentsMargins( + self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN + ) + self.passphrase_form.setLayout(passphrase_form_layout) + passphrase_label = SecureQLabel(_("Passphrase")) + font = QFont() + font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING) + passphrase_label.setFont(font) + self.passphrase_field = PasswordEdit(self) + self.passphrase_form.setObjectName("QWizard_passphrase_form") + self.passphrase_field.setEchoMode(QLineEdit.Password) + effect = QGraphicsDropShadowEffect(self) + effect.setOffset(0, -1) + effect.setBlurRadius(4) + effect.setColor(QColor("#aaa")) + self.passphrase_field.setGraphicsEffect(effect) + + # Makes the password text accessible outside of this panel + self.registerField("passphrase*", self.passphrase_field) + + check = SDCheckBox() + check.checkbox.stateChanged.connect(self.passphrase_field.on_toggle_password_Action) + + passphrase_form_layout.addWidget(passphrase_label) + passphrase_form_layout.addWidget(self.passphrase_field) + passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight) + + layout.insertWidget(1, self.passphrase_form) + return layout + + @pyqtSlot(object) + def on_status_received(self, status: ExportStatus) -> None: + self.status = status + if self.wizard() and isinstance(self.wizard().currentPage(), PassphraseWizardPage): + logger.debug(f"Passphrase page received {status.value}") + if status in ( + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.ERROR_UNLOCK_GENERIC, + ): + self.update_content(status, should_show_hint=True) + else: + self.wizard().next() + + def validatePage(self) -> bool: + return self.status not in ( + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.ERROR_UNLOCK_GENERIC, + ExportStatus.DEVICE_LOCKED, + ) diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py deleted file mode 100644 index 4db886dd2..000000000 --- a/client/securedrop_client/gui/conversation/export/file_dialog.py +++ /dev/null @@ -1,273 +0,0 @@ -""" -A dialog that allows journalists to export sensitive files to a USB drive. -""" -from gettext import gettext as _ -from typing import Optional - -from pkg_resources import resource_string -from PyQt5.QtCore import QSize, Qt, pyqtSlot -from PyQt5.QtGui import QColor, QFont -from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QLineEdit, QVBoxLayout, QWidget - -from securedrop_client.export import ExportError, ExportStatus -from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel -from securedrop_client.gui.base.checkbox import SDCheckBox - -from .device import Device - - -class FileDialog(ModalDialog): - DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8") - - PASSPHRASE_LABEL_SPACING = 0.5 - NO_MARGIN = 0 - FILENAME_WIDTH_PX = 260 - - def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: - super().__init__() - self.setStyleSheet(self.DIALOG_CSS) - - self._device = device - self.file_uuid = file_uuid - self.file_name = SecureQLabel( - file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX - ).text() - # Hold onto the error status we receive from the Export VM - self.error_status: Optional[ExportStatus] = None - - # Connect device signals to slots - self._device.export_preflight_check_succeeded.connect( - self._on_export_preflight_check_succeeded - ) - self._device.export_preflight_check_failed.connect(self._on_export_preflight_check_failed) - self._device.export_succeeded.connect(self._on_export_succeeded) - self._device.export_failed.connect(self._on_export_failed) - - # Connect parent signals to slots - self.continue_button.setEnabled(False) - self.continue_button.clicked.connect(self._run_preflight) - - # Dialog content - self.starting_header = _( - "Preparing to export:
" '{}' - ).format(self.file_name) - self.ready_header = _( - "Ready to export:
" '{}' - ).format(self.file_name) - self.insert_usb_header = _("Insert encrypted USB drive") - self.passphrase_header = _("Enter passphrase for USB drive") - self.success_header = _("Export successful") - self.error_header = _("Export failed") - self.starting_message = _( - "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files on another " - "computer, any embedded malware may spread to your computer or network. If you are " - "unsure how to manage this risk, please print the file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - self.exporting_message = _("Exporting: {}").format(self.file_name) - self.insert_usb_message = _( - "Please insert one of the export drives provisioned specifically " - "for the SecureDrop Workstation." - ) - self.usb_error_message = _( - "Either the drive is not encrypted or there is something else wrong with it." - ) - self.passphrase_error_message = _("The passphrase provided did not work. Please try again.") - self.generic_error_message = _("See your administrator for help.") - self.success_message = _( - "Remember to be careful when working with files outside of your Workstation machine." - ) - - # Passphrase Form - self.passphrase_form = QWidget() - self.passphrase_form.setObjectName("FileDialog_passphrase_form") - passphrase_form_layout = QVBoxLayout() - passphrase_form_layout.setContentsMargins( - self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN - ) - self.passphrase_form.setLayout(passphrase_form_layout) - passphrase_label = SecureQLabel(_("Passphrase")) - font = QFont() - font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING) - passphrase_label.setFont(font) - self.passphrase_field = PasswordEdit(self) - self.passphrase_field.setEchoMode(QLineEdit.Password) - effect = QGraphicsDropShadowEffect(self) - effect.setOffset(0, -1) - effect.setBlurRadius(4) - effect.setColor(QColor("#aaa")) - self.passphrase_field.setGraphicsEffect(effect) - - check = SDCheckBox() - check.checkbox.stateChanged.connect(self.passphrase_field.on_toggle_password_Action) - - passphrase_form_layout.addWidget(passphrase_label) - passphrase_form_layout.addWidget(self.passphrase_field) - passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight) - self.body_layout.addWidget(self.passphrase_form) - self.passphrase_form.hide() - - self._show_starting_instructions() - self.start_animate_header() - self._run_preflight() - - def _show_starting_instructions(self) -> None: - self.header.setText(self.starting_header) - self.body.setText(self.starting_message) - self.adjustSize() - - def _show_passphrase_request_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_file) - self.header.setText(self.passphrase_header) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.error_details.hide() - self.body.hide() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - def _show_passphrase_request_message_again(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_file) - self.header.setText(self.passphrase_header) - self.error_details.setText(self.passphrase_error_message) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.body.hide() - self.error_details.show() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - def _show_success_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self.close) - self.header.setText(self.success_header) - self.continue_button.setText(_("DONE")) - self.body.setText(self.success_message) - self.cancel_button.hide() - self.error_details.hide() - self.passphrase_form.hide() - self.header_line.show() - self.body.show() - self.adjustSize() - - def _show_insert_usb_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._run_preflight) - self.header.setText(self.insert_usb_header) - self.continue_button.setText(_("CONTINUE")) - self.body.setText(self.insert_usb_message) - self.error_details.hide() - self.passphrase_form.hide() - self.header_line.show() - self.body.show() - self.adjustSize() - - def _show_insert_encrypted_usb_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._run_preflight) - self.header.setText(self.insert_usb_header) - self.error_details.setText(self.usb_error_message) - self.continue_button.setText(_("CONTINUE")) - self.body.setText(self.insert_usb_message) - self.passphrase_form.hide() - self.header_line.show() - self.error_details.show() - self.body.show() - self.adjustSize() - - def _show_generic_error_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self.close) - self.continue_button.setText(_("DONE")) - self.header.setText(self.error_header) - self.body.setText( # nosemgrep: semgrep.untranslated-gui-string - "{}: {}".format(self.error_status, self.generic_error_message) - ) - self.error_details.hide() - self.passphrase_form.hide() - self.header_line.show() - self.body.show() - self.adjustSize() - - @pyqtSlot() - def _run_preflight(self) -> None: - self._device.run_export_preflight_checks() - - @pyqtSlot() - def _export_file(self, checked: bool = False) -> None: - self.start_animate_activestate() - self.cancel_button.setEnabled(False) - self.passphrase_field.setDisabled(True) - self._device.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text()) - - @pyqtSlot() - def _on_export_preflight_check_succeeded(self) -> None: - # If the continue button is disabled then this is the result of a background preflight check - self.stop_animate_header() - self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) - self.header.setText(self.ready_header) - if not self.continue_button.isEnabled(): - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._show_passphrase_request_message) - self.continue_button.setEnabled(True) - self.continue_button.setFocus() - return - - self._show_passphrase_request_message() - - @pyqtSlot(object) - def _on_export_preflight_check_failed(self, error: ExportError) -> None: - self.stop_animate_header() - self.header_icon.update_image("savetodisk.svg", QSize(64, 64)) - self._update_dialog(error.status) - - @pyqtSlot() - def _on_export_succeeded(self) -> None: - self.stop_animate_activestate() - self._show_success_message() - - @pyqtSlot(object) - def _on_export_failed(self, error: ExportError) -> None: - self.stop_animate_activestate() - self.cancel_button.setEnabled(True) - self.passphrase_field.setDisabled(False) - self._update_dialog(error.status) - - def _update_dialog(self, error_status: ExportStatus) -> None: - self.error_status = error_status - # If the continue button is disabled then this is the result of a background preflight check - if not self.continue_button.isEnabled(): - self.continue_button.clicked.disconnect() - if self.error_status == ExportStatus.BAD_PASSPHRASE: - self.continue_button.clicked.connect(self._show_passphrase_request_message_again) - elif self.error_status == ExportStatus.USB_NOT_CONNECTED: - self.continue_button.clicked.connect(self._show_insert_usb_message) - elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR: - self.continue_button.clicked.connect(self._show_insert_encrypted_usb_message) - else: - self.continue_button.clicked.connect(self._show_generic_error_message) - - self.continue_button.setEnabled(True) - self.continue_button.setFocus() - else: - if self.error_status == ExportStatus.BAD_PASSPHRASE: - self._show_passphrase_request_message_again() - elif self.error_status == ExportStatus.USB_NOT_CONNECTED: - self._show_insert_usb_message() - elif self.error_status == ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR: - self._show_insert_encrypted_usb_message() - else: - self._show_generic_error_message() diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py index e3ce34ba2..11ab07c53 100644 --- a/client/securedrop_client/gui/conversation/export/print_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_dialog.py @@ -1,22 +1,22 @@ from gettext import gettext as _ -from typing import Optional +from typing import List, Optional from PyQt5.QtCore import QSize, pyqtSlot -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.base import ModalDialog, SecureQLabel -from .device import Device +from ....export import Export class PrintDialog(ModalDialog): FILENAME_WIDTH_PX = 260 - def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: + def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None: super().__init__() self._device = device - self.file_uuid = file_uuid + self.filepaths = filepaths self.file_name = SecureQLabel( file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() @@ -29,6 +29,10 @@ def __init__(self, device: Device, file_uuid: str, file_name: str) -> None: ) self._device.print_preflight_check_failed.connect(self._on_print_preflight_check_failed) + # For now, connect both success and error signals to close the print dialog. + self._device.print_succeeded.connect(self._on_print_complete) + self._device.print_failed.connect(self._on_print_complete) + # Connect parent signals to slots self.continue_button.setEnabled(False) self.continue_button.clicked.connect(self._run_preflight) @@ -94,11 +98,20 @@ def _run_preflight(self) -> None: @pyqtSlot() def _print_file(self) -> None: - self._device.print_file(self.file_uuid) - self.close() + self._device.print(self.filepaths) @pyqtSlot() - def _on_print_preflight_check_succeeded(self) -> None: + def _on_print_complete(self) -> None: + """ + Send a signal to close the print dialog. + """ + self.close() + + @pyqtSlot(object) + def _on_print_preflight_check_succeeded(self, status: ExportStatus) -> None: + # We don't use the ExportStatus for now for "success" status, + # but in future work we will migrate towards a wizard-style dialog, where + # success and intermediate status values all use the same PyQt slot. # If the continue button is disabled then this is the result of a background preflight check self.stop_animate_header() self.header_icon.update_image("printer.svg", svg_size=QSize(64, 64)) @@ -120,7 +133,7 @@ def _on_print_preflight_check_failed(self, error: ExportError) -> None: # If the continue button is disabled then this is the result of a background preflight check if not self.continue_button.isEnabled(): self.continue_button.clicked.disconnect() - if error.status == ExportStatus.PRINTER_NOT_FOUND: + if error.status == ExportStatus.ERROR_PRINTER_NOT_FOUND: self.continue_button.clicked.connect(self._show_insert_usb_message) else: self.continue_button.clicked.connect(self._show_generic_error_message) @@ -128,7 +141,7 @@ def _on_print_preflight_check_failed(self, error: ExportError) -> None: self.continue_button.setEnabled(True) self.continue_button.setFocus() else: - if error.status == ExportStatus.PRINTER_NOT_FOUND: + if error.status == ExportStatus.ERROR_PRINTER_NOT_FOUND: self._show_insert_usb_message() else: self._show_generic_error_message() diff --git a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py index 9f47735ce..b6508fa06 100644 --- a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py +++ b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py @@ -1,8 +1,10 @@ +from typing import List + from PyQt5.QtCore import QSize, pyqtSlot from securedrop_client.gui.conversation.export import PrintDialog -from .device import Device +from ....export import Export class PrintTranscriptDialog(PrintDialog): @@ -13,13 +15,15 @@ class PrintTranscriptDialog(PrintDialog): - Overrides the slot that handles the printing action to call said method. """ - def __init__(self, device: Device, file_name: str, transcript_location: str) -> None: - super().__init__(device, "", file_name) + def __init__(self, device: Export, file_name: str, filepath: List[str]) -> None: + super().__init__(device, file_name, filepath) - self.transcript_location = transcript_location + # List might seem like an odd choice for this, but this is on the + # way to standardizing one export/print dialog that can send multiple items + self.transcript_location = filepath def _print_transcript(self) -> None: - self._device.print_transcript(self.transcript_location) + self._device.print(self.transcript_location) self.close() @pyqtSlot() diff --git a/client/securedrop_client/gui/conversation/export/transcript_dialog.py b/client/securedrop_client/gui/conversation/export/transcript_dialog.py deleted file mode 100644 index 331819707..000000000 --- a/client/securedrop_client/gui/conversation/export/transcript_dialog.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -A dialog that allows journalists to export sensitive files to a USB drive. -""" -from gettext import gettext as _ - -from PyQt5.QtCore import pyqtSlot - -from .device import Device -from .file_dialog import FileDialog - - -class TranscriptDialog(FileDialog): - """Adapts the dialog used to export files to allow exporting a conversation transcript. - - - Adjust the init arguments to the needs of conversation transcript export. - - Adds a method to allow a transcript to be exported. - - Overrides the two slots that handles the export action to call said method. - """ - - def __init__(self, device: Device, file_name: str, transcript_location: str) -> None: - super().__init__(device, "", file_name) - - self.transcript_location = transcript_location - - def _export_transcript(self, checked: bool = False) -> None: - self.start_animate_activestate() - self.cancel_button.setEnabled(False) - self.passphrase_field.setDisabled(True) - self._device.export_transcript(self.transcript_location, self.passphrase_field.text()) - - @pyqtSlot() - def _show_passphrase_request_message(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_transcript) - self.header.setText(self.passphrase_header) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.error_details.hide() - self.body.hide() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() - - @pyqtSlot() - def _show_passphrase_request_message_again(self) -> None: - self.continue_button.clicked.disconnect() - self.continue_button.clicked.connect(self._export_transcript) - self.header.setText(self.passphrase_header) - self.error_details.setText(self.passphrase_error_message) - self.continue_button.setText(_("SUBMIT")) - self.header_line.hide() - self.body.hide() - self.error_details.show() - self.passphrase_field.setFocus() - self.passphrase_form.show() - self.adjustSize() diff --git a/client/securedrop_client/gui/conversation/export/dialog.css b/client/securedrop_client/gui/conversation/export/wizard.css similarity index 71% rename from client/securedrop_client/gui/conversation/export/dialog.css rename to client/securedrop_client/gui/conversation/export/wizard.css index 1814ed903..580ded204 100644 --- a/client/securedrop_client/gui/conversation/export/dialog.css +++ b/client/securedrop_client/gui/conversation/export/wizard.css @@ -1,12 +1,16 @@ -#ModalDialog { +#QWizard_export { min-width: 800px; max-width: 800px; - min-height: 300px; + min-height: 500px; max-height: 800px; - background-color: #fff; + background: #ffffff; } -#ModalDialog_header_icon, #ModalDialog_header_spinner { +#QWizard_export_page { + background: #ffffff; +} + +#QWizard_header_icon, #QWizard_header_spinner { min-width: 80px; max-width: 80px; min-height: 64px; @@ -14,7 +18,7 @@ margin: 0px 0px 0px 30px; } -#ModalDialog_header { +#QWizard_header { min-height: 68px; max-height: 68px; margin: 0; @@ -24,7 +28,7 @@ color: #2a319d; } -#ModalDialog_header_line { +#QWizard_header_line { margin: 0; min-height: 2px; max-height: 2px; @@ -32,7 +36,7 @@ border: none; } -#ModalDialog_body { +#QWizard_body { font-family: 'Montserrat'; font-size: 16px; color: #302aa3; @@ -40,7 +44,7 @@ padding: 0; } -#ModalDialogConfirmation { +#QWizardConfirmation { font-family: 'Montserrat'; font-size: 16px; font-weight: 600; @@ -48,15 +52,8 @@ margin: 0; } -#ModalDialog.dangerous #ModalDialogConfirmation { - color: #ff3366; -} - -#ModalDialog_button_box { - border: 1px solid #ff0000; -} -#ModalDialog_button_box QPushButton { +#QWizard_button_box QWizardButton { margin: 0px 0px 0px 12px; height: 40px; margin: 0; @@ -69,12 +66,12 @@ color: #2a319d; } -#ModalDialog_button_box QPushButton::disabled { +#QWizard_button_box QWizardButton::disabled { border: 2px solid rgba(42, 49, 157, 0.4); color: rgba(42, 49, 157, 0.4); } -#FileDialog_passphrase_form QLabel { +#QWizard_passphrase_form QLabel { font-family: 'Montserrat'; font-weight: 500; font-size: 12px; @@ -82,7 +79,7 @@ padding-top: 6px; } -#FileDialog_passphrase_form QLineEdit { +#QWizard_passphrase_form QLineEdit { border-radius: 0px; min-height: 30px; max-height: 30px; diff --git a/client/securedrop_client/gui/conversation/export/wizard_button.css b/client/securedrop_client/gui/conversation/export/wizard_button.css new file mode 100644 index 000000000..5925e5412 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/wizard_button.css @@ -0,0 +1,47 @@ +#QWizardButton_PrimaryButton { + background-color: #2a319d; + color: #ffffff; + border: 2px solid #2a319d; + font-family: 'Montserrat'; + font-weight: 500; + margin: 0px; + font-size: 15px; + padding: 11px 18px; + max-height: 40px; + min-height: 40px; +} + +#QWizardButton_PrimaryButton:hover { + background-color: #05a6fe; + border: 2px solid #05a6fe; +} + +#QWizardButton_PrimaryButton:disabled { + border: 2px solid rgba(42, 49, 157, 0.4); + background-color: 2px solid rgba(42, 49, 157, 0.4); + color: #c2c4e3; +} + +#QWizardButton_GenericButton { + background-color: #ffffff; + color: #2a319d; + border: 2px solid #2a319d; + margin: 0; + font-family: 'Montserrat'; + font-weight: 500; + font-size: 15px; + padding: 11px 18px; + max-height: 40px; + min-height: 40px; +} + +#QWizardButton_GenericButton:hover { + color: #05a6fe; + border: 2px solid #05a6fe; +} + +#QWizardButton_GenericButton:disabled { + border: 2px solid #c2c4e3; + background-color: #c2c4e3; + color: #e1e2f1; +} diff --git a/client/securedrop_client/gui/conversation/export/wizard_message.css b/client/securedrop_client/gui/conversation/export/wizard_message.css new file mode 100644 index 000000000..e3bf33ef3 --- /dev/null +++ b/client/securedrop_client/gui/conversation/export/wizard_message.css @@ -0,0 +1,13 @@ +#QWizard_error_details { + margin: 0px 40px 0px 36px; + font-family: 'Montserrat'; + font-size: 16px; + color: #ff0064; +} + +#QWizard_error_details:active { + margin: 0px 40px 0px 36px; + font-family: 'Montserrat'; + font-size: 16px; + color: #ff66c4; +} diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 85364a594..714fef467 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -70,6 +70,7 @@ Source, User, ) +from securedrop_client.export import Export from securedrop_client.gui import conversation from securedrop_client.gui.actions import ( DeleteConversationAction, @@ -2255,8 +2256,6 @@ def __init__( self.controller = controller - self._export_device = conversation.ExportDevice(controller) - self.file = self.controller.get_file(file_uuid) self.uuid = file_uuid self.index = index @@ -2455,13 +2454,18 @@ def _on_export_clicked(self) -> None: """ Called when the export button is clicked. """ + file_location = self.file.location(self.controller.data_dir) + if not self.controller.downloaded_file_exists(self.file): + logger.debug("Clicked export but file not downloaded") return - self.export_dialog = conversation.ExportFileDialog( - self._export_device, self.uuid, self.file.filename + export_device = Export() + + self.export_wizard = conversation.ExportWizard( + export_device, self.file.filename, [file_location] ) - self.export_dialog.show() + self.export_wizard.show() @pyqtSlot() def _on_print_clicked(self) -> None: @@ -2469,9 +2473,14 @@ def _on_print_clicked(self) -> None: Called when the print button is clicked. """ if not self.controller.downloaded_file_exists(self.file): + logger.debug("Clicked print but file not downloaded") return - dialog = conversation.PrintFileDialog(self._export_device, self.uuid, self.file.filename) + filepath = self.file.location(self.controller.data_dir) + + export_device = Export() + + dialog = conversation.PrintDialog(export_device, self.file.filename, [filepath]) dialog.exec() def _on_left_click(self) -> None: diff --git a/client/securedrop_client/locale/messages.pot b/client/securedrop_client/locale/messages.pot index 2710d75bb..0155f5cb1 100644 --- a/client/securedrop_client/locale/messages.pot +++ b/client/securedrop_client/locale/messages.pot @@ -270,49 +270,79 @@ msgid_plural "{message_count} messages" msgstr[0] "" msgstr[1] "" -msgid "SUBMIT" +msgid "DONE" msgstr "" -msgid "Preparing to export:
{}" +msgid "BACK" msgstr "" -msgid "Ready to export:
{}" +msgid "No device detected" msgstr "" -msgid "Insert encrypted USB drive" +msgid "Too many USB devices detected; please insert one supported device." msgstr "" -msgid "Enter passphrase for USB drive" +msgid "Either the drive is not encrypted or there is something else wrong with it.
If this is a VeraCrypt drive, please unlock it from within `sd-devices`, then try again." +msgstr "" + +msgid "The device is ready for export." +msgstr "" + +msgid "The device is locked." +msgstr "" + +msgid "The passphrase provided did not work. Please try again." +msgstr "" + +msgid "Error mounting drive" +msgstr "" + +msgid "Error during export" +msgstr "" + +msgid "Files were exported succesfully, but the USB device could not be unmounted." +msgstr "" + +msgid "Files were exported succesfully, but some temporary files remain on disk.Reboot to remove them." msgstr "" msgid "Export successful" msgstr "" -msgid "Export failed" +msgid "Error encountered with this device. See your administrator for help." msgstr "" -msgid "

Understand the risks before exporting files

Malware
This workstation lets you open files securely. If you open files on another computer, any embedded malware may spread to your computer or network. If you are unsure how to manage this risk, please print the file, or contact your administrator.

Anonymity
Files submitted by sources may contain information or hidden metadata that identifies who they are. To protect your sources, please consider redacting files before working with them on network-connected computers." +msgid "Files were moved or missing and could not be exported." msgstr "" -msgid "Exporting: {}" +msgid "Error encountered. Please contact support." msgstr "" -msgid "Please insert one of the export drives provisioned specifically for the SecureDrop Workstation." +msgid "Preparing to export:
{}" msgstr "" -msgid "Either the drive is not encrypted or there is something else wrong with it." +msgid "

Understand the risks before exporting files

Malware
This workstation lets you open files securely. If you open files on another computer, any embedded malware may spread to your computer or network. If you are unsure how to manage this risk, please print the file, or contact your administrator.

Anonymity
Files submitted by sources may contain information or hidden metadata that identifies who they are. To protect your sources, please consider redacting files before working with them on network-connected computers." msgstr "" -msgid "The passphrase provided did not work. Please try again." +msgid "Ready to export:
{}" msgstr "" -msgid "See your administrator for help." +msgid "Export Failed" +msgstr "" + +msgid "Please insert one of the export drives provisioned specifically for the SecureDrop Workstation.
If you're using a VeraCrypt drive, unlock it manually before proceeding." msgstr "" msgid "Remember to be careful when working with files outside of your Workstation machine." msgstr "" -msgid "DONE" +msgid "Export sucessful, but drive was not locked" +msgstr "" + +msgid "Working..." +msgstr "" + +msgid "Enter passphrase for USB drive" msgstr "" msgid "Preparing to print:
{}" @@ -333,6 +363,9 @@ msgstr "" msgid "Please connect your printer to a USB port." msgstr "" +msgid "See your administrator for help." +msgstr "" + msgid "YES, DELETE ENTIRE SOURCE ACCOUNT" msgstr "" diff --git a/client/tests/conftest.py b/client/tests/conftest.py index 9266c0957..40bde3b51 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -10,7 +10,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMainWindow -from securedrop_client import export, state +from securedrop_client import state from securedrop_client.app import configure_locale_and_language from securedrop_client.config import Config from securedrop_client.db import ( @@ -22,6 +22,8 @@ Source, make_session_maker, ) +from securedrop_client.export import Export +from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.main import Window from securedrop_client.logic import Controller @@ -47,8 +49,9 @@ TIME_CLICK_ACTION = 1000 TIME_RENDER_SOURCE_LIST = 20000 TIME_RENDER_CONV_VIEW = 1000 -TIME_RENDER_EXPORT_DIALOG = 1000 +TIME_RENDER_EXPORT_WIZARD = 1000 TIME_FILE_DOWNLOAD = 5000 +TIME_KEYCLICK_ACTION = 5000 @pytest.fixture(scope="function") @@ -76,9 +79,9 @@ def lang(request): def print_dialog(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) - dialog = conversation.PrintFileDialog(export_device, "file_UUID", "file123.jpg") + dialog = conversation.PrintDialog(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @@ -87,49 +90,49 @@ def print_dialog(mocker, homedir): def print_transcript_dialog(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) dialog = conversation.PrintTranscriptDialog( - export_device, "transcript.txt", "some/path/transcript.txt" + export_device, "transcript.txt", ["some/path/transcript.txt"] ) yield dialog @pytest.fixture(scope="function") -def export_dialog(mocker, homedir): +def export_wizard_multifile(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) - dialog = conversation.ExportDialog( + wizard = conversation.ExportWizard( export_device, "3 files", ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"], ) - yield dialog + yield wizard @pytest.fixture(scope="function") -def export_file_dialog(mocker, homedir): +def export_wizard(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) - dialog = conversation.ExportFileDialog(export_device, "file_UUID", "file123.jpg") + dialog = conversation.ExportWizard(export_device, "file123.jpg", ["/mock/path/to/file"]) yield dialog @pytest.fixture(scope="function") -def export_transcript_dialog(mocker, homedir): +def export_transcript_wizard(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) - export_device = mocker.MagicMock(spec=conversation.ExportDevice) + export_device = mocker.MagicMock(spec=Export) - dialog = conversation.ExportTranscriptDialog( - export_device, "transcript.txt", "/some/path/transcript.txt" + dialog = conversation.ExportWizard( + export_device, "transcript.txt", ["/some/path/transcript.txt"] ) yield dialog @@ -168,16 +171,122 @@ def homedir(i18n): @pytest.fixture(scope="function") -def mock_export_service(): - """An export service that assumes the Qubes RPC calls are successful and skips them.""" - export_service = export.Service() - # Ensure the export_service doesn't rely on Qubes OS: - export_service._run_disk_test = lambda dir: None - export_service._run_usb_test = lambda dir: None - export_service._run_disk_export = lambda dir, paths, passphrase: None - export_service._run_printer_preflight = lambda dir: None - export_service._run_print = lambda dir, paths: None - return export_service +def mock_export_locked(): + """ + Represents the following scenario: + * No USB + * Export wizard launched + * USB inserted + * Passphrase successfully entered on first attempt (and export suceeeds) + """ + device = Export() + status = iter( + [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_LOCKED, + ExportStatus.SUCCESS_EXPORT, + ] + ) + + def get_status() -> ExportStatus: + return next(status) + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.NO_DEVICE_DETECTED + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = lambda filepaths, passphrase: device.export_state_changed.emit(get_status()) + + return device + + +@pytest.fixture(scope="function") +def mock_export_unlocked(): + """ + Represents the following scenario: + * USB already inserted and unlocked by the user + * Export wizard launched + * Export succeeds + """ + device = Export() + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.DEVICE_WRITABLE + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = lambda filepaths, passphrase: device.export_state_changed.emit( + ExportStatus.SUCCESS_EXPORT + ) + + return device + + +@pytest.fixture(scope="function") +def mock_export_no_usb_then_bad_passphrase(): + """ + Represents the following scenario: + * Export wizard launched + * Locked USB detected + * Mistyped Passphrase + * Correct passphrase + * Export succeeds + """ + device = Export() + status = iter( + [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_LOCKED, + ExportStatus.ERROR_UNLOCK_LUKS, + ExportStatus.SUCCESS_EXPORT, + ] + ) + + def get_status() -> ExportStatus: + return next(status) + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.NO_DEVICE_DETECTED + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = lambda filepaths, passphrase: device.export_state_changed.emit(get_status()) + + return device + + +@pytest.fixture(scope="function") +def mock_export_fail_early(): + """ + Represents the following scenario: + * No USB inserted + * Export wizard launched + * Locked USB inserted + * Unrecoverable error before export happens + (eg, mount error) + """ + device = Export() + # why does it need an extra ERROR_MOUNT report? + status = iter( + [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_LOCKED, + ExportStatus.ERROR_MOUNT, + ] + ) + + def get_status() -> ExportStatus: + return next(status) + + device.run_export_preflight_checks = lambda: device.export_state_changed.emit( + ExportStatus.NO_DEVICE_DETECTED + ) + device.run_printer_preflight_checks = lambda: None + device.print = lambda filepaths: None + device.export = lambda filepaths, passphrase: device.export_state_changed.emit(get_status()) + + return device @pytest.fixture(scope="function") diff --git a/client/tests/functional/cassettes/test_export_file_dialog.yaml b/client/tests/functional/cassettes/test_export_file_dialog.yaml deleted file mode 100644 index 9b62d31d1..000000000 --- a/client/tests/functional/cassettes/test_export_file_dialog.yaml +++ /dev/null @@ -1,1685 +0,0 @@ -interactions: -- request: - body: '{"username": "journalist", "passphrase": "correct horse battery staple - profanity oil chewy", "one_time_code": "123456"}' - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Connection: - - keep-alive - Content-Length: - - '119' - User-Agent: - - python-requests/2.26.0 - method: POST - uri: http://localhost:8081/api/v1/token - response: - body: - string: "{\n \"expiration\": \"2022-01-20T07:09:09.326384Z\", \n \"journalist_first_name\": - null, \n \"journalist_last_name\": null, \n \"journalist_uuid\": \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"token\": \"eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo\"\n}\n" - headers: - Content-Length: - - '317' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/users - response: - body: - string: "{\n \"users\": [\n {\n \"first_name\": null, \n \"last_name\": - null, \n \"username\": \"journalist\", \n \"uuid\": \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n - \ }, \n {\n \"first_name\": null, \n \"last_name\": null, \n - \ \"username\": \"dellsberg\", \n \"uuid\": \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ }\n ]\n}\n" - headers: - Content-Length: - - '324' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources - response: - body: - string: "{\n \"sources\": [\n {\n \"add_star_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"consistent synonym\", \n \"key\": - {\n \"fingerprint\": \"04EAA26CE5C74286E78299ADA6122A68D47035C3\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEADL8YaMOqcq70cdpry7h52gS+aPmIYnC2PStdwCojU0ntOI0B21\\nGQvOHmxgcwMvXfSqBBEYNIC3r3IRUouQgl3oOvf7+RK5GqDgnV3lcrm9wDKBE7he\\ncqBPfZ+5AcOcqubAYXUCSznMGoMIxbCtQWaOpiqGU2ruSpwlq4jukzdVXvo4Zb/L\\nHn89r7TJc4Udg3lz36gxp3Jm7aTdGX8VKafLFiuK2LT3lakgurUO87M8DIdULn04\\nMJaujBVxYmbCJnjLg/flhjRUA4PKw9Hdc9vYp/e0k/eueJsB+Xhixc7XCnh9eaZn\\nNOrMz+IHZ5AY77Gopq23cidWPWFj2b/+g9+k6/MUsg9S3tzYOJ+kU1vncZipnsnc\\nW+wJMlu2o6wU5nSPoNUf0JFN+rI/ZTsK3jjADMyIUIN0abXMZ/GeNoH4olsfJcSb\\nM/INzmXIoSAmEd6/gZ8d1dDJsPA9Wd1zBySWiHXzfpihEvSseCdZBYuBE9iSs/x0\\nG83FiOG1x5JtEl8Bc42m74KaeM8QjgujnpYODqYdnWI2VVH66GjOgYDbb72spEe2\\nXobdk8KtABq0yEav26ZmS0/Wqd4RD67mRbp0FRpblt5Bl4qb2fFy0jZeFQ8M0Msy\\nfF4YWDDgpkPSp0wINLrSWCDR9VkWTmIKW7F70aP/KjD1RN8421PesKKggwARAQAB\\ntHVTb3VyY2UgS2V5IDxDMjVZQkdOQVIzR05FNlRDWFBUM040VkVON01HRDNZUVA2\\nRVNHM1lIUkVEM1I2VzM3VEtMQkpKSjVIVEpZVkFNU1FDVlJRRE9KWEs2R1ZVRExD\\nSDIyNkdMSFU1TjZWS08zNUFDSzdZPT6JAk4EEwEKADgWIQQE6qJs5cdChueCma2m\\nEipo1HA1wwUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCmEipo\\n1HA1w7iLEAClnTccq87JEHCp9mJ0mT7BHPGakRNzzvyZj8xgW+jaIdFH3lF+x3vE\\nWoJzvUP3js+Cne/hd/+I1fWBMcEERajWPUSXC+pqEBsOdAWrJ4xi0zI32ofEuFGc\\noTVoXLhJnrzDZM1TqK58nwZZxjwL1XzuLtvkAz+utkbI7rnNXRQMzoR3LazUjz9+\\nArPFjaiDjxAsF90VELvBjKmC1tYSNrr/XEwl6yTXBagf2VchVLUE+Y/0ozTFv+Cz\\nLeiQh+EqE8xhKkuELLkNUjx6Z2oVK91MVrCTLvnxsNGyoSLyH7CWZeFodCQYF3k7\\nF/zGe9/KE6/n6uZ8EdjI50Rd/h99cYDbHt8ljDeqhu59V2xqzb+sTWpl7WliiVx3\\nbrboXxIFWuidXYJFlaXy3X342dTwqVDVE3rW+T0r77ZMO3MPMRrtbyjSL5+yqWuw\\nS/BLuhorFgNdxP/uMKIz89xAp2diQ+6USAOoEIaWkOk+f45s2bXyjS0EzmeowYRG\\n6IwgqLqopx2w8Mx8o2/3NkC0RfehkF0ideMHZpTXW2WAjApJcnXDDxDfwhr/xSwh\\nzS0dgD4dsdpRWoocv3zXnSv5L9JetZGYM0/CnxG8SjZ48zStjpsenKz8X0vDJAai\\nSlXnUn6TGzHZxuPyNegZ4hwLW4YlMkktJAZRLWZNW8BYQZGc03Z2DQ==\\n=lJ7v\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:08:49.528506Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/remove_star\", - \n \"replies_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies\", - \n \"submissions_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions\", - \n \"url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"uuid\": \"56d6777c-fdb6-474c-9d3b-0b7b43beabfa\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"concrete limerick\", \n \"key\": - {\n \"fingerprint\": \"CA8A176B4D5D3666ED88B03BC5E9954B1492AE1F\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEACtbh8mDuBbRxk7YGntX40e41q3r6mLgGmV5p26GZi3b/fAPoWA\\nJjo/Np5uBI+Ye/MZNZBl22aGIh3iamNXpywjrro1xCFryAhdFMj4eKuarekVbsNV\\nj0K5AWH2gomzJ27f+9+rkn+R5gtvRqeMA0tVu7pQQ7gw/n/1XIJ4X0M7oRHPWNAX\\nOvAJe/60jKTAiwNdgwE2a5aOTXrtXz20Je7bBq6TtKAWa9tdB+W2JUNH5IEmnhYA\\ntWw3/GliQHphPizpa4eE1jgF3IJtNf7hPTeJ7S50XXpolfmIaLYohWDuVi4LFVGC\\n2GGzasNefQJIoQXkK2UmYhhck0T4U5zwfl5RkuftOjGvHDa4U7bSRz3rl3MCzmGc\\nlvA028aMRrYg4nBu0ryVlVjAV93n8FTKasURjsyLVBfb+Fzxu1ebbG8rakvHbAbk\\nK25ZP+mNyu2QZ0WsM6j3C7afvAJDR0Mkj0KWBjc5JHMUtqupPwpK/8eswlecx7Yx\\ngLAwqkmYvFUiKjKAbUYbaOe4YJEUj4h/nxayXE2XhptLlL8m4oopflANRsqc00+F\\npQqcznyL0a89JKBmBaT8xPPK+GOtrs0EU9mz2IhAB4HxEKuVFuwOg7AIFLO6gRN/\\nLbqJvLz1IO3yM10O7gCb8ErPxrnByBkP417YWddnx9pPw0vPgPXy2lbo6QARAQAB\\ntHVTb3VyY2UgS2V5IDxPM1hKVUg2TkNaWEEzSlpOUlpSRlM2RlRaQURTUzNNVk5F\\nVFlNU0lRWjVZSDNUTDc2WFk3VjNQRTZSSkVINDRKMjZXM1pZMlVJNU9KMk00V0VG\\nWE1aRVdJWlBHS0NKN0VLTVRCQjJBPT6JAk4EEwEKADgWIQTKihdrTV02Zu2IsDvF\\n6ZVLFJKuHwUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRDF6ZVL\\nFJKuH04ID/9Xl2jbyBsu+JHS3fsMDSZE0L39HhqbRKqrUxq5U9vb3aWU3Imf3Tu3\\nez8Sp/aThXOJKuC9QeJ2gCIe9+V+OGVYvUl67P3xxKzIUmlLlk5cbosC9m/J4MMZ\\ndmSok8XBgOWYWuNbcCNiW0msfDijJH1diH6tDc9UEzcTvTbWHqbl3S27uwVced3O\\n8OAY0MGcB6Tw1yRBbv7fJ4nWKeu3kmzrepRQYh9cEMEf+pDE0RUkoORiQI5vmtzL\\nv454PfNGGuGijMQm64tYqe33fwMR0marbLyYXTSMlzEDF5AxeaKjDVI4kEe6eUT4\\n8kLsvRl2nPX1gbrBSkHSZ21/oMkhdlGhPyb4xKcqCVkzpQJpCsATmAkjtp/IHJib\\n2mu6TzhAIvANP5jqiGE128lZpPBILq3PIrhXqVDyLWpl6xTSHz7rhxVXtDHJZoIz\\n4QJM7Dl9V0s/UQ5hJdmx5L0aEP+7b46+3kvgbPvItaRiF11L7fRQwXMNoI8bm47T\\nbfW5nJK8p6O5VssHtFYqL9rKYBDdk6JYsiZ8xvTrqTRMK1xJEsuF3Tuv73JmMQtF\\n7wQq8rZg0cbINpJuOBRsvEAo6ATJBq+HOCAuqvhJ3Kx9lixLnURP4dybKJoTdWJP\\nSDgLwly7bulTF+fHQSlD9cypaLiw4cyzFubhw4OWEJYMAsYcbfBqYA==\\n=i+xf\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:08:51.571224Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/remove_star\", - \n \"replies_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies\", - \n \"submissions_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions\", - \n \"url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"uuid\": \"ae59153b-0871-411a-a72a-0f4c41a76ee0\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"indecorous creamery\", \n \"key\": - {\n \"fingerprint\": \"04DD6C14755616B9F944F87311961223C70DEA58\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEADEMD/A2IVlAmhB3Vu3jDlG3UFli/e20GXvfeW6S0PFEuvE9Po9\\nCjI43sFdMVvRUvtaIP5PE1zU4OuN1gi6jpKp5puulnddV6jP0GXqK+hqVXjiaf58\\nhUkuvpK2CaHf/5DvGdSW2IZLB9oP/UtWYTBUm7dER2Fc+rMY13fUMEsGKyJZ9wB3\\ny4CrJpMw7TNTefVx6vrlbCVEB4nksod+A7wteLILbeGj26D1A94vH1V4iLdOObW3\\npbTX4Yra1CpxclEsHyaS7tZ+4bQOmh0OdVG7ZW4MZPYp+1BIqt+e48042Rq1jIHu\\nHVApvHynPDt2tD/KiymDM3Bt69Dy9rHrWEFlWAS+Fpgo7qBQ9QF2fHWzpHQyhcTB\\nM3zQ2LraeOrBWgzjgCRIei+sga6w9Tjk8fMZKLl7HPkjRZxOFU4GJLjkxf3Lw1Av\\nCo3kQijDzj0nN/qyebcD2/v6vz5/5D8iS85fJdgLwds7ajXXgk9/M11Bkze1RT+2\\nYCmsUW999wF+AZmeR6ZFdUfcOpJE/99zs6GIRIo+ikPWiMcs4/7jAlrierrAtuhH\\nl6luFRIz6utBFWIleZosxnx3ZqRAv1DUdig3BnIliD3Y53y0cHbFFLOX428ZGKCp\\nJ9Of22l5XfMlT6B8NCJgRcQc77beedl+1XcQP64X+FgddottileDhsiRNQARAQAB\\ntHVTb3VyY2UgS2V5IDxHSlFBVTRBVUlOQVNBRk81R1I3N0NMNUpDWFRMV0FXWjJU\\nV1BKSExDSTRJWFpNRVFKWVlIRFVUWUFNWjJOVDJLUDdPN09GSlRRSkdTSFJJT1dP\\nM05aS0VGRU01QklHTkNWVkFFT0xRPT6JAk4EEwEKADgWIQQE3WwUdVYWuflE+HMR\\nlhIjxw3qWAUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRARlhIj\\nxw3qWEDyEACKKS0y7ApY7CMGuuU6BltrUyc7A5UcCe6vnCREX4662qkHgaDLmIpa\\nb5t+hvtOicEwegoFsBAnjnG+Vs+AU1DDzXREojZ0T39Hyq0PYS7HbDWJRUSfl//Z\\na566rtbdzv1GEc7hMAEi9pKplR3uEQlQAp1G6W1Yzf5WuwmdWMOactzbENJTnbc1\\nSBe/oKbH56UEMX7KLr5MODQ6IM+VCqRI/k6Px065q8scAeEQERwUFdy33BBzk+g7\\n/uYPC74NnfISP6Tj94oFEySs1HC2hIaZlUQor3ZJOzvZ3Vm8hix7JdjBVdqdHFmx\\n5+Ft211Va6v1dKCUW73GPvYkv0bt4CeAV9fhyQOSMSENTiNVVh8L2+dCXVQXhFUw\\n3Hmu/tOj+r2B8+vWWHuhbFjgeAiXFkHFDT1a3xZ98n5g5SNwoiBJDuyWjPgr0vG3\\n/+1wgTovRVbt62H1VgRsP49wMS9EBz1DV1q60GcWD40FNfkJx7W1T0RtUgpKp3hH\\nw06RJFAzeMJtXz89mFpIQfkVwBflne5HDQywIT8o0TnxAh06Q4ROqhFydDSB0HTv\\n6NJVRhiSiwGmYiZi4DVwv7exttrfv6h1TX99MjR1e3kjki/IjeI/pW42GgFUZVN5\\nWRzx2yiSIfz1rhBqnRAtZWConlmG2X3LRbUFtz1LHsbC8UqKdtlB1g==\\n=oWMO\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:08:57.846667Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/remove_star\", - \n \"replies_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies\", - \n \"submissions_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions\", - \n \"url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"uuid\": \"55fb95c1-cff3-430a-8c05-125c67c81a6a\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"conjunctive lavage\", \n \"key\": - {\n \"fingerprint\": \"F71969D1705E2E3E374B95992DA6D8DCEE36946B\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEADcaB1fww19PLIREowYKfNZiVoWpLYxRnw1U/Iz4JbnEJ7TuIlm\\n2Q46Hr9kR7zQVb5okjn40whN0JA3lJcfPZdjfxiCt8VYI7vacUxVZgXWJCR83vO4\\nNSD1YnZD5KXi0B6PGKIhry1Hqc+hzmMAFYGGdi4h5EKxinNmKTO+E3Zupeydm0KK\\nCBwXroROAs/5+s63oj5+nuqlPCTcEL3SGjH8zXIw+TN0mBhQhGlyqofIW/JEaviP\\n+frUL6WPa3AoUBE+TAF1rmXr30phZU271zfAYhe0B81gtrUTSg49uUYQuCf1xu92\\ngbuOmcYTQvdzgGDp8cNWL5cmQCdvoGTGH5PYodqMGcRfWqB1dl37RCsqDcCzssdv\\nJiUe8qC88n0tQl/gJOgniEhKEok5EiaGuuLz9j7waGB1aBgHLPsibDGQVyYn9ZYD\\na3E9cL0BHzsWJc9i1hFE2cmTXzmJ7rTXyvHSvidT6s2cljuih1Q6e5qNOcJPAuv9\\nY2xuZHn+rTaJSLM30X7PngrAP2jfepraz7zy2lE4Uex9dLQNPMcYhjPc9SwKjk8g\\njDkhCGW6daRCpzNUR/ydYGlfN00L6MPo0S3XG/x88f+OwqgfSpgrfSijqDTLxbo1\\nO4rTW+KSiVy2P9DfuLhZv+HcNiinY0EP3qbuuXKk7VSMeCir+HgeDce+pwARAQAB\\ntHVTb3VyY2UgS2V5IDxTV1pWS0hOTlBLQkIzVTJDRjNBMjRIUkJZRlRNNkNDU1U3\\nM05XQlhNTkw2NVFRWktEM1gzRllNTFVDVlBKRlhCRUJWRVkyQklKWVJLUUQ2NUFT\\nS1IzQTNaTVhaMkFOTkI2Sk1CVEJJPT6JAk4EEwEKADgWIQT3GWnRcF4uPjdLlZkt\\nptjc7jaUawUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRAtptjc\\n7jaUawViD/9O2J9EsxOmaSB3XI9q0EyLvOZPh+r9TeOGA4kqQlOH3PeMP1lxQ7v1\\n3LR7OCjM2pBNHww7rRkYNoJX4dA/UjZ1UcerIAbxa2Z4v7X69akKItw889UCW6Go\\ncUtco2XqkjaThsV/io19+6qFicrWAumpFtH2Dt8iVsHzOYWpijPK70AxJqODg+nK\\nv4k/+zqAePbLOCCCuvnhBduJCEd3dA0G7ow0H/AzgpPKOEswbYK6JJYX8Gsq9F3n\\ne+PkBJ3Op4/qUELYQYEBbF2qy+XPfhOZsJ4v/HDb+eutZNmATtpGZGNJznyLFoZX\\nbNI/U2XIlQYBDeYTOVbNPPVwoVucoXG1iGsp+2ZFvLgP4XGRxdH9oyiia9FC+id1\\nwCtS6dRWRKv1VJwVetGBncAdwmugCkQoJ/gGwcTkJLhOVyoZZruTR8aLOE+ArTUg\\nfgKBVpeT9he8ELDZFrPtAnDTpMS+RrVsF8Y1sih7O8VCxsxGRegKlQcxgPp7/MdG\\nwFOlulTqCSu+fZfkid4rvnRGcPRp1DQohwXiK/UpDIRYTPERHQTEm2fK29FzmruI\\nr4zotTaeHhztY5jrqZqzkMy6/teHE5CGq5mKQsXzQFjb5hKEYg4TwAazPRtH3WOo\\nkZ5ISlxKvOdf8jA9hWKFrREtk9t9blD0IA3ffzfO5aad+aZjnqsgyw==\\n=ccYc\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:09:00.294006Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/remove_star\", - \n \"replies_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies\", - \n \"submissions_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions\", - \n \"url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"uuid\": \"50c5fa95-eb69-49b6-8599-62b12cff7d7d\"\n }, \n {\n - \ \"add_star_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/add_star\", - \n \"interaction_count\": 6, \n \"is_flagged\": false, \n \"is_starred\": - false, \n \"journalist_designation\": \"sixty-nine alliance\", \n \"key\": - {\n \"fingerprint\": \"7034A99B359CA2DD3F57E251437B6C3C6984302F\", - \n \"public\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\\nmQINBFGRfoABEAC8d/LgDtvyeg/SNsUcUPRY7JZGFbE3peoduYiqd29LW/BXoInn\\ntRV3Ks5H8QLH3/qS/zWwiE4x2yE8cOykWj/lPMlFCDYdWK4f55eS1LcxN+WtLiaL\\ndDQG84KICZznbqTxlvdizLwCvch9Y19dPszPuwrBJ2KbOsngPfHDARs2aU++J1d1\\n7MjIpBLJHTlYKRdutANtxEKCq+KX9/K8GnjZYLhmmecaVr6OoSp3Nq6zlvJe7qPb\\nc1IUJhA1oDyNVBAPs5ROKkM6qhDJmI9OpKoGVGWG7u3kDQ3Oo59wBoC65xTZNFy1\\nGKcQbCcegKsnxdchBO9nMK3wh8H6JUkpdXPrurysHqQ6JIAar0rXIlOvg8kD6yNU\\n7bYK6xetBzkYBGgz7vbgYq+k2ur3nQLvJmBnPVqY/7bjSGDIfbkJWOudD2LaqQUc\\nIUeBpTlOsqfVhXwfen+ynntPdSQU14ILmQAztFzZor2leNWAR6pYG6ZI3vEzAX3l\\nWPzmS7L13VC1w11IG0wdKuzhx1jHGJ32JrNyL4LoJ1O++8GWlJS0+ZC85gwIaFQC\\nLB+sGw4PruxLUGFe2ZLYWgYnN3Iw5JBPxfc+Kxrp1xhHCZNdC1B1ajtkOwvdZbIU\\nOP9Cp5MRt5AeGBZ9ujIMsAxOZrPeN574ewqnY+z431eC6rNFzdmlY1Av8wARAQAB\\ntHVTb3VyY2UgS2V5IDwzSjI1UlA3NlZUWUwzVktMV1haTENOUFI1WUZMQzJQMk9Q\\nRFRETE9IVVFQQkkzN0RZWE1CWkpORFRVSkxSWjNDUlc1RkdXVEJNSDY0UTNBN1BZ\\nTk9KTjY0T09YSFpRRE5STDNRWEVJPT6JAk4EEwEKADgWIQRwNKmbNZyi3T9X4lFD\\ne2w8aYQwLwUCUZF+gAIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBDe2w8\\naYQwL7osD/9nj9I/89AR2p+MDw+RqGjNrEMnIyCC1+IneGC5MJXLYb/9oz9JRMrb\\n/+Gk+MhPjkgHVbI9BzByIzNh0stYF1T17rJDIyehjfbejYQKFwJd1+QHCfSgLIQQ\\nNOtKKr9iX5fUdPzlLzkdaTRGMidUTqWuY77wgmZoKN1a1Q801NXjIkY3QJ5GpjSf\\ncdvgu77k7y/0juUu0eTeNpd1TXs/GBitETnfDEKcVUkk8x+OwvSFE7VrWJCNAH/x\\nOAQUeT9S7CczoUeFWCII880xFcpdynt+ogYyxVh22RV13HJ/HJlmUA+9cpQ6ntAW\\nXdhKS814mJjqfTk5j2ZzLwKekqQgUSjCB7ucbEPhaHdQHShfuNQg9EhtP2Qy+Ptg\\ntGFMF4f+s9anFobioeYnS9S3JuR73UHD6XOz4GDgGx/3kdlxwRfjOqnRWzC3oNmU\\nVmT2caEmXnjEqL3FP1wVOEcciBqOAgT0QsMB06eOHL+cJxMOE6j/Wo4Y2loF0+Bq\\nR0KMqbg0lpSyLHjTmOo15DgzohSALI44niM1SaVGGlzOawb5zOd8ownvfwcut1wG\\n0UxhwbyoiHblTySzzjhekJQGMGQOyRUIfbjbNtHKeVFVEosM5dUhXWRA+8n1uhc+\\npqdAhXSd9yEIjy8dIc7USlTTqEEOYYXetEWYJP6tolKuggSiiUB49A==\\n=9Hjj\\n-----END - PGP PUBLIC KEY BLOCK-----\\n\", \n \"type\": \"PGP\"\n }, \n \"last_updated\": - \"2022-01-19T23:09:01.659060Z\", \n \"number_of_documents\": 2, \n \"number_of_messages\": - 2, \n \"remove_star_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/remove_star\", - \n \"replies_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies\", - \n \"submissions_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions\", - \n \"url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"uuid\": \"92b1914a-1b1c-4674-baad-1fb662aed682\"\n }\n ]\n}\n" - headers: - Content-Length: - - '13467' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/submissions - response: - body: - string: "{\n \"submissions\": [\n {\n \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7e2de803-ccc1-42d0-87f3-76972745d11c/download\", - \n \"filename\": \"1-consistent_synonym-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 623, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7e2de803-ccc1-42d0-87f3-76972745d11c\", - \n \"uuid\": \"7e2de803-ccc1-42d0-87f3-76972745d11c\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7064722a-8970-4fc0-b8df-8b8c05a95d81/download\", - \n \"filename\": \"2-consistent_synonym-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 692, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7064722a-8970-4fc0-b8df-8b8c05a95d81\", - \n \"uuid\": \"7064722a-8970-4fc0-b8df-8b8c05a95d81\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/d2aa85bc-28b7-40e4-bbc2-fb7fa588965b/download\", - \n \"filename\": \"3-consistent_synonym-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/d2aa85bc-28b7-40e4-bbc2-fb7fa588965b\", - \n \"uuid\": \"d2aa85bc-28b7-40e4-bbc2-fb7fa588965b\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/42f45442-ee20-4745-8518-c8a01bad5f46/download\", - \n \"filename\": \"4-consistent_synonym-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"submission_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/42f45442-ee20-4745-8518-c8a01bad5f46\", - \n \"uuid\": \"42f45442-ee20-4745-8518-c8a01bad5f46\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/48bcb4a3-6f23-479e-a718-e0b93fd4b9c1/download\", - \n \"filename\": \"1-concrete_limerick-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 611, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/48bcb4a3-6f23-479e-a718-e0b93fd4b9c1\", - \n \"uuid\": \"48bcb4a3-6f23-479e-a718-e0b93fd4b9c1\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/d8db9ba7-4789-41c8-9f7b-3761a367816c/download\", - \n \"filename\": \"2-concrete_limerick-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 757, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/d8db9ba7-4789-41c8-9f7b-3761a367816c\", - \n \"uuid\": \"d8db9ba7-4789-41c8-9f7b-3761a367816c\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/648932a9-7e82-4fde-a65a-fee812b50ec0/download\", - \n \"filename\": \"3-concrete_limerick-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/648932a9-7e82-4fde-a65a-fee812b50ec0\", - \n \"uuid\": \"648932a9-7e82-4fde-a65a-fee812b50ec0\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/e0565187-d9ea-494b-8ea0-173befacb1f3/download\", - \n \"filename\": \"4-concrete_limerick-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"submission_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/e0565187-d9ea-494b-8ea0-173befacb1f3\", - \n \"uuid\": \"e0565187-d9ea-494b-8ea0-173befacb1f3\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/ecc07e49-be88-40d5-8e99-bfb3b3812668/download\", - \n \"filename\": \"1-indecorous_creamery-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 593, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/ecc07e49-be88-40d5-8e99-bfb3b3812668\", - \n \"uuid\": \"ecc07e49-be88-40d5-8e99-bfb3b3812668\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/c60627e5-dfc6-42dc-8874-b290ef09a2d9/download\", - \n \"filename\": \"2-indecorous_creamery-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [], \n \"size\": 595, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/c60627e5-dfc6-42dc-8874-b290ef09a2d9\", - \n \"uuid\": \"c60627e5-dfc6-42dc-8874-b290ef09a2d9\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/0e734035-3193-4c94-a86a-41d04332d8c0/download\", - \n \"filename\": \"3-indecorous_creamery-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/0e734035-3193-4c94-a86a-41d04332d8c0\", - \n \"uuid\": \"0e734035-3193-4c94-a86a-41d04332d8c0\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/93d72061-a8f5-4166-9a7a-3beeea4989e2/download\", - \n \"filename\": \"4-indecorous_creamery-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"submission_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/93d72061-a8f5-4166-9a7a-3beeea4989e2\", - \n \"uuid\": \"93d72061-a8f5-4166-9a7a-3beeea4989e2\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/f2fc98d1-8acb-405f-a4c3-c93bf23febba/download\", - \n \"filename\": \"1-conjunctive_lavage-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": true, \n \"seen_by\": - [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n ], \n \"size\": - 638, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/f2fc98d1-8acb-405f-a4c3-c93bf23febba\", - \n \"uuid\": \"f2fc98d1-8acb-405f-a4c3-c93bf23febba\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/4abcd4b4-3922-4ae0-ad97-9186f51e172c/download\", - \n \"filename\": \"2-conjunctive_lavage-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 667, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/4abcd4b4-3922-4ae0-ad97-9186f51e172c\", - \n \"uuid\": \"4abcd4b4-3922-4ae0-ad97-9186f51e172c\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/2281fccc-4cae-4228-a837-e6f3a3e1e6d2/download\", - \n \"filename\": \"3-conjunctive_lavage-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": true, \n \"seen_by\": - [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 661, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/2281fccc-4cae-4228-a837-e6f3a3e1e6d2\", - \n \"uuid\": \"2281fccc-4cae-4228-a837-e6f3a3e1e6d2\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/098a7d90-0ae4-47cf-a7a2-2afc00094a3b/download\", - \n \"filename\": \"4-conjunctive_lavage-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 661, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"submission_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/098a7d90-0ae4-47cf-a7a2-2afc00094a3b\", - \n \"uuid\": \"098a7d90-0ae4-47cf-a7a2-2afc00094a3b\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/546e7e6b-ac50-4ba7-b738-82f0d261feee/download\", - \n \"filename\": \"1-sixty-nine_alliance-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 591, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/546e7e6b-ac50-4ba7-b738-82f0d261feee\", - \n \"uuid\": \"546e7e6b-ac50-4ba7-b738-82f0d261feee\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/987ef070-4e9e-43e0-98e0-2c623607aae1/download\", - \n \"filename\": \"2-sixty-nine_alliance-msg.gpg\", \n \"is_file\": - false, \n \"is_message\": true, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 591, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/987ef070-4e9e-43e0-98e0-2c623607aae1\", - \n \"uuid\": \"987ef070-4e9e-43e0-98e0-2c623607aae1\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/2df5a904-e89a-48f9-9e33-5b9759317f1b/download\", - \n \"filename\": \"3-sixty-nine_alliance-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 661, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/2df5a904-e89a-48f9-9e33-5b9759317f1b\", - \n \"uuid\": \"2df5a904-e89a-48f9-9e33-5b9759317f1b\"\n }, \n {\n - \ \"download_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/03d1920d-d4d8-4580-9c42-6333c812383a/download\", - \n \"filename\": \"4-sixty-nine_alliance-doc.gz.gpg\", \n \"is_file\": - true, \n \"is_message\": false, \n \"is_read\": false, \n \"seen_by\": - [], \n \"size\": 661, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"submission_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/03d1920d-d4d8-4580-9c42-6333c812383a\", - \n \"uuid\": \"03d1920d-d4d8-4580-9c42-6333c812383a\"\n }\n ]\n}\n" - headers: - Content-Length: - - '12367' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/replies - response: - body: - string: "{\n \"replies\": [\n {\n \"filename\": \"5-consistent_synonym-reply.gpg\", - \n \"is_deleted_by_source\": false, \n \"journalist_first_name\": - null, \n \"journalist_last_name\": null, \n \"journalist_username\": - \"dellsberg\", \n \"journalist_uuid\": \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", - \n \"reply_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/9df9083e-1ac1-4085-883d-8c9982b6ad79\", - \n \"seen_by\": [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ ], \n \"size\": 1150, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"uuid\": \"9df9083e-1ac1-4085-883d-8c9982b6ad79\"\n }, \n {\n - \ \"filename\": \"6-consistent_synonym-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/ba38afd6-aadf-48d1-a599-bd74601105d9\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1219, \n \"source_url\": \"/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa\", - \n \"uuid\": \"ba38afd6-aadf-48d1-a599-bd74601105d9\"\n }, \n {\n - \ \"filename\": \"5-concrete_limerick-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/9bb8030a-8561-4a03-85dc-e921bd6a891c\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1138, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"uuid\": \"9bb8030a-8561-4a03-85dc-e921bd6a891c\"\n }, \n {\n - \ \"filename\": \"6-concrete_limerick-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": \"\", \n \"journalist_last_name\": - \"\", \n \"journalist_username\": \"deleted\", \n \"journalist_uuid\": - \"deleted\", \n \"reply_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/0a82f046-581c-49ef-9b51-ce5b73e45c1a\", - \n \"seen_by\": [], \n \"size\": 1284, \n \"source_url\": \"/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0\", - \n \"uuid\": \"0a82f046-581c-49ef-9b51-ce5b73e45c1a\"\n }, \n {\n - \ \"filename\": \"5-indecorous_creamery-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": \"\", \n \"journalist_last_name\": - \"\", \n \"journalist_username\": \"deleted\", \n \"journalist_uuid\": - \"deleted\", \n \"reply_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/1c2ff7fa-252a-426a-83e9-5840cf657739\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n - \ ], \n \"size\": 1120, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"uuid\": \"1c2ff7fa-252a-426a-83e9-5840cf657739\"\n }, \n {\n - \ \"filename\": \"6-indecorous_creamery-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/5f5707b7-ee1d-410f-94be-1ba8c1929264\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1122, \n \"source_url\": \"/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a\", - \n \"uuid\": \"5f5707b7-ee1d-410f-94be-1ba8c1929264\"\n }, \n {\n - \ \"filename\": \"5-conjunctive_lavage-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/158dfd73-3cb3-4a6e-85b3-f37ae54e0802\", - \n \"seen_by\": [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ ], \n \"size\": 1165, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"uuid\": \"158dfd73-3cb3-4a6e-85b3-f37ae54e0802\"\n }, \n {\n - \ \"filename\": \"6-conjunctive_lavage-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": \"\", \n \"journalist_last_name\": - \"\", \n \"journalist_username\": \"deleted\", \n \"journalist_uuid\": - \"deleted\", \n \"reply_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/24fbb6b4-504c-4fa7-9971-e6f2d1447a48\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\"\n - \ ], \n \"size\": 1194, \n \"source_url\": \"/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d\", - \n \"uuid\": \"24fbb6b4-504c-4fa7-9971-e6f2d1447a48\"\n }, \n {\n - \ \"filename\": \"5-sixty-nine_alliance-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/4dad63f1-dc12-4162-9c59-065c88b2a8b4\", - \n \"seen_by\": [\n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n - \ ], \n \"size\": 1118, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"uuid\": \"4dad63f1-dc12-4162-9c59-065c88b2a8b4\"\n }, \n {\n - \ \"filename\": \"6-sixty-nine_alliance-reply.gpg\", \n \"is_deleted_by_source\": - false, \n \"journalist_first_name\": null, \n \"journalist_last_name\": - null, \n \"journalist_username\": \"dellsberg\", \n \"journalist_uuid\": - \"dfb1ff22-a373-42ae-9c40-28e04b08548d\", \n \"reply_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/d5b658be-aabd-4d7b-89c1-51de7fa246a0\", - \n \"seen_by\": [\n \"a9f8835b-52a6-4845-b428-61cc10561a0b\", - \n \"dfb1ff22-a373-42ae-9c40-28e04b08548d\"\n ], \n \"size\": - 1118, \n \"source_url\": \"/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682\", - \n \"uuid\": \"d5b658be-aabd-4d7b-89c1-51de7fa246a0\"\n }\n ]\n}\n" - headers: - Content-Length: - - '6430' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/546e7e6b-ac50-4ba7-b738-82f0d261feee/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqARAAmwUjOf3oGUcC5K7tSj2wxiaUdEVeNF4vF3dX1fehU6KBpQhv1Fq1RkRg - 1xM0d/QOpfw31CX3ZS2hPdA0YkFt8xCNHi2UYY2Klumo9clEx5TsyF2xQ0YKSZ5zNlqVJWKRpa1t - bhtG3nRC7KQfEsQNQyLgQM/l9EJtzrYoYJEgd6vj9m8kPYsPhNnX4xtV9I4CFam1fwKqdJvjRiHd - 2v48TXcqxYywEwUKyrPyeLUvhFaPfYX3d7QVKd94Wj9FUcccV3Sn1JNeggVKuyo2i4k4ISkGGRr5 - Dr+Z7WVOTzZ2A/Ec7X5onGDbi1XGlrK94PaOEe00ER8sSqGQKDmfTu/RgHp2vwi5hvBUtOy7171f - 5lf16EIXP9WzNq5svfBBcRSiqTAXIIZ7L1gT1XT78edb/1UTAzj8MWv7AjOCWX893AzSS0QT52qb - vtdFygfDSLjTlOLS5S5mSwXySnTMEWgxtr7MEMOiNOiYmL/DGlHHMBv+k0KwcCj7UAQ6Sxs5Ek2V - nUP12NtHqUv50LWhIx1sec4SlinNwRyUXlBz03ZKazij654snOziaTHIS5ColH1Dybymz04FjWsZ - 1g7J09SSwH2SFCX/ZC/F1+DrJf6aXvjBtS6K1jB0179vzLqtOc+g+IT4R7RGZoc5SJNcIwNzSAhR - Psvoid62jXUBMluHUGnSPgFsdF4s8vKoV+3hb12cuGou87Qthv62oGM2k5aX2KHk/AWAcQw4LeT+ - iYWJWWBwFLOt2WUfZcX+rKQUquZi - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-sixty-nine_alliance-msg.gpg - Content-Length: - - '591' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Etag: - - sha256:c2f54737913721bc1c2984e1d18ff6e7c21633f61d6e6cbd64d55367d4de1aee - Expires: - - Thu, 20 Jan 2022 11:09:09 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/submissions/987ef070-4e9e-43e0-98e0-2c623607aae1/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//aY9hxX2ogbaW32nmX01SSuMf0f9p/d916Nmkjcy19fl/FJYYuicgocKt - /sae44rGh/mrxSAPlujS9BA+kFAaKC1mHvIKwZNRIX95XjjOXj83ndEju5DEkWpS10j5fVQ6JsMy - HV71GP5RZpOvOd6h7MB84MtKsKwTNRiuafeRaBdYWsT+RfuAURTHnWY3PpyBFDYwqlh3UeRdJfXu - J2XSc6H/2071WCOFvJqD47fkdtD2ox+pWXjP4D4ZDjNRqx2apSYqdQWmDuPM9cxDbIMbELnoZZ8R - /e0hgHzbEq7bTwytpyZKnW5fdx0MWoE1GL5l9a6Yr8HdzzbOxYO3vYCf1+gQCDX+/4pRQePzS4+r - 7lJGkIQ8ioaX5ow/nDlllLqEXsHxybCI8du+a/DvlDJrpf7ZcfZRGpsOyU1w0+ZTizPfknMaDK9/ - xhhBt1JU4huxZKH3F1F6y9ws9tVIcfk6eVRkWRbvcVIf1W5yPb3hGPwZe8TpQmp4EgG9Ub6ExLjc - S1lyJ5IVBm+MUy12DRUIHKDU9ZEtkCcqZ9WdNj0FeUGiCUg6Q9ODrOVkuX53JVHwbOBMpOu7Az6h - Vb3CCImEt1VKsPRNNIMdJj4OiF0ycUwlIlZNtTvhP737zjX+FKx7fA8WhusxvrxN7bWj5YHaJ6ur - 89WzLagmFrEBFNvz7Y/SPgGyUwWol+H/UJhuwiMxQPzXQZFSMVaf8kNud+FEcsVwLlr+7RxltIUk - Cg8CSW0Qc7K0zX+aT1t1ybpjAxAU - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-sixty-nine_alliance-msg.gpg - Content-Length: - - '591' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:09 GMT - Etag: - - sha256:c8d979c2a5ddbe1442b987bf52676c27952972e9b5cfc65e8725808aa0c00ece - Expires: - - Thu, 20 Jan 2022 11:09:09 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/f2fc98d1-8acb-405f-a4c3-c93bf23febba/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//ZGSn6Joprv5rISp7I9pxfmNwnQywlsFX1PCfQd9yWWVg0BBVIgEp1oe1 - 8d5CkW840whZxhT/+2RIqDIHZ/sLXJabXXDa1NIYBLCehXbkFvZDTBeyuxWRxk0QPFLlyB8MYN6c - 3MbLOsyjppgQS4wtcYSlcDva5tuYn0wnlWz1DEUAAgC1mfuNa4AjlfEDh6pN+52tq5ysl9vE3WHA - CHAuw5Wbql3NhJgjmWBCY+5OirTUWz+UBX+XhyPVD0g1HMD9mbpbgUFhuBOZt68YNPBdrtosLKp1 - c7PdajSwRqmE4hx2s568npRbFjL9l4GpGAcLef3+hjCfK4kTb1wcIsEcZX/dptfId9Ny4opzos3S - r/v3TckuSbzWkbO4sLgjFxR48vByIvB8DgDPTLF1wFn8KjmRI9uy3+lvjjhQ4FecRceYOkZRKf8E - DOzcGlbcxQMADYTUkikD48fEeVp7GrqCcamdT5xtVK1EC5BgrU411KNV9W98rWAJKiwc/ZM5TlRg - A7EaVllksthnB/R2nt7wYXB2yhi3iFOQXWOXvgyp+TEAtmMGXZXxhOCAPasxiiGk5lssxmckhgyJ - sEZY5vkrcUgEp6rw1afkDpzrcnKYxe/B5e3nxzB4HY8/VoNLuV0qCsyn7KF4QQgeFSblbbPrXGa3 - avOREyv2eBcOX9INYBPSbQFIF34xe1cmsu9LRxvJtNw+7L2jfbAt/p+K0uWbL2iWGAzWOdIz4ER3 - ZGE7ejn1FV5LkUiusfADKIvWh/Jcf4rRSY5noaaUdBkyT0JDXWLPvbANUZPIOysB/tO36MRBBdTF - fq8mcxnERAHf5Ok= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-conjunctive_lavage-msg.gpg - Content-Length: - - '638' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:369cbfc86fb18c430582307d6f64110de459504027b3132883d89ab50bd50ff4 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/4abcd4b4-3922-4ae0-ad97-9186f51e172c/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//bwoOwi3Zwszz1n7ylgcD++Vx2S1yUzLOqNi8KWe4xAJSCaJw39dcbkiT - 1OqJpJDWwIjWd1yRIoeLqH21SX4+PWt2Ra2j/MqjsnQdmXa4hEqdnTgaiLHXC8DvUF3Kk4YfJ2Ro - e32INfFkpT+AuXRSZFTmVlmzFYKTEvlnAGhOGubEbZPc0/pWZt2f9FlnVbHGTYeiD7mZfxmpwVTL - ilTxm0nAZMVsv+sD/f4yLoYn0f34e3zMwWgWFJ8n5G0Avnhkxq7NmzOLeAIcmY+jA3enYAUrhCNX - SXWgI+sUfNh9Fxyp+2DkXtW3hEctclLyIpSmRbMSfhGdUbGSDlRwyrZXZXvE2GkE20xiFbilnhjw - dIsgCwGWjIHduH5S84+l49bbAQ3lHnaQUzrIBM5CAipsubdp4UJQW5MH+QcEf6u6P4YS9PhRs6c3 - oFRoCAvY9mRSXe1iqjxE5jAXQeKZkZGzB3AJdoBrzM6ZsOFXPALJy+eKk1/k1NrR4md/MUtAxsej - V3CIH96BC8GUNMXAaEzHAd7aOEN4acdT9QY0uua9cq42bJ7Em3zpzxG7x30SLL9eHvYuGSqeJr5T - K1HF10GEjdQBzpR3PBl0eFwO0qjqW5YBQyHB4+exT+vVYJ1sSeOQor5yCFDDxjplYDonYeQLJOWl - fkg3UPOpFbMvB21nCRzSigH1RFaR4mU110vETzz+BSfNqDawJdGdtsvgo/qjszTVhRstgSSMRJkP - Oi5gpNSjAKP4oHwSf1YS8EPdA0lnR1/keAlNkIMfogWicxyzegEbkFFVdvxZDw++a3rdFanSEhqn - B/y6C4BhoY0kF3V3RbHUG4xB2voOTgdqbuB34EjiXqg13epvVzH5Ng== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-conjunctive_lavage-msg.gpg - Content-Length: - - '667' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:d92a7cb9901368d8ce9478c1ee67a9becf3789330648c801de9070b5d1c38232 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/ecc07e49-be88-40d5-8e99-bfb3b3812668/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqARAA0bqrq1QpF62ZAMgrtbCo/7kmm8IGB/7Lddclop2NH0P4BEOO0yCFruoE - oh/JvsHnA1aOiB+OWUba0jqytICNts/SmkUCMGawCvB4f0mCFTwqnPKZolol1juhi+v0Nj0I4No7 - FL8hYgu4OQnUJoUaHnk1G27QayFc8bpA/uIqD3Wc7vy1stVmjIbwRZibEUgUThiW05jvPST7bCcf - a91lPAOpIB7n3jY43omHBfCnwXlhCmkl5ruyKJK0a6buP0UlZJv0eMNjLJ8cIZmIabOsKYJT4JGD - fXGJ/NBOa0Nv++crzLYu8tL+8iApEdyegHsKpzKDoT0t97IemCABPjLi18ZRh1YRlrOPKSre0HeE - 94d8fylTU3gP/j0oWt9tDxhMuLyqAjqfB5OvwNyO4Q44UovqnLdiCQvkTKavmXlfIoQ+mex6jlbD - AbPj5zwPU1ms+fqZ5BMNWagpuvGpW4+uDG4yQCbwKq0OWtdqMC5Ml/NC7bTXdowAUTZxcK4L1UZv - 8BliQ0bS8jKsFLC26KEdO9kHYwhoUVhJI6sS8IFTUBRpfuw7sc3ucjGC9a9Vbfc2ytTnSA4thwcn - 7kE8ElvDn1lpOx74+EeoGAksYQTw77FAf0OwYALZ0MlahyzxcZeW0WUShR4nUFkfwSghPmMYwmOW - NgiAEgyTB9G474RoPLzSQAH3kq3MXakzuGOxoH0BJyCV7pjx3DdpQxlg/PddYwURy1JO/2aQlEcd - dDew0WaSU00mRSf187RA0izsOoPJZGg= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-indecorous_creamery-msg.gpg - Content-Length: - - '593' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:36167d9be8bd62598eecb1b8cc4f7cd2e6571141907ab2ff46a3add6c164fb96 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/submissions/c60627e5-dfc6-42dc-8874-b290ef09a2d9/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//QX+Kk2k7xQF0Izm5HeQ5s5yL46DBQOX3HFSVih7JkpcjVGWREQyAfnOa - UgsMZ/sxJzKxLK41rRDMNAX2tWevCXoJdULFntJ1RQkUhNcqs1h9MPnavQxij9qSmaLFiEcfaSnk - jBooeYToIKaZ2jw/krVqqa57wbQlEexGMc1wTlfstEdmN3sQ70bZesNXBN7Cqv6HpVLbwjhhYXnw - 3mBjrCNwajvKVTW8kZvW0w/bufTewd4HYjycS+LlL0vtm6gNS1L+6FrMHktMjKyv0v4Fb3W2OQVu - hCVWvXccSY7Bv6cBNhiQOu9TcsR9MYrCSEBx7PTB2elznj3rcGsI5NTTcbt3mDqMlqSzAFY6JggH - riUPbNiiVwbGuMq/1QCpuHm7fSuLfxcEJCWbMhWBiYGbx/q+0YuuVnAwq4ECpo9OU/pWawUS7MqC - E2FktiBBlWJNlU7l3uKA6NpF2Reo1tsdSBsSBxg9JuU8hmein+PQtDgiUfqxb/z5OynsbKgEErs7 - 9+2uWvTzZB4N/4D49RcJQC+SY9rR6a4+bY1acVXF6lSDwvgrmdhtYRLh206Kk7GLyWWlW38EPB1v - vG51N48usjrAIUZGwyftERf7eZyqQGQeGCEqxBkjnTwACDUuEwNFuHDcEPgE7wFlM4vUzqnvbJRL - y9Xh4po9fRm/aUGQ7QjSQgFGYkMyvxrOBNoz2u8GYFhQcOZsBwx0s/pNwWHzjtqTFWu5QYG6kHmz - NOMplDrqSg18sbLUra1CifTy2uGLP7+EUQ== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-indecorous_creamery-msg.gpg - Content-Length: - - '595' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:49c83b1c967bf7f87885f8a9e50e375c297ffe1a0f4b4369775f87a1d761d5a2 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/48bcb4a3-6f23-479e-a718-e0b93fd4b9c1/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ/+N0q7FdEEMxFB+ckGtjSjsKZpRrDlSd9P9hQNspERIrjrUL//aTlkRuyC - Bf+MtKJwjHl5QQvwSCmK9j+6tx2r1mtVBAvjB+Cd01Hr23buxhZ2nILVbCjq4lNwdWbbxYf1B2BN - VC38P3+hzfoToDaBYqPY8o98XeHxcn9ogqg7BXF73lHcum1A3Orq331qzrdnd5Hc0uk4euCytLIj - HvLLOt1fahV0sem0GwJjKgKcw3KNYElUipL82TUvVXmM4oxUSRbx7c1qvmMHE3RAvzMmevZUV502 - 5hzDJjtjbdSjwEQbsGTvmAy8Hu8nTIKmqZnLUNQAeNMLUiS9P/jy9eXn0EuOyNL7IcAeFZx1F5M5 - VekU1FiIFWM9ialJT9+muMkDUsgZqaCXQANzNyQDOvankAfDwLJYZtiUXCXAwI5QVzK9PmGotWii - 1DLqR1Rq26WKe/trbztyI+22Vkow4IJVvKSlzFftnDML3C2GnsAbo5+vwMBqkAX4F6m8VBeeMeem - ylDCh8bq2BoibodzbopQsKPZHcbsD14Okno7moKH3OFUReGqi+a7GDMJvN/XkEIRWjTDPnwWlfIU - rXZzpkZCwwDgrflLRVQoUBvQ3gu2+4T3/xE0J7kEFMR+qjlMUVCQFIQAfoTELFIlTOaN9a4T8Xg9 - l6XTUfpvJnz0PZ7s56PSUgGngyQhpYO4x6gHOBLO4+OLJSYshdB+qNB0iTLoJwjj6Spe3u7TTs34 - XQpIPp466dFfsJclGaqGodCghkn+6OYUt3pMeTxX61meRhYjpgdceLE= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-concrete_limerick-msg.gpg - Content-Length: - - '611' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:2d4f3f0281c2b3da41855bafc3c90d3c1509d7f7b2cff3456c55a2b7efec5e31 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/submissions/d8db9ba7-4789-41c8-9f7b-3761a367816c/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//d0r7U80dRHjHvMi5LkGOjtP+uHC46RsTkcshCNSH6++lYRWF8Y7USW4x - 66I8tWPIuuCIs9GcooUKO4b0kLz9NJlu0znbaIJN2OPeCJQ4GsQg49aPzTh6aRtOVt54sr9Lzlgu - d75mTqLtgMriTPKg8047lTxw1430feJdKSXIIPgce2S36CPPmS/yXYQOLMnsdvnpJ0lUkjSU27hb - PnF46hXehR0MKUArrSqeKAdOGUfkXHW13Kzss8tEvcfRlClz9gHePp2lVSvN7Urq8jEwt+EAQIJ8 - EKEGMVgdu+hQenjoKoubG0kP7trTg0gWdYP9jfprQEznCFIsDi7H71U3ek1o/eZz3Se1gkrxTDf4 - 3cTIHRjdw7szTjwO3jGIWe+PslKpMvPm7xxDI7LUk/7s4NIlMIPmHPEWOek/GrwCf5yp0L9554Ti - 4FF4LQwCposVIAmN9Haus6iJdAj3Br17tbkdW+SQmuZ9goRSotlA+mCMLDTIxnPKZItn53m5zHBy - InK+vOdre0gmCs40O+z5u2TPNw4SflxvJbk7v/jmoWMcRlURt+JajxpNPko6zluuRxJyNM3Qn4t7 - gLHmYIKMwjpr9RdHrPkSwxQLzAcW+DITCl6crxRTibi+QQIEz5bSf285lwby+66xdzgqX663KH5Y - p0dV99rZgiLwlpl0PHLSwCMBS9rTj0edt0rrwikTltaCqj5aOsOdCTYH8SQeSOzU9sreZbrLLAJu - ca+7tsvRFAQDl+YvIxN9UifQI2h7Kyma5F6EGOQ+OlAdpPFgtN2lKnX/5LLIaEf3M4uU+BPX+Rem - fPHbDPy/szIORpdcLA6z7AYk/a4i6ngzmBdqEGhXaBqkeVItHR5beyCcks++evNGECfcodK4SLDA - 14pFiLtnOAIa6GzJHpI7uiK4mPUQk+2ccMP2pdhpt76XVpShKkvAgjTexaZBZ3ELKwQDVZOOYf5d - 6FmaxpeN5Tx4/hQ2aN0oYA== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-concrete_limerick-msg.gpg - Content-Length: - - '757' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:9253415712bbff3a68beddda5f93781c81399d5639f7f14a93b49c8fd8539ea6 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7e2de803-ccc1-42d0-87f3-76972745d11c/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ/+LTnLpo/pLzl6tUqLxckEJCSe8zdn+H2XqP+NOQoZ5pcmzqtPjPDI31fv - ibuvBSE5IHzZfvg3X/wNkE1s1IFVRf0kjC8jcJD4MZX4bpyB2uQatoovA1X9J6OjLjoBRbEseRfW - 5ubE0nxFpdCX3XvFDT0371u6GLpi4Y0fsfC/Oom6XI3waop59NbYYqi614DF1GIcI/vXo9B4cOnl - bkuSJ/Sf5+uZnwEhDUkTuFSnfIHWfTP+ENeXCUYRqu/w6dEqnVTwVWWdwQL88Bgvpuif8wCVTA0w - SmX8LVnhudWxRCnPS7GDxhV1OiCRvvOBx80Isy+XXfoTf/UiJbP/zO0zF25FFS8jIWgHxiqzHFDd - QY1cGTwM8nPciaiW5PPj0ghlv1TDyqDIbl+QNd91dOPVqxFt0/EwT+RA74ukHmYbfFnE3BGA6ibJ - /brtdNcgwosfgeyN+9bI1rNUAPWMeMb2qnuQn3KwaYfLSv9hOxkVtE/xfocXdws6zqgiKCS84mHB - zfoeWSPKD+5pGxuR0VNNPezCWRoAuSSeZ7YUEK4PehfV1OrWo9/eAlvqzY/wDMEdGP3aaGLrGesH - cNGrfawhNQsndIlZTf/KTaFxSXIoc/BAP2l+GzwM3JyL1lTQp1d/nIdeVoq8Qfs7EWnhUiWy+03x - 2fEfBRADY8tKxtLoP8bSXgFoywsO2/jD8BHKxf3Bihb7bf8inYGjdVpG+uPyyo1gy9jg7LcNU764 - mU0m+ArM/b5cQa9jmplYDHL3fZ3xuCfUgldu2jvuErfhdkPxZ+F9qgPfYFrpjKbxsE/V7QY= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=1-consistent_synonym-msg.gpg - Content-Length: - - '623' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:1b629a42600affd777665af40e1324db3de989f51d0ec3943857461718fd7acc - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/submissions/7064722a-8970-4fc0-b8df-8b8c05a95d81/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//f06OY7TdROea0h8wAPqHBVj4vLBLKY4e435urytRmWqQI1MnPC2Du5BR - Eb3OGYIsZeuYR27gnkxXQxAMUR8R7NWCn2/6eTEQHh7YuLxIHXFs2uyPLe219sdM/9vPhlWjbET/ - qEPsn42WKW2bFZFvv/Not5ouEfn1PuxyplGaFXKJ2i96pziQ+0rBFYU0Gc/psQ2qqUYT3fG4lCGy - Poi8Fnken38RMRYh0cM/hesB1XlXiIDrDBClGYhmcN6h61Daqgo6Z1k4HQfsDO9B6PR8AQ3y385p - QXhzMN10p5kp7aCRbFCqMgd+eYWWD63NnqyB3BI421tZcULIXow0/ddkZRErg4iUnRrqY0ZJKxm2 - PNAh3B/d6LMyeO5LAC1K1xE07ZAruGNCmTpdC1xXLSoSbnwLN1ORjtc+2ZR60voFWkmp4CgUA/mk - zfFwf9WYsXFPSHTIH007M7LzTFU4xsKLqjoD62z4HBQXDtpfLdXY3Hdb8ybdV68GrCKXY4GytQpj - ZSrHlpfTzaesyBKQpPDkSYjTlrhPdfeE5c41ny50zqwMYMrI3uyrVBdcFyaoRs8LrGqgrtigiUG2 - BgVGwPEOGbpjFQ120lnLS4mvG0M/3oWPOenUJayNhRAXesB9mJa0cLC+9xvXzJXs5ZKH2ZzxB7wk - wc9+9wJoUa3fYiGV/UbSowHMr6W3J365h8lkRpclFeQWogkO8wMaoRzuqFwe3DnMdcQjUG0rmBCi - QUbynFI54RiEinJNDDIVzDp1qx1TADMskGMLc6/vxT/JB5lGBK6ueXCdvCIoQrcUdkpOlvDaFomM - kLQCAih3421QTr055Hz0tAHvXn1nqZHYSh2Njstra1FzMDBlI8yaL28HtgpMr93hShTJwq8dzarq - SI4U99qiJHw= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=2-consistent_synonym-msg.gpg - Content-Length: - - '692' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:8df755c2ad5b82e4c47c0564176df0e406d33e444386fcafcd7c524b8b558467 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/4dad63f1-dc12-4162-9c59-065c88b2a8b4/download - response: - body: - string: !!binary | - hQIMA0N7bDxphDAvAQ//XuxXLagu7zv1n7lcws75pYK8tSmE5tH3eqDw9imBkXqOWtutqRX3E9YB - EKvcPoTSZwAxhU5vdHuWHtMbglo6no6eEyzVXnSUHUzr4Pdzv4uL+0prIX9q1u4b30qsZ6wmdIOt - KkGav+8P2ifFfGvleyCigFBV/ipIailap8mkDIKvxGRmAmCQqCJHiUpRNe6QkEddaBLwdcfOzubZ - 4XxsaGwYo0cYK30+NP8LCgnDtSv7la+mtd+qHVylkascHnGL1nHP9yFbGTxKZ7RvlPixo8qMc1Y3 - INLmgKyOca6iDyH5swWWEptE0AU7fPi8ghDhtXZv8jEknClZM71BHF8YkzieeNpYozvhJLayQvV+ - sDDwe6IDn/hDXJtYSNSa0XHo79hVQafknZAfiMXBSS1LTsCSRkcSHvb3KHoe1s7GqNprbx+p/49T - MFHo2HOJ8/UIcCFM0VoB0LhQlzcj7vORQSNrDpVS3AKgdZPsJ8qpsjLTeEKszU8B/GPbBNVpAuv2 - i0YwAtkNN3nzOQE2Mq8mpj+SYS/iTSgJFs5q6VKN0mwf6nu+d96BteocdQrA27aSMXo11adLbReS - NEUkBjRL9/sNl6d4qGCXesp3DZym6pA1Zf7numhJmqVdFHy+XgmfSOZaSGHBDMpt6csHtBa11mmB - 1w4S6WN5e2jKiVq+30WFAgwDw+fEwKIgGyoBD/4khdTGj/2wC01WQJ4CG53Z8e5mATqpPjBJdNKY - Y1OfJXRZLKdbNvAu9MLzVlQlHmVZkadmierHaDStK5prpxlQHZrrcuWrRjZZhRd72EujVSVwEHP0 - hEYleON7I0LQlc7Dac812iw+Qzfaqk9AEe+0GR9xrjsc13bfLdplVK5g3mc8rJMPbK21L9c/5JUu - tEwMQNN00sbdhqaQ38tAqcGCc4CiQK7t68PnGxpiD6WqGng0v0bjpr4m7l4M0RGix44QFuMh8fOg - ysNxdgikEjwcIqwYvuXYJOJKvl/B3NrLRgSjc08HBYbBS9731ic5UGrMXMb489Soey7Z1K/d8paK - fQI01En3bxq9Uu/px1+W61ckFVxnuJ8SkM7Dgb9GQiG4msoB1y3SoKqpbq9Ny7ZETfIcneG5eeJ0 - e6IvIwKXbEamUBAK91p0FPrNF0/x0bea7i+9topmiVt3N5FweHRx/l/iqWUkXI2Q3UHkd8Gelp3g - 4TEs67qGeM+BwIgIuy5PLMu0ajDjSiVjgZ2BQsPYzwWVjWW9igInW1RSaV48qe4bsgRDhreUllkV - i0qNnwZ3fj3XURBPYdU4W+dKaD9F6LGF6OqxG/M5tR2scjOlyCB5K4qnh0VS83+UyavLndBt7W0E - 8n4aqrGdVwmnSqzRC2WLqxwhlkPkwWRWvuJRvdI+AcBdl+2EImV99JPQeNxJtsoYIeBDmYSXKwQu - OJSgU9W+y7dIlve7qXljjmVYqZ+n789KN1w7J6Y1BxQfQyM= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-sixty-nine_alliance-reply.gpg - Content-Length: - - '1118' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:a37f717849486b9aee64abb4a643ddd68b1113b084b1877331662db5faf2d4b7 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/92b1914a-1b1c-4674-baad-1fb662aed682/replies/d5b658be-aabd-4d7b-89c1-51de7fa246a0/download - response: - body: - string: !!binary | - hQIMA0N7bDxphDAvAQ//TH6p5AOn1CrjxYM86z+RMEqJA3KAWtZRfG0DN+HrRi4U+4jqoRkNuScK - jGMANsbTgKVFe8ho6dS9Vx4YFxcAxrNSRnOAkKqCK6EzSMXQ9ndwhBGTfLQDsMM8UCQTHb05OSyn - MUxwFS90J+WcoeAXimrX++kseH2p5UQGxe1wDooQqSvDJtPuIjYCnpWaNvp72/z153ihGAZ/83Hh - vHC2huc43vtGLKNgYYH3ZualcGBoQVbCGSLxVukaouLC2sqh2gqhSinEUdf+A62p66QexT5SqYaB - AHt1FhOtUey+LKaMskLv3LZc6GVr6UEEsybveRMgMtYwLHIkrKYIB1NQde1W60nUEax9MwKA4ZqN - 1ArV78ssvbVzAFcqfvIuHlXfPXOmD7t8yuT96hTNAhe2Ih9fiYGVhHAbltP/d6lCzFEzvEve0BNj - xwH31OrmzsrGMKTD5xpjaQnJko8enAK9/V/s+SFevWJeGuzPUd7M3ymD4pGXzWAw03BGK2B0+YOZ - IpAoPKbh6Z8FlBL0tujL0PS77PM4s7kxKZ7pWAU2m/PTJv57GtBaw2t7GTpWdFNu+9zx2vygdQwe - SkJpaM7tgonvBvbwyqT2jYbzqCfRqj45AHntTzEw3UZlxLmUvHh+u/LLPvn7EDOPtl5UQANgj6Rl - XnTgw8k0znG3VRJ6vfGFAgwDw+fEwKIgGyoBD/9pw1xQzuUiV+uEuopup9unQa1XTkfL6X72Tqp5 - eCvRNOHHYmThZCp9QHnsJm2NBwHyZfrYgzl48quf86iekCoPgyW1RPTUEGDCJjK7XvtNULsMZB4m - sDzS32TgP5MKzxGmAwQWTj7o8s1QXv9gy2wr/GpVfF6mbHtWALY+fovm1TkQ8UGBv6j9LZcBjqn3 - MZfZnCqwOqa267ToB5AjxbL6X756TMaydpJ0MCHhh2JcGYEKzyp67BON2lqF3pYfaw/E5u+4N8pc - +H5N1E9T23xANWJhiydk+BE1I4moDVTR+iVn3SywDKFqO3VdblMVAEHS8ZS+sTSXi5KJw0k/+v5+ - Q9j6uKeMCSjCwGInby4AQnFhlKXL1hBaYFVAjHAaXZZhGrPZOErESOJAFCGW9WhJkedsi9HP0FUC - TuNZpJB31EDo34+LYIrfBmHXoefL1vwJKHSKR9KNROiEUT0hv6pK+psT3jXx/dCM1H3Ads6D6Rc5 - 1hcS1alsjXoWZJmgugON/U5WnMvDDWlKtgbHZQyelqEzcDvItemBqWNLqfrsDJ9wi4nQiEeih/xq - /uYB8dwYMhi1sW8R4Agn4hsQhchMtiu7sFqAm69KJR2c38x7njcZnym3mEn6KS11ttbv5Q1kBRLm - O8c7jLtVxyxdwUGIBsUfwFM+xQq3cGcZ6Dfdx9I+AbOZaVM3gHy0lp3wxwUiEVWuyRG/1/ys8jlG - Y7W8jTPskwLJRay6Z0wkCcGMYPKnvyIuMuv9gaU/FluEaAY= - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-sixty-nine_alliance-reply.gpg - Content-Length: - - '1118' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:506b499968c47ee42d2aa758cf2043499810091417f99d4bca76a2aa239d5b52 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:01 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/158dfd73-3cb3-4a6e-85b3-f37ae54e0802/download - response: - body: - string: !!binary | - hQIMAy2m2NzuNpRrAQ/9F+gtuJpPO37A/NM6OacHAK+lBUvHM8icpiAz35EqSbr4OnnAQ8IRX0MU - v8Z0QpNB7+MCWlWY4QL59zaBuqHwIeg6GAu/szkpRxhD5eKAvRa1ukR9XrQ3pDmpNHU7k0l3x+jI - tmKqt7WtxqiZ2GjIDTitpgowd40k3Af/BeYQ7IEHqzv0xbpsVp06+RtLFc05Tg2mVlK9lt5mJmht - VcZQJJ3P+d3wcROuKuwmPqzi5FAlLQx3opOy1hbukgpH4E+lBSA1EwsYZ91/4AgnhB+VLgw1EAsh - SSCtNfhTE5AAaJ/a78zf30ukkZ1v+mIaoX+MFYCk0/eGVQpuElK2OO3MH8lrOylr9/388cP+aWC/ - iN7RpXdUDWi1iUtLLieBwyVYTNnw7yea7Mbpme9gwjk0Jg56dgs+npUWLp+BTChxWAR67nG4M6Fz - vNZbclyvwyrojcBWKDnP5zIGIxGFufNeJcAehapP1SVuxuOO5aCwaKSZLiZZeINDhn7qJ4rNNhja - 2fwQbVmMW8WMh8m0ofijC1mQEj6bh0ElUzkZlRcD1WgfExnlyHov8AWu107IidLqalAU/rAGksd1 - CxxCUZ8cixPktyV6jE+g2IMGD9iNKuXmlJy20ITvVpaq5OHT8lUPJIMyoZCcfbNJy4ys5YASATH0 - aDU5C7iLg3hWltKCUU6FAgwDw+fEwKIgGyoBEADbIZ0faKpZjWxU8Pu6ZGNEphU3jYPg6CT1j3M7 - 0Sc0kBu3WZZDbAH3wUPbMCD4xNnTWhxjBCUmqLorPXXXm2LpE7FaApUS/DXl/TjTdYlKml+MsXph - AhdQQs/P6w/WhiHI92UAOdWnAtKebjMqh23oaFVVuVdkdXEdz62aSOqkE5PLJ4EggzaAEo9hwc3H - m/zq6f5bxS2BdgnEUuL+4Q3iOiydQ80obTJZNIRDPL6cmC+XKDrDA3sXluviOA3ct8nnwtwtkSGH - /cq81wt9lNVxpVriOZfFIe74bxJ3PQvxaLGpcqFg8nT57bfVzkhfPuXYh5AlNO459RUkiaZa3vmZ - ZlltTq5iNIrlTPqX6GerzOCHYYu3CT64DgviXF9isKgukzDyZmeGJK/LKSG+uC/CuBSzF1opE6SY - F9B0sUTTqPJ7mBmJU9wpoNnQG9uGx9/qEqRJ43k5KNGLUs8LwtqBhKkBNUA9HnasfjYdDvhmNxxk - ENr+Vg3IWqwsCrSeaOI7BaYiokDpympu44q5NZ1f/akfXjdcdO3Z7fStB3lOJX/ZvVRcZyg5fkXd - wSg5jQ3Nqyq1ZxTkuUt+QHx+74VMQXJ7e/w/OLrJNoedgLM4eY+U2PqhsdNY0qXQAU10eu/yoK20 - IWQoqBEDNvKbs8T0zTELQ8Rw3527ujnro1cfoNJtAfJ5dQ7MOzrxUSNXW38Y/O2idXYllsvRNodt - kahbwINUfi3i91KBHXq7wAdQ8wODMmyLXZ5tJCbnpkHrH9wx0/Q+W8omR2zjdOgD298MjO0f0wAR - 27+cdwdzVlNEWsvI2nIr4bWhIq/iEq0ZCw== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-conjunctive_lavage-reply.gpg - Content-Length: - - '1165' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:10 GMT - Etag: - - sha256:6fc20abac42bbb6e36d08f16e84de997605c3caa88b1b6610cff5453f0a78bd2 - Expires: - - Thu, 20 Jan 2022 11:09:10 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/replies/24fbb6b4-504c-4fa7-9971-e6f2d1447a48/download - response: - body: - string: !!binary | - hQIMAy2m2NzuNpRrARAAv2fCgqOcLQn5BgYTSajwFM4sm++V+BFhV2RMZ0Ywc7yIGObndNc3H4v2 - 6CFo9OdMA2+uQrRzF3sNwoFn1tFLkRLZR4g2c0R8cynrB8XYgV2dR+T1/969ZEfOcCpFVOeAl8tD - Mld8VeC2HIiz7ttYMnRhO0LSuDEegI78z2idd/ugDgJa6oDCdtC1H4iFWiyES/arZQhlNBonZcJb - K9ujj6KWKysqB78+APhIUBF8DuAhRFv13raTqR+y5YZBJoGLqCt/K37Gkj9oV2Ty/juFBKKEZgiA - wEGgIYY5DmytKgErLRIZhKr/mfjeSpAgtMLFp3MLH6BXASzbGvUZoVmPcRcg0zujYARWu0cj4NFf - tfEHO2qqW0WQIUhzjEYvQEf9lbu4hZp9tNJ71hASCXJpVMJSkazq/5Xnh2ukFlSpvEaSOl1nX7jk - 7UMHQFd6ckTIssp7aIrZmBJB3kfcGxSWkCmu05fMFDr6LfxeyfJlt7kDv4PP0xHaY0A+aJ4Pce+s - WSlRkl6akI0+ZLsADxRNq1MwSVi9G9wqgoJ64CUJyjo9nMWZUyNISx7bYnZLG/0RzS57N6iXQkwf - pr9c08+zL360sJGnJOKSaAD05VCgduE+EbQ02fd/GN8sC7pJ7vc1bFoOssmIHjVtuJtCX1hxXuie - wzWk3g9HLU0Ge/P7wHiFAgwDw+fEwKIgGyoBEACzLkBPhzq0XbOkNrJ0mgsG6Te9AIHzZMmCpSJB - FBBaGUwkJP2njofVnMzUzGZEiloNlHU0JqU2h+OygDwKZWopcnAvjSf44nSXVLariWywWWtRrTUp - /qLymnpIEkK8LVrwGKwNhavEzg1xRM0FadGTGPOpHhm9WWU1cVU+zxy7JD/RJCqByXhZgwBnveK9 - 7o7/8MtANWmmEK/08zzfRKJAUDjReQFlbyTLtTzLhZ5qRapDPMHEc/5iE0FyArfUxmzgoC8abvuR - xXcE/rqj+jXpekfJGnh/b1KSa//3FU1KglcEN12aDT09hfYZLs5aNYLfhRGCsGUwI/sGhyr7fTEC - swj4DcYhbRdhcMn2LvaLLxHDzT/CYwniLzryFGN/yYFqoWH4VNK/k+fd63ovJoz2gvTOGtF85bKP - D/djVUB8ZHrwQPYhmVPAq9GgE83APidKDVpiV9o6CoGc8lNVnUNqMg1m8OQd70wxbSNQR1UscTsv - pafXWb8BGCv2Dh92nGgYDsVG4Q7kyxacH8/6b3Ej0NAxlmq7T4KEhtK4zWAxNW98fuXvU6x/xOed - GUyIJcC6LRy2nvHKpebo+x/m9c+z5kL2IkNszDrn6K+v6zRge5KjwB8ZVaQWviVOCO4XBevHyBM4 - QUTOhwvaKSO+Lfr/d6SUkFeXPW4DszXo4aPFPNKKAdRQneC2tRM/jHptBBJcUOh30yiyVZXtqyWJ - bUWisJsylbza/CcoxEe9YVWqq8LATiXuOiIovJw9Hl4PowDN/a/tzELxzkEvkSpliChiOETfCBtz - sUrPgThyINfRHpw1vW0URz4mXgArtxIVXf41HDU6Ks4Jk6dI2ZV9RIfHvP/D0pXi+cBES5kf - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-conjunctive_lavage-reply.gpg - Content-Length: - - '1194' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:3d7492dee4392a2c7180f236615ebd6c26d772529d502c5124258127ef40a391 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/1c2ff7fa-252a-426a-83e9-5840cf657739/download - response: - body: - string: !!binary | - hQIMAxGWEiPHDepYARAAu/TMRu3Ff5fRgQqO/E5Bv/94dfp2b5I+AyQ5+ejoEVp1xxS+IiQWM+Sn - YWnrgUSCRlPRZLzlgORkyg9hV+Hke6/ycie75w4z5C2yLMp4fS2/bsIsAfUpd4diUUjc/L++RWvw - GX91oQB9aFsEJxiD6LIb5DvXf4EeU34XmTGpTUNx2st1bcTvsw3ApzsW4isLgZipHKYekOnX8qvx - vpOjVjyWEeSdNNQg+hAgB9JK+vp4Ueykhyz5Xg6EaPbWciYV/pgP4kDa0yilHImH+eSABa/SKUar - ykt0ny6BbbyfvZJXCC16sHuCzmddXBuhoEm7Z9dn4cBbP/mWVbkw0aPTYEdTNYMi5pMdVvSWlLbt - u8A2wKiOCkzUkaguZjbsJVJPc+jm0XQuccVqTdQkUiXiKZWw/pFxgc0UgqiHF6cqO57xZS9I7OQs - yx2CrR20ITwb2rRUxsF5SiUvGN39aj/2ycIZ5PGZ3dweQHDOMo5kR47aOph2Ac2BztN/s3x7fqfJ - 8KVjxCW5xlv9yhl/lIr6CPgH+4NqJvxQu5M3zXVr3hTnoTnBoLX/g7w7oxEwAi43jI0FEzm2e2bn - W03ezM3b1P0uLeNx2nmqo2HDZPJPItU7BgN03A8GBmxPsDojeR0khzZtWPBPPxIyELg2I2gPvDo+ - hQI5s2Zwu3b1jYnq012FAgwDw+fEwKIgGyoBEADHptlGI/S5RTU8LAGF5COwuVWEIGieqkNRnIEi - +aq3ln+i9lDHpbUoqjtcxAGYaoC/AkmWwu8Zb08LPOw6yGj9Vq8HkvqYoF3PVfR44gr2g8MGBGhl - Y6NqAAXAe/SxqGJUsN22Ag9TDKWcMPxM/K5+7IqQixQy6FvqNrQ4EHwAJUxTZZH/8A9q0r7SWYtv - 65OSbkaIaO7ZNxzqvCP5f+Ut05BX7xpVlJ2JMxZFGZCy+s0/0uinhtPbUsL3XqFhYVVFFGlfMpSX - KZMNIo7I4e+NzMpm90gHQpfbCoR2zafhxgADsEaHi6LNrm+1kHbL/acPKctAXbFeWFrUbjceBYH7 - kVdEVMP5B2ycHD8ER3HRXAOD/UDHb0Udn7zHNXojsNFQ30A5PD15IbdyL10eGm5LaFndkxcQRonm - 7ALRJXXV6veXtE2glXCMmbtzIBycZpxipEG6T0046uBZs9XhQ27UoQ88d5ar0MsgoZkTK4WKQlUv - Sz78qafEIhiuqxSNT6NErjBmgxFCcMvu1OP5XgCMsSBYCIVB2VmbrFVv7mpH0apqc9doMmveYsAd - L20u7ejj82IipaNxJNvTcwuXpbWt5woSIgY/icD1v8ms7ugDbPxHgUpqDOUhC6PBtTq/so2bDxEA - peMxY5zLQgFxKFJW4pOa1104hg1TdZyClxU629JAAfXer+CmTbev26iQ375glBw+rNyWA9J+iX02 - nu/JwYp6Z/VLK8FY/5WWZicqRnG/4G96w6zHwkB5zD7rF44utw== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-indecorous_creamery-reply.gpg - Content-Length: - - '1120' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:8ea8d0a16663cc9b7d4f2e196ecd9d675ffef7f0f9d6b51294cfbba8ec99eb33 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/55fb95c1-cff3-430a-8c05-125c67c81a6a/replies/5f5707b7-ee1d-410f-94be-1ba8c1929264/download - response: - body: - string: !!binary | - hQIMAxGWEiPHDepYAQ//XWIBkXv/uh9NDRr86nlqZ2gJGssV96HBp10HVUbyyyEVcQkZ/evEOizB - 0gFUBwqBwuYAazPNgfgEVywXbAERoFR9Pul4AjVCkHqbrPOl62BSsKpLSAQIV7YP6AlJPiO3Pm0R - VeENWPAjmotBHfpjgVwiCkQeSyF8lCAveo7707ppJT2hCpxjJQhtt1WIyOQSE7dAaAnywUWbBkoB - HldRJiKQa1O8CsNm8ELPQaczYR41LZpPHZ+h1xLR15iGnXq630ZjDagxok3+aXqI20MemxYOvIc9 - nqxQLduSWS0HykifTX7wYcCGj0PCFqSSngNUaQTAjSQPOQjUurWV4T2/aT2ixT9waPaHut8jpQ6p - Lzc0Pe/C0+yB6YBJJxINovWHzGL0N7ZWuPVmkWgPWcOCPu5Cc3pn+cv5fuqoFuJs1/G1t6eIBfqI - BMw3FPbq1sa6QUPY6RDqpRE8+48cHpVHBHKl2wc1uL9WVUtZdFKAOz2bLu7CAnD1VmZvpszgq12/ - j5m2UwINjo4N78UbjClCMgbyzji2hM9q2B7qkTf5JFZtg3YbhNKgiJxDpTcYdMG92BQS3vYjxHiR - FzxGwbDuQofyqIhIVhlK/42NZUns3W6Sm+fkl03fR72xtBdGCdC6tGJn7lrys8B8JG/Dr3LsBqnr - A+KqQNu3Xr2ERHqrie+FAgwDw+fEwKIgGyoBD/4m3ypz6E9TqX/mm4Syc+KOtfb1XXWGlrI+YtxW - cZHPI+9iCA2XamMXFdYDYueb0CGB0bP5l1THXkYsN+kvuVzcdXZ2hY1U8tMhvU/UNirdau3Is2vJ - uOQ1cqvJHTkEXEZdTzZG66Q6ZjtQjr8OMH4XugC8TKIR0gq/xmxk+49fq3FT09d+bccvFJQil5SF - +OKqJQGVhKaUd3bF/ITS20psYNcR36QK/QDrCM1dp2s85wWe8j8cnVPscuJ0lbX0qey5tMUf74Yn - cT8qNeKhh+Z/6oOjBB+UtcN/c6okMAKbX0IoQjCskE/D6ldscXkYTx4wVp3CyzZMGwyh8sjDBPp2 - ilsccaHhybJkFx+qwx/R8AoLc9za7qlfU6BYvasGeY0LmU9DCsS+fMQjL/34rkq248h4mxee1rap - v9vipuGDkd8EJMZPkR6PDL8iIHW13xqDlTEAkhuD0fsFVxGxdXgUdvNrPHb3/X+c+BDCdP1OFO7S - 0SQOO7NijD9O3NhNuKBkW3FnCYHb6sbJ5XRsD6h4LbGe6KwH97xULC4jRVSIHssTl+Nozcv1Xml1 - 4AJbGtcpna3Fc3Arjjop8UNoDntuDfXEuRulX+Hckib/IrIGTqgoEHYCEd/RMhY0ZE2hT/7iQBaT - FMuOpyvlV+Mb6zjynz3qy63WKV/cIAT3LwrWOdJCAelNp8jPH79glm+vZoeaZwjztzVucJRxKxtN - CuvLf+ziRI9v1FiL5GT5LAPpr3jtZ+qi9j9rKKAtPKfINXiS/B7M - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-indecorous_creamery-reply.gpg - Content-Length: - - '1122' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:1342def77aa79e3babeb0b709cf3dce39e69a8e1e04ec0c6a41c8aca6a979600 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:57 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/9bb8030a-8561-4a03-85dc-e921bd6a891c/download - response: - body: - string: !!binary | - hQIMA8XplUsUkq4fAQ//dI3ZLYYvp5nURcYqnL+N6qkdzdZ8SV8zxGd6wI7Oc0pUQy6Ri/Ap4gVy - 0fQGy+gA6QI599dDmRA5IxNV2GJ7D1KGkwCw9149ZGx3s1Qk9xDLN1+2xr6wbC8WnO7aMb3XPnzK - hEOpX+GIxK+PShiwR0TedX/M1ZybM2YNxuJn14/yO3tbr9nhBSujuQkSitQ7xdccdYiO3laJNjD1 - h1/aSUPz5yz77as0ctRoSwjl1+JY9O0RhclEKF8T7lIBms+uZwZ9b5yfQIlaMHd7iGT4HdIUMPjn - QWn2JmJ51BEa5G1Gpu6wEu5xT3fl6Z3Il3T3ARY96z9Ps2sCQg5FrTJ7U+RRj7yt2Xw1PF13DNpm - Sd2y6Qhpu3lFkM8cIzI+4O5mYL9Qe+9vy6B83vCtDKUZ9jqcZuGa5HD6f4Gzcu0FW1WAbCT02MAY - YTM8p/tyAoEIKgKcQxFmEhMFbaPOQ20TRXKb+x5sJGh1i5M4CmMQsGvczZrh13Zm5QIw9cIqc4de - uh4WrMsSHlGpdR+glbzRq7kCoofi3QOSrsTGrnaIPqPp4M3VNNJnR47yipKLFSGMI4T6zqHSTelW - ClhS4svd0qSPVK+DWD+XJ5lHrUDIzBM7FyGEkWAQpbqHIaE2fAN9QloAkcTPSO3A3/MdnYKHFLPC - BT+m30B2N7D1S7HC5geFAgwDw+fEwKIgGyoBEADBNF5oX0O6LpdpJAYuZpoZkVbZ6ZK0uc9gTh5N - CaoJNS2gHZtwhzqfgFzZVu9hERuUFvELXaeebv8zxNSRohUtIr0uDOWA9ZFJ+IrzaSBEfns28jkX - b8GsGeJQ3FPnvdp0LtOGAsrUGj02e71lJOx8qCfVgo1d4ZKxHpCSdC9+CoZbCxPE6a4TRCiE3Khu - /DDi96t2C5jNRHCIsfgwbaBB7sLeibkiIMhiKYGMz994UmA82XAHPdkIgXUsgju1UwxKfxk84Kwe - F1hybOfkpjAJ7kH/E5l5Udy7eEk5kz1M9TWr2UHSpY48x/enEOWYoAcC7f3tBKWak3WrxOhCjPjk - /7y1vSMKCLnsVkdNAJ6DTMpjQsv5aKuk8UPMTqw5oEl7JuFKcwdztXOgtsVXbe8t0rYTLMJlDa7w - 5Q5erJ5PdkQexpccw68Xswa85GZSUCIwqywrW4v5T3oemN8ZdZWuACFLBPvv6/JeRg+wEN4lk7qk - 7Q9FnjbY8a2Wn6ydCh81gAm6XQn5s+HH5FEVrJBbVrshDXZgdFIfLer2yyVOBOi8HnzYylxBgOHH - IRRlW5zV3c8bcPsX4doyiHXg6Rq8xs0vJRghqVRJXLYzsp2KG9h8gUvvX4F6I1o3zTE9RRI7jUVQ - sg81ViU4toOfaLcKuwpXKfy8tZR37+FZqWQSy9JSAdJ2DdIiaFHAX636/MO3AYocVMKsfQHHMmES - zPxveAOVrYp9wctgh3dNe7tJqFJZgObxmyKWdeLTmC3LE0P9d73Py9yfqmlZ8ADishAQToTzkQ== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-concrete_limerick-reply.gpg - Content-Length: - - '1138' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:be7131a49df1b7a26d0610a96294198e1b27d7f13c18fc7b420132e9604e878a - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/ae59153b-0871-411a-a72a-0f4c41a76ee0/replies/0a82f046-581c-49ef-9b51-ce5b73e45c1a/download - response: - body: - string: !!binary | - hQIMA8XplUsUkq4fAQ/9HcK9M4c7Tks2GRPrAJgP7c3FGgz8Q/2HPBNoc73Fu1vsFUy19Zk2UfKX - 5LpKqFvMvo9T+HZPscKkoYpru68WahEAyIvdWRXl1OP072usBa/pOel4MdsX0l+ShrjK5H860zp3 - shnbNAhmpeEJ2TNQmDNj6UQsJWTS6hMoxAWIxBbuScqUk5T9oNEL7BSxZQnBfsMt50EPf3F4Fcn4 - aFRwWZtQZYlJjTodr1QiPykSaN88+ipqB2WatT+zxwBDVhjZTDWLZeprizvV+Ezxk4HwkGVm4C3C - lGquJCjAKt6t392zDVd1jEy83ctiu9DFZ/RBuVt6ath47JpXXKYu9Pm+hwYOZ5jOlE1C6z+B4xWd - sEDpocvIUxt+8VZx7DGACGRzHbJ5NapObt2eX6sQgxyMOwmg+bYqo7DHfbyMdPLY4SE+mytI0/Z2 - mm3/6yOOnAEOl3+5/M7aUPH3qUy/4S63iJKQ2banBSD0yDNQ6I/0MnU31AysERrRCSdxOExq/9u0 - IqHhb0In7hX+6EM3mQSg+z0AvX/xHWcn24TeSjMv/9WMFcasm85Xb305FVFrRyeMPUDcrbwepp8G - J/pj7mldMCe+5I17pxnQ8sImFt/GZG8DqoVrR6K2s5s2DCKywizUjifHg6L1sM8gY8d80y50U6mR - Tr8WNtdIdVuANcufU26FAgwDw+fEwKIgGyoBD/9+mmWhGDd48AshmcJ2SiqkgYuYUdp10ujWVZNx - IN2o5monN2AXkTyLUH6h0f/5HtJEGkoqXzQUs/DysOIRu27QqMS4BjW3fWXfqcKlBXItYHdd+BBw - czdqXrEMxdFv4MiP8q796+keQsJizPInpyApvFz4j7n9oLyshNLU2z+QoDkhKir6q+kSoDkuySug - JS0qzkdP0zp1QF/IzmmdyOLbApIZpYCY/wJMxVrqeBijl6cwHV5O+PMw0415WRxNXZ6PzEGzMeX0 - zSgputz0Jx4f7wpRjS/jgcP66VHTAl0dAKtEY7FHPUS68/0tBhsLOYGv8AJA4evAeVPCWhj4zJH9 - dpTTJd8PDOapoQH/xBEvt6AN2WKXeDH52tl0QFdtmVDPYjbqo5zh/qctHKv0QdsDjZZXmpnTCrfq - nnLRagcPeW6YKyn8yhrP44VR6Gzt9CSN3HGPmjfy72vqnyB0rEdkYoSEZZ0hxTsZ3QMT0bZ7sDPA - XK19LW9BRzjZtlKSFGONuciDN5lR4tQntGacjMcOj/xGe65PmuL484mak/900Cx9jwrw1hdq+a9e - gpKDsc4KG9suXkiJrzEHQE+18kgRBvoMZTAbumECKOuHUgZ919F1GgV3No6XjQZ+botyN4mgSwJm - VIV18ep7w0SQF8Qb+BCo8mbS64+nXd4cQfwtktLAIwHCxqT2yTvD2UXrLQXoCIvFP8xJ6T92oCgn - sSzyBciKz7C4EQ6N9dKQSo2ZXHSRO81/LuBGhreMQnhYiV90OceTFJ+U0nFWh7smggqjZSlqflg/ - W5wcqd945LAnGlQPky0AQcOYl5cFa2cHE6FZNhs/hQL5CAIir9AosMeOz7A+msaijWWsnkfc8KAF - HIrk8/qi7WDKd3ni++4dUBP9+xWijpy6jHzD3DJgP30sXFCDAjlvz+4Qopz4wXTncY03ypkcEGjP - sGsGWkGeBwyOvdxwk02XXTWyFv6aFenv6dNoJ/Mv - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-concrete_limerick-reply.gpg - Content-Length: - - '1284' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:df33b47b1b077cad3b8ab00a5eecef38faf353be83387ca4ddafe193a8ee81f6 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:51 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/50c5fa95-eb69-49b6-8599-62b12cff7d7d/submissions/2281fccc-4cae-4228-a837-e6f3a3e1e6d2/download - response: - body: - string: !!binary | - hQIMA8PnxMCiIBsqAQ//VfnuIjbnRUrH1WRMvSak2SMigZymPdL6hvNluiuGz4hYbNIZqKWVjuzy - 3BNnWhvWpljKFy86NsonbdF29kuOIPePWLdXVe8mL4a3Kc3IY8T5JBWsvnkSl3TEaRGrlWsG5/ag - 4NkyBH45p070Rr57RqVcUBGe/ckzVhuiIOzmj3ujImMGG+ozo2kWPY2RfovqDtocUzywbh4fxtRD - lZQ5lgercImj8uvOaR0vbGzl67zg8HN4tz9U7QMxd37M2+PEBQoNILaRx2OQwyXEAjP89zEbqQmB - +N+I8WcHfvnj5V95JQ9DJP3LjOBYDb9fcesY5mu7E3yDzrd7OJkUhAimik7ImjkeVTnJx3IkNiRp - GutO8DunsgomolaehXlZrJ5dRU/SIISKcEPZlXc4sXpls+zS0S6d0hhwF8sgOKmxv55hWWe0+2Nu - nkXNUR3rxxKYyYf4Pv2VPJVxnr9+4u0MAAV7q3ztemLJNSAS8T2eRX3pkhKo3tRfDLvovSpqCIqT - ZMTSODjs+whuLDoR8DZuW+rGllZDu9OZO2V+UnODrH8ilbZ3wxt6Ryo6MR7wZbocbrMYNewtJFML - SS7I9xVzHmLDSfRePHo+kXa2qsD2nH7TQJ2H9VIyA21SvHVtDuqTjiZPSuypsuHldnpJbnrGQtX3 - CChqw5bh+aBLR5K8t1TShAGS5bRN7WaLcnaEqZfWFHTduPGNEOtpHZtVnxWrI/Khxwlm/HDmmuRV - I+CC5eQeIv1dQ889/JZNOq8z8EuofNes2mnw+fkEWdyFfllb55HBxwrtRYphlujUDTVy82+FfY2a - ozhTgY58FyjhaY3t8Y48vMHJ8j4BfsXkTHGGXGDPuLBg+Q== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=3-conjunctive_lavage-doc.gz.gpg - Content-Length: - - '661' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:1ae2759fd28879da3d3ba964ce8dfc13280583a08219127997508118eed6b4a5 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:09:00 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/9df9083e-1ac1-4085-883d-8c9982b6ad79/download - response: - body: - string: !!binary | - hQIMA6YSKmjUcDXDAQ/7BrrIWGBja8P2KDQIoAT4IclJDo5po5P93oEFQpUnbOUGwkeLnZeY1EXP - DPthD6FUmgE4p+afTgeAJHa5p7aZ3cBunjGpx7CUwXXubZTEt6nl6xcENtfrgIzUG8SZDCDJcsZS - kXd0JMqxLswy0eCiQo+zDv5BOf5TT3P/RCCWI0MYWLSchTkdjyGeWJd5+SdPlSTHB54J+PGOipQ1 - 6FWWxpYA5/vYVWg+4vwFJt3RYqUITyWGi8RI5E5aXEhMs3bShrXZ1WLpjpJg34ybfNg+ZAYq9ava - Sxv/PR0NcZRaPAFz25DRZIB2IN0pbNOsr17nKEmOszuAfi65+VCBNGpuGtb1/B6VnBKZ2D1beUEJ - oVpYaSr/VU0eEv6YcsaqUfaGcNyzpipfqQ1aLYXyhdLzXYKlj2qUQpntMVvfa9tp/p+FX6CxyG2Z - vCyzC28sGaQfizjYeVqV1xxu2/Q2Yb087pQgq9R+JWNgy3uyDss3YrooACirO4/pYc8qWUda15Hp - xIqmgnuYUJ0/albmzwc1GGR2AFqYALnhmZodifqvhmfmICytmh8LQhEVVInVn3ma6EMcFd2p6z5K - a8Y8G0bN4c79iFK75bUg3sNvP7osGB427a6JicZu3uMGzl8zH+7UFtOVeV5zoPB/USoHBJVLwxbG - 3EphCCaYHpk6ER7DRz2FAgwDw+fEwKIgGyoBD/9rgU6OldLEAOLqLwSF1gq7bgBfFzYHiiJcsSyr - +XTWr58Po+7pbGwBwIbr7eOmqga+hvJEDUZxYRkd59fgrnKU0GB882ig0H95Uu3kdzYIG5g79KVA - UOsbHAjXPSpm+8w18OLxdaz/rYM6V1M+Td2+KnPPcdETMLRliFMOJvj1gAJmKXQNhStnkJ68nJNC - I21O3GcU0suoOXFTMtLSqpFZX6g0BXaK+WN3dw5RM68zZ+eFvanqfCCZwGUx4KjJCuxAVsUP9eaH - Jga5hBbRBdXNHcUlrMGJW7Ig0YMZW3Sao2Z75rObITzLimMdMWay9Qfgh91I8TKspFiOLYe3yvxW - oRemzwTeC6vQX8RjNzzHN1zqCS/7UDiHT8kMGfhldo0mmVAwf3Uwl9DHul+T8X0Ci2551E2KFUE7 - Muj9VXBs6+3Uo83RCKwo3HIHMlYIyvpoThmP/w2QFEYJc4wQfCDl3N2DjdLe1oRVwmi82oRn5/8O - 6HlJYoSG38NMgVXdGG3UMrlK5S4yZ+gWtXtXGpqCihc3pT1VzNs5wuZvmxlGkSDDWMKkHu74TaB4 - 7dwKsPhctAPlunVxgy0tjUUJvU86gkGy/Tk/DqKfPwDMwMbMuQD4MQuYuWgcoxp86TKKxkmjhZYq - b0uxys2dUyJqjaQ3SiPjRTM7PZrA9nl2S6cmENJeAYOuj+E7SpEkg0H98JvFb71VbMIMq7BWYGp4 - 8QwhQ2ljYD2T8K3TCBJ4Z0caYoI44kIFBmDBwva6DRjYEv5I8+SU8q/cXAcJkemGFs6ncohM4uuQ - eXikZTP4UDJRUg== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=5-consistent_synonym-reply.gpg - Content-Length: - - '1150' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:0065e475a3573a3aab789202cfec080705a8b07558bf68612591af3a10166942 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: null - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: GET - uri: http://localhost:8081/api/v1/sources/56d6777c-fdb6-474c-9d3b-0b7b43beabfa/replies/ba38afd6-aadf-48d1-a599-bd74601105d9/download - response: - body: - string: !!binary | - hQIMA6YSKmjUcDXDAQ/9FNU33HR0bX5ci79Lq1YwYMPu9QUmS1qviasV5DFtV/YIFaog+Ip30R+a - DUEPVCMQuOTfJd/zuX15bFh6BbkJ+fVfo9GsGW6NrgDIDnt7GKDmbcm64CVvtAf0sa5KaU8405mk - LseOtJAuKXxBm9vNRBHjwgxdl5zZprIhjAa/biJh03jy+BihB5uEF5gGqLVVIRFIZQz4jA1MsCXQ - 4EpGjQYCsrBqPzdKWLRhmfWZ7h6GiWHzoz0LYMwqTxQcMfcOYe5kOZ7yvRO71u/MXXn5WK6u4CC9 - PA8oh45bbJVdC6I/fRxcYh8RYwIhxnDl5/EtW1CEknCkNPp/GIlEvu2jAQGO/bK/paGoOyY7wAgV - 0q3aRGHRoUs+DLG+WQ+YFt5jN2P4JAiW01Zr8HLPl+cQdkQUlprP19ODTepGQm1lwK37oPHvQwtg - PdpFpJDUWFkbg4q0hpGTVk5HCr3/DgNHUk10Hae2lQpf6Q9P40E87cOwsiJrWsPMpL+g6V4rebqU - 2BPj+CrWpMgHe/zuy0cwX2lYj0Put+kBDoXJsDQopn4/Wc8aISmnxLMfpAv4kXA6x5KhvApuGZ3L - uMU63cb/m+5NKeGqpo1kZOG0cim8lApnqzFqwXjkbaoDL6W2yGsX0VZcugjvU9IFifAAoiLCs/eU - 28r9t+HevU3+fhAT1KWFAgwDw+fEwKIgGyoBD/9etXKoKImkUN7va98DeaW4fE/pqDUw+2vU7CVm - DkcR6ay5okyHbR6zwtjdW8EHscStZR7WA04e8YxwqkVSlVecDr7Oey/WaEqT730+4HRUI2QuMJYk - 48sqf5BlGd+vz7+hv0jRB4eeVPwRZm22o1252jrrbzwgvOncNKW+F25rOQEMrGo2VrweOwzjsUQh - Bk1HZGrXfxnyikH/mFQe4qZEKNbf/zu2dYz+9z4lX+G/yIrdn/bACQMulnl6UNQKOF6curDaysPb - BX1xqFTHjeCzQ2lQ73bjX3Zhc70Sww6MR6NQuz4Z8cJ9c2LCpbAT2JCfCdhukedjrS6SpeULveP9 - a5g3vQJevdnwPITGAz59Qsx7Uw5jv/cN/pAGb7RbzvQERWPJFKg/MDz2cCIQb4gga1uDyJTXzY8j - xXZ2h+n9RXac9YvpzlstyDg+9H02cbJn5z8euQ30CGKwD/Ydls7X+Q4v6QFTdZxJrQiIw+dBjOaH - Y4c5AgqYwq7eYCDlWEromT+nBfz8xOFo7/0Ea7iU7eWzvPt1z7X3i2rUOU85+m2lmgNxm1bvr/oO - hJpttyj0k5yv1nSEnwzgjC/HNImQLawyZhAFGM1NCn66Sk005EVPFppu2zodz/rMRdjTm2JliEBa - X4VmnwUtaEE6CqdFAViOFum7s/CFNIS5xENngNKjAZlQSzKpVecUBuO3nndeVxrdmd4B8n4wLiTo - 0OqNHOhmS4r5sIFdYmnNfmeK6Ksg/yS094ri3D9MeHHEjAXwrw8FAh37cyN73kdXW0sKtkY4VGIf - TDDwwx2bS/muPCZ3VfgTLHLuZrwPX6KFpkRyJyAX8UPeZwN312yqX3mcrtB60rPlAMtibq05KKSd - rqK/U9A1vzBorijE8RNFXihbW41PvA== - headers: - Cache-Control: - - public, max-age=43200 - Content-Disposition: - - attachment; filename=6-consistent_synonym-reply.gpg - Content-Length: - - '1219' - Content-Type: - - application/pgp-encrypted - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Etag: - - sha256:3a1d257181881c338f2dae2618c62d53f72da2e93789d25b032bcd6a72cc0257 - Expires: - - Thu, 20 Jan 2022 11:09:11 GMT - Last-Modified: - - Wed, 19 Jan 2022 23:08:49 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -- request: - body: '{"files": ["2281fccc-4cae-4228-a837-e6f3a3e1e6d2", "098a7d90-0ae4-47cf-a7a2-2afc00094a3b"], - "messages": ["4abcd4b4-3922-4ae0-ad97-9186f51e172c"], "replies": ["158dfd73-3cb3-4a6e-85b3-f37ae54e0802"]}' - headers: - Accept: - - application/json - Accept-Encoding: - - gzip, deflate - Authorization: - - Token eyJhbGciOiJIUzI1NiIsImlhdCI6MTY0MjYzMzc0OSwiZXhwIjoxNjQyNjYyNTQ5fQ.eyJpZCI6MX0.6NIaLTlHC5UOUsp52Tzm0KESJzyOKe46QgHLSNrhpvo - Connection: - - keep-alive - Content-Length: - - '198' - Content-Type: - - application/json - User-Agent: - - python-requests/2.26.0 - method: POST - uri: http://localhost:8081/api/v1/seen - response: - body: - string: "{\n \"message\": \"resources marked seen\"\n}\n" - headers: - Content-Length: - - '41' - Content-Type: - - application/json - Date: - - Wed, 19 Jan 2022 23:09:11 GMT - Server: - - Werkzeug/0.16.0 Python/3.8.10 - status: - code: 200 - message: OK -version: 1 diff --git a/client/tests/functional/cassettes/test_export_wizard_device_already_unlocked.yaml b/client/tests/functional/cassettes/test_export_wizard_device_already_unlocked.yaml new file mode 100644 index 000000000..d59c25ebb --- /dev/null +++ b/client/tests/functional/cassettes/test_export_wizard_device_already_unlocked.yaml @@ -0,0 +1,1518 @@ +interactions: +- request: + body: '{"username": "journalist", "passphrase": "correct horse battery staple + profanity oil chewy", "one_time_code": "123456"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '119' + User-Agent: + - python-requests/2.31.0 + method: POST + uri: http://localhost:8081/api/v1/token + response: + body: + string: '{"expiration":"2023-12-08T21:31:36.503560Z","journalist_first_name":null,"journalist_last_name":null,"journalist_uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9","token":"IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM"} + + ' + headers: + Connection: + - close + Content-Length: + - '265' + Content-Type: + - application/json + Date: + - Fri, 08 Dec 2023 19:31:36 GMT + Server: + - Werkzeug/2.2.3 Python/3.8.10 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8081/api/v1/users + response: + body: + string: '{"users":[{"first_name":null,"last_name":null,"username":"journalist","uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9"},{"first_name":null,"last_name":null,"username":"dellsberg","uuid":"ac647c21-82f5-4d19-8350-6657a7d32f6b"},{"first_name":null,"last_name":null,"username":"deleted","uuid":"200a587e-b40c-48eb-b18a-0d1263f8af2e"}]} + + ' + headers: + Connection: + - close + Content-Length: + - '329' + Content-Type: + - application/json + Date: + - Fri, 08 Dec 2023 19:31:36 GMT + Server: + - Werkzeug/2.2.3 Python/3.8.10 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8081/api/v1/sources + response: + body: + string: '{"sources":[{"add_star_url":"/api/v1/sources/1924d581-a3af-45c6-a3c9-0ec2f1205bc1/add_star","interaction_count":6,"is_flagged":false,"is_starred":false,"journalist_designation":"oriental + hutch","key":{"fingerprint":"DF4DC2E19F0A6A304C8C3188AEF8C5E2BD8AE199","public":"-----BEGIN + PGP PUBLIC KEY BLOCK-----\nComment: DF4D C2E1 9F0A 6A30 4C8C 3188 AEF8 C5E2 + BD8A E199\nComment: Source Key " - '3 files' - ) - assert ( - export_dialog.body.text() == "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files on another " - "computer, any embedded malware may spread to your computer or network. If you are " - "unsure how to manage this risk, please print the file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() - - -def test_ExportDialog___show_passphrase_request_message(mocker, export_dialog): - export_dialog._show_passphrase_request_message() - - assert export_dialog.header.text() == "Enter passphrase for USB drive" - assert not export_dialog.header.isHidden() - assert export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert export_dialog.body.isHidden() - assert not export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog): - export_dialog._show_passphrase_request_message_again() - - assert export_dialog.header.text() == "Enter passphrase for USB drive" - assert ( - export_dialog.error_details.text() - == "The passphrase provided did not work. Please try again." - ) - assert export_dialog.body.isHidden() - assert not export_dialog.header.isHidden() - assert export_dialog.header_line.isHidden() - assert not export_dialog.error_details.isHidden() - assert export_dialog.body.isHidden() - assert not export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_success_message(mocker, export_dialog): - export_dialog._show_success_message() - - assert export_dialog.header.text() == "Export successful" - assert ( - export_dialog.body.text() - == "Remember to be careful when working with files outside of your Workstation machine." - ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert export_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_usb_message(mocker, export_dialog): - export_dialog._show_insert_usb_message() - - assert export_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog.body.text() - == "Please insert one of the export drives provisioned specifically " - "for the SecureDrop Workstation." - ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog): - export_dialog._show_insert_encrypted_usb_message() - - assert export_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_dialog.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_dialog.body.text() - == "Please insert one of the export drives provisioned specifically for the SecureDrop " - "Workstation." - ) - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert not export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_generic_error_message(mocker, export_dialog): - export_dialog.error_status = "mock_error_status" - - export_dialog._show_generic_error_message() - - assert export_dialog.header.text() == "Export failed" - assert export_dialog.body.text() == "mock_error_status: See your administrator for help." - assert not export_dialog.header.isHidden() - assert not export_dialog.header_line.isHidden() - assert export_dialog.error_details.isHidden() - assert not export_dialog.body.isHidden() - assert export_dialog.passphrase_form.isHidden() - assert not export_dialog.continue_button.isHidden() - assert not export_dialog.cancel_button.isHidden() - - -def test_ExportDialog__export_files(mocker, export_dialog): - device = mocker.MagicMock() - device.export_file_to_usb_drive = mocker.MagicMock() - export_dialog._device = device - export_dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") - - export_dialog._export_files() - - device.export_files.assert_called_once_with( - ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"], - "mock_passphrase", - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog): - export_dialog._show_passphrase_request_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) - - export_dialog._on_export_preflight_check_succeeded() - - export_dialog._show_passphrase_request_message.assert_not_called() - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_passphrase_request_message - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_dialog -): - export_dialog._show_passphrase_request_message = mocker.MagicMock() - export_dialog.continue_button.setEnabled(True) - - export_dialog._on_export_preflight_check_succeeded() - - export_dialog._show_passphrase_request_message.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_dialog -): - assert not export_dialog.continue_button.isEnabled() - export_dialog._on_export_preflight_check_succeeded() - assert export_dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_dialog -): - assert not export_dialog.continue_button.isEnabled() - export_dialog._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog): - export_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog._on_export_preflight_check_failed(error) - - export_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__on_export_succeeded(mocker, export_dialog): - export_dialog._show_success_message = mocker.MagicMock() - - export_dialog._on_export_succeeded() - - export_dialog._show_success_message.assert_called_once_with() - - -def test_ExportDialog__on_export_failed(mocker, export_dialog): - export_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_dialog._on_export_failed(error) - - export_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker, export_dialog): - export_dialog._show_insert_usb_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED) - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_insert_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED) - export_dialog._show_insert_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_dialog): - export_dialog._show_passphrase_request_message_again = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog(ExportStatus.BAD_PASSPHRASE) - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_passphrase_request_message_again - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.BAD_PASSPHRASE) - export_dialog._show_passphrase_request_message_again.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_dialog -): - export_dialog._show_insert_encrypted_usb_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR) - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_insert_encrypted_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR) - export_dialog._show_insert_encrypted_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(mocker, export_dialog): - export_dialog._show_generic_error_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_generic_error_message - ) - assert export_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_dialog._show_generic_error_message.assert_called_once_with() - assert export_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - -def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_dialog): - export_dialog._show_generic_error_message = mocker.MagicMock() - export_dialog.continue_button = mocker.MagicMock() - export_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_dialog._update_dialog("Some Unknown Error Status") - export_dialog.continue_button.clicked.connect.assert_called_once_with( - export_dialog._show_generic_error_message - ) - assert export_dialog.error_status == "Some Unknown Error Status" - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True) - export_dialog._update_dialog("Some Unknown Error Status") - export_dialog._show_generic_error_message.assert_called_once_with() - assert export_dialog.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/conversation/export/test_export_wizard.py b/client/tests/gui/conversation/export/test_export_wizard.py new file mode 100644 index 000000000..23bdfcc43 --- /dev/null +++ b/client/tests/gui/conversation/export/test_export_wizard.py @@ -0,0 +1,185 @@ +from unittest import mock + +from PyQt5.QtCore import Qt + +from securedrop_client.export import Export +from securedrop_client.export_status import ExportStatus +from securedrop_client.gui.conversation.export import ExportWizard +from securedrop_client.gui.conversation.export.export_wizard_constants import STATUS_MESSAGES, Pages +from securedrop_client.gui.conversation.export.export_wizard_page import ( + ErrorPage, + FinalPage, + InsertUSBPage, + PassphraseWizardPage, + PreflightPage, +) +from tests import factory + + +class TestExportWizard: + @classmethod + def _mock_export_preflight_success(cls) -> Export: + export = Export() + export.run_export_preflight_checks = lambda: export.export_state_changed.emit( + ExportStatus.DEVICE_LOCKED + ) + export.export = ( + mock.MagicMock() + ) # We will choose different signals and emit them during testing + return export + + @classmethod + def setup_class(cls): + cls.mock_controller = mock.MagicMock() + cls.mock_controller.data_dir = "/pretend/data-dir/" + cls.mock_source = factory.Source() + cls.mock_export = cls._mock_export_preflight_success() + cls.mock_file = factory.File(source=cls.mock_source) + cls.filepath = cls.mock_file.location(cls.mock_controller.data_dir) + + @classmethod + def setup_method(cls): + cls.wizard = ExportWizard(cls.mock_export, cls.mock_file.filename, [cls.filepath]) + + @classmethod + def teardown_method(cls): + cls.wizard.destroy() + cls.wizard = None + + def test_wizard_setup(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + assert len(self.wizard.pageIds()) == len(Pages._member_names_), self.wizard.pageIds() + assert isinstance(self.wizard.currentPage(), PreflightPage) + + def test_wizard_skips_insert_page_when_device_found_preflight(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.wizard.next() + + assert isinstance(self.wizard.currentPage(), PassphraseWizardPage) + + def test_wizard_exports_directly_to_unlocked_device(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + # Simulate an unlocked device + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_WRITABLE) + self.wizard.next() + + assert isinstance( + self.wizard.currentPage(), FinalPage + ), f"Actually, f{type(self.wizard.currentPage())}" + + def test_wizard_rewinds_if_device_removed(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.wizard.next() + assert isinstance(self.wizard.currentPage(), PassphraseWizardPage) + + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + + def test_wizard_all_steps(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + + self.mock_export.export_state_changed.emit(ExportStatus.MULTI_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + assert self.wizard.currentPage().error_details.isVisible() + + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_LOCKED) + self.wizard.next() + page = self.wizard.currentPage() + assert isinstance(page, PassphraseWizardPage) + + # No password entered, we shouldn't be able to advance + self.wizard.next() + assert isinstance(page, PassphraseWizardPage) + + # Type a passphrase. According to pytest-qt's own documentation, using + # qtbot.keyClicks and other interactions can lead to flaky tests, + # so using the setText method is fine, esp for unit testing. + page.passphrase_field.setText("correct horse battery staple!") + + # How dare you try a commonly-used password like that + self.mock_export.export_state_changed.emit(ExportStatus.ERROR_UNLOCK_LUKS) + + assert isinstance(page, PassphraseWizardPage) + assert page.error_details.isVisible() + + self.wizard.next() + + # Ok + page.passphrase_field.setText("substantial improvements encrypt accordingly") + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_WRITABLE) + + self.wizard.next() + self.mock_export.export_state_changed.emit(ExportStatus.ERROR_EXPORT_CLEANUP) + + page = self.wizard.currentPage() + assert isinstance(page, FinalPage) + assert page.body.text() == STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP) + + def test_wizard_hides_error_details_on_success(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + self.wizard.next() + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + + # Try to advance, but there's still no USB inserted + qtbot.mouseClick(self.wizard.next_button, Qt.LeftButton) + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + assert self.wizard.currentPage().error_details.isVisible() + + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_LOCKED) + self.wizard.next() + self.wizard.back() + assert not self.wizard.currentPage().error_details.isVisible() + + def test_wizard_only_shows_error_page_on_unrecoverable_error(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), InsertUSBPage) + + self.mock_export.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), ErrorPage) + + def test_wizard_error_after_passphrase_page(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + + self.wizard.next() + self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_LOCKED) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), PassphraseWizardPage) + + self.mock_export.export_state_changed.emit(ExportStatus.ERROR_MOUNT) + self.wizard.next() + assert isinstance(self.wizard.currentPage(), ErrorPage) + + def test_wizard_keypress_events(self, qtbot): + self.wizard.show() + qtbot.addWidget(self.wizard) + self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED) + + qtbot.keyPress(self.wizard, Qt.Key_Enter) + qtbot.wait(1000) + assert isinstance(self.wizard.currentPage(), InsertUSBPage) diff --git a/client/tests/gui/conversation/export/test_file_dialog.py b/client/tests/gui/conversation/export/test_file_dialog.py deleted file mode 100644 index 8bf9a03fd..000000000 --- a/client/tests/gui/conversation/export/test_file_dialog.py +++ /dev/null @@ -1,341 +0,0 @@ -from securedrop_client.export import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportFileDialog -from tests.helper import app # noqa: F401 - - -def test_ExportDialog_init(mocker): - _show_starting_instructions_fn = mocker.patch( - "securedrop_client.gui.conversation.ExportFileDialog._show_starting_instructions" - ) - - export_file_dialog = ExportFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") - - _show_starting_instructions_fn.assert_called_once_with() - assert export_file_dialog.passphrase_form.isHidden() - - -def test_ExportDialog_init_sanitizes_filename(mocker): - secure_qlabel = mocker.patch( - "securedrop_client.gui.conversation.export.file_dialog.SecureQLabel" - ) - mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") - filename = '' - - ExportFileDialog(mocker.MagicMock(), "mock_uuid", filename) - - secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) - - -def test_ExportDialog__show_starting_instructions(mocker, export_file_dialog): - export_file_dialog._show_starting_instructions() - - # file123.jpg comes from the export_file_dialog fixture - assert ( - export_file_dialog.header.text() == "Preparing to export:" - "
" - 'file123.jpg' - ) - assert ( - export_file_dialog.body.text() == "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files on another " - "computer, any embedded malware may spread to your computer or network. If you are " - "unsure how to manage this risk, please print the file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() - - -def test_ExportDialog___show_passphrase_request_message(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message() - - assert export_file_dialog.header.text() == "Enter passphrase for USB drive" - assert not export_file_dialog.header.isHidden() - assert export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert export_file_dialog.body.isHidden() - assert not export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_passphrase_request_message_again(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message_again() - - assert export_file_dialog.header.text() == "Enter passphrase for USB drive" - assert ( - export_file_dialog.error_details.text() - == "The passphrase provided did not work. Please try again." - ) - assert export_file_dialog.body.isHidden() - assert not export_file_dialog.header.isHidden() - assert export_file_dialog.header_line.isHidden() - assert not export_file_dialog.error_details.isHidden() - assert export_file_dialog.body.isHidden() - assert not export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_success_message(mocker, export_file_dialog): - export_file_dialog._show_success_message() - - assert export_file_dialog.header.text() == "Export successful" - assert ( - export_file_dialog.body.text() - == "Remember to be careful when working with files outside of your Workstation machine." - ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert export_file_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_usb_message(mocker, export_file_dialog): - export_file_dialog._show_insert_usb_message() - - assert export_file_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_file_dialog.body.text() - == "Please insert one of the export drives provisioned specifically " - "for the SecureDrop Workstation." - ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_file_dialog): - export_file_dialog._show_insert_encrypted_usb_message() - - assert export_file_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_file_dialog.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_file_dialog.body.text() - == "Please insert one of the export drives provisioned specifically for the SecureDrop " - "Workstation." - ) - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert not export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() - - -def test_ExportDialog__show_generic_error_message(mocker, export_file_dialog): - export_file_dialog.error_status = "mock_error_status" - - export_file_dialog._show_generic_error_message() - - assert export_file_dialog.header.text() == "Export failed" - assert export_file_dialog.body.text() == "mock_error_status: See your administrator for help." - assert not export_file_dialog.header.isHidden() - assert not export_file_dialog.header_line.isHidden() - assert export_file_dialog.error_details.isHidden() - assert not export_file_dialog.body.isHidden() - assert export_file_dialog.passphrase_form.isHidden() - assert not export_file_dialog.continue_button.isHidden() - assert not export_file_dialog.cancel_button.isHidden() - - -def test_ExportDialog__export_file(mocker, export_file_dialog): - device = mocker.MagicMock() - device.export_file_to_usb_drive = mocker.MagicMock() - export_file_dialog._device = device - export_file_dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase") - - export_file_dialog._export_file() - - device.export_file_to_usb_drive.assert_called_once_with( - export_file_dialog.file_uuid, "mock_passphrase" - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) - - export_file_dialog._on_export_preflight_check_succeeded() - - export_file_dialog._show_passphrase_request_message.assert_not_called() - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_passphrase_request_message - ) - - -def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_file_dialog -): - export_file_dialog._show_passphrase_request_message = mocker.MagicMock() - export_file_dialog.continue_button.setEnabled(True) - - export_file_dialog._on_export_preflight_check_succeeded() - - export_file_dialog._show_passphrase_request_message.assert_called_once_with() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_file_dialog -): - assert not export_file_dialog.continue_button.isEnabled() - export_file_dialog._on_export_preflight_check_succeeded() - assert export_file_dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_file_dialog -): - assert not export_file_dialog.continue_button.isEnabled() - export_file_dialog._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_file_dialog.continue_button.isEnabled() - - -def test_ExportDialog__on_export_preflight_check_failed(mocker, export_file_dialog): - export_file_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_file_dialog._on_export_preflight_check_failed(error) - - export_file_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__on_export_succeeded(mocker, export_file_dialog): - export_file_dialog._show_success_message = mocker.MagicMock() - - export_file_dialog._on_export_succeeded() - - export_file_dialog._show_success_message.assert_called_once_with() - - -def test_ExportDialog__on_export_failed(mocker, export_file_dialog): - export_file_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_file_dialog._on_export_failed(error) - - export_file_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker, export_file_dialog): - export_file_dialog._show_insert_usb_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_insert_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED) - export_file_dialog._show_insert_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_file_dialog): - export_file_dialog._show_passphrase_request_message_again = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.BAD_PASSPHRASE) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_passphrase_request_message_again - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.BAD_PASSPHRASE) - export_file_dialog._show_passphrase_request_message_again.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_file_dialog -): - export_file_dialog._show_insert_encrypted_usb_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_insert_encrypted_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR) - export_file_dialog._show_insert_encrypted_usb_message.assert_called_once_with() - - -def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( - mocker, export_file_dialog -): - export_file_dialog._show_generic_error_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_generic_error_message - ) - assert export_file_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_file_dialog._show_generic_error_message.assert_called_once_with() - assert export_file_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - -def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_file_dialog): - export_file_dialog._show_generic_error_message = mocker.MagicMock() - export_file_dialog.continue_button = mocker.MagicMock() - export_file_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_file_dialog._update_dialog("Some Unknown Error Status") - export_file_dialog.continue_button.clicked.connect.assert_called_once_with( - export_file_dialog._show_generic_error_message - ) - assert export_file_dialog.error_status == "Some Unknown Error Status" - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True) - export_file_dialog._update_dialog("Some Unknown Error Status") - export_file_dialog._show_generic_error_message.assert_called_once_with() - assert export_file_dialog.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/conversation/export/test_print_dialog.py b/client/tests/gui/conversation/export/test_print_dialog.py index ff46bee0f..d6365b5f3 100644 --- a/client/tests/gui/conversation/export/test_print_dialog.py +++ b/client/tests/gui/conversation/export/test_print_dialog.py @@ -1,30 +1,30 @@ -from securedrop_client.export import ExportError, ExportStatus -from securedrop_client.gui.conversation import PrintFileDialog +from securedrop_client.export_status import ExportError, ExportStatus +from securedrop_client.gui.conversation import PrintDialog from tests.helper import app # noqa: F401 -def test_PrintFileDialog_init(mocker): +def test_PrintDialog_init(mocker): _show_starting_instructions_fn = mocker.patch( - "securedrop_client.gui.conversation.PrintFileDialog._show_starting_instructions" + "securedrop_client.gui.conversation.PrintDialog._show_starting_instructions" ) - PrintFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg") + PrintDialog(mocker.MagicMock(), "mock.jpg", ["/mock/path/to/file"]) _show_starting_instructions_fn.assert_called_once_with() -def test_PrintFileDialog_init_sanitizes_filename(mocker): +def test_PrintDialog_init_sanitizes_filename(mocker): secure_qlabel = mocker.patch( "securedrop_client.gui.conversation.export.print_dialog.SecureQLabel" ) filename = '' - PrintFileDialog(mocker.MagicMock(), "mock_uuid", filename) + PrintDialog(mocker.MagicMock(), filename, ["/mock/path/to/file"]) secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) -def test_PrintFileDialog__show_starting_instructions(mocker, print_dialog): +def test_PrintDialog__show_starting_instructions(mocker, print_dialog): print_dialog._show_starting_instructions() # file123.jpg comes from the print_dialog fixture @@ -55,7 +55,7 @@ def test_PrintFileDialog__show_starting_instructions(mocker, print_dialog): assert not print_dialog.cancel_button.isHidden() -def test_PrintFileDialog__show_insert_usb_message(mocker, print_dialog): +def test_PrintDialog__show_insert_usb_message(mocker, print_dialog): print_dialog._show_insert_usb_message() assert print_dialog.header.text() == "Connect USB printer" @@ -68,7 +68,7 @@ def test_PrintFileDialog__show_insert_usb_message(mocker, print_dialog): assert not print_dialog.cancel_button.isHidden() -def test_PrintFileDialog__show_generic_error_message(mocker, print_dialog): +def test_PrintDialog__show_generic_error_message(mocker, print_dialog): print_dialog.error_status = "mock_error_status" print_dialog._show_generic_error_message() @@ -83,46 +83,46 @@ def test_PrintFileDialog__show_generic_error_message(mocker, print_dialog): assert not print_dialog.cancel_button.isHidden() -def test_PrintFileDialog__print_file(mocker, print_dialog): +def test_PrintDialog__print_file(mocker, print_dialog): print_dialog.close = mocker.MagicMock() print_dialog._print_file() - print_dialog.close.assert_called_once_with() + print_dialog._device.print.assert_called_once_with(print_dialog.filepaths) -def test_PrintFileDialog__on_print_preflight_check_succeeded(mocker, print_dialog): +def test_PrintDialog__on_print_preflight_check_succeeded(mocker, print_dialog): print_dialog._print_file = 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) - print_dialog._on_print_preflight_check_succeeded() + print_dialog._on_print_preflight_check_succeeded(ExportStatus.PRINT_PREFLIGHT_SUCCESS) print_dialog._print_file.assert_not_called() print_dialog.continue_button.clicked.connect.assert_called_once_with(print_dialog._print_file) -def test_PrintFileDialog__on_print_preflight_check_succeeded_when_continue_enabled( +def test_PrintDialog__on_print_preflight_check_succeeded_when_continue_enabled( mocker, print_dialog ): print_dialog._print_file = mocker.MagicMock() print_dialog.continue_button.setEnabled(True) - print_dialog._on_print_preflight_check_succeeded() + print_dialog._on_print_preflight_check_succeeded(ExportStatus.PRINT_PREFLIGHT_SUCCESS) print_dialog._print_file.assert_called_once_with() -def test_PrintFileDialog__on_print_preflight_check_succeeded_enabled_after_preflight_success( +def test_PrintDialog__on_print_preflight_check_succeeded_enabled_after_preflight_success( mocker, print_dialog ): assert not print_dialog.continue_button.isEnabled() - print_dialog._on_print_preflight_check_succeeded() + print_dialog._on_print_preflight_check_succeeded(ExportStatus.PRINT_PREFLIGHT_SUCCESS) assert print_dialog.continue_button.isEnabled() -def test_PrintFileDialog__on_print_preflight_check_succeeded_enabled_after_preflight_failure( +def test_PrintDialog__on_print_preflight_check_succeeded_enabled_after_preflight_failure( mocker, print_dialog ): assert not print_dialog.continue_button.isEnabled() @@ -130,7 +130,7 @@ def test_PrintFileDialog__on_print_preflight_check_succeeded_enabled_after_prefl assert print_dialog.continue_button.isEnabled() -def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_PRINTER_NOT_FOUND( +def test_PrintDialog__on_print_preflight_check_failed_when_status_is_PRINTER_NOT_FOUND( mocker, print_dialog ): print_dialog._show_insert_usb_message = mocker.MagicMock() @@ -139,18 +139,18 @@ def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_PRINTER 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._on_print_preflight_check_failed(ExportError(ExportStatus.ERROR_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._on_print_preflight_check_failed(ExportError(ExportStatus.ERROR_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( +def test_PrintDialog__on_print_preflight_check_failed_when_status_is_ERROR_PRINTER_URI( mocker, print_dialog ): print_dialog._show_generic_error_message = mocker.MagicMock() @@ -159,20 +159,20 @@ def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_MISSING 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._on_print_preflight_check_failed(ExportError(ExportStatus.ERROR_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 + assert print_dialog.error_status == ExportStatus.ERROR_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._on_print_preflight_check_failed(ExportError(ExportStatus.ERROR_PRINTER_URI)) print_dialog._show_generic_error_message.assert_called_once_with() - assert print_dialog.error_status == ExportStatus.MISSING_PRINTER_URI + assert print_dialog.error_status == ExportStatus.ERROR_PRINTER_URI -def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_CALLED_PROCESS_ERROR( +def test_PrintDialog__on_print_preflight_check_failed_when_status_is_CALLED_PROCESS_ERROR( mocker, print_dialog ): print_dialog._show_generic_error_message = mocker.MagicMock() @@ -194,9 +194,7 @@ def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_CALLED_ assert print_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR -def test_PrintFileDialog__on_print_preflight_check_failed_when_status_is_unknown( - mocker, print_dialog -): +def test_PrintDialog__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() diff --git a/client/tests/gui/conversation/export/test_print_transcript_dialog.py b/client/tests/gui/conversation/export/test_print_transcript_dialog.py index bff413bba..af8679784 100644 --- a/client/tests/gui/conversation/export/test_print_transcript_dialog.py +++ b/client/tests/gui/conversation/export/test_print_transcript_dialog.py @@ -1,4 +1,4 @@ -from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.export_status import ExportError, ExportStatus from securedrop_client.gui.conversation import PrintTranscriptDialog from tests.helper import app # noqa: F401 @@ -144,7 +144,7 @@ def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_P # When the continue button is enabled, ensure clicking continue will show next instructions print_transcript_dialog._on_print_preflight_check_failed( - ExportError(ExportStatus.PRINTER_NOT_FOUND) + ExportError(ExportStatus.ERROR_PRINTER_NOT_FOUND) ) print_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( print_transcript_dialog._show_insert_usb_message @@ -153,12 +153,12 @@ def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_P # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=True) print_transcript_dialog._on_print_preflight_check_failed( - ExportError(ExportStatus.PRINTER_NOT_FOUND) + ExportError(ExportStatus.ERROR_PRINTER_NOT_FOUND) ) print_transcript_dialog._show_insert_usb_message.assert_called_once_with() -def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_MISSING_PRINTER_URI( +def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_ERROR_PRINTER_URI( mocker, print_transcript_dialog ): print_transcript_dialog._show_generic_error_message = mocker.MagicMock() @@ -168,20 +168,20 @@ def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_M # When the continue button is enabled, ensure clicking continue will show next instructions print_transcript_dialog._on_print_preflight_check_failed( - ExportError(ExportStatus.MISSING_PRINTER_URI) + ExportError(ExportStatus.ERROR_PRINTER_URI) ) print_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( print_transcript_dialog._show_generic_error_message ) - assert print_transcript_dialog.error_status == ExportStatus.MISSING_PRINTER_URI + assert print_transcript_dialog.error_status == ExportStatus.ERROR_PRINTER_URI # When the continue button is enabled, ensure next instructions are shown mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=True) print_transcript_dialog._on_print_preflight_check_failed( - ExportError(ExportStatus.MISSING_PRINTER_URI) + ExportError(ExportStatus.ERROR_PRINTER_URI) ) print_transcript_dialog._show_generic_error_message.assert_called_once_with() - assert print_transcript_dialog.error_status == ExportStatus.MISSING_PRINTER_URI + assert print_transcript_dialog.error_status == ExportStatus.ERROR_PRINTER_URI def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_CALLED_PROCESS_ERROR( diff --git a/client/tests/gui/conversation/export/test_transcript_dialog.py b/client/tests/gui/conversation/export/test_transcript_dialog.py deleted file mode 100644 index d5c81b4f4..000000000 --- a/client/tests/gui/conversation/export/test_transcript_dialog.py +++ /dev/null @@ -1,351 +0,0 @@ -from securedrop_client.export import ExportError, ExportStatus -from securedrop_client.gui.conversation import ExportTranscriptDialog -from tests.helper import app # noqa: F401 - - -def test_TranscriptDialog_init(mocker): - _show_starting_instructions_fn = mocker.patch( - "securedrop_client.gui.conversation.ExportTranscriptDialog._show_starting_instructions" - ) - - export_transcript_dialog = ExportTranscriptDialog( - mocker.MagicMock(), "transcript.txt", "/some/path/transcript.txt" - ) - - _show_starting_instructions_fn.assert_called_once_with() - assert export_transcript_dialog.passphrase_form.isHidden() - - -def test_TranscriptDialog_init_sanitizes_filename(mocker): - secure_qlabel = mocker.patch( - "securedrop_client.gui.conversation.export.file_dialog.SecureQLabel" - ) - mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget") - filename = '' - - ExportTranscriptDialog(mocker.MagicMock(), filename, "/some/path/transcript.txt") - - secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) - - -def test_TranscriptDialog__show_starting_instructions(mocker, export_transcript_dialog): - export_transcript_dialog._show_starting_instructions() - - # transcript.txt comes from the export_transcript_dialog fixture - assert ( - export_transcript_dialog.header.text() == "Preparing to export:" - "
" - 'transcript.txt' - ) - assert ( - export_transcript_dialog.body.text() - == "

Understand the risks before exporting files

" - "Malware" - "
" - "This workstation lets you open files securely. If you open files on another " - "computer, any embedded malware may spread to your computer or network. If you are " - "unsure how to manage this risk, please print the file, or contact your " - "administrator." - "

" - "Anonymity" - "
" - "Files submitted by sources may contain information or hidden metadata that " - "identifies who they are. To protect your sources, please consider redacting files " - "before working with them on network-connected computers." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog___show_passphrase_request_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_passphrase_request_message() - - assert export_transcript_dialog.header.text() == "Enter passphrase for USB drive" - assert not export_transcript_dialog.header.isHidden() - assert export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert export_transcript_dialog.body.isHidden() - assert not export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_passphrase_request_message_again(mocker, export_transcript_dialog): - export_transcript_dialog._show_passphrase_request_message_again() - - assert export_transcript_dialog.header.text() == "Enter passphrase for USB drive" - assert ( - export_transcript_dialog.error_details.text() - == "The passphrase provided did not work. Please try again." - ) - assert export_transcript_dialog.body.isHidden() - assert not export_transcript_dialog.header.isHidden() - assert export_transcript_dialog.header_line.isHidden() - assert not export_transcript_dialog.error_details.isHidden() - assert export_transcript_dialog.body.isHidden() - assert not export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_success_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_success_message() - - assert export_transcript_dialog.header.text() == "Export successful" - assert ( - export_transcript_dialog.body.text() - == "Remember to be careful when working with files outside of your Workstation machine." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_insert_usb_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_insert_usb_message() - - assert export_transcript_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_transcript_dialog.body.text() - == "Please insert one of the export drives provisioned specifically " - "for the SecureDrop Workstation." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_insert_encrypted_usb_message(mocker, export_transcript_dialog): - export_transcript_dialog._show_insert_encrypted_usb_message() - - assert export_transcript_dialog.header.text() == "Insert encrypted USB drive" - assert ( - export_transcript_dialog.error_details.text() - == "Either the drive is not encrypted or there is something else wrong with it." - ) - assert ( - export_transcript_dialog.body.text() - == "Please insert one of the export drives provisioned specifically for the SecureDrop " - "Workstation." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert not export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__show_generic_error_message(mocker, export_transcript_dialog): - export_transcript_dialog.error_status = "mock_error_status" - - export_transcript_dialog._show_generic_error_message() - - assert export_transcript_dialog.header.text() == "Export failed" - assert ( - export_transcript_dialog.body.text() - == "mock_error_status: See your administrator for help." - ) - assert not export_transcript_dialog.header.isHidden() - assert not export_transcript_dialog.header_line.isHidden() - assert export_transcript_dialog.error_details.isHidden() - assert not export_transcript_dialog.body.isHidden() - assert export_transcript_dialog.passphrase_form.isHidden() - assert not export_transcript_dialog.continue_button.isHidden() - assert not export_transcript_dialog.cancel_button.isHidden() - - -def test_TranscriptDialog__export_transcript(mocker, export_transcript_dialog): - device = mocker.MagicMock() - device.export_transcript = mocker.MagicMock() - export_transcript_dialog._device = device - export_transcript_dialog.passphrase_field.text = mocker.MagicMock( - return_value="mock_passphrase" - ) - - export_transcript_dialog._export_transcript() - - device.export_transcript.assert_called_once_with("/some/path/transcript.txt", "mock_passphrase") - - -def test_TranscriptDialog__on_export_preflight_check_succeeded(mocker, export_transcript_dialog): - export_transcript_dialog._show_passphrase_request_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - export_transcript_dialog._on_export_preflight_check_succeeded() - - export_transcript_dialog._show_passphrase_request_message.assert_not_called() - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_passphrase_request_message - ) - - -def test_TranscriptDialog__on_export_preflight_check_succeeded_when_continue_enabled( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_passphrase_request_message = mocker.MagicMock() - export_transcript_dialog.continue_button.setEnabled(True) - - export_transcript_dialog._on_export_preflight_check_succeeded() - - export_transcript_dialog._show_passphrase_request_message.assert_called_once_with() - - -def test_TranscriptDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success( - mocker, export_transcript_dialog -): - assert not export_transcript_dialog.continue_button.isEnabled() - export_transcript_dialog._on_export_preflight_check_succeeded() - assert export_transcript_dialog.continue_button.isEnabled() - - -def test_TranscriptDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure( - mocker, export_transcript_dialog -): - assert not export_transcript_dialog.continue_button.isEnabled() - export_transcript_dialog._on_export_preflight_check_failed(mocker.MagicMock()) - assert export_transcript_dialog.continue_button.isEnabled() - - -def test_TranscriptDialog__on_export_preflight_check_failed(mocker, export_transcript_dialog): - export_transcript_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_transcript_dialog._on_export_preflight_check_failed(error) - - export_transcript_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_TranscriptDialog__on_export_succeeded(mocker, export_transcript_dialog): - export_transcript_dialog._show_success_message = mocker.MagicMock() - - export_transcript_dialog._on_export_succeeded() - - export_transcript_dialog._show_success_message.assert_called_once_with() - - -def test_TranscriptDialog__on_export_failed(mocker, export_transcript_dialog): - export_transcript_dialog._update_dialog = mocker.MagicMock() - - error = ExportError("mock_error_status") - export_transcript_dialog._on_export_failed(error) - - export_transcript_dialog._update_dialog.assert_called_with("mock_error_status") - - -def test_TranscriptDialog__update_dialog_when_status_is_USB_NOT_CONNECTED( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_insert_usb_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_insert_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.USB_NOT_CONNECTED) - export_transcript_dialog._show_insert_usb_message.assert_called_once_with() - - -def test_TranscriptDialog__update_dialog_when_status_is_BAD_PASSPHRASE( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_passphrase_request_message_again = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.BAD_PASSPHRASE) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_passphrase_request_message_again - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.BAD_PASSPHRASE) - export_transcript_dialog._show_passphrase_request_message_again.assert_called_once_with() - - -def test_TranscriptDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_insert_encrypted_usb_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_insert_encrypted_usb_message - ) - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.DISK_ENCRYPTION_NOT_SUPPORTED_ERROR) - export_transcript_dialog._show_insert_encrypted_usb_message.assert_called_once_with() - - -def test_TranscriptDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR( - mocker, export_transcript_dialog -): - export_transcript_dialog._show_generic_error_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_generic_error_message - ) - assert export_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR) - export_transcript_dialog._show_generic_error_message.assert_called_once_with() - assert export_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR - - -def test_TranscriptDialog__update_dialog_when_status_is_unknown(mocker, export_transcript_dialog): - export_transcript_dialog._show_generic_error_message = mocker.MagicMock() - export_transcript_dialog.continue_button = mocker.MagicMock() - export_transcript_dialog.continue_button.clicked = mocker.MagicMock() - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False) - - # When the continue button is enabled, ensure clicking continue will show next instructions - export_transcript_dialog._update_dialog("Some Unknown Error Status") - export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( - export_transcript_dialog._show_generic_error_message - ) - assert export_transcript_dialog.error_status == "Some Unknown Error Status" - - # When the continue button is enabled, ensure next instructions are shown - mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True) - export_transcript_dialog._update_dialog("Some Unknown Error Status") - export_transcript_dialog._show_generic_error_message.assert_called_once_with() - assert export_transcript_dialog.error_status == "Some Unknown Error Status" diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py index 8f3bde2cb..dde243e09 100644 --- a/client/tests/gui/test_actions.py +++ b/client/tests/gui/test_actions.py @@ -275,20 +275,13 @@ def test_trigger(self, _): return_value="☠ A string with unicode characters." ) - action._export_device.run_printer_preflight_checks = ( - lambda: action._export_device.print_preflight_check_succeeded.emit() - ) - action._export_device.print_transcript = ( - lambda transcript: action._export_device.print_succeeded.emit() - ) - action.trigger() assert True # the transcript is written without errors class TestExportConversationTranscriptAction(unittest.TestCase): - @patch("securedrop_client.gui.actions.ExportConversationTranscriptDialog") + @patch("securedrop_client.gui.actions.ExportWizard") def test_trigger(self, _): with managed_locale(): locale.setlocale(locale.LC_ALL, ("en_US", "latin-1")) @@ -303,20 +296,13 @@ def test_trigger(self, _): return_value="☠ A string with unicode characters." ) - action._export_device.run_printer_preflight_checks = ( - lambda: action._export_device.print_preflight_check_succeeded.emit() - ) - action._export_device.print_transcript = ( - lambda transcript: action._export_device.print_succeeded.emit() - ) - action.trigger() assert True # the transcript is written without errors class TestExportConversationAction(unittest.TestCase): - @patch("securedrop_client.gui.actions.ExportConversationDialog") + @patch("securedrop_client.gui.actions.ExportWizard") def test_trigger(self, _): with managed_locale(): locale.setlocale(locale.LC_ALL, ("en_US", "latin-1")) @@ -336,13 +322,6 @@ def test_trigger(self, _): return_value="☠ A string with unicode characters." ) - action._export_device.run_printer_preflight_checks = ( - lambda: action._export_device.print_preflight_check_succeeded.emit() - ) - action._export_device.print_transcript = ( - lambda transcript: action._export_device.print_succeeded.emit() - ) - action.trigger() assert True # the transcript is written without errors diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index 11b4234bc..508b89e86 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3587,7 +3587,7 @@ def test_FileWidget__on_export_clicked(mocker, session, source): get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) - export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice") + file_location = file.location(controller.data_dir) fw = FileWidget( file.uuid, controller, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock(), 0, 123 @@ -3597,10 +3597,14 @@ def test_FileWidget__on_export_clicked(mocker, session, source): controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog") + export_device = mocker.patch("securedrop_client.gui.widgets.Export") + wizard = mocker.patch("securedrop_client.gui.conversation.ExportWizard") fw._on_export_clicked() - dialog.assert_called_once_with(export_device(), file.uuid, file.filename) + wizard.assert_called_once() + wizard.assert_called_once_with( + export_device(), file.filename, [file_location] + ), f"{wizard.call_args}" def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): @@ -3627,17 +3631,17 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): mocker.patch("PyQt5.QtWidgets.QDialog.exec") controller.run_export_preflight_checks = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=False) - dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog") + wizard = mocker.patch("securedrop_client.gui.conversation.ExportWizard") fw._on_export_clicked() controller.run_export_preflight_checks.assert_not_called() - dialog.assert_not_called() + wizard.assert_not_called() def test_FileWidget__on_print_clicked(mocker, session, source): """ - Ensure print_file is called when the PRINT button is clicked + Ensure print() is called when the PRINT button is clicked """ file = factory.File(source=source["source"], is_downloaded=True) session.add(file) @@ -3645,7 +3649,7 @@ def test_FileWidget__on_print_clicked(mocker, session, source): get_file = mocker.MagicMock(return_value=file) controller = mocker.MagicMock(get_file=get_file) - export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice") + file_location = file.location(controller.data_dir) fw = FileWidget( file.uuid, @@ -3661,11 +3665,13 @@ def test_FileWidget__on_print_clicked(mocker, session, source): controller.print_file = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=True) - dialog = mocker.patch("securedrop_client.gui.conversation.PrintFileDialog") + export_device = mocker.patch("securedrop_client.gui.widgets.Export") + dialog = mocker.patch("securedrop_client.gui.conversation.PrintDialog") fw._on_print_clicked() - dialog.assert_called_once_with(export_device(), file.uuid, file.filename) + dialog.assert_called_once() + dialog.assert_called_once_with(export_device(), file.filename, [file_location]) def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): @@ -3692,7 +3698,7 @@ def test_FileWidget__on_print_clicked_missing_file(mocker, session, source): mocker.patch("PyQt5.QtWidgets.QDialog.exec") controller.print_file = mocker.MagicMock() controller.downloaded_file_exists = mocker.MagicMock(return_value=False) - dialog = mocker.patch("securedrop_client.gui.conversation.PrintFileDialog") + dialog = mocker.patch("securedrop_client.gui.conversation.PrintDialog") fw._on_print_clicked() diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py index d965c84e5..c9cd13485 100644 --- a/client/tests/integration/conftest.py +++ b/client/tests/integration/conftest.py @@ -1,8 +1,9 @@ import pytest from PyQt5.QtWidgets import QApplication -from securedrop_client import export from securedrop_client.app import threads +from securedrop_client.export import Export +from securedrop_client.export_status import ExportStatus from securedrop_client.gui import conversation from securedrop_client.gui.base import ModalDialog from securedrop_client.gui.main import Window @@ -11,11 +12,7 @@ @pytest.fixture(scope="function") -def main_window(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.gui.conversation.export.device.export.getService", - return_value=mock_export_service, - ) +def main_window(mocker, homedir): # Setup app = QApplication([]) gui = Window() @@ -67,11 +64,7 @@ def main_window(mocker, homedir, mock_export_service): @pytest.fixture(scope="function") -def main_window_no_key(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.gui.conversation.export.device.export.getService", - return_value=mock_export_service, - ) +def main_window_no_key(mocker, homedir): # Setup app = QApplication([]) gui = Window() @@ -154,24 +147,25 @@ def modal_dialog(mocker, homedir): @pytest.fixture(scope="function") -def mock_export_service(): - """An export service that assumes the Qubes RPC calls are successful and skips them.""" - export_service = export.Service() - # Ensure the export_service doesn't rely on Qubes OS: - export_service._run_disk_test = lambda dir: None - export_service._run_usb_test = lambda dir: None - export_service._run_disk_export = lambda dir, paths, passphrase: None - export_service._run_printer_preflight = lambda dir: None - export_service._run_print = lambda dir, paths: None - return export_service +def mock_export(mocker): + export = Export() + + """An export that assumes the Qubes RPC calls are successful and skips them.""" + export.run_export_preflight_checks = lambda: export.export_state_changed.emit( + ExportStatus.DEVICE_LOCKED + ) + export.export = lambda paths, passphrase: export.export_state_changed.emit( + ExportStatus.SUCCESS_EXPORT + ) + export.run_printer_preflight_checks = lambda: export.export_state_changed.emit( + ExportStatus.PRINT_PREFLIGHT_SUCCESS + ) + export.print = lambda paths: export.export_state_changed.emit(ExportStatus.PRINT_SUCCESS) + return export @pytest.fixture(scope="function") -def print_dialog(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.gui.conversation.export.device.export.getService", - return_value=mock_export_service, - ) +def print_dialog(mocker, homedir, mock_export): app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -193,10 +187,9 @@ def print_dialog(mocker, homedir, mock_export_service): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller) gui.setup(controller) gui.login_dialog.close() - dialog = conversation.PrintFileDialog(export_device, "file_uuid", "file_name") + dialog = conversation.PrintDialog(mock_export, "file_name", ["/mock/export/file"]) yield dialog @@ -206,11 +199,7 @@ def print_dialog(mocker, homedir, mock_export_service): @pytest.fixture(scope="function") -def export_file_dialog(mocker, homedir, mock_export_service): - mocker.patch( - "securedrop_client.gui.conversation.export.device.export.getService", - return_value=mock_export_service, - ) +def export_file_wizard(mocker, homedir, mock_export): app = QApplication([]) gui = Window() app.setActiveWindow(gui) @@ -229,10 +218,9 @@ def export_file_dialog(mocker, homedir, mock_export_service): ) controller.authenticated_user = factory.User() controller.qubes = False - export_device = conversation.ExportDevice(controller) gui.setup(controller) gui.login_dialog.close() - dialog = conversation.ExportFileDialog(export_device, "file_uuid", "file_name") + dialog = conversation.ExportWizard(mock_export, "file_name", ["/mock/export/filepath"]) dialog.show() yield dialog diff --git a/client/tests/integration/test_styles_sdclient.py b/client/tests/integration/test_styles_sdclient.py index 3fab23e9e..43f309fe3 100644 --- a/client/tests/integration/test_styles_sdclient.py +++ b/client/tests/integration/test_styles_sdclient.py @@ -4,6 +4,11 @@ from PyQt5.QtGui import QFont, QPalette from PyQt5.QtWidgets import QLabel, QLineEdit, QPushButton, QWidget +from securedrop_client.gui.conversation.export.export_wizard_page import ( + PassphraseWizardPage, + PreflightPage, +) + def test_css(main_window): login_dialog = main_window.login_dialog @@ -129,9 +134,9 @@ def test_class_name_matches_css_object_name_for_print_dialog(print_dialog): assert "PrintDialog" == print_dialog.__class__.__name__ -def test_class_name_matches_css_object_name_for_export_file_dialog(export_file_dialog): - assert "FileDialog" == export_file_dialog.__class__.__name__ - assert "FileDialog" in export_file_dialog.passphrase_form.objectName() +def test_class_name_matches_css_object_name_for_export_file_dialog(export_file_wizard): + assert "ExportWizard" == export_file_wizard.__class__.__name__ + assert "QWizard_export" in export_file_wizard.objectName() def test_class_name_matches_css_object_name_for_modal_dialog(modal_dialog): @@ -507,62 +512,78 @@ def test_styles_for_print_dialog(print_dialog): assert 15 == c.font().pixelSize() -def test_styles_for_export_file_dialog(export_file_dialog): - assert 800 == export_file_dialog.minimumSize().width() - assert 800 == export_file_dialog.maximumSize().width() - assert 300 == export_file_dialog.minimumSize().height() - assert 800 == export_file_dialog.maximumSize().height() - assert "#ffffff" == export_file_dialog.palette().color(QPalette.Background).name() - assert 110 == export_file_dialog.header_icon.minimumSize().width() # 80px + 30px margin - assert 110 == export_file_dialog.header_icon.maximumSize().width() # 80px + 30px margin - assert 64 == export_file_dialog.header_icon.minimumSize().height() # 64px + 0px margin - assert 64 == export_file_dialog.header_icon.maximumSize().height() # 64px + 0px margin - assert ( - 110 == export_file_dialog.header_spinner_label.minimumSize().width() - ) # 80px + 30px margin - assert ( - 110 == export_file_dialog.header_spinner_label.maximumSize().width() - ) # 80px + 30px margin - assert 64 == export_file_dialog.header_spinner_label.minimumSize().height() # 64px + 0px margin - assert 64 == export_file_dialog.header_spinner_label.maximumSize().height() # 64px + 0px margin - assert 68 == export_file_dialog.header.minimumSize().height() # 68px + 0px margin - assert 68 == export_file_dialog.header.maximumSize().height() # 68px + 0px margin - assert "Montserrat" == export_file_dialog.header.font().family() - assert QFont.Bold == export_file_dialog.header.font().weight() - assert 24 == export_file_dialog.header.font().pixelSize() - assert "#2a319d" == export_file_dialog.header.palette().color(QPalette.Foreground).name() - assert (0, 0, 0, 0) == export_file_dialog.header.getContentsMargins() - assert 2 == export_file_dialog.header_line.minimumSize().height() # 2px + 20px margin - assert 2 == export_file_dialog.header_line.maximumSize().height() # 2px + 20px margin - assert 38 == math.floor(255 * 0.15) # sanity check - assert ( - 38 == export_file_dialog.header_line.palette().color(QPalette.Background).rgba64().alpha8() - ) - assert 42 == export_file_dialog.header_line.palette().color(QPalette.Background).red() - assert 49 == export_file_dialog.header_line.palette().color(QPalette.Background).green() - assert 157 == export_file_dialog.header_line.palette().color(QPalette.Background).blue() - - assert "Montserrat" == export_file_dialog.body.font().family() - assert 16 == export_file_dialog.body.font().pixelSize() - assert "#302aa3" == export_file_dialog.body.palette().color(QPalette.Foreground).name() - window_buttons = export_file_dialog.layout().itemAt(4).widget() - button_box = window_buttons.layout().itemAt(0).widget() - button_box_children = button_box.findChildren(QPushButton) - for c in button_box_children: - assert 44 == c.height() # 40px + 4px of border +def test_styles_for_export_file_wizard(export_file_wizard): + assert 800 == export_file_wizard.minimumSize().width() + assert 800 == export_file_wizard.maximumSize().width() + assert 500 == export_file_wizard.minimumSize().height() + assert 800 == export_file_wizard.maximumSize().height() + assert "#ffffff" == export_file_wizard.palette().color(QPalette.Background).name() + + buttons = [ + export_file_wizard.next_button, + export_file_wizard.back_button, + export_file_wizard.finish_button, + export_file_wizard.cancel_button, + ] + + for c in buttons: + assert 40 == c.height() assert "Montserrat" == c.font().family() assert QFont.DemiBold - 1 == c.font().weight() assert 15 == c.font().pixelSize() - passphrase_children_qlabel = export_file_dialog.passphrase_form.findChildren(QLabel) - for c in passphrase_children_qlabel: - assert "Montserrat" == c.font().family() or "Source Sans Pro" == c.font().family() - assert QFont.DemiBold - 1 == c.font().weight() - assert 12 == c.font().pixelSize() - assert "#2a319d" == c.palette().color(QPalette.Foreground).name() - form_children_qlineedit = export_file_dialog.passphrase_form.findChildren(QLineEdit) +def test_styles_for_export_file_wizard_page(export_file_wizard): + page = export_file_wizard.currentPage() + assert isinstance(page, PreflightPage) + assert "#ffffff" == page.palette().color(QPalette.Background).name() + assert 110 == page.header_icon.minimumSize().width() # 80px + 30px margin + assert 110 == page.header_icon.maximumSize().width() # 80px + 30px margin + assert 64 == page.header_icon.minimumSize().height() # 64px + 0px margin + assert 64 == page.header_icon.maximumSize().height() # 64px + 0px margin + assert 110 == page.header_spinner_label.minimumSize().width() # 80px + 30px margin + assert 110 == page.header_spinner_label.maximumSize().width() # 80px + 30px margin + assert 64 == page.header_spinner_label.minimumSize().height() # 64px + 0px margin + assert 64 == page.header_spinner_label.maximumSize().height() # 64px + 0px margin + assert 68 == page.header.minimumSize().height() # 68px + 0px margin + assert 68 == page.header.maximumSize().height() # 68px + 0px margin + assert "Montserrat" == page.header.font().family() + assert QFont.Bold == page.header.font().weight() + assert 24 == page.header.font().pixelSize() + assert "#2a319d" == page.header.palette().color(QPalette.Foreground).name() + assert (0, 0, 0, 0) == page.header.getContentsMargins() + assert 2 == page.header_line.minimumSize().height() # 2px + 20px margin + assert 2 == page.header_line.maximumSize().height() # 2px + 20px margin + assert 38 == math.floor(255 * 0.15) # sanity check + assert 38 == page.header_line.palette().color(QPalette.Background).rgba64().alpha8() + assert 42 == page.header_line.palette().color(QPalette.Background).red() + assert 49 == page.header_line.palette().color(QPalette.Background).green() + assert 157 == page.header_line.palette().color(QPalette.Background).blue() + + assert "Montserrat" == page.body.font().family() + assert 16 == page.body.font().pixelSize() + assert "#302aa3" == page.body.palette().color(QPalette.Foreground).name() + + +def test_style_passphrase_wizard_page(export_file_wizard): + page = export_file_wizard.currentPage() + assert isinstance(page, PreflightPage) + export_file_wizard.next() + + # The mock_export fixture starts with a device inserted, so the next page will be + # the passphrase prompt + page = export_file_wizard.currentPage() + assert isinstance(page, PassphraseWizardPage) + + form_children_qlineedit = page.passphrase_form.findChildren(QLineEdit) for c in form_children_qlineedit: + assert "#f8f8f8" == c.palette().color(QPalette.Background).name() assert 32 == c.minimumSize().height() # 30px + 2px padding-bottom assert 32 == c.maximumSize().height() # 30px + 2px padding-bottom - assert "#f8f8f8" == c.palette().color(QPalette.Background).name() + + passphrase_children_qlabel = page.passphrase_form.findChildren(QLabel) + for c in passphrase_children_qlabel: + assert "Montserrat" == c.font().family() or "Source Sans Pro" == c.font().family() + assert "#2a319d" == c.palette().color(QPalette.Foreground).name() + assert 12 == c.font().pixelSize() + assert QFont.DemiBold - 1 == c.font().weight() diff --git a/client/tests/test_export.py b/client/tests/test_export.py index a289a44d5..b3f7c7fa5 100644 --- a/client/tests/test_export.py +++ b/client/tests/test_export.py @@ -1,491 +1,367 @@ import os -import subprocess -import unittest +import tarfile from tempfile import NamedTemporaryFile, TemporaryDirectory +from unittest import mock import pytest +from PyQt5.QtTest import QSignalSpy + +from securedrop_client.export import Export +from securedrop_client.export_status import ExportError, ExportStatus +from tests import factory + +_PATH_TO_PRETEND_ARCHIVE = "/tmp/archive-pretend" +_QREXEC_EXPORT_COMMAND = ( + "/usr/bin/qrexec-client-vm", + [ + "--", + "sd-devices", + "qubes.OpenInVM", + "/usr/lib/qubes/qopen-in-vm", + "--view-only", + "--", + f"{_PATH_TO_PRETEND_ARCHIVE}", + ], +) +_MOCK_FILEDIR = "/tmp/mock_tmpdir/" + +# A few different status values to be used in test paramaterization +_SAMPLE_EXPORT = [ + ExportStatus.NO_DEVICE_DETECTED, + ExportStatus.DEVICE_WRITABLE, + ExportStatus.ERROR_MOUNT, + ExportStatus.ERROR_MISSING_FILES, + ExportStatus.SUCCESS_EXPORT, +] +_SAMPLE_PRINT_PREFLIGHT_FAIL = [ + ExportStatus.ERROR_PRINTER_NOT_FOUND, + ExportStatus.ERROR_PRINTER_DRIVER_UNAVAILABLE, +] + + +class TestDevice: + @classmethod + def setup_class(cls): + cls.device = None + + # Reset any manually-changed mock values before next test + @classmethod + def setup_method(cls): + cls.mock_file = factory.File(source=factory.Source()) + cls.mock_file_location = f"{_MOCK_FILEDIR}{cls.mock_file.filename}" + cls.device = Export() + cls.device._create_archive = mock.MagicMock() + cls.device._create_archive.return_value = _PATH_TO_PRETEND_ARCHIVE + cls.mock_tmpdir = mock.MagicMock() + cls.mock_tmpdir.__enter__ = mock.MagicMock(return_value=_MOCK_FILEDIR) + + @classmethod + def teardown_method(cls): + cls.mock_file = None + cls.device._create_archive = None + + def test_Device_run_printer_preflight_checks(self): + with mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch.object( + self.device, "_create_archive" + ) as mock_archive: + mock_archive.return_value = _PATH_TO_PRETEND_ARCHIVE + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + mock_qproc.readAllStandardError.return_value = ( + ExportStatus.PRINT_PREFLIGHT_SUCCESS.value.encode("utf-8") + ) + self.device.run_printer_preflight_checks() + + mock_qproc.start.assert_called_once() + assert ( + mock_qproc.start.call_args[0] == _QREXEC_EXPORT_COMMAND + ), f"Actual: {mock_qproc.start.call_args[0]}" + + def test_Device_run_print_preflight_checks_with_error(self): + spy = QSignalSpy(self.device.print_preflight_check_failed) + with mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch.object( + self.device, "_create_archive" + ) as mock_archive, mock.patch( + "shutil.rmtree" + ) as mock_rmtree: + mock_archive.return_value = _PATH_TO_PRETEND_ARCHIVE + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + mock_qproc.start.side_effect = ( + lambda proc, args: self.device._on_print_preflight_complete() + ) # This ain't doin it + mock_qproc.readAllStandardError.data.return_value = b"Not a real status\n" + + self.device.run_printer_preflight_checks() + + mock_qproc.start.assert_called_once() + mock_rmtree.assert_called_once() + + # Note: in future can return UNEXPECTED_RETURN_STATUS instead + assert ( + len(spy) == 1 + and isinstance(spy[0][0], ExportError) + and spy[0][0].status == ExportStatus.ERROR_PRINT + ) + + def test_Device_print(self): + with mock.patch("securedrop_client.export.QProcess") as mock_qprocess, mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ): + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + + self.device.print([self.mock_file_location]) + + mock_qproc.start.assert_called_once() + assert mock_qproc.start.call_args[0] == _QREXEC_EXPORT_COMMAND + + self.device._create_archive.assert_called_once_with( + archive_dir=self.mock_tmpdir, + archive_fn=self.device._PRINT_FN, + metadata=self.device._PRINT_METADATA, + filepaths=[self.mock_file_location], + ) + + @mock.patch("shutil.rmtree") + def test_Device_print_file_file_missing(self, mock_shutil): + device = Export() + spy = QSignalSpy(device.print_failed) -from securedrop_client import export -from securedrop_client.export import Export, ExportError, ExportStatus + with mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + device.print("some-missing-file-uuid") -class TestService(unittest.TestCase): - def tearDown(self): - # ensure any changes to the export.Service instance are reset - # export.resetService() - pass + mock_qproc.start.assert_not_called() - def test_service_is_unique(self): - service = export.getService() - same_service = export.getService() # Act. + # Print doesn't use the new ERROR_MISSING_FILES status yet + assert ( + len(spy) == 1 + and isinstance(spy[0][0], ExportError) + and spy[0][0].status == ExportStatus.ERROR_PRINT + ) + + def test_Device_run_export_preflight_checks(self): + with mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + + self.device.run_export_preflight_checks() + + mock_qproc.start.assert_called_once() + assert mock_qproc.start.call_args[0] == _QREXEC_EXPORT_COMMAND - self.assertTrue( - service is same_service, - "expected successive calls to getService to return the same service, got different services", # noqa: E501 + self.device._create_archive.assert_called_once_with( + archive_dir=self.mock_tmpdir, + archive_fn=self.device._USB_TEST_FN, + metadata=self.device._USB_TEST_METADATA, ) - def test_service_can_be_reset(self): - service = export.getService() - export.resetService() # Act. - different_service = export.getService() + @mock.patch("shutil.rmtree") + def test_Device_run_export_preflight_checks_with_error(self, mock_shutil): + spy = QSignalSpy(self.device.export_state_changed) + + with mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch.object(self.device, "_create_archive"), mock.patch( + "securedrop_client.export.QProcess" + ) as mock_qprocess, mock.patch.object( + self.device, "_create_archive" + ) as mock_archive: + mock_archive.return_value = _PATH_TO_PRETEND_ARCHIVE + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + mock_qproc.start.side_effect = ( + lambda proc, args: self.device._on_export_process_complete() + ) + mock_qproc.readAllStandardError = mock.MagicMock() + mock_qproc.readAllStandardError.data.return_value = b"Houston, we have a problem\n" + + self.device.run_export_preflight_checks() + + assert len(spy) == 1 and spy[0][0] == ExportStatus.UNEXPECTED_RETURN_STATUS + + def test_Device_export_file_missing(self, mocker): + device = Export() + + warning_logger = mocker.patch("securedrop_client.export.logger.warning") + with mock.patch( + "securedrop_client.export.tarfile.open", + return_value=mock.MagicMock(), + ), mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch( + "securedrop_client.export.QProcess" + ) as mock_qprocess: + device.export(["/not/a/real/location"], "mock passphrase") + + mock_qprocess.assert_not_called() + + warning_logger.assert_called_once() + # Todo: could get more specific about looking for the emitted failure signal + + def test_Device_export(self): + filepath = "some/file/path" + passphrase = "passphrase" + + expected_metadata = self.device._DISK_METADATA.copy() + expected_metadata[self.device._DISK_ENCRYPTION_KEY_NAME] = passphrase + + with mock.patch( + "securedrop_client.export.mkdtemp", + return_value=self.mock_tmpdir, + ), mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.start = mock.MagicMock() + self.device.export([filepath], passphrase) + + mock_qproc.start.assert_called_once() + assert mock_qproc.start.call_args[0] == _QREXEC_EXPORT_COMMAND + + self.device._create_archive.assert_called_once_with( + archive_dir=self.mock_tmpdir, + archive_fn=self.device._DISK_FN, + metadata=expected_metadata, + filepaths=[filepath], + ) + + @pytest.mark.parametrize("status", [i.value for i in _SAMPLE_EXPORT]) + def test__run_qrexec_sends_export_signal(self, status): + spy = QSignalSpy(self.device.export_state_changed) + enum = ExportStatus(status) + with mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.finished = mock.MagicMock() + mock_qproc.start = mock.MagicMock() + mock_qproc.start.side_effect = ( + lambda proc, args: self.device._on_export_process_complete() + ) + mock_qproc.readAllStandardError.return_value.data.return_value = f"{status}\n".encode( + "utf-8" + ) + + self.device._run_qrexec_export( + _PATH_TO_PRETEND_ARCHIVE, + self.device._on_export_process_complete, + self.device._on_export_process_error, + ) + + mock_qproc.start.assert_called_once() + assert len(spy) == 1 and spy[0][0] == enum + + @pytest.mark.parametrize("status", [i.value for i in _SAMPLE_PRINT_PREFLIGHT_FAIL]) + def test__run_qrexec_sends_print_failed_signal(self, status): + spy = QSignalSpy(self.device.print_preflight_check_failed) + enum = ExportStatus(status) + with mock.patch("securedrop_client.export.QProcess") as mock_qprocess: + mock_qproc = mock_qprocess.return_value + mock_qproc.finished = mock.MagicMock() + mock_qproc.start = mock.MagicMock() + mock_qproc.start.side_effect = ( + lambda proc, args: self.device._on_print_preflight_complete() + ) + mock_qproc.readAllStandardError.return_value.data.return_value = f"{status}\n".encode( + "utf-8" + ) + + self.device._run_qrexec_export( + _PATH_TO_PRETEND_ARCHIVE, + self.device._on_print_preflight_complete, + self.device._on_print_prefight_error, + ) + + mock_qproc.start.assert_called_once() + assert len(spy) == 1 and isinstance(spy[0][0], ExportError) and spy[0][0].status == enum + + @mock.patch("securedrop_client.export.tarfile") + def test__add_virtual_file_to_archive(self, mock_tarfile): + mock_tarinfo = mock.MagicMock(spec=tarfile.TarInfo) + mock_tarfile.TarInfo.return_value = mock_tarinfo - self.assertTrue( - different_service is not service, - "expected resetService to reset the service, got same service after reset", + self.device._add_virtual_file_to_archive( + mock_tarfile, "mock_file", {"test_filedata": "lgtm"} ) + mock_tarfile.TarInfo.assert_called_once() + + def test__create_archive(self, mocker): + """ + Ensure _create_archive creates an archive in the supplied directory. + """ + archive_path = None + with TemporaryDirectory() as temp_dir: + # We'll do this in the tmpdir for ease of cleanup + open(os.path.join(temp_dir, "temp_1"), "w+").close() + open(os.path.join(temp_dir, "temp_2"), "w+").close() + filepaths = [os.path.join(temp_dir, "temp_1"), os.path.join(temp_dir, "temp_2")] + device = Export() + + archive_path = device._create_archive(temp_dir, "mock.sd-export", {}, filepaths) + + assert archive_path == os.path.join(temp_dir, "mock.sd-export") + assert os.path.exists(archive_path) # sanity check + + assert not os.path.exists(archive_path) -def test_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 - transcript_path = os.path.join(temp_dir, "transcript.txt") - with open(transcript_path, "a+") as transcript: - archive_path = export._create_archive( - temp_dir, - "mock.sd-export", - {}, - [export_file_one.name, export_file_two.name, transcript.name], + def test__create_archive_with_an_export_file(self): + device = Export() + archive_path = None + with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file: + archive_path = device._create_archive( + temp_dir, "mock.sd-export", {}, [export_file.name] ) assert archive_path == os.path.join(temp_dir, "mock.sd-export") assert os.path.exists(archive_path) # sanity check - assert not os.path.exists(archive_path) - - -def test__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 + assert not os.path.exists(archive_path) + + def test__create_archive_with_multiple_export_files(self): + device = Export() + archive_path = None + with TemporaryDirectory() as tmpdir, NamedTemporaryFile() as f1, NamedTemporaryFile() as f2: + transcript_path = os.path.join(tmpdir, "transcript.txt") + with open(transcript_path, "a+") as transcript: + archive_path = device._create_archive( + tmpdir, + "mock.sd-export", + {}, + [f1.name, f2.name, transcript.name], + ) + assert archive_path == os.path.join(tmpdir, "mock.sd-export") + assert os.path.exists(archive_path) # sanity check + + assert not os.path.exists(archive_path) + + def test__tmpdir_cleaned_up_on_exception(self): + """ + Sanity check. If we encounter an error after archive has been built, + ensure the tmpdir directory cleanup happens. + """ + with mock.patch( + "securedrop_client.export.mkdtemp", return_value=self.mock_tmpdir + ), mock.patch("securedrop_client.export.QProcess") as qprocess, mock.patch.object( + self.device, "_cleanup_tmpdir" + ) as mock_cleanup: + mock_qproc = qprocess.return_value + mock_qproc.readAllStandardError.data.return_value = b"Something awful happened!\n" + mock_qproc.start = lambda proc, args: self.device._on_export_process_error() + self.device.run_printer_preflight_checks() + assert self.device.tmpdir == self.mock_tmpdir + mock_cleanup.assert_called_once() diff --git a/debian/control b/debian/control index 95fb5003e..6ad809729 100644 --- a/debian/control +++ b/debian/control @@ -14,7 +14,7 @@ Description: securedrop client for qubes workstation Package: securedrop-export Architecture: all -Depends: ${python3:Depends}, ${misc:Depends}, cryptsetup, cups, printer-driver-brlaser, printer-driver-hpcups, system-config-printer, xpp, libcups2-dev, python3-dev, libtool-bin, unoconv, gnome-disk-utility +Depends: ${python3:Depends}, ${misc:Depends}, udisks2, cups, printer-driver-brlaser, printer-driver-hpcups, system-config-printer, xpp, libcups2-dev, python3-dev, libtool-bin, unoconv, gnome-disk-utility Description: Submission export scripts for SecureDrop Workstation This package provides scripts used by the SecureDrop Qubes Workstation to export submissions from the client to external storage, via the sd-export diff --git a/debian/rules b/debian/rules index 88d2cead3..535ba6833 100755 --- a/debian/rules +++ b/debian/rules @@ -19,7 +19,10 @@ override_dh_strip_nondeterminism: # Override debhelper's auto-generated files in `/etc/` # to force an exact replacement of the files we are modifying -# there (specifically, `/etc/apt/trusted.gpg.d/securedrop-keyring.gpg`). +# there (specifically, `/etc/apt/trusted.gpg.d/securedrop-keyring.gpg` +# for the keyring package and `/etc/udisks2/tcrypt.conf` for the +# securedrop-export package). override_dh_installdeb: dh_installdeb cat /dev/null > ${CURDIR}/debian/securedrop-keyring/DEBIAN/conffiles + cat /dev/null > ${CURDIR}/debian/securedrop-export/DEBIAN/conffiles diff --git a/debian/securedrop-export.install b/debian/securedrop-export.install index 0d356bcf2..b095f316f 100644 --- a/debian/securedrop-export.install +++ b/debian/securedrop-export.install @@ -1,3 +1,4 @@ export/files/application-x-sd-export.xml usr/share/mime/packages export/files/send-to-usb.desktop usr/share/applications export/files/sd-logo.png usr/share/securedrop/icons +export/files/tcrypt.conf etc/udisks2 diff --git a/export/Makefile b/export/Makefile index ba88300a9..3af40b071 100644 --- a/export/Makefile +++ b/export/Makefile @@ -15,7 +15,8 @@ check-black: ## Check Python source code formatting with black TESTS ?= tests .PHONY: test test: ## Run tests - poetry run pytest -v --cov-report html --cov-report term-missing --cov=securedrop_export $$TESTS + poetry run pytest -v --cov-report html --cov-report term-missing \ + --cov=securedrop_export --log-disable=securedrop_export.main $$TESTS .PHONY: flake8 flake8: ## Run flake8 linter diff --git a/export/README.md b/export/README.md index 852a7f04f..7c66cc72f 100644 --- a/export/README.md +++ b/export/README.md @@ -60,9 +60,6 @@ Metadata contains three possible keys which may contain several possible values: `device` : specifies the method used for export, and can be either a device or a preflight check. See the Devices section below for possible values. It is a required key. -`encryption_method` -: used exclusively when exporting to USB storage. It is an optional key. Possible values are: -luks `encryption_passphrase` : used exclusively when exporting to USB storage. It is an optional key. It contains an arbitrary string that contains the disk encryption passphrase of the device. @@ -72,7 +69,6 @@ Example archive metadata (`metadata.json`): ``` { "device": "disk" - "encryption-method": "luks" "encryption-key": "Your encryption passphrase goes here" } ``` @@ -90,34 +86,38 @@ For all device types (described in detail below), the following standard error t The supported device types for export are as follows, including the possible errors specific to that device type: -1. `usb-test` : Preflight check that probes for USB connected devices, that returns: - - `USB_CONNECTED` if a USB device is attached to the dedicated slot - - `USB_NOT_CONNECTED` if no USB is attached - - `USB_CHECK_ERROR` if an error occurred during pre-flight - -2. `disk-test`: Preflight check that checks for LUKS-encrypted volume that returns: - - `USB_ENCRYPTED` if a LUKS volume is attached to sd-devices - - `USB_ENCRYPTION_NOT_SUPPORTED` if a LUKS volume is not attached or there was any other error - - `USB_DISK_ERROR` - -3. `printer-test`: prints a test page that returns: - - `ERROR_PRINTER_NOT_FOUND` if no printer is connected - - `ERROR_PRINTER_NOT_SUPPORTED` if the printer is not currently supported by the export script - - `ERROR_PRINTER_DRIVER_UNAVAILABLE` if the printer driver is not available - - `ERROR_PRINTER_INSTALL` If there is an error installing the printer - - `ERROR_PRINT` if there is an error printing - -4. `printer`: sends files to printer that returns: +1. `disk-test`: Preflight check that probes for USB connected devices, that returns: + - `NO_DEVICE_DETECTED`, `MULTI_DEVICE_DETECTED`: wrong number of inserted USB drives + - `INVALID_DEVICE_DETECTED`: Wrong number of partitions, unsupported encryption scheme, etc. + Note: locked VeraCrypt drives also return this status, and a hint is shown to the user that they must + manually unlock such drives before proceeding. + - `DEVICE_LOCKED` if a supported drive is inserted but locked (a LUKS drive, since locked Veracrypt detection is not supported) + - `DEVICE_WRITABLE` if a supported USB device is attached and unlocked. (Only used for Preflight check) + - `DEVICE_ERROR`: A problem was encountered and device state cannot be reported. + +2. `disk`: Attempts to send files to disk. Can return any Preflight status except `DEVICE_WRITABLE`, as well as + the following status results below, which replace `DEVICE_WRITABLE` since they attempt the export action. + Because export is a linear process, a status such as `ERROR_EXPORT_CLEANUP` indicates that the file export + succeeded and the problem occurred after that point in the process. + - `ERROR_UNLOCK_LUKS` if LUKS decryption failed due to bad passphrase + - `ERROR_UNLOCK_GENERIC` if unlocking failed due to some other reason + - `ERROR_MOUNT` if there was an error mounting the volume + - `ERROR_UNMOUT_VOLUME_BUSY` if there was an error unmounting the drive after export + - `ERROR_EXPORT_CLEANUP` if there was an error removing temporary directories after export + - `SUCCESS_EXPORT`: Entire routine, including export and cleanup, was successful + +3. `printer-preflight`, `printer-test`: test the printer and ensure it is ready. - `ERROR_PRINTER_NOT_FOUND` if no printer is connected - `ERROR_PRINTER_NOT_SUPPORTED` if the printer is not currently supported by the export script - `ERROR_PRINTER_DRIVER_UNAVAILABLE` if the printer driver is not available + - `ERROR_PRINTER_URI` if `lpinfo` fails to retrieve printer information - `ERROR_PRINTER_INSTALL` If there is an error installing the printer - `ERROR_PRINT` if there is an error printing + - `PRINT_PREFLIGHT_SUCCESS` if preflight checks were successful (Preflight only) -5. `disk`: sends files to disk that returns: - - `USB_BAD_PASSPHRASE` if the luks decryption failed (likely due to bad passphrase) - - `ERROR_USB_MOUNT` if there was an error mounting the volume (after unlocking the luks volume) - - `ERROR_USB_WRITE` if there was an error writing to disk (e.g., no space left on device) +4. `printer`: sends files to printer that returns any of the `printer-preflight` statuses except + `PRINT_PREFLIGHT_SUCCESS`, as well as: + - `PRINT_SUCCESS` if the job is dispatched successfully ### Export Folder Structure @@ -128,6 +128,7 @@ When exporting to a USB drive, the files will be placed on the drive as follows: └── sd-export-20200116-003153 └── export_data + └── transcript.txt └── secret_memo.pdf ``` diff --git a/export/build-requirements.txt b/export/build-requirements.txt index e69de29bb..172081b68 100644 --- a/export/build-requirements.txt +++ b/export/build-requirements.txt @@ -0,0 +1,2 @@ +pexpect==4.9.0 --hash=sha256:9eaf9cf5e3332373fab8184f455314ac01ebbb33e110331bc9e8e12daea1f68e +ptyprocess==0.7.0 --hash=sha256:b6194d9cb391fd7e02697548610c8eb18ba0226af2a9584bda50605958dc1a6b diff --git a/export/files/tcrypt.conf b/export/files/tcrypt.conf new file mode 100644 index 000000000..e69de29bb diff --git a/export/poetry.lock b/export/poetry.lock index 07624456b..6b1f3115e 100644 --- a/export/poetry.lock +++ b/export/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. [[package]] name = "attrs" @@ -569,6 +569,20 @@ files = [ {file = "peewee-3.17.0.tar.gz", hash = "sha256:3a56967f28a43ca7a4287f4803752aeeb1a57a08dee2e839b99868181dfb5df8"}, ] +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + [[package]] name = "platformdirs" version = "3.11.0" @@ -599,6 +613,17 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + [[package]] name = "pycodestyle" version = "2.11.1" @@ -978,6 +1003,17 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "types-pexpect" +version = "4.9.0.20240207" +description = "Typing stubs for pexpect" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-pexpect-4.9.0.20240207.tar.gz", hash = "sha256:910e20f0f177aeee5f2808d1b3221e3a23dfa1ca3bb02f685c2788fce6ddeb73"}, + {file = "types_pexpect-4.9.0.20240207-py3-none-any.whl", hash = "sha256:22b3fdccf253a8627bac0d3169845743fe0b1dbc87e5d33a438faaf879eb1f7a"}, +] + [[package]] name = "types-setuptools" version = "68.2.0.0" diff --git a/export/pyproject.toml b/export/pyproject.toml index 166a08e64..6d185858e 100644 --- a/export/pyproject.toml +++ b/export/pyproject.toml @@ -8,6 +8,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.9" +pexpect = "^4.9.0" [tool.poetry.group.dev.dependencies] black = "^23.7.0" @@ -18,6 +19,7 @@ pytest = "^7.4.0" pytest-cov = "^4.1.0" pytest-mock = "^3.11.1" semgrep = "^1.31.2" +types-pexpect = "^4.9.0.20240207" [tool.mypy] python_version = "3.9" diff --git a/export/securedrop_export/archive.py b/export/securedrop_export/archive.py index ed8108221..56edf4ddb 100755 --- a/export/securedrop_export/archive.py +++ b/export/securedrop_export/archive.py @@ -6,10 +6,10 @@ import os import tempfile -from securedrop_export.exceptions import ExportException -from securedrop_export.status import BaseStatus from securedrop_export.command import Command from securedrop_export.directory import safe_extractall +from securedrop_export.exceptions import ExportException +from securedrop_export.status import BaseStatus logger = logging.getLogger(__name__) @@ -26,7 +26,6 @@ class Metadata(object): """ METADATA_FILE = "metadata.json" - SUPPORTED_ENCRYPTION_METHODS = ["luks"] def __init__(self, archive_path: str): self.metadata_path = os.path.join(archive_path, self.METADATA_FILE) @@ -38,13 +37,9 @@ def validate(self) -> "Metadata": logger.info("Parsing archive metadata") json_config = json.loads(f.read()) self.export_method = json_config.get("device", None) - self.encryption_method = json_config.get("encryption_method", None) self.encryption_key = json_config.get("encryption_key", None) - logger.info( - "Target: {}, encryption_method {}".format( - self.export_method, self.encryption_method - ) - ) + self.encryption_method = json_config.get("encryption_method", None) + logger.info("Command: {}".format(self.export_method)) except Exception as ex: logger.error("Metadata parsing failure") @@ -54,12 +49,6 @@ def validate(self) -> "Metadata": try: logger.debug("Validate export action") self.command = Command(self.export_method) - if ( - self.command is Command.EXPORT - and self.encryption_method not in self.SUPPORTED_ENCRYPTION_METHODS - ): - logger.error("Unsupported encryption method") - raise ExportException(sdstatus=Status.ERROR_ARCHIVE_METADATA) except ValueError as v: raise ExportException(sdstatus=Status.ERROR_ARCHIVE_METADATA) from v @@ -95,7 +84,5 @@ def set_metadata(self, metadata: Metadata) -> "Archive": """ self.command = metadata.command if self.command is Command.EXPORT: - # When we support multiple encryption types, we will also want to add the - # encryption_method here self.encryption_key = metadata.encryption_key return self diff --git a/export/securedrop_export/disk/__init__.py b/export/securedrop_export/disk/__init__.py index e61094546..760c6e07c 100644 --- a/export/securedrop_export/disk/__init__.py +++ b/export/securedrop_export/disk/__init__.py @@ -1,2 +1,2 @@ -from .legacy_service import Service as LegacyService # noqa: F401 -from .legacy_status import Status as LegacyStatus # noqa: F401 +from .service import Service # noqa: F401 +from .status import Status # noqa: F401 diff --git a/export/securedrop_export/disk/cli.py b/export/securedrop_export/disk/cli.py index abdc0c104..906918992 100644 --- a/export/securedrop_export/disk/cli.py +++ b/export/securedrop_export/disk/cli.py @@ -1,429 +1,529 @@ +import json import logging import os import subprocess +import time +from re import Pattern +from shlex import quote +from typing import Optional, Union -from typing import List, Optional, Union +import pexpect from securedrop_export.exceptions import ExportException -from .volume import EncryptionScheme, Volume, MountedVolume from .status import Status +from .volume import EncryptionScheme, MountedVolume, Volume logger = logging.getLogger(__name__) +_DEVMAPPER_PREFIX = "/dev/mapper/" +_DEV_PREFIX = "/dev/" +_UDISKS_PREFIX = ( + "MODEL REVISION SERIAL DEVICE\n" + "--------------------------------------------------------------------------\n" +) + +# pexpect allows for a complex type to be passed to `expect` in order to match with input +# that includes regular expressions, byte or string patterns, *or* pexpect.EOF and pexpect.TIMEOUT, +# but mypy needs a little help with it, so the below alias is used as a typehint. +# See https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect +PexpectList = Union[ + Pattern[str], + Pattern[bytes], + str, + bytes, + type[pexpect.EOF], + type[pexpect.TIMEOUT], + list[ + Union[ + Pattern[str], + Pattern[bytes], + Union[str, bytes, Union[type[pexpect.EOF], type[pexpect.TIMEOUT]]], + ] + ], +] + class CLI: """ - A Python wrapper for various shell commands required to detect, map, and - mount Export devices. + A Python wrapper for shell commands required to detect, map, and + mount USB devices. - CLI callers must handle ExportException and all exceptions and exit with - sys.exit(0) so that another program does not attempt to open the submission. + CLI callers must handle ExportException. """ - # Default mountpoint (unless drive is already mounted manually by the user) - _DEFAULT_MOUNTPOINT = "/media/usb" - - def get_connected_devices(self) -> List[str]: + def get_volume(self) -> Union[Volume, MountedVolume]: """ - List all block devices attached to VM that are disks and not partitions. - Return list of all removable connected block devices. - - Raise ExportException if any commands fail. + Search for valid connected device. + Raise ExportException on error. """ logger.info("Checking connected volumes") try: - lsblk = subprocess.Popen( - ["lsblk", "-o", "NAME,TYPE"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - grep = subprocess.Popen( - ["grep", "disk"], - stdin=lsblk.stdout, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + usbs = ( + subprocess.check_output(["udisksctl", "status"]) + .decode("utf-8") + .removeprefix(_UDISKS_PREFIX) + .strip() + .split("\n") ) - command_output = grep.stdout.readlines() # type: ignore[union-attr] - - # The first word in each element of the command_output list is the device name - attached_devices = [x.decode("utf8").split()[0] for x in command_output] - - except subprocess.CalledProcessError as ex: - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - - return self._get_removable_devices(attached_devices) - - def _get_removable_devices(self, attached_devices: List[str]) -> List[str]: - """ - Determine which block devices are USBs by selecting those that are removable. - """ - logger.info("Checking removable devices") - usb_devices = [] - for device in attached_devices: - is_removable = False - try: - removable = subprocess.check_output( - ["cat", f"/sys/class/block/{device}/removable"], - stderr=subprocess.PIPE, - ) - - # removable is "0" for non-removable device, "1" for removable, - # convert that into a Python boolean - is_removable = bool(int(removable.decode("utf8").strip())) - - except subprocess.CalledProcessError: - # Not a removable device - continue - - if is_removable: - usb_devices.append(f"/dev/{device}") - - logger.info(f"{len(usb_devices)} connected") - return usb_devices - - def get_partitioned_device(self, blkid: str) -> str: - """ - Given a string representing a block device, return string that includes correct partition - (such as "/dev/sda" or "/dev/sda1"). - - Raise ExportException if partition check fails or device has unsupported partition scheme - (currently, multiple partitions are unsupported). - """ - device_and_partitions = self._check_partitions(blkid) - if device_and_partitions: - partition_count = ( - device_and_partitions.decode("utf-8").split("\n").count("part") - ) - logger.debug(f"Counted {partition_count} partitions") - if partition_count > 1: - # We don't currently support devices with multiple partitions - logger.error( - f"Multiple partitions not supported ({partition_count} partitions" - f" on {blkid})" - ) + # Collect a space-separated list of USB device names. + # Format: + # Label (may contain spaces) Revision Serial# Device + # The last string is the device identifier (/dev/{device}). + targets = [] + for i in usbs: + item = i.strip().split() + if len(item) > 0: + targets.append(item[-1]) + + if len(targets) == 0: + logger.info("No USB devices found") + raise ExportException(sdstatus=Status.NO_DEVICE_DETECTED) + elif len(targets) > 1: + logger.error("Too many USB devices! Detach a device before continuing.") + raise ExportException(sdstatus=Status.MULTI_DEVICE_DETECTED) + + # lsblk -o NAME,RM,RO,TYPE,MOUNTPOINT,FSTYPE --json + # devices such as /dev/xvda are marked as "removable", + # which is why we do the previous check to pick a device + # recognized by udisks2 + lsblk = subprocess.check_output( + [ + "lsblk", + "--output", + "NAME,RO,TYPE,MOUNTPOINT,FSTYPE", + "--json", + ] + ).decode("utf-8") + + lsblk_json = json.loads(lsblk) + if not lsblk_json.get("blockdevices"): + logger.error("Unrecoverable: could not parse lsblk.") + raise ExportException(sdstatus=Status.DEVICE_ERROR) + + # mypy complains that this is a list[str], but it is a + # list[Union[Volume, MountedVolume]] + volumes = [] # type: ignore + for device in lsblk_json.get("blockdevices"): + if device.get("name") in targets and device.get("ro") is False: + logger.debug( + f"Checking removable, writable device {_DEV_PREFIX}{device.get('name')}" + ) + # Inspect partitions or whole volume. + # For sanity, we will only support encrypted partitions one level deep. + if "children" in device: + for partition in device.get("children"): + # /dev/sdX1, /dev/sdX2 etc + item = self._get_supported_volume(partition) # type: ignore + if item: + volumes.append(item) # type: ignore + # /dev/sdX + else: + item = self._get_supported_volume(device) # type: ignore + if item: + volumes.append(item) # type: ignore + + if len(volumes) != 1: + logger.error(f"Need one target, got {len(volumes)}") raise ExportException(sdstatus=Status.INVALID_DEVICE_DETECTED) + else: + logger.debug(f"Export target is {volumes[0].device_name}") # type: ignore + return volumes[0] # type: ignore - # redefine device to /dev/sda if disk is encrypted, /dev/sda1 if partition encrypted - if partition_count == 1: - logger.debug("One partition found") - blkid += "1" - - return blkid - - else: - # lsblk did not return output we could process - logger.error("Error checking device partitions") - raise ExportException(sdstatus=Status.DEVICE_ERROR) - - def _check_partitions(self, blkid: str) -> bytes: - try: - logger.debug(f"Checking device partitions on {blkid}") - device_and_partitions = subprocess.check_output( - ["lsblk", "-o", "TYPE", "--noheadings", blkid], stderr=subprocess.PIPE - ) - return device_and_partitions - - except subprocess.CalledProcessError as ex: - logger.error(f"Error checking block device {blkid}") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - - def is_luks_volume(self, device: str) -> bool: - """ - Given a string representing a volume (/dev/sdX or /dev/sdX1), return True if volume is - LUKS-encrypted, otherwise False. - """ - isLuks = False - - try: - logger.debug("Checking if target device is luks encrypted") - - # cryptsetup isLuks returns 0 if the device is a luks volume - # subprocess will throw if the device is not luks (rc !=0) - subprocess.check_call(["sudo", "cryptsetup", "isLuks", device]) - - isLuks = True - - except subprocess.CalledProcessError: - # Not necessarily an error state, just means the volume is not LUKS encrypted - logger.info("Target device is not LUKS-encrypted") - - return isLuks - - def _get_luks_name_from_headers(self, device: str) -> str: - """ - Dump LUKS header and determine name of volume. + except json.JSONDecodeError as err: + logger.error(err) + raise ExportException(sdstatus=Status.DEVICE_ERROR) from err - Raise ExportException if errors encounterd during attempt to parse LUKS headers. - """ - logger.debug("Get LUKS name from headers") - try: - luks_header = subprocess.check_output( - ["sudo", "cryptsetup", "luksDump", device] - ) - if luks_header: - luks_header_list = luks_header.decode("utf-8").split("\n") - for line in luks_header_list: - items = line.split("\t") - if "UUID" in items[0]: - return "luks-" + items[1] - - # If no header or no UUID field, we can't use this drive - logger.error( - f"Failed to get UUID from LUKS header; {device} may not be correctly formatted" - ) - raise ExportException(sdstatus=Status.INVALID_DEVICE_DETECTED) except subprocess.CalledProcessError as ex: - logger.error("Failed to dump LUKS header") raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - def get_luks_volume(self, device: str) -> Union[Volume, MountedVolume]: + def _get_supported_volume( + self, device: dict + ) -> Optional[Union[Volume, MountedVolume]]: """ - Given a string corresponding to a LUKS-partitioned volume, return a corresponding Volume - object. - - If LUKS volume is already mounted, existing mountpoint will be preserved and a - MountedVolume object will be returned. - If LUKS volume is unlocked but not mounted, volume will be mounted at _DEFAULT_MOUNTPOINT, - and a MountedVolume object will be returned. - - If device is still locked, mountpoint will not be set, and a Volume object will be retuned. - Once the decrpytion passphrase is available, call unlock_luks_volume(), passing the Volume - object and passphrase to unlock the volume. - - Raise ExportException if errors are encountered. + Given JSON-formatted lsblk output for one device, determine if it + is suitably partitioned and return it to be used for export, + mounting it if possible. + + Supported volumes: + * Unlocked Veracrypt drives + * Locked or unlocked LUKS drives + * No more than one encrypted partition (multiple non-encrypted partitions + are OK as they will be ignored). + + Note: It would be possible to support other unlocked encrypted drives, as long as + udisks2 can tell they contain an encrypted partition. """ - try: - mapped_name = self._get_luks_name_from_headers(device) - logger.debug(f"Mapped name is {mapped_name}") - - # Setting the mapped_name does not mean the device has already been unlocked. - luks_volume = Volume( - device_name=device, - mapped_name=mapped_name, - encryption=EncryptionScheme.LUKS, - ) - - # If the device has been unlocked, we can see if it's mounted and - # use the existing mountpoint, or mount it ourselves. - # Either way, return a MountedVolume. - if os.path.exists(os.path.join("/dev/mapper/", mapped_name)): - return self.mount_volume(luks_volume) - - # It's still locked + device_name = device.get("name") + device_fstype = device.get("fstype") + + vol = Volume(f"{_DEV_PREFIX}{device_name}", EncryptionScheme.UNKNOWN) + + if device_fstype == "crypto_LUKS": + logger.debug(f"{device_name} is LUKS-encrypted") + vol.encryption = EncryptionScheme.LUKS + + children = device.get("children") + if children: + if len(children) != 1: + logger.error(f"Unexpected volume format on {vol.device_name}") + return None + elif children[0].get("type") != "crypt": + return None else: - return luks_volume - - except ExportException: - logger.error("Failed to return luks volume") - raise + # It's an unlocked drive, possibly mounted + mapped_name = f"{_DEVMAPPER_PREFIX}{children[0].get('name')}" + + # Unlocked VC/TC drives will still have EncryptionScheme.UNKNOWN; + # see if we can do better + if vol.encryption == EncryptionScheme.UNKNOWN: + vol.encryption = self._is_it_veracrypt(vol) + + if children[0].get("mountpoint"): + logger.debug(f"{vol.device_name} is mounted") + + return MountedVolume( + device_name=vol.device_name, + unlocked_name=mapped_name, + encryption=vol.encryption, + mountpoint=children[0].get("mountpoint"), + ) + else: + # To opportunistically mount any unlocked encrypted partition + # (i.e. third-party devices such as IronKeys), remove this condition. + if vol.encryption in ( + EncryptionScheme.LUKS, + EncryptionScheme.VERACRYPT, + ): + logger.debug( + f"{device_name} is unlocked but unmounted; attempting mount" + ) + return self._mount_volume(vol, mapped_name) + + # Locked VeraCrypt drives are rejected here (EncryptionScheme.UNKNOWN) + if vol.encryption in (EncryptionScheme.LUKS, EncryptionScheme.VERACRYPT): + logger.debug(f"{vol.device_name} is supported export target") + return vol + else: + logger.debug(f"No suitable volume found on {vol.device_name}") + return None - def unlock_luks_volume(self, volume: Volume, decryption_key: str) -> Volume: + def _is_it_veracrypt(self, volume: Volume) -> EncryptionScheme: """ - Unlock a LUKS-encrypted volume. - - Raise ExportException if errors are encountered during device unlocking. + Helper. Best-effort detection of unlocked VeraCrypt drives. + udisks2 requires the flag file /etc/udisks2/tcrypt.conf to + enable VeraCrypt drive detection, which we ship with this package. """ - if volume.encryption is not EncryptionScheme.LUKS: - logger.error("Must call unlock_luks_volume() on LUKS-encrypted device") - raise ExportException(sdstatus=Status.DEVICE_ERROR) - try: - logger.debug("Unlocking luks volume {}".format(volume.device_name)) - p = subprocess.Popen( + info = subprocess.check_output( [ - "sudo", - "cryptsetup", - "luksOpen", - volume.device_name, - volume.mapped_name, - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - logger.debug("Passing key") - p.communicate(input=str.encode(decryption_key, "utf-8")) - rc = p.returncode - - if rc == 0: - return Volume( - device_name=volume.device_name, - mapped_name=volume.mapped_name, - encryption=EncryptionScheme.LUKS, - ) + "udisksctl", + "info", + "--block-device", + quote(volume.device_name), + ] + ).decode("utf-8") + if "IdType: crypto_TCRYPT\n" in info: + return EncryptionScheme.VERACRYPT + elif "IdType: crypto_LUKS\n" in info: + # Don't downgrade LUKS to UNKNOWN if someone + # calls this method on a LUKS drive + return EncryptionScheme.LUKS else: - logger.error("Bad volume passphrase") - raise ExportException(sdstatus=Status.ERROR_UNLOCK_LUKS) - - except subprocess.CalledProcessError as ex: - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - - def _get_mountpoint(self, volume: Volume) -> Optional[str]: - """ - Check for existing mountpoint. - Raise ExportException if errors encountered during command. + return EncryptionScheme.UNKNOWN + except subprocess.CalledProcessError as err: + logger.debug(f"Error checking disk info of {volume.device_name}") + logger.error(err) + # Not a showstopper + return EncryptionScheme.UNKNOWN + + def unlock_volume(self, volume: Volume, encryption_key: str) -> MountedVolume: """ - logger.debug("Checking mountpoint") - try: - output = subprocess.check_output( - ["lsblk", "-o", "MOUNTPOINT", "--noheadings", volume.device_name] - ) - return output.decode("utf-8").strip() + Unlock and mount an encrypted volume. If volume is already mounted, preserve + existing mountpoint. - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise ExportException(sdstatus=Status.ERROR_MOUNT) from ex + Throws ExportException if errors are encountered during device unlocking. - def mount_volume(self, volume: Volume) -> MountedVolume: + `pexpect.ExeptionPexpect` can't be try/caught, since it's not a + child of BaseException, but instead, exceptions can be included + in the list of results to check for. (See + https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect) """ - Given an unlocked LUKS volume, return MountedVolume object. + logger.debug("Unlocking volume {}".format(quote(volume.device_name))) + + command = "udisksctl" + args = ["unlock", "--block-device", quote(volume.device_name)] + + # pexpect allows for a match list that contains pexpect.EOF and pexpect.TIMEOUT + # as well as string/regex matches: + # https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect + prompt = [ + "Passphrase: ", + pexpect.EOF, + pexpect.TIMEOUT, + ] # type: PexpectList + expected = [ + f"Unlocked {volume.device_name} as (.*)[^\r\n].", + "GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Device " # string continues + f"{volume.device_name} is already unlocked as (.*)[^\r\n].", + "GDBus.Error:org.freedesktop.UDisks2.Error.Failed: Error " # string continues + f"unlocking {volume.device_name}: Failed to activate device: Incorrect passphrase", + pexpect.EOF, + pexpect.TIMEOUT, + ] # type: PexpectList + unlock_error = Status.ERROR_UNLOCK_GENERIC + + child = pexpect.spawn(command, args) + index = child.expect(prompt) + if index != 0: + logger.error("Did not receive disk unlock prompt") + raise ExportException(sdstatus=Status.ERROR_UNLOCK_GENERIC) + else: + logger.debug("Passing key") + child.sendline(encryption_key) + index = child.expect(expected) + if index == 0 or index == 1: + # Pexpect includes a re.Match object at `child.match`, but this freaks mypy out: + # see https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect + # We know what format the results are in + dm_name = child.match.group(1).decode("utf-8").strip() # type: ignore + logger.debug(f"Device is unlocked as {dm_name}") + + child.close() + if child.exitstatus is not None and child.exitstatus not in (0, 1): + logger.warning(f"pexpect: child exited with {child.exitstatus}") + + # dm_name format is /dev/dm-X + return self._mount_volume(volume, dm_name) + + elif index == 2: + # Still an error, but we can report more specific error to the user + logger.debug("Bad volume passphrase") + unlock_error = Status.ERROR_UNLOCK_LUKS + + # Any other index values are also an error. Clean up and raise + child.close() + + logger.error(f"Error encountered while unlocking {volume.device_name}") + raise ExportException(sdstatus=unlock_error) + + def _mount_volume(self, volume: Volume, full_unlocked_name: str) -> MountedVolume: + """ + Given an unlocked volume, mount volume in /media/user/ by udisksctl and + return MountedVolume object. - If volume is already mounted, mountpoint is not changed. Otherwise, - volume is mounted at _DEFAULT_MOUNTPOINT. + Unlocked name could be `/dev/mapper/$id` or `/dev/dm-X`. Raise ExportException if errors are encountered during mounting. + + `pexpect.ExeptionPexpect` can't be try/caught, since it's not a + child of BaseException, but instead, exceptions can be included + in the list of results to check for. (See + https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect) """ - if not volume.unlocked: - logger.error("Volume is not unlocked.") - raise ExportException(sdstatus=Status.ERROR_MOUNT) - mountpoint = self._get_mountpoint(volume) + info_cmd = "udisksctl" + info_args = ["info", "--block-device", quote(volume.device_name)] + # The terminal output has colours and other formatting. A match is anything + # that includes our device identified as PreferredDevice on one line + # \x1b[37mPreferredDevice:\x1b[0m /dev/sdaX\r\n + expected_info = [ + f"PreferredDevice:.*[^\r\n]{volume.device_name}", + "Error looking up object for device", + pexpect.EOF, + pexpect.TIMEOUT, + ] # type: PexpectList + max_retries = 3 + + mount_cmd = "udisksctl" + mount_args = ["mount", "--block-device", quote(full_unlocked_name)] + + # We can't pass {full_unlocked_name} in the match statement since even if we + # pass in /dev/mapper/xxx, udisks2 may refer to the disk as /dev/dm-X. + expected_mount = [ + "Mounted .* at (.*)", + "Error mounting .*: GDBus.Error:org.freedesktop.UDisks2.Error.AlreadyMounted: " + "Device (.*) is already mounted at `(.*)'.", + "Error looking up object for device", + pexpect.EOF, + pexpect.TIMEOUT, + ] # type: PexpectList + mountpoint = None + + logger.debug( + f"Check to make sure udisks identified {volume.device_name} " + f"(unlocked as {full_unlocked_name})" + ) + for _ in range(max_retries): + child = pexpect.spawn(info_cmd, info_args) + index = child.expect(expected_info) + child.close() + + if index != 0: + logger.debug(f"udisks can't identify {volume.device_name}, retrying...") + time.sleep(0.5) + else: + logger.debug(f"udisks found {volume.device_name}") + break - if mountpoint: - logger.info("The device is already mounted--use existing mountpoint") - return MountedVolume.from_volume(volume, mountpoint) + logger.info(f"Mount {full_unlocked_name} using udisksctl") + child = pexpect.spawn(mount_cmd, mount_args) + index = child.expect(expected_mount) - else: - logger.info("Mount volume at default mountpoint") - return self._mount_at_mountpoint(volume, self._DEFAULT_MOUNTPOINT) + if index == 0: + # As above, we know the format. + # Per https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn.expect, + # `child.match` is a re.Match object + mountpoint = child.match.group(1).decode("utf-8").strip() # type: ignore + logger.info(f"Successfully mounted device at {mountpoint}") - def _mount_at_mountpoint(self, volume: Volume, mountpoint: str) -> MountedVolume: - """ - Mount a volume at the supplied mountpoint, creating the mountpoint directory and - adjusting permissions (user:user) if need be. `mountpoint` must be a full path. + elif index == 1: + # Use udisks unlocked name + logger.debug("Already mounted, get unlocked_name and mountpoint") + full_unlocked_name = child.match.group(1).decode("utf-8").strip() # type: ignore + mountpoint = child.match.group(2).decode("utf-8").strip() # type: ignore + logger.info(f"Device {full_unlocked_name} already mounted at {mountpoint}") - Return MountedVolume object. - Raise ExportException if unable to mount volume at target mountpoint. - """ - if not os.path.exists(mountpoint): - try: - subprocess.check_call(["sudo", "mkdir", mountpoint]) - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise ExportException(sdstatus=Status.ERROR_MOUNT) from ex - - # Mount device /dev/mapper/{mapped_name} at /media/usb/ - mapped_device_path = os.path.join( - volume.MAPPED_VOLUME_PREFIX, volume.mapped_name - ) + elif index == 2: + logger.debug("Device is not ready") - try: - logger.info(f"Mounting volume at {mountpoint}") - subprocess.check_call(["sudo", "mount", mapped_device_path, mountpoint]) - subprocess.check_call(["sudo", "chown", "-R", "user:user", mountpoint]) + logger.debug("Close pexpect process") + child.close() - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise ExportException(sdstatus=Status.ERROR_MOUNT) from ex + if mountpoint: + return MountedVolume( + device_name=volume.device_name, + unlocked_name=full_unlocked_name, + encryption=volume.encryption, + mountpoint=mountpoint, + ) - return MountedVolume.from_volume(volume, mountpoint) + logger.error("Could not get mountpoint") + raise ExportException(sdstatus=Status.ERROR_MOUNT) def write_data_to_device( self, - submission_tmpdir: str, - submission_target_dirname: str, device: MountedVolume, + archive_tmpdir: str, + archive_target_dirname: str, ): """ Move files to drive (overwrites files with same filename) and unmount drive. + Drive is unmounted and files are cleaned up as part of the `finally` block to ensure that cleanup happens even if export fails or only partially succeeds. """ try: - target_path = os.path.join(device.mountpoint, submission_target_dirname) + # Flag to pass to cleanup method + is_error = False + + target_path = os.path.join(device.mountpoint, archive_target_dirname) subprocess.check_call(["mkdir", target_path]) - export_data = os.path.join(submission_tmpdir, "export_data/") - logger.debug("Copying file to {}".format(submission_target_dirname)) + export_data = os.path.join(archive_tmpdir, "export_data/") + logger.debug("Copying file to {}".format(archive_target_dirname)) subprocess.check_call(["cp", "-r", export_data, target_path]) - logger.info( - "File copied successfully to {}".format(submission_target_dirname) - ) + logger.info("File copied successfully to {}".format(archive_target_dirname)) except (subprocess.CalledProcessError, OSError) as ex: logger.error(ex) + + # Ensure we report an export error out after cleanup + is_error = True raise ExportException(sdstatus=Status.ERROR_EXPORT) from ex finally: - self.cleanup_drive_and_tmpdir(device, submission_tmpdir) + self.cleanup(device, archive_tmpdir, is_error) - def cleanup_drive_and_tmpdir(self, volume: MountedVolume, submission_tmpdir: str): + def cleanup( + self, + volume: MountedVolume, + archive_tmpdir: str, + is_error: bool = False, + should_close_volume: bool = True, + ): """ Post-export cleanup method. Unmount and lock drive and remove temporary - directory. Currently called at end of `write_data_to_device()` to ensure - device is always locked after export. + directory. + + Raises ExportException if errors during cleanup are encountered. - Raise ExportException if errors during cleanup are encountered. + Method is called whether or not export succeeds; if `is_error` is True, + will report export error status on error (insted of cleanup status). """ + error_status = Status.ERROR_EXPORT if is_error else Status.ERROR_EXPORT_CLEANUP + logger.debug("Syncing filesystems") try: subprocess.check_call(["sync"]) - umounted = self._unmount_volume(volume) - if umounted: - self._close_luks_volume(umounted) - self._remove_temp_directory(submission_tmpdir) + self._remove_temp_directory(archive_tmpdir) + + # Future configurable option + if should_close_volume: + self._close_volume(volume) except subprocess.CalledProcessError as ex: logger.error("Error syncing filesystem") - raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex + raise ExportException(sdstatus=error_status) from ex - def _unmount_volume(self, volume: MountedVolume) -> Volume: + def _close_volume(self, mv: MountedVolume) -> Volume: """ - Helper. Unmount volume + Unmount and close volume. """ - if os.path.exists(volume.mountpoint): - logger.debug(f"Unmounting drive from {volume.mountpoint}") - try: - subprocess.check_call(["sudo", "umount", volume.mountpoint]) - - except subprocess.CalledProcessError as ex: - logger.error("Error unmounting device") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex - else: - logger.info("Mountpoint does not exist; volume was already unmounted") + logger.debug(f"Unmounting drive {mv.unlocked_name} from {mv.mountpoint}") + try: + subprocess.check_call( + [ + "udisksctl", + "unmount", + "--block-device", + quote(mv.unlocked_name), + ], + # Redirect stderr/stdout to avoid broken pipe when subprocess terminates, + # which results in qrexec attempting to parse error lines written to stderr + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) - return Volume( - device_name=volume.device_name, - mapped_name=volume.mapped_name, - encryption=volume.encryption, - ) + except subprocess.CalledProcessError as ex: + logger.error(ex) + logger.error("Error unmounting device") - def _close_luks_volume(self, unlocked_device: Volume) -> None: - """ - Helper. Close LUKS volume - """ - if os.path.exists(os.path.join("/dev/mapper", unlocked_device.mapped_name)): - logger.debug("Locking luks volume {}".format(unlocked_device)) - try: - subprocess.check_call( - ["sudo", "cryptsetup", "luksClose", unlocked_device.mapped_name] - ) + raise ExportException(sdstatus=Status.ERROR_UNMOUNT_VOLUME_BUSY) from ex + + logger.debug(f"Closing drive {mv.device_name}") + try: + subprocess.check_call( + [ + "udisksctl", + "lock", + "--block-device", + quote(mv.device_name), + ], + # Redirect stderr/stdout to avoid broken pipe when subprocess terminates + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) - except subprocess.CalledProcessError as ex: - logger.error("Error closing device") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex + except subprocess.CalledProcessError as ex: + logger.error("Error closing device") + raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex + + return Volume( + device_name=f"{_DEV_PREFIX}{mv.device_name}", + encryption=mv.encryption, + ) def _remove_temp_directory(self, tmpdir: str): """ - Helper. Remove temporary directory used during archive export. + Helper. Remove temporary directory used during export. """ logger.debug(f"Deleting temporary directory {tmpdir}") try: subprocess.check_call(["rm", "-rf", tmpdir]) except subprocess.CalledProcessError as ex: logger.error("Error removing temporary directory") - raise ExportException(sdstatus=Status.DEVICE_ERROR) from ex + raise ExportException(sdstatus=Status.ERROR_EXPORT_CLEANUP) from ex diff --git a/export/securedrop_export/disk/legacy_service.py b/export/securedrop_export/disk/legacy_service.py deleted file mode 100644 index 3dbe6acaa..000000000 --- a/export/securedrop_export/disk/legacy_service.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging - -from securedrop_export.exceptions import ExportException - -from .cli import CLI -from .legacy_status import Status as LegacyStatus -from .status import Status as Status -from .volume import MountedVolume - -logger = logging.getLogger(__name__) - - -class Service: - def __init__(self, submission, cli=None): - self.submission = submission - self.cli = cli or CLI() - - def check_connected_devices(self) -> LegacyStatus: - """ - Check if single USB is inserted. - """ - logger.info("Export archive is usb-test") - - try: - all_devices = self.cli.get_connected_devices() - num_devices = len(all_devices) - - except ExportException as ex: - logger.error(f"Error encountered during USB check: {ex.sdstatus.value}") - # Use legacy status instead of new status values - raise ExportException(sdstatus=LegacyStatus.LEGACY_ERROR_USB_CHECK) from ex - - if num_devices == 0: - raise ExportException(sdstatus=LegacyStatus.LEGACY_USB_NOT_CONNECTED) - elif num_devices == 1: - return LegacyStatus.LEGACY_USB_CONNECTED - elif num_devices > 1: - raise ExportException( - sdstatus=LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - ) - else: - # Unreachable, num_devices is a non-negative integer, - # and we handled all possible cases already - raise ValueError(f"unreachable: num_devices is negative: {num_devices}") - - def check_disk_format(self) -> LegacyStatus: - """ - Check if volume is correctly formatted for export. - """ - try: - all_devices = self.cli.get_connected_devices() - - if len(all_devices) == 1: - device = self.cli.get_partitioned_device(all_devices[0]) - logger.info("Check if LUKS") - if not self.cli.is_luks_volume(device): - raise ExportException( - sdstatus=LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - ) - # We can support checking if a drive is already unlocked, but for - # backwards compatibility, this is the only expected status - # at this stage - return LegacyStatus.LEGACY_USB_ENCRYPTED - else: - logger.error("Multiple partitions not supported") - return LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - - except ExportException as ex: - logger.error( - f"Error encountered during disk format check: {ex.sdstatus.value}" - ) - # Return legacy status values for now for ongoing client compatibility - if ex.sdstatus in [s for s in Status]: - status = self._legacy_status(ex.sdstatus) - raise ExportException(sdstatus=status) - elif ex.sdstatus: - raise - else: - raise ExportException(sdstatus=LegacyStatus.LEGACY_USB_DISK_ERROR) - - def export(self): - """ - Export all files to target device. - """ - logger.info("Export archive is disk") - - try: - all_devices = self.cli.get_connected_devices() - - if len(all_devices) == 1: - device = self.cli.get_partitioned_device(all_devices[0]) - - # Decide what kind of volume it is - logger.info("Check if LUKS") - if self.cli.is_luks_volume(device): - volume = self.cli.get_luks_volume(device) - logger.info("Check if writable") - if not isinstance(volume, MountedVolume): - logger.info("Not writable-will try unlocking") - volume = self.cli.unlock_luks_volume( - volume, self.submission.encryption_key - ) - mounted_volume = self.cli.mount_volume(volume) - - logger.info(f"Export submission to {mounted_volume.mountpoint}") - self.cli.write_data_to_device( - self.submission.tmpdir, - self.submission.target_dirname, - mounted_volume, - ) - # This is SUCCESS_EXPORT, but the 0.7.0 client is not expecting - # a return status from a successful export operation. - # When the client is updated, we will return SUCCESS_EXPORT here. - - else: - # Another kind of drive: VeraCrypt/TC, or unsupported. - # For now this is an error--in future there will be support - # for additional encryption formats - logger.error(f"Export failed because {device} is not supported") - raise ExportException( - sdstatus=LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - ) - - except ExportException as ex: - logger.error( - f"Error encountered during disk format check: {ex.sdstatus.value}" - ) - # Return legacy status values for now for ongoing client compatibility - if ex.sdstatus in [s for s in Status]: - status = self._legacy_status(ex.sdstatus) - raise ExportException(sdstatus=status) - elif ex.sdstatus: - raise - else: - raise ExportException(sdstatus=LegacyStatus.LEGACY_ERROR_GENERIC) - - def _legacy_status(self, status: Status) -> LegacyStatus: - """ - Backwards-compatibility - status values that client (@0.7.0) is expecting. - """ - logger.info(f"Convert to legacy: {status.value}") - if status is Status.ERROR_MOUNT: - return LegacyStatus.LEGACY_ERROR_USB_MOUNT - elif status in [Status.ERROR_EXPORT, Status.ERROR_EXPORT_CLEANUP]: - return LegacyStatus.LEGACY_ERROR_USB_WRITE - elif status in [Status.ERROR_UNLOCK_LUKS, Status.ERROR_UNLOCK_GENERIC]: - return LegacyStatus.LEGACY_USB_BAD_PASSPHRASE - elif status in [ - Status.INVALID_DEVICE_DETECTED, - Status.MULTI_DEVICE_DETECTED, - ]: - return LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - # The other status values, such as Status.NO_DEVICE_DETECTED, are not returned by the - # CLI, so we don't need to check for them here - else: - return LegacyStatus.LEGACY_ERROR_GENERIC diff --git a/export/securedrop_export/disk/legacy_status.py b/export/securedrop_export/disk/legacy_status.py deleted file mode 100644 index 77f0fa6ce..000000000 --- a/export/securedrop_export/disk/legacy_status.py +++ /dev/null @@ -1,25 +0,0 @@ -from securedrop_export.status import BaseStatus - - -class Status(BaseStatus): - LEGACY_ERROR_GENERIC = "ERROR_GENERIC" - - # Legacy USB preflight related - LEGACY_USB_CONNECTED = "USB_CONNECTED" # Success - LEGACY_USB_NOT_CONNECTED = "USB_NOT_CONNECTED" - LEGACY_ERROR_USB_CHECK = "ERROR_USB_CHECK" - - # Legacy USB Disk preflight related errors - LEGACY_USB_ENCRYPTED = "USB_ENCRYPTED" # Success - LEGACY_USB_ENCRYPTION_NOT_SUPPORTED = "USB_ENCRYPTION_NOT_SUPPORTED" - - # Can be raised during disk format check - LEGACY_USB_DISK_ERROR = "USB_DISK_ERROR" - - # Legacy Disk export errors - LEGACY_USB_BAD_PASSPHRASE = "USB_BAD_PASSPHRASE" - LEGACY_ERROR_USB_MOUNT = "ERROR_USB_MOUNT" - LEGACY_ERROR_USB_WRITE = "ERROR_USB_WRITE" - - # New - SUCCESS_EXPORT = "SUCCESS_EXPORT" diff --git a/export/securedrop_export/disk/service.py b/export/securedrop_export/disk/service.py index 1db9a8338..9a8034ee5 100644 --- a/export/securedrop_export/disk/service.py +++ b/export/securedrop_export/disk/service.py @@ -1,24 +1,24 @@ import logging from securedrop_export.archive import Archive +from securedrop_export.exceptions import ExportException from .cli import CLI from .status import Status -from .volume import Volume, MountedVolume -from securedrop_export.exceptions import ExportException - +from .volume import MountedVolume, Volume logger = logging.getLogger(__name__) class Service: """ - Checks that can be performed against the device(s). + Actions that can be performed against USB device(s). This is the "API" portion of the export workflow. """ - def __init__(self, cli: CLI): + def __init__(self, submission: Archive, cli: CLI = CLI()): self.cli = cli + self.submission = submission def scan_all_devices(self) -> Status: """ @@ -26,89 +26,55 @@ def scan_all_devices(self) -> Status: status. """ try: - all_devices = self.cli.get_connected_devices() - number_devices = len(all_devices) - - if number_devices == 0: - return Status.NO_DEVICE_DETECTED - elif number_devices > 1: - return Status.MULTI_DEVICE_DETECTED + volume = self.cli.get_volume() + if isinstance(volume, MountedVolume): + return Status.DEVICE_WRITABLE + elif isinstance(volume, Volume): + return Status.DEVICE_LOCKED else: - return self.scan_single_device(all_devices[0]) + # Above will return MountedVolume, Volume, or raise error; + # this shouldn't be reachable + raise ExportException(sdstatus=Status.DEVICE_ERROR) except ExportException as ex: - logger.error(ex) - return Status.DEVICE_ERROR # Could not assess devices + logger.debug(ex) + status = ex.sdstatus if ex.sdstatus is not None else Status.DEVICE_ERROR + logger.error(f"Encountered {status.value} while checking volumes") + return status - def scan_single_device(self, blkid: str) -> Status: + def export(self) -> Status: """ - Given a string representing a single block device, see if it - is a suitable export target and return information about its state. + Export material to USB drive. """ try: - target = self.cli.get_partitioned_device(blkid) - - # See if it's a LUKS drive - if self.cli.is_luks_volume(target): - # Returns Volume or throws ExportException - self.volume = self.cli.get_luks_volume(target) - - # See if it's unlocked and mounted - if isinstance(self.volume, MountedVolume): - logger.debug("LUKS device is already mounted") - return Status.DEVICE_WRITABLE + volume = self.cli.get_volume() + if isinstance(volume, MountedVolume): + logger.debug("Mounted volume detected, exporting files") + self.cli.write_data_to_device( + volume, self.submission.tmpdir, self.submission.target_dirname + ) + return Status.SUCCESS_EXPORT + elif isinstance(volume, Volume): + if self.submission.encryption_key is not None: + logger.debug("Volume is locked, try unlocking") + mv = self.cli.unlock_volume(volume, self.submission.encryption_key) + if isinstance(mv, MountedVolume): + logger.debug("Export to device") + # Exports then locks the drive. + # If the export succeeds but the drive is in use, will raise + # exception. + self.cli.write_data_to_device( + mv, self.submission.tmpdir, self.submission.target_dirname + ) + return Status.SUCCESS_EXPORT + else: + raise ExportException(sdstatus=Status.ERROR_UNLOCK_GENERIC) else: - # Prompt for passphrase + logger.info("Volume is locked and no key has been provided") return Status.DEVICE_LOCKED - else: - # Might be VeraCrypt, might be madness - logger.info("LUKS drive not found") - - # Currently we don't support anything other than LUKS. - # In future, we will support TC/VC volumes as well - return Status.INVALID_DEVICE_DETECTED - - except ExportException as ex: - logger.error(ex) - if ex.sdstatus: - return ex.sdstatus - else: - return Status.DEVICE_ERROR - - def unlock_device(self, passphrase: str, volume: Volume) -> Status: - """ - Given provided passphrase, unlock target volume. Currently, - LUKS volumes are supported. - """ - if volume: - try: - self.volume = self.cli.unlock_luks_volume(volume, passphrase) - - if isinstance(volume, MountedVolume): - return Status.DEVICE_WRITABLE - else: - return Status.ERROR_UNLOCK_LUKS - - except ExportException as ex: - logger.error(ex) - return Status.ERROR_UNLOCK_LUKS - else: - # Trying to unlock devices before having an active device - logger.warning("Tried to unlock_device but no current volume detected.") - return Status.NO_DEVICE_DETECTED - - def write_to_device(self, volume: MountedVolume, data: Archive) -> Status: - """ - Export data to volume. CLI unmounts and locks volume on completion, even - if export was unsuccessful. - """ - try: - self.cli.write_data_to_device(data.tmpdir, data.target_dirname, volume) - return Status.SUCCESS_EXPORT except ExportException as ex: - logger.error(ex) - if ex.sdstatus: - return ex.sdstatus - else: - return Status.ERROR_EXPORT + logger.debug(ex) + status = ex.sdstatus if ex.sdstatus is not None else Status.ERROR_EXPORT + logger.error(f"Enountered {status.value} while trying to export") + return status diff --git a/export/securedrop_export/disk/status.py b/export/securedrop_export/disk/status.py index 7ce713913..59f868672 100644 --- a/export/securedrop_export/disk/status.py +++ b/export/securedrop_export/disk/status.py @@ -3,24 +3,32 @@ class Status(BaseStatus): NO_DEVICE_DETECTED = "NO_DEVICE_DETECTED" + INVALID_DEVICE_DETECTED = ( - "INVALID_DEVICE_DETECTED" # Multi partitioned, not encrypted, etc + "INVALID_DEVICE_DETECTED" # Not encrypted, or partitions too many/too nested + ) + + MULTI_DEVICE_DETECTED = ( + "MULTI_DEVICE_DETECTED" # Multiple devices are not currently supported ) - MULTI_DEVICE_DETECTED = "MULTI_DEVICE_DETECTED" # Not currently supported - DEVICE_LOCKED = "DEVICE_LOCKED" # One device detected, and it's locked + DEVICE_LOCKED = "DEVICE_LOCKED" # One valid device detected, and it's locked DEVICE_WRITABLE = ( - "DEVICE_WRITABLE" # One device detected, and it's unlocked (and mounted) + "DEVICE_WRITABLE" # One valid device detected, and it's unlocked (and mounted) ) - ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" - ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" + ERROR_UNLOCK_LUKS = "ERROR_UNLOCK_LUKS" # Bad LUKS passphrase + ERROR_UNLOCK_GENERIC = "ERROR_UNLOCK_GENERIC" # Other error during unlocking ERROR_MOUNT = "ERROR_MOUNT" # Unlocked but not mounted SUCCESS_EXPORT = "SUCCESS_EXPORT" ERROR_EXPORT = "ERROR_EXPORT" # Could not write to disk - # export succeeds but drives were not properly unmounted + # Export succeeds but drive was not unmounted because the volume is busy. + # This could happen if the user has an application using the drive elsewhere + ERROR_UNMOUNT_VOLUME_BUSY = "ERROR_UNMOUNT_VOLUME_BUSY" + + # Export succeeds but drives were not properly unmounted (generic) ERROR_EXPORT_CLEANUP = "ERROR_EXPORT_CLEANUP" DEVICE_ERROR = ( diff --git a/export/securedrop_export/disk/volume.py b/export/securedrop_export/disk/volume.py index aae7d9332..09ba45f96 100644 --- a/export/securedrop_export/disk/volume.py +++ b/export/securedrop_export/disk/volume.py @@ -1,5 +1,4 @@ from enum import Enum -import os class EncryptionScheme(Enum): @@ -9,25 +8,21 @@ class EncryptionScheme(Enum): UNKNOWN = 0 LUKS = 1 + VERACRYPT = 2 class Volume: - MAPPED_VOLUME_PREFIX = "/dev/mapper/" - """ A volume on a removable device. - Volumes have a device name ("/dev/sdX"), a mapped name ("/dev/mapper/xxx"), an encryption - scheme, and a mountpoint if they are mounted. + Volumes have a device name ("/dev/sdX") and an encryption scheme. """ def __init__( self, device_name: str, - mapped_name: str, encryption: EncryptionScheme, ): self.device_name = device_name - self.mapped_name = mapped_name self.encryption = encryption @property @@ -41,39 +36,22 @@ def encryption(self, scheme: EncryptionScheme): else: self._encryption = EncryptionScheme.UNKNOWN - @property - def unlocked(self) -> bool: - return ( - self.mapped_name is not None - and self.encryption is not EncryptionScheme.UNKNOWN - and os.path.exists( - os.path.join(self.MAPPED_VOLUME_PREFIX, self.mapped_name) - ) - ) - class MountedVolume(Volume): """ An unlocked and mounted Volume. + + Device name (from Volume) and unlocked name + are full paths (/dev/sdX, /dev/dm-X, /dev/mapper/idx). """ def __init__( self, device_name: str, - mapped_name: str, + unlocked_name: str, encryption: EncryptionScheme, mountpoint: str, ): - super().__init__( - device_name=device_name, mapped_name=mapped_name, encryption=encryption - ) + super().__init__(device_name=device_name, encryption=encryption) + self.unlocked_name = unlocked_name self.mountpoint = mountpoint - - @classmethod - def from_volume(cls, vol: Volume, mountpoint: str): - return cls( - device_name=vol.device_name, - mapped_name=vol.mapped_name, - encryption=vol.encryption, - mountpoint=mountpoint, - ) diff --git a/export/securedrop_export/main.py b/export/securedrop_export/main.py index bc55ae159..812203f98 100755 --- a/export/securedrop_export/main.py +++ b/export/securedrop_export/main.py @@ -1,22 +1,20 @@ +import contextlib +import io +import logging import os -import shutil import platform -import logging +import shutil import sys -from typing import Optional +from logging.handlers import SysLogHandler, TimedRotatingFileHandler +from securedrop_export import __version__ from securedrop_export.archive import Archive, Metadata from securedrop_export.command import Command -from securedrop_export.status import BaseStatus from securedrop_export.directory import safe_mkdir +from securedrop_export.disk import Service as ExportService from securedrop_export.exceptions import ExportException - -from securedrop_export.disk import LegacyService as ExportService -from securedrop_export.disk import LegacyStatus from securedrop_export.print import Service as PrintService - -from logging.handlers import TimedRotatingFileHandler, SysLogHandler -from securedrop_export import __version__ +from securedrop_export.status import BaseStatus DEFAULT_HOME = os.path.join(os.path.expanduser("~"), ".securedrop_export") LOG_DIR_NAME = "logs" @@ -43,8 +41,10 @@ def entrypoint(): Non-zero exit values will cause the system to try alternative solutions for mimetype handling, which we want to avoid. + + The program is called with the archive name as the first argument. """ - status, submission = None, None + status, archive = None, None try: _configure_logging() @@ -54,35 +54,38 @@ def entrypoint(): # Halt if target file is absent if not os.path.exists(data_path): - logger.info("Archive is not found {}.".format(data_path)) + logger.error("Archive not found at provided path.") + logger.debug("Archive missing, path: {}".format(data_path)) status = Status.ERROR_FILE_NOT_FOUND else: logger.debug("Extract tarball") - submission = Archive(data_path).extract_tarball() + archive = Archive(data_path).extract_tarball() logger.debug("Validate metadata") - metadata = Metadata(submission.tmpdir).validate() + metadata = Metadata(archive.tmpdir).validate() logger.info("Archive extraction and metadata validation successful") # If all we're doing is starting the vm, we're done; otherwise, # run the appropriate print or export routine if metadata.command is not Command.START_VM: - submission.set_metadata(metadata) + archive.set_metadata(metadata) logger.info(f"Start {metadata.command.value} service") - status = _start_service(submission) + status = _start_service(archive) + logger.info(f"Status: {status.value}") - except ExportException as ex: - logger.error(f"Encountered exception {ex.sdstatus.value}, exiting") + # A nonzero exit status will cause other programs + # to try to handle the files, which we don't want. + except Exception as ex: logger.error(ex) - status = ex.sdstatus - - except Exception as exc: - logger.error("Encountered exception during export, exiting") - logger.error(exc) - status = Status.ERROR_GENERIC + if isinstance(ex, ExportException): + logger.error(f"Encountered exception {ex.sdstatus.value}, exiting") + status = ex.sdstatus + else: + logger.error("Encountered exception during export, exiting") + status = Status.ERROR_GENERIC finally: - _exit_gracefully(submission, status) + _exit_gracefully(archive, status) def _configure_logging(): @@ -125,46 +128,42 @@ def _configure_logging(): raise ExportException(sdstatus=Status.ERROR_LOGGING) from ex -def _start_service(submission: Archive) -> LegacyStatus: +def _start_service(archive: Archive) -> BaseStatus: """ Start print or export service. """ # Print Routines - if submission.command is Command.PRINT: - return PrintService(submission).print() - elif submission.command is Command.PRINTER_PREFLIGHT: - return PrintService(submission).printer_preflight() - elif submission.command is Command.PRINTER_TEST: - return PrintService(submission).printer_test() + if archive.command is Command.PRINT: + return PrintService(archive).print() + elif archive.command is Command.PRINTER_PREFLIGHT: + return PrintService(archive).printer_preflight() + elif archive.command is Command.PRINTER_TEST: + return PrintService(archive).printer_test() # Export routines - elif submission.command is Command.EXPORT: - return ExportService(submission).export() - elif submission.command is Command.CHECK_USBS: - return ExportService(submission).check_connected_devices() - elif submission.command is Command.CHECK_VOLUME: - return ExportService(submission).check_disk_format() + elif archive.command is Command.EXPORT: + return ExportService(archive).export() + elif ( + archive.command is Command.CHECK_USBS or archive.command is Command.CHECK_VOLUME + ): + return ExportService(archive).scan_all_devices() # Unreachable raise ExportException( - f"unreachable: unknown submission.command value: {submission.command}" + f"unreachable: unknown submission.command value: {archive.command}" ) -def _exit_gracefully(submission: Archive, status: Optional[BaseStatus] = None): +def _exit_gracefully(archive: Archive, status: BaseStatus): """ Write status code, ensure file cleanup, and exit with return code 0. Non-zero exit values will cause the system to try alternative solutions for mimetype handling, which we want to avoid. """ - if status: - logger.info(f"Exit gracefully with status: {status.value}") - else: - logger.info("Exit gracefully (no status code supplied)") try: # If the file archive was extracted, delete before returning - if submission and os.path.isdir(submission.tmpdir): - shutil.rmtree(submission.tmpdir) + if archive and os.path.isdir(archive.tmpdir): + shutil.rmtree(archive.tmpdir) # Do this after deletion to avoid giving the client two error messages in case of the # block above failing _write_status(status) @@ -177,13 +176,30 @@ def _exit_gracefully(submission: Archive, status: Optional[BaseStatus] = None): sys.exit(0) -def _write_status(status: Optional[BaseStatus]): +def _write_status(status: BaseStatus): """ - Write string to stderr. + Write status string to stderr. Flush stderr and stdout before we exit. """ - if status: - logger.info(f"Write status {status.value}") + logger.info(f"Write status {status.value}") + try: + # First we will log errors from stderr elsewhere + tmp_stderr = io.StringIO() + tmp_stdout = io.StringIO() + with contextlib.redirect_stderr(tmp_stderr), contextlib.redirect_stdout( + tmp_stdout + ): + sys.stderr.flush() + sys.stdout.flush() + if len(tmp_stderr.getvalue()) > 0: + logger.error(f"Error capture: {tmp_stderr.getvalue()}") + if len(tmp_stdout.getvalue()) > 0: + logger.info(f"stdout capture: {tmp_stderr.getvalue()}") + sys.stderr.write(status.value) sys.stderr.write("\n") - else: - logger.info("No status value supplied") + sys.stderr.flush() + sys.stdout.flush() + except BrokenPipeError: + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + os.dup2(devnull, sys.stderr.fileno()) diff --git a/export/securedrop_export/print/service.py b/export/securedrop_export/print/service.py index dbff034bf..0583346c1 100644 --- a/export/securedrop_export/print/service.py +++ b/export/securedrop_export/print/service.py @@ -4,7 +4,8 @@ import subprocess import time -from securedrop_export.exceptions import handler, TimeoutException, ExportException +from securedrop_export.exceptions import ExportException, TimeoutException, handler + from .status import Status logger = logging.getLogger(__name__) @@ -32,7 +33,7 @@ def __init__(self, submission, printer_timeout_seconds=PRINTER_WAIT_TIMEOUT): self.printer_name = self.PRINTER_NAME self.printer_wait_timeout = printer_timeout_seconds # Override during testing - def print(self): + def print(self) -> Status: """ Routine to print all files. Throws ExportException if an error is encountered. @@ -42,9 +43,9 @@ def print(self): self._print_all_files() # When client can accept new print statuses, we will return # a success status here - # return Status.PRINT_SUCCESS + return Status.PRINT_SUCCESS - def printer_preflight(self): + def printer_preflight(self) -> Status: """ Routine to perform preflight printer testing. @@ -54,9 +55,9 @@ def printer_preflight(self): self._check_printer_setup() # When client can accept new print statuses, we will return # a success status here - # return Status.PREFLIGHT_SUCCESS + return Status.PREFLIGHT_SUCCESS - def printer_test(self): + def printer_test(self) -> Status: """ Routine to print a test page. @@ -67,7 +68,7 @@ def printer_test(self): self._print_test_page() # When client can accept new print statuses, we will return # a success status here - # return Status.TEST_SUCCESS + return Status.PRINT_TEST_PAGE_SUCCESS def _wait_for_print(self): """ diff --git a/export/securedrop_export/print/status.py b/export/securedrop_export/print/status.py index 116316a46..e96d813be 100644 --- a/export/securedrop_export/print/status.py +++ b/export/securedrop_export/print/status.py @@ -15,7 +15,7 @@ class Status(BaseStatus): # New PREFLIGHT_SUCCESS = "PRINTER_PREFLIGHT_SUCCESS" - TEST_SUCCESS = "PRINTER_TEST_SUCCESS" + PRINT_TEST_PAGE_SUCCESS = "PRINTER_TEST_SUCCESS" PRINT_SUCCESS = "PRINTER_SUCCESS" ERROR_UNKNOWN = "ERROR_GENERIC" # Unknown printer error, backwards-compatible diff --git a/export/tests/disk/test_cli.py b/export/tests/disk/test_cli.py index 798980905..7d0950eb4 100644 --- a/export/tests/disk/test_cli.py +++ b/export/tests/disk/test_cli.py @@ -1,33 +1,73 @@ -import pytest +import re +import subprocess from unittest import mock -import subprocess +import pytest +from securedrop_export.archive import Archive from securedrop_export.disk.cli import CLI -from securedrop_export.disk.volume import EncryptionScheme, Volume, MountedVolume -from securedrop_export.exceptions import ExportException from securedrop_export.disk.status import Status +from securedrop_export.disk.volume import EncryptionScheme, MountedVolume, Volume +from securedrop_export.exceptions import ExportException -from securedrop_export.archive import Archive - +# Sample lsblk and udisk inputs for testing the parsing of different device conditions +from ..lsblk_sample import ( + ERROR_DEVICE_MULTI_ENC_PARTITION, + ERROR_NO_SUPPORTED_DEVICE, + ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE, + ERROR_UNENCRYPTED_DEVICE_MOUNTED, + ONE_DEVICE_LUKS_UNMOUNTED, + ONE_DEVICE_VC_UNLOCKED, + SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED, + SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED, + SINGLE_DEVICE_LOCKED, + SINGLE_PART_LUKS_UNLOCKED_UNMOUNTED, + SINGLE_PART_LUKS_WRITABLE, + SINGLE_PART_UNLOCKED_VC_UNMOUNTED, + SINGLE_PART_VC_WRITABLE, + UDISKS_STATUS_MULTI_CONNECTED, + UDISKS_STATUS_NOTHING_CONNECTED, + UDISKS_STATUS_ONE_DEVICE_CONNECTED, +) + +_PRETEND_LUKS_ID = "/dev/mapper/luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094" +_PRETEND_VC = "/dev/mapper/tcrypt-2049" _DEFAULT_USB_DEVICE = "/dev/sda" -_DEFAULT_USB_DEVICE_ONE_PART = "/dev/sda1" -_PRETEND_LUKS_ID = "luks-id-123456" -# Sample stdout from shell commands -_SAMPLE_OUTPUT_NO_PART = b"disk\ncrypt" # noqa -_SAMPLE_OUTPUT_ONE_PART = b"disk\npart\ncrypt" # noqa -_SAMPLE_OUTPUT_MULTI_PART = b"disk\npart\npart\npart\ncrypt" # noqa -_SAMPLE_OUTPUT_USB = b"/dev/sda" # noqa +# Lists for test paramaterization -_SAMPLE_LUKS_HEADER = b"\n\nUUID:\t123456-DEADBEEF" # noqa +supported_volumes_no_mount_required = [ + SINGLE_DEVICE_LOCKED, + SINGLE_PART_LUKS_WRITABLE, + SINGLE_PART_VC_WRITABLE, +] + +# Volume, expected device name, expected mapped device name +# (used to mount) +supported_volumes_mount_required = [ + (SINGLE_PART_UNLOCKED_VC_UNMOUNTED, "/dev/sda1", "/dev/mapper/tcrypt-2049"), + ( + SINGLE_PART_LUKS_UNLOCKED_UNMOUNTED, + "/dev/sda1", + "/dev/mapper/luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", + ), +] + +unsupported_volumes = [ + SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED, + SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED, +] class TestCli: """ Test the CLI wrapper that handless identification and locking/unlocking of USB volumes. + + This class is a wrapper around commandline tools like udsisks and lsblk, + so a lot of the testing involves supplying sample input and ensuring it + is parsed correctly (see `lsblk_sample.py`). """ @classmethod @@ -38,432 +78,286 @@ def setup_class(cls): def teardown_class(cls): cls.cli = None - def _setup_usb_devices(self, mocker, disks, is_removable): - """ - Helper function to set up mocked shell calls representing - the search for attached USB devices. - The original calls are `lsblk | grep disk` and - `cat /sys/class/block/{disk}/removable` + @mock.patch("subprocess.check_output") + def test_get_volume_no_devices(self, mock_sp): + mock_sp.side_effect = [ + UDISKS_STATUS_NOTHING_CONNECTED, + ERROR_NO_SUPPORTED_DEVICE, + ] - Parameters: - disks (byte array): Array of disk names separated by newline. - is_removable (byte array): Array of removable status results (1 for removable), - separated by newline - """ - - # Patch commandline calls to `lsblk | grep disk` - command_output = mock.MagicMock() - command_output.stdout = mock.MagicMock() - command_output.stdout.readlines = mock.MagicMock(return_value=disks) - mocker.patch("subprocess.Popen", return_value=command_output) - - # Patch commandline call to 'cat /sys/class/block/{device}/removable' - - # Using side_effect with an iterable allows for different return value each time, - # which matches what would happen if iterating through list of devices - mocker.patch("subprocess.check_output", side_effect=is_removable) + with pytest.raises(ExportException) as ex: + self.cli.get_volume() + assert ex.value.sdstatus == Status.NO_DEVICE_DETECTED + + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_one_device(self, mock_sp, mock_mount): + mock_sp.side_effect = [ + UDISKS_STATUS_ONE_DEVICE_CONNECTED, + ONE_DEVICE_LUKS_UNMOUNTED, + ] + v = self.cli.get_volume() + + assert v.encryption == EncryptionScheme.LUKS + # todo: list call args, make this test more specific + + @mock.patch("subprocess.check_output") + def test_get_volume_multi_devices_error(self, mock_sp): + mock_sp.side_effect = [ + UDISKS_STATUS_MULTI_CONNECTED, + ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE, + ] + with pytest.raises(ExportException) as ex: + self.cli.get_volume() - def test_get_connected_devices(self, mocker): - disks = [b"sda disk\n", b"sdb disk\n"] - removable = [b"1\n", b"1\n"] + assert ex.value.sdstatus == Status.MULTI_DEVICE_DETECTED - self._setup_usb_devices(mocker, disks, removable) - result = self.cli.get_connected_devices() + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_too_many_encrypted_partitions(self, mock_sp, mock_mount): + mock_sp.side_effect = [ + UDISKS_STATUS_ONE_DEVICE_CONNECTED, + ERROR_DEVICE_MULTI_ENC_PARTITION, + ] + with pytest.raises(ExportException) as ex: + self.cli.get_volume() - assert result[0] == "/dev/sda" and result[1] == "/dev/sdb" + assert ex.value.sdstatus == Status.INVALID_DEVICE_DETECTED - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test_get_removable_devices_none_removable(self, mocker): - disks = [b"sda disk\n", b"sdb disk\n"] - removable = [b"0\n", b"0\n"] + @mock.patch("securedrop_export.disk.cli.CLI._get_supported_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_no_encrypted_partition(self, mock_sp, mock_gsv): + mock_sp.side_effect = [ + UDISKS_STATUS_ONE_DEVICE_CONNECTED, + ERROR_UNENCRYPTED_DEVICE_MOUNTED, + ] + with pytest.raises(ExportException) as ex: + self.cli.get_volume() - self._setup_usb_devices(mocker, disks, removable) + assert ex.value.sdstatus == Status.INVALID_DEVICE_DETECTED - result = self.cli._get_removable_devices(disks) - assert len(result) == 0 + @mock.patch("securedrop_export.disk.cli.CLI._get_supported_volume") + @mock.patch("subprocess.check_output") + def test_get_volume_empty_udisks_does_not_keep_checking(self, mock_sp, mock_gsv): + mock_sp.side_effect = [ + UDISKS_STATUS_NOTHING_CONNECTED, + ONE_DEVICE_VC_UNLOCKED, + ] - @mock.patch( - "subprocess.Popen", side_effect=subprocess.CalledProcessError(1, "Popen") - ) - def test_get_connected_devices_error(self, mocked_subprocess): - with pytest.raises(ExportException): - self.cli.get_connected_devices() + # If udisks2 didn't find it, don't keep looking + with pytest.raises(ExportException) as ex: + self.cli.get_volume() - @mock.patch("subprocess.check_output", return_value=_SAMPLE_OUTPUT_NO_PART) - def test_get_partitioned_device_no_partition(self, mocked_call): - assert ( - self.cli.get_partitioned_device(_DEFAULT_USB_DEVICE) == _DEFAULT_USB_DEVICE - ) + assert ex.value.sdstatus == Status.NO_DEVICE_DETECTED + mock_gsv.assert_not_called() - @mock.patch("subprocess.check_output", return_value=_SAMPLE_OUTPUT_ONE_PART) - def test_get_partitioned_device_one_partition(self, mocked_call): - assert ( - self.cli.get_partitioned_device(_DEFAULT_USB_DEVICE) - == _DEFAULT_USB_DEVICE + "1" + @pytest.mark.parametrize("input", supported_volumes_no_mount_required) + @mock.patch("subprocess.check_output") + def test__get_supported_volume_success_no_mount(self, mock_sp, input): + # mock subprocess results on the _is_it_veracrypt method + mock_sp.return_value = "IdType: crypto_TCRYPT\n".encode( + "utf-8" ) + vol = self.cli._get_supported_volume(input) - @mock.patch("subprocess.check_output", return_value=_SAMPLE_OUTPUT_MULTI_PART) - def test_get_partitioned_device_multi_partition(self, mocked_call): - with pytest.raises(ExportException) as ex: - self.cli.get_partitioned_device(_SAMPLE_OUTPUT_MULTI_PART) - - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + assert vol - @mock.patch("subprocess.check_output", return_value=None) - def test_get_partitioned_device_lsblk_error(self, mocked_subprocess): - with pytest.raises(ExportException) as ex: - self.cli.get_partitioned_device(_SAMPLE_OUTPUT_ONE_PART) + @mock.patch("subprocess.check_output") + def test__get_supported_volume_locked_success(self, mock_subprocess): + vol = self.cli._get_supported_volume(SINGLE_DEVICE_LOCKED) + assert vol.device_name == "/dev/sda" - assert ex.value.sdstatus is Status.DEVICE_ERROR - - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), + @pytest.mark.parametrize( + "input,expected_device,expected_devmapper", supported_volumes_mount_required ) - def test_get_partitioned_device_multi_partition_error(self, mocked_call): - # Make sure we wrap CalledProcessError and throw our own exception - with pytest.raises(ExportException) as ex: - self.cli.get_partitioned_device(_DEFAULT_USB_DEVICE) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - @mock.patch("subprocess.check_call", return_value=0) - def test_is_luks_volume_true(self, mocked_call): - # `sudo cryptsetup isLuks` returns 0 if true - assert self.cli.is_luks_volume(_SAMPLE_OUTPUT_ONE_PART) - + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), + "securedrop_export.disk.cli.CLI._is_it_veracrypt", + return_value=EncryptionScheme.VERACRYPT, ) - def test_is_luks_volume_false(self, mocked_subprocess): - # `sudo cryptsetup isLuks` returns 1 if false; CalledProcessError is thrown - assert not self.cli.is_luks_volume(_SAMPLE_OUTPUT_ONE_PART) + def test__get_supported_volume_requires_mounting( + self, mock_v, mock_mount, input, expected_device, expected_devmapper + ): + self.cli._get_supported_volume(input) - @mock.patch("subprocess.check_output", return_value=_SAMPLE_LUKS_HEADER) - def test__get_luks_name_from_headers(self, mocked_subprocess): - result = self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + mock_mount.assert_called_once() - assert result is not None and result.split("-")[ - 1 - ] in _SAMPLE_LUKS_HEADER.decode("utf8") + assert mock_mount.call_args_list[0][0][0].device_name == expected_device + assert mock_mount.call_args_list[0][0][1] == expected_devmapper - @mock.patch( - "subprocess.check_output", return_value=b"corrupted-or-invalid-header\n" - ) - def test__get_luks_name_from_headers_error_invalid(self, mocked_subprocess): - with pytest.raises(ExportException) as ex: - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + @pytest.mark.parametrize("input", unsupported_volumes) + @mock.patch("securedrop_export.disk.cli.CLI._mount_volume") + def test__get_supported_volume_none_supported(self, mock_subprocess, input): + result = self.cli._get_supported_volume(input) - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + assert result is None - @mock.patch("subprocess.check_output", return_value=b"\n") - def test__get_luks_name_from_headers_error_no_header(self, mocked_subprocess): - with pytest.raises(ExportException) as ex: - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + @mock.patch("pexpect.spawn") + def test_unlock_success(self, mock_p): + child = mock_p() + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + # This return value is derived from the "expected" list in the + # unlock_volume method (list item with index 0 is success) + child.expect.side_effect = [0, 0] + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = "/dev/dm-0".encode("utf-8") - @mock.patch("subprocess.check_output", return_value=None) - def test__get_luks_name_from_headers_error_nothing_returned( - self, mocked_subprocess - ): - with pytest.raises(ExportException) as ex: - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + mv = mock.MagicMock(spec=MountedVolume) - assert ex.value.sdstatus is Status.INVALID_DEVICE_DETECTED + with mock.patch.object(self.cli, "_mount_volume") as mock_mount: + mock_mount.return_value = mv + result = self.cli.unlock_volume(vol, "a passw0rd!") - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test__get_luks_name_from_headers_error(self, mocked_subprocess): - with pytest.raises(ExportException): - self.cli._get_luks_name_from_headers(_DEFAULT_USB_DEVICE) + mock_mount.assert_called_once_with(vol, "/dev/dm-0") + assert isinstance(result, MountedVolume) - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_output", return_value=_SAMPLE_LUKS_HEADER) - def test_get_luks_volume_already_unlocked(self, mocked_subprocess, mocked_os_call): - result = self.cli.get_luks_volume(_DEFAULT_USB_DEVICE_ONE_PART) + @mock.patch("pexpect.spawn") + def test_unlock_already_unlocked(self, mock_p): + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) + child = mock_p() + child.expect.side_effect = [0, 1] + child.match = mock.MagicMock(spec=re.Match) + error_msg = "/dev/dm-0".encode("utf-8") + child.match.group.return_value = error_msg + mv = mock.MagicMock(spec=MountedVolume) - assert result.encryption is EncryptionScheme.LUKS - assert result.unlocked + with mock.patch.object(self.cli, "_mount_volume") as mock_mount: + mock_mount.return_value = mv + result = self.cli.unlock_volume(vol, "a passw0rd!") - @mock.patch("os.path.exists", return_value=False) - @mock.patch("subprocess.check_output", return_value=_SAMPLE_LUKS_HEADER) - def test_get_luks_volume_still_locked(self, mocked_subprocess, mocked_os_call): - result = self.cli.get_luks_volume(_DEFAULT_USB_DEVICE_ONE_PART) + mock_mount.assert_called_once_with(vol, "/dev/dm-0") + assert isinstance(result, MountedVolume) - assert result.encryption is EncryptionScheme.LUKS - assert not result.unlocked + @mock.patch("pexpect.spawn") + def test_unlock_remote_fail(self, mock_p): + child = mock_p() + child.expect.return_value = 3 + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test_get_luks_volume_error(self, mocked_subprocess): with pytest.raises(ExportException) as ex: - self.cli.get_luks_volume(_DEFAULT_USB_DEVICE_ONE_PART) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - @mock.patch("os.path.exists", return_value=True) - def test_unlock_luks_volume_success(self, mock_path, mocker): - mock_popen = mocker.MagicMock() - mock_popen_communicate = mocker.MagicMock() - mock_popen.returncode = 0 - - mocker.patch("subprocess.Popen", return_value=mock_popen) - mocker.patch( - "subprocess.Popen.communicate", return_value=mock_popen_communicate - ) + self.cli.unlock_volume(vol, "a passw0rd!") - mapped_name = "luks-id-123456" - vol = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=mapped_name, - encryption=EncryptionScheme.LUKS, - ) - key = "a_key&_!" - result = self.cli.unlock_luks_volume(vol, key) - assert result.unlocked + assert ex.value.sdstatus == Status.ERROR_UNLOCK_GENERIC - @mock.patch("os.path.exists", return_value=True) - def test_unlock_luks_volume_not_luks(self, mocker): - mock_popen = mocker.MagicMock() - mock_popen.communicate = mocker.MagicMock() - mock_popen.communicate.returncode = 1 # An error unlocking + @mock.patch("pexpect.spawn") + def test_unlock_luks_bad_passphrase(self, mock_p): + child = mock_p() - mocker.patch("subprocess.Popen", mock_popen) + # This return value is derived from the "expected" list in the + # unlock_volume method (list item with index 1 is the "Bad passphrase" + # error) + child.expect.side_effect = [0, 2] + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - vol = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.UNKNOWN, - ) - key = "a key!" + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) with pytest.raises(ExportException) as ex: - self.cli.unlock_luks_volume(vol, key) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - def test_unlock_luks_volume_passphrase_failure(self, mocker): - mock_popen = mocker.MagicMock() - mock_popen.communicate = mocker.MagicMock() - mock_popen.communicate.returncode = 1 # An error unlocking + self.cli.unlock_volume(vol, "a mistaken p4ssw0rd!") - mocker.patch("subprocess.Popen", mock_popen) + assert ex.value.sdstatus == Status.ERROR_UNLOCK_LUKS - vol = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - key = "a key!" + @mock.patch("pexpect.spawn") + def test_unlock_fail(self, mock_p): + child = mock_p() - with pytest.raises(ExportException): - self.cli.unlock_luks_volume(vol, key) + # This is derived from the "expected" list in the unlock_volume method + # (list item with index 3 is the "pexpect.EOF" error) + child.expect.side_effect = [0, 3] + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - @mock.patch( - "subprocess.Popen", side_effect=subprocess.CalledProcessError(1, "Popen") - ) - def test_unlock_luks_volume_luksOpen_exception(self, mocked_subprocess): - pd = Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - key = "a key!" + vol = Volume(_DEFAULT_USB_DEVICE, EncryptionScheme.LUKS) with pytest.raises(ExportException) as ex: - self.cli.unlock_luks_volume(pd, key) + self.cli.unlock_volume(vol, "a passw0rd!") - assert ex.value.sdstatus is Status.DEVICE_ERROR + assert ex.value.sdstatus == Status.ERROR_UNLOCK_GENERIC - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_output", return_value=b"\n") - @mock.patch("subprocess.check_call", return_value=0) - def test_mount_volume(self, mocked_call, mocked_output, mocked_path): - vol = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - mv = self.cli.mount_volume(vol) - assert isinstance(mv, MountedVolume) - assert mv.mountpoint is self.cli._DEFAULT_MOUNTPOINT + @mock.patch("pexpect.spawn") + def test__mount_volume_already_mounted(self, mock_p): + child = mock_p() + child.expect.return_value = 1 + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_output", return_value=b"/dev/pretend/luks-id-123456\n" - ) - @mock.patch("subprocess.check_call", return_value=0) - def test_mount_volume_already_mounted( - self, mocked_output, mocked_call, mocked_path - ): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + md = MountedVolume( + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, encryption=EncryptionScheme.LUKS, + mountpoint="/media/usb", ) - result = self.cli.mount_volume(md) - assert result.mountpoint == "/dev/pretend/luks-id-123456" + result = self.cli._mount_volume(md, _PRETEND_LUKS_ID) + + assert result.mountpoint == "/media/usb" assert isinstance(result, MountedVolume) - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_output", return_value=b"\n") - @mock.patch("subprocess.check_call", return_value=0) - def test_mount_volume_mkdir(self, mocked_output, mocked_subprocess, mocked_path): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - mv = self.cli.mount_volume(md) - assert mv.mapped_name == _PRETEND_LUKS_ID - assert isinstance(mv, MountedVolume) + @mock.patch("pexpect.spawn") + def test__mount_volume_success(self, mock_p): + child = mock_p() + child.expect.return_value = 0 + child.match = mock.MagicMock(spec=re.Match) + child.match.group.return_value = b"/media/usb" - @mock.patch("subprocess.check_output", return_value=b"\n") - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test_mount_volume_error(self, mocked_subprocess, mocked_output): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + md = MountedVolume( + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, encryption=EncryptionScheme.LUKS, + mountpoint="/media/usb", ) - with pytest.raises(ExportException) as ex: - self.cli.mount_volume(md) - - assert ex.value.sdstatus is Status.ERROR_MOUNT - - @mock.patch("os.path.exists", return_value=False) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test_mount_at_mountpoint_mkdir_error(self, mocked_subprocess, mocked_path): - md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) + result = self.cli._mount_volume(md, _PRETEND_LUKS_ID) - with pytest.raises(ExportException) as ex: - self.cli._mount_at_mountpoint(md, self.cli._DEFAULT_MOUNTPOINT) + assert result.mountpoint == "/media/usb" + assert isinstance(result, MountedVolume) - assert ex.value.sdstatus is Status.ERROR_MOUNT + @mock.patch("pexpect.spawn") + def test__mount_volume_error(self, mock_p): + child = mock_p() + child.expect.return_value = 2 - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test_mount_at_mountpoint_mounting_error(self, mocked_subprocess, mocked_path): md = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + device_name="/dev/sda", encryption=EncryptionScheme.LUKS, ) with pytest.raises(ExportException) as ex: - self.cli._mount_at_mountpoint(md, self.cli._DEFAULT_MOUNTPOINT) + self.cli._mount_volume(md, _PRETEND_LUKS_ID) assert ex.value.sdstatus is Status.ERROR_MOUNT - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_call", return_value=0) - def test__unmount_volume(self, mocked_subprocess, mocked_mountpath): + @mock.patch("subprocess.check_call") + def test__unmount_volume(self, mock_sp): + mock_sp.returncode = 0 mounted = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, + device_name="/dev/sda", + unlocked_name=f"/dev/mapper{_PRETEND_LUKS_ID}", + mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) - result = self.cli._unmount_volume(mounted) + result = self.cli._close_volume(mounted) assert not isinstance(result, MountedVolume) - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test__unmount_volume_error(self, mocked_subprocess, mocked_mountpath): - mounted = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, - encryption=EncryptionScheme.LUKS, - ) - - with pytest.raises(ExportException) as ex: - self.cli._unmount_volume(mounted) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - @mock.patch("os.path.exists", return_value=True) - @mock.patch("subprocess.check_call", return_value=0) - def test__close_luks_volume(self, mocked_subprocess, mocked_os_call): - mapped = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - - # If call completes without error, drive was successfully closed with luksClose - self.cli._close_luks_volume(mapped) - - @mock.patch("os.path.exists", return_value=True) - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test__close_luks_volume_error(self, mocked_subprocess, mocked_os_call): - mapped = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - - with pytest.raises(ExportException) as ex: - self.cli._close_luks_volume(mapped) - - assert ex.value.sdstatus is Status.DEVICE_ERROR - - @mock.patch( - "subprocess.check_call", - side_effect=subprocess.CalledProcessError(1, "check_call"), - ) - def test__remove_temp_directory_error(self, mocked_subprocess): - with pytest.raises(ExportException): - self.cli._remove_temp_directory("tmp") - @mock.patch("subprocess.check_call", return_value=0) def test_write_to_disk(self, mock_check_call): # Temporarily patch a method, to later assert it is called - patch = mock.patch.object(self.cli, "cleanup_drive_and_tmpdir") + patch = mock.patch.object(self.cli, "cleanup") patch.return_value = mock.MagicMock() patch.start() vol = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, + mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) submission = Archive("testfile") - self.cli.write_data_to_device(submission.tmpdir, submission.target_dirname, vol) - self.cli.cleanup_drive_and_tmpdir.assert_called_once() + self.cli.write_data_to_device(vol, submission.tmpdir, submission.target_dirname) + self.cli.cleanup.assert_called_once() # Don't want to patch it indefinitely though, that will mess with the other tests patch.stop() @@ -473,24 +367,24 @@ def test_write_to_disk(self, mock_check_call): side_effect=subprocess.CalledProcessError(1, "check_call"), ) def test_write_to_disk_error_still_does_cleanup(self, mock_call): - # see above - patch internal method only for this test - patch = mock.patch.object(self.cli, "cleanup_drive_and_tmpdir") + # patch internal method only for this test + patch = mock.patch.object(self.cli, "cleanup") patch.return_value = mock.MagicMock() patch.start() vol = MountedVolume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - mountpoint=self.cli._DEFAULT_MOUNTPOINT, + device_name=_DEFAULT_USB_DEVICE, + unlocked_name=_PRETEND_LUKS_ID, + mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) submission = Archive("testfile") with pytest.raises(ExportException): self.cli.write_data_to_device( - submission.tmpdir, submission.target_dirname, vol + vol, submission.tmpdir, submission.target_dirname ) - self.cli.cleanup_drive_and_tmpdir.assert_called_once() + self.cli.cleanup.assert_called_once() patch.stop() @@ -498,33 +392,47 @@ def test_write_to_disk_error_still_does_cleanup(self, mock_call): "subprocess.check_call", side_effect=subprocess.CalledProcessError(1, "check_call"), ) - def test_cleanup_drive_and_tmpdir_error(self, mocked_subprocess): + def test_cleanup_error(self, mock_popen): submission = Archive("testfile") mock_volume = mock.MagicMock(Volume) with pytest.raises(ExportException) as ex: - self.cli.cleanup_drive_and_tmpdir(mock_volume, submission.tmpdir) + self.cli.cleanup(mock_volume, submission.tmpdir) assert ex.value.sdstatus is Status.ERROR_EXPORT_CLEANUP + @mock.patch( + "subprocess.check_call", + side_effect=subprocess.CalledProcessError(1, "check_call"), + ) + def test_cleanup_error_reports_exporterror_if_flagged(self, mock_popen): + submission = Archive("testfile") + mock_volume = mock.MagicMock(Volume) + + with pytest.raises(ExportException) as ex: + self.cli.cleanup(mock_volume, submission.tmpdir, is_error=True) + assert ex.value.sdstatus is Status.ERROR_EXPORT + @mock.patch("os.path.exists", return_value=False) @mock.patch("subprocess.check_call", return_value=0) - def test_cleanup_drive_and_tmpdir(self, mock_subprocess, mocked_path): + def test_cleanup(self, mock_subprocess, mocked_path): submission = Archive("testfile") vol = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, + device_name=_DEFAULT_USB_DEVICE, encryption=EncryptionScheme.LUKS, ) - mv = MountedVolume.from_volume(vol, mountpoint=self.cli._DEFAULT_MOUNTPOINT) + mv = MountedVolume( + vol.device_name, + f"/dev/mapper/{_PRETEND_LUKS_ID}", + vol.encryption, + mountpoint="/media/usb", + ) - close_patch = mock.patch.object(self.cli, "_close_luks_volume") + close_patch = mock.patch.object(self.cli, "_close_volume") remove_tmpdir_patch = mock.patch.object(self.cli, "_remove_temp_directory") - close_mock = close_patch.start() rm_tpdir_mock = remove_tmpdir_patch.start() - # That was all setup. Here's our test - self.cli.cleanup_drive_and_tmpdir(mv, submission.tmpdir) + self.cli.cleanup(mv, submission.tmpdir) close_mock.assert_called_once() rm_tpdir_mock.assert_called_once_with(submission.tmpdir) @@ -533,35 +441,18 @@ def test_cleanup_drive_and_tmpdir(self, mock_subprocess, mocked_path): close_patch.stop() remove_tmpdir_patch.stop() - @mock.patch( - "subprocess.check_output", - side_effect=subprocess.CalledProcessError(1, "check_output"), - ) - def test_mountpoint_error(self, mock_subprocess): - with pytest.raises(ExportException) as ex: - self.cli._get_mountpoint( - Volume( - device_name=_DEFAULT_USB_DEVICE, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, - ) - ) - - assert ex.value.sdstatus is Status.ERROR_MOUNT - - @mock.patch("os.path.exists", return_value=False) - def test_mount_mkdir_fails(self, mocked_path): - mock_mountpoint = mock.patch.object(self.cli, "_get_mountpoint") - mock_mountpoint.return_value = None - - vol = Volume( - device_name=_DEFAULT_USB_DEVICE_ONE_PART, - mapped_name=_PRETEND_LUKS_ID, - encryption=EncryptionScheme.LUKS, + @mock.patch("pexpect.spawn") + def test_parse_correct_mountpoint_from_pexpect(self, mock_pexpect): + child = mock_pexpect() + child.expect.return_value = 1 + child.match = mock.MagicMock() + child.match.group.side_effect = [ + "/dev/dm-0".encode("utf-8"), + "/media/usb".encode("utf-8"), + ] + + mv = self.cli._mount_volume( + Volume("/dev/sda1", EncryptionScheme.VERACRYPT), "/dev/mapper/vc" ) - mock.patch.object(vol, "unlocked", return_value=True) - - with pytest.raises(ExportException) as ex: - self.cli.mount_volume(vol) - - assert ex.value.sdstatus is Status.ERROR_MOUNT + assert mv.unlocked_name == "/dev/dm-0" + assert mv.mountpoint == "/media/usb" diff --git a/export/tests/disk/test_service.py b/export/tests/disk/test_service.py index d7053e1d0..66943f481 100644 --- a/export/tests/disk/test_service.py +++ b/export/tests/disk/test_service.py @@ -1,39 +1,47 @@ -import pytest -from unittest import mock import os import tempfile +from unittest import mock -from securedrop_export.exceptions import ExportException -from securedrop_export.disk.legacy_status import Status as LegacyStatus -from securedrop_export.disk.status import Status as Status -from securedrop_export.disk.volume import Volume, MountedVolume, EncryptionScheme from securedrop_export.archive import Archive, Metadata -from securedrop_export.disk.legacy_service import Service from securedrop_export.disk.cli import CLI +from securedrop_export.disk.service import Service +from securedrop_export.disk.status import Status +from securedrop_export.disk.volume import EncryptionScheme, MountedVolume, Volume +from securedrop_export.exceptions import ExportException -SAMPLE_OUTPUT_LSBLK_NO_PART = b"disk\ncrypt" # noqa -SAMPLE_OUTPUT_USB = "/dev/sda" # noqa +SAMPLE_OUTPUT_USB = "/dev/sda" SAMPLE_OUTPUT_USB_PARTITIONED = "/dev/sda1" class TestExportService: @classmethod def setup_class(cls): - cls.mock_cli = mock.MagicMock(CLI) + cls.mock_cli = mock.MagicMock(spec=CLI) cls.mock_submission = cls._setup_submission() cls.mock_luks_volume_unmounted = Volume( device_name=SAMPLE_OUTPUT_USB, - mapped_name="fake-luks-id-123456", encryption=EncryptionScheme.LUKS, ) cls.mock_luks_volume_mounted = MountedVolume( device_name=SAMPLE_OUTPUT_USB, - mapped_name="fake-luks-id-123456", + unlocked_name="/dev/mapper/fake-luks-id-123456", mountpoint="/media/usb", encryption=EncryptionScheme.LUKS, ) + cls.mock_vc_volume_mounted = MountedVolume( + device_name=SAMPLE_OUTPUT_USB, + unlocked_name="/dev/mapper/mock-veracrypt-vol", + encryption=EncryptionScheme.VERACRYPT, + mountpoint="/media/usb/", + ) + + cls.mock_vc_volume_locked = Volume( + device_name=SAMPLE_OUTPUT_USB, + encryption=EncryptionScheme.UNKNOWN, + ) + cls.service = Service(cls.mock_submission, cls.mock_cli) @classmethod @@ -51,153 +59,78 @@ def _setup_submission(cls) -> Archive: temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write( - '{"device": "disk", "encryption_method":' - ' "luks", "encryption_key": "hunter1"}' - ) + f.write('{"device": "disk", "encryption_key": "hunter1"}') return submission.set_metadata(Metadata(temp_folder).validate()) def setup_method(self, method): - """ - By default, mock CLI will return the "happy path" of a correctly-formatted LUKS drive. - Override this behaviour in the target method as required, for example to simulate CLI - errors. `teardown_method()` will reset the side effects so they do not affect subsequent - test methods. - """ - self.mock_cli.get_connected_devices.return_value = [SAMPLE_OUTPUT_USB] - self.mock_cli.get_partitioned_device.return_value = ( - SAMPLE_OUTPUT_USB_PARTITIONED - ) - self.mock_cli.get_luks_volume.return_value = self.mock_luks_volume_unmounted - self.mock_cli.mount_volume.return_value = self.mock_luks_volume_mounted + pass def teardown_method(self, method): self.mock_cli.reset_mock(return_value=True, side_effect=True) - def test_check_usb(self): - status = self.service.check_connected_devices() - - assert status is LegacyStatus.LEGACY_USB_CONNECTED - - def test_no_devices_connected(self): - self.mock_cli.get_connected_devices.return_value = [] - with pytest.raises(ExportException) as ex: - self.service.check_connected_devices() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_NOT_CONNECTED - - def test_too_many_devices_connected(self): - self.mock_cli.get_connected_devices.return_value = [ - SAMPLE_OUTPUT_USB, - "/dev/sdb", - ] - with pytest.raises(ExportException) as ex: - self.service.check_connected_devices() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED + def test_scan_all_device_is_locked(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_unmounted + status = self.service.scan_all_devices() - def test_device_is_not_luks(self): - self.mock_cli.is_luks_volume.return_value = False + assert status == Status.DEVICE_LOCKED - # When VeraCrypt is supported, this will no longer be an exception - # and the return status will change - with pytest.raises(ExportException) as ex: - self.service.check_disk_format() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - - def test_check_usb_error(self): - self.mock_cli.get_connected_devices.side_effect = ExportException( - sdstatus=LegacyStatus.LEGACY_ERROR_USB_CHECK + def test_scan_all_no_devices_connected(self): + self.mock_cli.get_volume.side_effect = ExportException( + sdstatus=Status.NO_DEVICE_DETECTED ) - with pytest.raises(ExportException) as ex: - self.service.check_connected_devices() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_CHECK - - def test_check_disk_format(self): - status = self.service.check_disk_format() - - assert status is LegacyStatus.LEGACY_USB_ENCRYPTED + assert self.service.scan_all_devices() == Status.NO_DEVICE_DETECTED - def test_check_disk_format_error(self): - self.mock_cli.get_partitioned_device.side_effect = ExportException( - sdstatus=Status.INVALID_DEVICE_DETECTED + def test_scan_all_too_many_devices_connected(self): + self.mock_cli.get_volume.side_effect = ExportException( + sdstatus=Status.MULTI_DEVICE_DETECTED ) - with pytest.raises(ExportException) as ex: - self.service.check_disk_format() + assert self.service.scan_all_devices() == Status.MULTI_DEVICE_DETECTED - # We still return the legacy status for now - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED + def test_scan_all_devices_error(self): + self.mock_cli.get_volume.side_effect = ExportException("zounds!") - def test_export(self): - # Currently, a successful export does not return a success status. - # When the client is updated, this will change to assert EXPORT_SUCCESS - # is returned. - self.service.export() + assert self.service.scan_all_devices() == Status.DEVICE_ERROR - def test_export_disk_not_supported(self): - self.mock_cli.is_luks_volume.return_value = False - - with pytest.raises(ExportException) as ex: - self.service.export() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_ENCRYPTION_NOT_SUPPORTED - - def test_export_write_error(self): - self.mock_cli.is_luks_volume.return_value = True - self.mock_cli.write_data_to_device.side_effect = ExportException( - sdstatus=LegacyStatus.LEGACY_ERROR_USB_WRITE - ) + def test_scan_all_device_is_unlocked_vc(self): + self.mock_cli.get_volume.return_value = self.mock_vc_volume_mounted - with pytest.raises(ExportException) as ex: - self.service.export() + assert self.service.scan_all_devices() == Status.DEVICE_WRITABLE - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_WRITE + def test_export_already_mounted_no_cleanup(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_mounted + with mock.patch.object(self.mock_cli, "write_data_to_device") as mock_write: + result = self.service.export() - def test_export_throws_new_exception_return_legacy_status(self): - self.mock_cli.get_connected_devices.side_effect = ExportException( - sdstatus=Status.ERROR_MOUNT + assert result == Status.SUCCESS_EXPORT + mock_write.assert_called_once_with( + self.mock_luks_volume_mounted, + self.mock_submission.tmpdir, + self.mock_submission.target_dirname, ) - with pytest.raises(ExportException) as ex: - self.service.export() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_MOUNT - - @mock.patch("os.path.exists", return_value=True) - def test_write_error_returns_legacy_status(self, mock_path): - self.mock_cli.is_luks_volume.return_value = True + def test_export_write_error(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_mounted self.mock_cli.write_data_to_device.side_effect = ExportException( sdstatus=Status.ERROR_EXPORT ) - with pytest.raises(ExportException) as ex: - self.service.export() + assert self.service.export() == Status.ERROR_EXPORT - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_USB_WRITE - - @mock.patch("os.path.exists", return_value=True) - def test_unlock_error_returns_legacy_status(self, mock_path): - self.mock_cli.unlock_luks_volume.side_effect = ExportException( + def test_export_unlock_error(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_unmounted + self.mock_cli.unlock_volume.side_effect = ExportException( sdstatus=Status.ERROR_UNLOCK_LUKS ) - with pytest.raises(ExportException) as ex: - self.service.export() - - assert ex.value.sdstatus is LegacyStatus.LEGACY_USB_BAD_PASSPHRASE - - @mock.patch("os.path.exists", return_value=True) - def test_unexpected_error_returns_legacy_status_generic(self, mock_path): - self.mock_cli.unlock_luks_volume.side_effect = ExportException( - sdstatus=Status.DEVICE_ERROR - ) + assert self.service.export() == Status.ERROR_UNLOCK_LUKS - with pytest.raises(ExportException) as ex: - self.service.export() + def test_export_unlock_error_unspecified(self): + self.mock_cli.get_volume.return_value = self.mock_luks_volume_unmounted + with mock.patch.object(self.mock_cli, "unlock_volume") as mock_unlock: + mock_unlock.side_effect = ExportException("oh no!") + result = self.service.export() - assert ex.value.sdstatus is LegacyStatus.LEGACY_ERROR_GENERIC + assert result == Status.ERROR_EXPORT diff --git a/export/tests/disk/test_volume.py b/export/tests/disk/test_volume.py deleted file mode 100644 index 10d4c6894..000000000 --- a/export/tests/disk/test_volume.py +++ /dev/null @@ -1,50 +0,0 @@ -from unittest import mock - -from securedrop_export.disk.volume import Volume, MountedVolume, EncryptionScheme - - -class TestVolume: - def test_overwrite_valid_encryption_scheme(self): - volume = Volume( - device_name="/dev/sda", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - assert volume.encryption is EncryptionScheme.LUKS - volume.encryption = None - assert volume.encryption is EncryptionScheme.UNKNOWN - - @mock.patch("os.path.exists", return_value=True) - def test_is_unlocked_true(self, mock_os_path): - volume = Volume( - device_name="/dev/sda1", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - - assert volume.unlocked - - @mock.patch("os.path.exists", return_value=False) - def test_is_unlocked_false_no_path(self, mock_os_path): - volume = Volume( - device_name="/dev/sda1", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - - assert not volume.unlocked - - -class TestMountedVolume: - @mock.patch("os.path.exists", return_value=True) - def test_is_unlocked_true(self, mock_os_path): - volume = Volume( - device_name="/dev/sda1", - mapped_name="pretend-luks-mapper-id", - encryption=EncryptionScheme.LUKS, - ) - - mounted_volume = MountedVolume.from_volume(volume, mountpoint="/media/usb") - - assert mounted_volume.unlocked - assert mounted_volume.mountpoint == "/media/usb" diff --git a/export/tests/files/sample_export.sd-export b/export/tests/files/sample_export.sd-export new file mode 100644 index 000000000..dab643352 Binary files /dev/null and b/export/tests/files/sample_export.sd-export differ diff --git a/export/tests/files/sample_print.sd-export b/export/tests/files/sample_print.sd-export new file mode 100644 index 000000000..911113c9a Binary files /dev/null and b/export/tests/files/sample_print.sd-export differ diff --git a/export/tests/lsblk_sample.py b/export/tests/lsblk_sample.py new file mode 100644 index 000000000..9e45b4c77 --- /dev/null +++ b/export/tests/lsblk_sample.py @@ -0,0 +1,162 @@ +""" +Sample output from `lsblk` used as input in `test_cli.py` +""" + +# udisks2 Status +UDISKS_STATUS_NOTHING_CONNECTED = ( + b"MODEL REVISION SERIAL DEVICE" + b"\n--------------------------------------------------------------------------\n" +) + +UDISKS_STATUS_ONE_DEVICE_CONNECTED = ( + b"MODEL REVISION SERIAL DEVICE\n" + b"--------------------------------------------------------------------------\n" + b"ADATA USB Flash Drive 1.00 2761505420110004 sda \n" +) +UDISKS_STATUS_MULTI_CONNECTED = ( + b"MODEL REVISION SERIAL DEVICE\n" + b"--------------------------------------------------------------------------\n" + b"ADATA USB Flash Drive 1.00 2761505420110004 sda \n" + b"Kingston DataTraveler 3.0 PMAP 40B0767E212CE481165906A8 sdb \n" +) + +# CLI.get_volume(): Supported +ONE_DEVICE_LUKS_UNMOUNTED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ONE_DEVICE_VC_UNLOCKED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"tcrypt-2049", "ro":false, "type":"crypt", "mountpoint":null, "fstype":"vfat"}\n ]\n },\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ONE_DEVICE_VC_MOUNTED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"tcrypt-2049", "ro":false, "type":"crypt", "mountpoint":null, "fstype":"vfat"}\n ]\n },\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ERROR_ONE_DEVICE_LUKS_MOUNTED_MULTI_UNKNOWN_AVAILABLE = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS",\n "children": [\n {"name":"luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", "ro":false, "type":"crypt", "mountpoint":"/media/user/tx2", "fstype":"ext4"}\n ]\n }\n ]\n },\n {"name":"sdb", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sdb1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sdb2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ERROR_NO_SUPPORTED_DEVICE = b'{\n "blockdevices": [\n {"name":"sdb", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sdb1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sdb2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +ERROR_UNENCRYPTED_DEVICE_MOUNTED = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"decoy", "ro":false, "type":"part", "mountpoint":"/media/usb", "fstype":"vfat"}\n ]\n },\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"ext4"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +# CLI.get_volume(): Unsupported +ERROR_DEVICE_MULTI_ENC_PARTITION = b'{\n "blockdevices": [\n {"name":"sda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"sda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"sda2", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS"},\n {"name":"sda3", "ro":false, "type":"part", "mountpoint":null, "fstype":"crypto_LUKS"}\n ]\n },\n {"name":"xvda", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvda1", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda2", "ro":false, "type":"part", "mountpoint":null, "fstype":null},\n {"name":"xvda3", "ro":false, "type":"part", "mountpoint":"/", "fstype":"ext4"}\n ]\n },\n {"name":"xvdb", "ro":false, "type":"disk", "mountpoint":"/rw", "fstype":"ext4"},\n {"name":"xvdc", "ro":false, "type":"disk", "mountpoint":null, "fstype":null,\n "children": [\n {"name":"xvdc1", "ro":false, "type":"part", "mountpoint":"[SWAP]", "fstype":"swap"},\n {"name":"xvdc3", "ro":false, "type":"part", "mountpoint":null, "fstype":null}\n ]\n },\n {"name":"xvdd", "ro":true, "type":"disk", "mountpoint":null, "fstype":"ext3"}\n ]\n}\n' # noqa: E501 + +# Cli._get_supported_volume(): Supported + +SINGLE_DEVICE_LOCKED = { + "name": "sda", + "type": "disk", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", +} + +SINGLE_PART_LUKS_WRITABLE = { + "name": "sda1", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", + "children": [ + { + "name": "luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", + "type": "crypt", + "rm": False, + "mountpoint": "/media/usb", + "fstype": "ext4", + } + ], +} + +SINGLE_PART_VC_WRITABLE = { + "name": "sda1", + "rm": True, + "ro": False, + "type": "part", + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "tcrypt-2049", + "rm": False, + "ro": False, + "type": "crypt", + "mountpoint": "/media/usb", + "fstype": "vfat", + } + ], +} + +SINGLE_PART_LUKS_UNLOCKED_UNMOUNTED = { + "name": "sda1", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", + "children": [ + { + "name": "luks-dbfb85f2-77c4-4b1f-99a9-2dd3c6789094", + "type": "crypt", + "rm": False, + "mountpoint": None, + "fstype": "ext4", + } + ], +} + + +SINGLE_PART_UNLOCKED_VC_UNMOUNTED = { + "name": "sda1", + "rm": True, + "ro": False, + "type": "part", + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "tcrypt-2049", + "rm": False, + "ro": False, + "type": "crypt", + "mountpoint": None, + "fstype": "vfat", + } + ], +} + +# Cli._get_supported_volume(): Unsupported + +SINGLE_DEVICE_ERROR_PARTITIONS_TOO_NESTED = { + "name": "sda2", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "sda2p1", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": "crypto_LUKS", + } + ], +} + +SINGLE_DEVICE_ERROR_MOUNTED_PARTITION_NOT_ENCRYPTED = { + "name": "sda2", + "type": "part", + "rm": True, + "ro": False, + "mountpoint": None, + "fstype": None, + "children": [ + { + "name": "unencrypted", + "type": "part", + "rm": False, + "mountpoint": "/media/unencrypted", + "fstype": "ext4", + } + ], +} diff --git a/export/tests/print/test_service.py b/export/tests/print/test_service.py index cf5c6ca1a..46bf6dc82 100644 --- a/export/tests/print/test_service.py +++ b/export/tests/print/test_service.py @@ -1,14 +1,13 @@ -import pytest - -from unittest import mock import os import subprocess from subprocess import CalledProcessError +from unittest import mock -from securedrop_export.directory import safe_mkdir +import pytest -from securedrop_export.exceptions import ExportException from securedrop_export.archive import Archive +from securedrop_export.directory import safe_mkdir +from securedrop_export.exceptions import ExportException from securedrop_export.print.service import Service from securedrop_export.print.status import Status @@ -187,7 +186,7 @@ def test_setup_printer_error(self, mocker): def test_safe_check_call(self): # This works, since `ls` is a valid comand - self.service.safe_check_call(["ls"], Status.TEST_SUCCESS) + self.service.safe_check_call(["ls"], Status.PRINT_TEST_PAGE_SUCCESS) def test_safe_check_call_invalid_call(self): with pytest.raises(ExportException) as ex: @@ -198,7 +197,7 @@ def test_safe_check_call_invalid_call(self): def test_safe_check_call_write_to_stderr_and_ignore_error(self): self.service.safe_check_call( ["python3", "-c", "import sys;sys.stderr.write('hello')"], - error_status=Status.TEST_SUCCESS, + error_status=Status.PRINT_TEST_PAGE_SUCCESS, ignore_stderr_startswith=b"hello", ) @@ -390,9 +389,11 @@ def test_safe_check_call_has_error_in_stderr(self): mock.patch("subprocess.run") with mock.patch("subprocess.run"), pytest.raises(ExportException) as ex: - self.service.safe_check_call(command="ls", error_status=Status.TEST_SUCCESS) + self.service.safe_check_call( + command="ls", error_status=Status.PRINT_TEST_PAGE_SUCCESS + ) - assert ex.value.sdstatus is Status.TEST_SUCCESS + assert ex.value.sdstatus is Status.PRINT_TEST_PAGE_SUCCESS @mock.patch("securedrop_export.print.service.time.sleep", return_value=None) @mock.patch( diff --git a/export/tests/test_archive.py b/export/tests/test_archive.py index 57791a82e..c1ae85fb2 100644 --- a/export/tests/test_archive.py +++ b/export/tests/test_archive.py @@ -1,16 +1,15 @@ +import json import os import subprocess # noqa: F401 +import tarfile import tempfile - +from io import BytesIO from unittest import mock -import json import pytest -import tarfile -from io import BytesIO -from securedrop_export.exceptions import ExportException from securedrop_export.archive import Archive, Metadata, Status +from securedrop_export.exceptions import ExportException def test_extract_tarball(): @@ -22,7 +21,6 @@ def test_extract_tarball(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -73,7 +71,6 @@ def test_extract_tarball_with_symlink(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -108,7 +105,6 @@ def test_extract_tarball_raises_if_doing_path_traversal(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -147,7 +143,6 @@ def test_extract_tarball_raises_if_doing_path_traversal_with_dir(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -184,7 +179,6 @@ def test_extract_tarball_raises_if_doing_path_traversal_with_symlink(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -223,7 +217,6 @@ def test_extract_tarball_raises_if_doing_path_traversal_with_symlink_linkname(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -260,7 +253,6 @@ def test_extract_tarball_raises_if_name_has_unsafe_absolute_path(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -303,7 +295,6 @@ def test_extract_tarball_raises_if_name_has_unsafe_absolute_path_with_symlink(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -347,7 +338,6 @@ def test_extract_tarball_raises_if_name_has_unsafe_absolute_path_with_symlink_to with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -380,7 +370,6 @@ def test_extract_tarball_raises_if_linkname_has_unsafe_absolute_path(): with tarfile.open(archive_path, "w:gz") as archive: metadata = { "device": "disk", - "encryption_method": "luks", "encryption_key": "test", } metadata_str = json.dumps(metadata) @@ -426,7 +415,6 @@ def test_valid_printer_test_config(capsys): config = Metadata(temp_folder).validate() assert config.encryption_key is None - assert config.encryption_method is None def test_valid_printer_config(capsys): @@ -439,23 +427,6 @@ def test_valid_printer_config(capsys): config = Metadata(temp_folder).validate() assert config.encryption_key is None - assert config.encryption_method is None - - -def test_invalid_encryption_config(capsys): - Archive("testfile") - - temp_folder = tempfile.mkdtemp() - metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) - with open(metadata, "w") as f: - f.write( - '{"device": "disk", "encryption_method": "base64", "encryption_key": "hunter1"}' - ) - - with pytest.raises(ExportException) as ex: - Metadata(temp_folder).validate() - - assert ex.value.sdstatus is Status.ERROR_ARCHIVE_METADATA def test_invalid_config(capsys): @@ -464,7 +435,7 @@ def test_invalid_config(capsys): temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write('{"device": "asdf", "encryption_method": "OHNO"}') + f.write('{"device": "asdf"}') with pytest.raises(ExportException) as ex: Metadata(temp_folder).validate() @@ -478,7 +449,7 @@ def test_malformed_config(capsys): temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write('{"device": "asdf", "encryption_method": {"OHNO", "MALFORMED"}') + f.write('{"device": {"OHNO", "MALFORMED"}') with pytest.raises(ExportException) as ex: Metadata(temp_folder).validate() @@ -491,14 +462,11 @@ def test_valid_encryption_config(capsys): temp_folder = tempfile.mkdtemp() metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) with open(metadata, "w") as f: - f.write( - '{"device": "disk", "encryption_method": "luks", "encryption_key": "hunter1"}' - ) + f.write('{"device": "disk", "encryption_key": "hunter1"}') config = Metadata(temp_folder).validate() assert config.encryption_key == "hunter1" - assert config.encryption_method == "luks" @mock.patch("json.loads", side_effect=json.decoder.JSONDecodeError("ugh", "badjson", 0)) diff --git a/export/tests/test_directory.py b/export/tests/test_directory.py index b0857f59c..bd81a66d8 100644 --- a/export/tests/test_directory.py +++ b/export/tests/test_directory.py @@ -1,9 +1,10 @@ -import pytest import os -import tempfile import shutil - +import tempfile from pathlib import Path + +import pytest + from securedrop_export import directory diff --git a/export/tests/test_exceptions.py b/export/tests/test_exceptions.py index 71af41143..272f58645 100644 --- a/export/tests/test_exceptions.py +++ b/export/tests/test_exceptions.py @@ -1,7 +1,8 @@ -import pytest import signal -from securedrop_export.exceptions import handler, TimeoutException +import pytest + +from securedrop_export.exceptions import TimeoutException, handler def test_handler(): diff --git a/export/tests/test_main.py b/export/tests/test_main.py index 06da9ef66..dd1306a85 100644 --- a/export/tests/test_main.py +++ b/export/tests/test_main.py @@ -1,124 +1,141 @@ -import pytest -import tempfile -import os -from unittest import mock import shutil +from pathlib import Path +from unittest import mock -from securedrop_export.archive import Archive, Metadata, Status as ArchiveStatus -from securedrop_export.status import BaseStatus +import pytest + +from securedrop_export.archive import Archive, Metadata +from securedrop_export.archive import Status as ArchiveStatus from securedrop_export.command import Command +from securedrop_export.disk.status import Status as ExportStatus from securedrop_export.exceptions import ExportException - from securedrop_export.main import ( Status, - entrypoint, + _configure_logging, _exit_gracefully, - _write_status, _start_service, - _configure_logging, + _write_status, + entrypoint, ) +from securedrop_export.status import BaseStatus -SUBMISSION_SAMPLE_ARCHIVE = "pretendfile.tar.gz" +_PRINT_SAMPLE_ARCHIVE = "sample_print.sd-export" +_EXPORT_SAMPLE_ARCHIVE = "sample_export.sd-export" class TestMain: def setup_method(self, method): - # This can't be a class method, since we expect sysexit during this test suite, - # which - self.submission = Archive("pretendfile.tar.gz") - assert os.path.exists(self.submission.tmpdir) + # These can't be class setup methods, since we expect sysexit during this test suite + self.print_archive_path = Path.cwd() / "tests/files" / _PRINT_SAMPLE_ARCHIVE + assert self.print_archive_path.exists() + + self.export_archive_path = Path.cwd() / "tests/files" / _EXPORT_SAMPLE_ARCHIVE + assert self.export_archive_path.exists() + + self.print_submission = Archive(str(self.print_archive_path)) + assert Path(self.print_submission.tmpdir).exists() + + self.export_submission = Archive(str(self.export_archive_path)) + assert Path(self.export_submission.tmpdir).exists() def teardown_method(self, method): - if os.path.exists(self.submission.tmpdir): - shutil.rmtree(self.submission.tmpdir) - self.submission = None + if Path(self.print_submission.tmpdir).exists(): + shutil.rmtree(self.print_submission.tmpdir) + + if Path(self.export_submission.tmpdir).exists(): + shutil.rmtree(self.export_submission.tmpdir) + + def _did_exit_gracefully(self, exit, capsys, status: BaseStatus) -> bool: + """ + Helper. True if exited with 0, writing supplied status to stderr. + """ + captured = capsys.readouterr() - def test_exit_gracefully_no_exception(self, capsys): + return ( + exit.value.code == 0 + and captured.err.rstrip().endswith(status.value) + and captured.out == "" + ) + + def test__exit_gracefully_no_exception(self, capsys): with pytest.raises(SystemExit) as sysexit: - _exit_gracefully(self.submission, Status.ERROR_GENERIC) + # `ERROR_GENERIC` is just a placeholder status here + _exit_gracefully(self.print_submission, Status.ERROR_GENERIC) assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_GENERIC) - def test_exit_gracefully_exception(self, capsys): - with pytest.raises(SystemExit) as sysexit: - _exit_gracefully(self.submission, Status.ERROR_GENERIC) + @mock.patch( + "securedrop_export.main.shutil.rmtree", + side_effect=FileNotFoundError("oh no", 0), + ) + def test__exit_gracefully_even_with_cleanup_exception(self, mock_rm, capsys): + with mock.patch( + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] + ), mock.patch( + "securedrop_export.main._start_service", + return_value=ExportStatus.SUCCESS_EXPORT, + ), pytest.raises( + SystemExit + ) as sysexit: + entrypoint() - # A graceful exit means a return code of 0 assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_GENERIC) + def test_entrypoint_success(self, capsys): + with mock.patch( + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] + ), mock.patch( + "securedrop_export.main._start_service", + return_value=ExportStatus.SUCCESS_EXPORT, + ), pytest.raises( + SystemExit + ) as sysexit: + entrypoint() + + assert self._did_exit_gracefully(sysexit, capsys, ExportStatus.SUCCESS_EXPORT) + @pytest.mark.parametrize("status", [s for s in Status]) - def test_write_status(self, status, capsys): + def test__write_status_success(self, status, capsys): _write_status(status) captured = capsys.readouterr() assert captured.err == status.value + "\n" @pytest.mark.parametrize("invalid_status", ["foo", ";ls", "&& echo 0", None]) - def test_write_status_error(self, invalid_status, capsys): + def test__write_status_will_not_write_bad_value(self, invalid_status, capsys): with pytest.raises(ValueError): _write_status(Status(invalid_status)) - def _did_exit_gracefully(self, exit, capsys, status: BaseStatus) -> bool: - """ - Helper. True if exited with 0, writing supplied status to stderr. - """ captured = capsys.readouterr() + assert captured.err == "" + assert captured.out == "" - return ( - exit.value.code == 0 - and captured.err.rstrip().endswith(status.value) - and captured.out == "" - ) - - @pytest.mark.parametrize("command", list(Command)) - @mock.patch("securedrop_export.main._configure_logging") - @mock.patch("os.path.exists", return_value=True) - def test_entrypoint_success_start_service(self, mock_log, mock_path, command): - metadata = os.path.join(self.submission.tmpdir, Metadata.METADATA_FILE) - - with open(metadata, "w") as f: - f.write(f'{{"device": "{command.value}", "encryption_method": "luks"}}') - + def test_entrypoint_success_start_service(self): with mock.patch( - "sys.argv", ["qvm-send-to-usb", SUBMISSION_SAMPLE_ARCHIVE] + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] ), mock.patch( "securedrop_export.main._start_service" - ) as mock_service, mock.patch( - "securedrop_export.main.Archive.extract_tarball", - return_value=self.submission, - ), pytest.raises( + ) as mock_service, pytest.raises( SystemExit ): entrypoint() - if command is not Command.START_VM: - assert self.submission.command == command - assert mock_service.call_args[0][0].archive == SUBMISSION_SAMPLE_ARCHIVE - mock_service.assert_called_once_with(self.submission) - - def test_valid_printer_test_config(self, capsys): - Archive("testfile") - temp_folder = tempfile.mkdtemp() - metadata = os.path.join(temp_folder, Metadata.METADATA_FILE) - with open(metadata, "w") as f: - f.write('{"device": "printer-test"}') + assert mock_service.call_args[0][0].archive == self.export_archive_path + assert mock_service.call_args[0][0].command == Command.EXPORT - config = Metadata(temp_folder).validate() + def test_validate_metadata(self): + for archive_path in [self.print_archive_path, self.export_archive_path]: + archive = Archive(archive_path) + extracted = archive.extract_tarball() - assert config.encryption_key is None - assert config.encryption_method is None + assert Metadata(extracted.tmpdir).validate() @mock.patch( "securedrop_export.archive.safe_extractall", side_effect=ValueError("A tarball problem!"), ) - @mock.patch("securedrop_export.main.os.path.exists", return_value=True) - @mock.patch("securedrop_export.main.shutil.rmtree") - @mock.patch("securedrop_export.main._configure_logging") - def test_entrypoint_failure_extraction( - self, mock_log, mock_rm, mock_path, mock_extract, capsys - ): + def test_entrypoint_failure_extraction(self, mock_extract, capsys): with mock.patch( - "sys.argv", ["qvm-send-to-usb", SUBMISSION_SAMPLE_ARCHIVE] + "sys.argv", ["qvm-send-to-usb", self.export_archive_path] ), pytest.raises(SystemExit) as sysexit: entrypoint() @@ -149,9 +166,10 @@ def test_entrypoint_fails_unexpected(self, mock_mkdir, capsys): assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_GENERIC) - @mock.patch("os.path.exists", return_value=False) - def test_entrypoint_archive_path_fails(self, mock_path, capsys): - with pytest.raises(SystemExit) as sysexit: + def test_entrypoint_archive_path_fails(self, capsys): + with mock.patch( + "sys.argv", ["qvm-send-to-usb", "THIS_FILE_DOES_NOT_EXIST.sd-export"] + ), pytest.raises(SystemExit) as sysexit: entrypoint() assert self._did_exit_gracefully(sysexit, capsys, Status.ERROR_FILE_NOT_FOUND) @@ -171,14 +189,15 @@ def test__start_service_calls_correct_services(self, command): if command is Command.START_VM: pytest.skip("Command does not start a service") - self.submission.command = command + mock_submission = Archive("mock_submission.sd-export") + mock_submission.command = command with mock.patch("securedrop_export.main.PrintService") as ps, mock.patch( "securedrop_export.main.ExportService" ) as es: - _start_service(self.submission) + _start_service(mock_submission) if command in [Command.PRINT, Command.PRINTER_TEST, Command.PRINTER_PREFLIGHT]: - assert ps.call_args[0][0] is self.submission + assert ps.call_args[0][0] is mock_submission else: - assert es.call_args[0][0] is self.submission + assert es.call_args[0][0] is mock_submission