diff --git a/client/securedrop_client/app.py b/client/securedrop_client/app.py
index 8e9fe9e9ca..8a6a990c42 100644
--- a/client/securedrop_client/app.py
+++ b/client/securedrop_client/app.py
@@ -34,7 +34,7 @@
from PyQt5.QtCore import Qt, QThread, QTimer
from PyQt5.QtWidgets import QApplication, QMessageBox
-from securedrop_client import __version__, export, state
+from securedrop_client import __version__, state
from securedrop_client.database import Database
from securedrop_client.db import make_session_maker
from securedrop_client.gui.main import Window
@@ -240,16 +240,11 @@ def start_app(args, qt_args) -> NoReturn: # type: ignore[no-untyped-def]
database = Database(session)
app_state = state.State(database)
- with threads(4) as [
- export_service_thread,
+ with threads(3) as [
sync_thread,
main_queue_thread,
file_download_queue_thread,
]:
- export_service = export.getService()
- export_service.moveToThread(export_service_thread)
- export_service_thread.start()
-
gui = Window(app_state)
controller = Controller(
diff --git a/client/securedrop_client/export.py b/client/securedrop_client/export.py
index 366f68eab6..8ab0a61de5 100644
--- a/client/securedrop_client/export.py
+++ b/client/securedrop_client/export.py
@@ -1,153 +1,292 @@
import json
import logging
import os
-import subprocess
import tarfile
-import threading
+import shutil
from io import BytesIO
from shlex import quote
-from tempfile import TemporaryDirectory
-from typing import List, Optional
+from tempfile import TemporaryDirectory, mkdtemp
+from typing import Callable, List, Optional
-from PyQt5.QtCore import QObject, pyqtBoundSignal, pyqtSignal, pyqtSlot
+from PyQt5.QtCore import QProcess, QObject, pyqtSignal
-from securedrop_client.export_status import ExportStatus
+from securedrop_client.export_status import ExportError, ExportStatus
logger = logging.getLogger(__name__)
-class ExportError(Exception):
- def __init__(self, status: "ExportStatus"):
- self.status: "ExportStatus" = status
-
-
class Export(QObject):
"""
- This class sends files over to the Export VM so that they can be copied to a luks-encrypted USB
+ Interface for sending files to Export VM for transfer to a
disk drive or printed by a USB-connected printer.
- Files are archived in a specified format, which you can learn more about in the README for the
- securedrop-export repository.
+ Files are archived in a specified format, (see `export` README).
+
+ A list of valid filepaths must be supplied.
"""
- METADATA_FN = "metadata.json"
+ _METADATA_FN = "metadata.json"
- USB_TEST_FN = "usb-test.sd-export"
- USB_TEST_METADATA = {"device": "usb-test"}
+ _USB_TEST_FN = "usb-test.sd-export"
+ _USB_TEST_METADATA = {"device": "usb-test"}
- PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export"
- PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"}
+ _PRINTER_PREFLIGHT_FN = "printer-preflight.sd-export"
+ _PRINTER_PREFLIGHT_METADATA = {"device": "printer-preflight"}
- DISK_TEST_FN = "disk-test.sd-export"
- DISK_TEST_METADATA = {"device": "disk-test"}
+ _PRINT_FN = "print_archive.sd-export"
+ _PRINT_METADATA = {"device": "printer"}
- PRINT_FN = "print_archive.sd-export"
- PRINT_METADATA = {"device": "printer"}
+ _DISK_FN = "archive.sd-export"
+ _DISK_METADATA = {"device": "disk"}
+ _DISK_ENCRYPTION_KEY_NAME = "encryption_key"
+ _DISK_EXPORT_DIR = "export_data"
- DISK_FN = "archive.sd-export"
- DISK_METADATA = {"device": "disk", "encryption_method": "luks"}
- DISK_ENCRYPTION_KEY_NAME = "encryption_key"
- DISK_EXPORT_DIR = "export_data"
+ # Emit export states
+ export_state_changed = pyqtSignal(object)
- # Set up signals for communication with the controller #
- # Emit ExportStatus
- preflight_check_call_success = pyqtSignal(object)
- export_usb_call_success = pyqtSignal(object)
- printer_preflight_success = pyqtSignal(object)
- print_call_success = pyqtSignal(object)
+ # Emit print states
+ print_preflight_check_succeeded = pyqtSignal(object)
+ print_succeeded = pyqtSignal(object)
- # Emit ExportError(status=ExportStatus)
- export_usb_call_failure = pyqtSignal(object)
- preflight_check_call_failure = pyqtSignal(object)
- printer_preflight_failure = pyqtSignal(object)
- print_call_failure = pyqtSignal(object)
+ export_completed = pyqtSignal(object)
- # Emit List[str] of filepaths
- export_completed = pyqtSignal(list)
+ print_preflight_check_failed = pyqtSignal(object)
+ print_failed = pyqtSignal(object)
- def __init__(
- self,
- export_preflight_check_requested: Optional[pyqtBoundSignal] = None,
- export_requested: Optional[pyqtBoundSignal] = None,
- print_preflight_check_requested: Optional[pyqtBoundSignal] = None,
- print_requested: Optional[pyqtBoundSignal] = None,
- ) -> None:
- super().__init__()
+ process = None # Optional[QProcess]
+ tmpdir = None # Note: context-managed tmpdir goes out of scope too quickly, so we create then clean it up
+
+ def run_printer_preflight_checks(self) -> None:
+ """
+ Make sure the Export VM is started.
+ """
+ logger.info("Beginning printer preflight check")
+ self.tmpdir = mkdtemp()
+ archive_path = self._create_archive(
+ archive_dir=self.tmpdir,
+ archive_fn=self._PRINTER_PREFLIGHT_FN,
+ metadata=self._PRINTER_PREFLIGHT_METADATA,
+ )
+ self._run_qrexec_export(
+ archive_path, self._on_print_preflight_success, self._on_print_prefight_error
+ )
+
+ def run_export_preflight_checks(self) -> None:
+ """
+ Run preflight check to verify that a valid USB device is connected.
+ """
+ logger.debug("Beginning export preflight check")
+
+ self.tmpdir = mkdtemp()
- self.connect_signals(
- export_preflight_check_requested,
- export_requested,
- print_preflight_check_requested,
- print_requested,
+ archive_path = self._create_archive(
+ archive_dir=self.tmpdir,
+ archive_fn=self._USB_TEST_FN,
+ metadata=self._USB_TEST_METADATA,
+ )
+ # Emits status via on_process_completed()
+ self._run_qrexec_export(
+ archive_path, self._on_export_process_finished, self._on_export_process_error
)
- def connect_signals(
- self,
- export_preflight_check_requested: Optional[pyqtBoundSignal] = None,
- export_requested: Optional[pyqtBoundSignal] = None,
- print_preflight_check_requested: Optional[pyqtBoundSignal] = None,
- print_requested: Optional[pyqtBoundSignal] = None,
+ def export(self, filepaths: List[str], passphrase: Optional[str]) -> None:
+ """
+ Bundle filepaths into a tarball and send to encrypted USB via qrexec,
+ optionally supplying a passphrase to unlock encrypted drives.
+ """
+ try:
+ logger.debug(f"Begin exporting {len(filepaths)} item(s)")
+
+ # Edit metadata template to include passphrase
+ metadata = self._DISK_METADATA.copy()
+ if passphrase:
+ metadata[self._DISK_ENCRYPTION_KEY_NAME] = passphrase
+
+ self.tmpdir = mkdtemp()
+ archive_path = self._create_archive(
+ archive_dir=self.tmpdir,
+ archive_fn=self._DISK_FN,
+ metadata=metadata,
+ filepaths=filepaths,
+ )
+
+ # Emits status through callbacks
+ self._run_qrexec_export(
+ archive_path, self._on_export_process_finished, self._on_export_process_error
+ )
+
+ except IOError as e:
+ logger.error("Export failed")
+ logger.debug(f"Export failed: {e}")
+ self.export_state_changed.emit(ExportStatus.ERROR_EXPORT)
+
+ def _run_qrexec_export(
+ self, archive_path: str, success_callback: Callable, error_callback: Callable
) -> None:
- # This instance can optionally react to events to prevent
- # coupling it to dependent code.
- if export_preflight_check_requested is not None:
- export_preflight_check_requested.connect(self.run_preflight_checks)
- if export_requested is not None:
- export_requested.connect(self.send_file_to_usb_device)
- if print_requested is not None:
- print_requested.connect(self.print)
- if print_preflight_check_requested is not None:
- print_preflight_check_requested.connect(self.run_printer_preflight)
-
- def _run_qrexec_export(cls, archive_path: str) -> ExportStatus:
"""
- Make the subprocess call to send the archive to the Export VM, where the archive will be
- processed.
+ Send the archive to the Export VM, where the archive will be processed.
+ Uses qrexec-client-vm (via QProcess). Results are emitted via the
+ `on_process_finished` callback; errors are reported via `on_process_error`.
Args:
archive_path (str): The path to the archive to be processed.
+ success_callback, err_callback: Callback functions to connect to the success and
+ error signals of QProcess. They are included to accommodate the print functions,
+ which still use separate signals for print preflight, print, and error states, but
+ can be removed in favour of a generic success callback and error callback when the
+ print code is updated.
+ Any callbacks must call _cleanup_tmpdir() to remove the temporary directory that held
+ the files to be exported.
+ """
+ # There are already talks of switching to a QVM-RPC implementation for unlocking devices
+ # and exporting files, so it's important to remember to shell-escape what we pass to the
+ # shell, even if for the time being we're already protected against shell injection via
+ # Python's implementation of subprocess, see
+ # https://docs.python.org/3/library/subprocess.html#security-considerations
+ qrexec = "/usr/bin/qrexec-client-vm"
+ args = [
+ quote("--"),
+ quote("sd-devices"),
+ quote("qubes.OpenInVM"),
+ quote("/usr/lib/qubes/qopen-in-vm"),
+ quote("--view-only"),
+ quote("--"),
+ quote(archive_path),
+ ]
+
+ self.process = QProcess()
+
+ self.process.finished.connect(success_callback)
+ self.process.errorOccurred.connect(error_callback)
+
+ self.process.start(qrexec, args)
+
+ def _cleanup_tmpdir(self):
+ """
+ Should be called in all qrexec completion callbacks.
+ """
+ if self.tmpdir and os.path.exists(self.tmpdir):
+ shutil.rmtree(self.tmpdir)
- Returns:
- str: The export status returned from the Export VM processing script.
+ def _on_export_process_finished(self):
+ """
+ Callback, handle and emit QProcess result. As with all such callbacks,
+ the method signature cannot change.
+ """
+ self._cleanup_tmpdir()
+ # securedrop-export writes status to stderr
+ err = self.process.readAllStandardError()
+
+ logger.debug(f"stderr: {err}")
+
+ try:
+ result = err.data().decode("utf-8").strip()
+ if result:
+ logger.debug(f"Result is {result}")
+ # This is a bit messy, but make sure we are just taking the last line
+ # (no-op if no newline, since we already stripped whitespace above)
+ status_string = result.split("\n")[-1]
+ self.export_state_changed.emit(ExportStatus(status_string))
+
+ else:
+ logger.error("Export subprocess did not return a value we could parse")
+ self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS)
+
+ except ValueError as e:
+ logger.debug(f"Export subprocess returned unexpected value: {e}")
+ self.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS)
+
+ def _on_export_process_error(self):
+ """
+ Callback, called if QProcess cannot complete export. As with all such, the method
+ signature cannot change.
+ """
+ self._cleanup_tmpdir()
+ err = self.process.readAllStandardError().data().decode("utf-8").strip()
+
+ logger.error(f"Export process error: {err}")
+ self.export_state_changed.emit(ExportStatus.CALLED_PROCESS_ERROR)
- Raises:
- ExportError: Raised if (1) CalledProcessError is encountered, which can occur when
- trying to start the Export VM when the USB device is not attached, or (2) when
- the return code from `check_output` is not 0.
+ def _on_print_preflight_success(self):
"""
+ Print preflight success callback.
+ """
+ self._cleanup_tmpdir()
+ output = self.process.readAllStandardError().data().decode("utf-8").strip()
try:
- # There are already talks of switching to a QVM-RPC implementation for unlocking devices
- # and exporting files, so it's important to remember to shell-escape what we pass to the
- # shell, even if for the time being we're already protected against shell injection via
- # Python's implementation of subprocess, see
- # https://docs.python.org/3/library/subprocess.html#security-considerations
- output = subprocess.check_output(
- [
- quote("qrexec-client-vm"),
- quote("--"),
- quote("sd-devices"),
- quote("qubes.OpenInVM"),
- quote("/usr/lib/qubes/qopen-in-vm"),
- quote("--view-only"),
- quote("--"),
- quote(archive_path),
- ],
- stderr=subprocess.STDOUT,
+ status = ExportStatus(output)
+ self.print_preflight_check_succeeded.emit(status)
+ logger.debug("Print preflight success")
+
+ except ValueError as error:
+ logger.debug(f"Print preflight check failed: {error}")
+ logger.error("Print preflight check failed")
+ self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT))
+
+ def _on_print_prefight_error(self):
+ """
+ Print Preflight error callback.
+ """
+ self._cleanup_tmpdir()
+ err = self.process.readAllStandardError().data().decode("utf-8").strip()
+ logger.debug(f"Print preflight error: {err}")
+ self.print_preflight_check_failed.emit(ExportError(ExportStatus.ERROR_PRINT))
+
+ # Todo: not sure if we need to connect here, since the print dialog is managed by sd-devices.
+ # We can probably use the export callback.
+ def _on_print_success(self):
+ self._cleanup_tmpdir()
+ logger.debug("Print success")
+ self.print_succeeded.emit(ExportStatus.PRINT_SUCCESS)
+ # TODO: Previously emitted [filepaths]
+ self.export_completed.emit([])
+
+ def end_process(self) -> None:
+ """
+ Tell QProcess to quit if it hasn't already.
+ Connected to the ExportWizard's `finished` signal, which fires
+ when the dialog is closed, cancelled, or finished.
+ """
+ self._cleanup_tmpdir()
+ logger.debug("Terminate process")
+ if self.process is not None and not self.process.waitForFinished(50):
+ self.process.terminate()
+
+ def _on_print_error(self):
+ """
+ Error callback for print qrexec.
+ """
+ self._cleanup_tmpdir()
+ err = self.process.readAllStandardError()
+ logger.debug(f"Print error: {err}")
+ self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT))
+
+ def print(self, filepaths: List[str]) -> None:
+ """
+ Bundle files at filepaths into tarball and send for
+ printing via qrexec.
+ """
+ try:
+ logger.debug("Beginning print")
+
+ self.tmpdir = mkdtemp()
+ archive_path = self._create_archive(
+ archive_dir=self.tmpdir,
+ archive_fn=self._PRINT_FN,
+ metadata=self._PRINT_METADATA,
+ filepaths=filepaths,
)
- result = output.decode("utf-8").strip()
+ self._run_qrexec_export(archive_path, self._on_print_success, self._on_print_error)
- return ExportStatus(result)
+ except IOError as e:
+ logger.error("Export failed")
+ logger.debug(f"Export failed: {e}")
+ self.print_failed.emit(ExportError(ExportStatus.ERROR_PRINT))
- except ValueError as e:
- logger.debug(f"Export subprocess returned unexpected value: {e}")
- raise ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS)
- except subprocess.CalledProcessError as e:
- logger.error("Subprocess failed")
- logger.debug(f"Subprocess failed: {e}")
- raise ExportError(ExportStatus.CALLED_PROCESS_ERROR)
+ self.export_completed.emit(filepaths)
def _create_archive(
- cls, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str]
+ self, archive_dir: str, archive_fn: str, metadata: dict, filepaths: List[str] = []
) -> str:
"""
Create the archive to be sent to the Export VM.
@@ -164,20 +303,35 @@ def _create_archive(
archive_path = os.path.join(archive_dir, archive_fn)
with tarfile.open(archive_path, "w:gz") as archive:
- cls._add_virtual_file_to_archive(archive, cls.METADATA_FN, metadata)
+ self._add_virtual_file_to_archive(archive, self._METADATA_FN, metadata)
# When more than one file is added to the archive,
# extra care must be taken to prevent name collisions.
is_one_of_multiple_files = len(filepaths) > 1
+ missing_count = 0
for filepath in filepaths:
- cls._add_file_to_archive(
- archive, filepath, prevent_name_collisions=is_one_of_multiple_files
- )
+ if not (os.path.exists(filepath)):
+ missing_count += 1
+ logger.debug(
+ f"'{filepath}' does not exist, and will not be included in archive"
+ )
+ # Controller checks files and keeps a reference open during export,
+ # so this shouldn't be reachable
+ logger.warning("File not found at specified filepath, skipping")
+ else:
+ self._add_file_to_archive(
+ archive, filepath, prevent_name_collisions=is_one_of_multiple_files
+ )
+ if missing_count == len(filepaths) and missing_count > 0:
+ # Context manager will delete archive even if an exception occurs
+ # since the archive is in a TemporaryDirectory
+ logger.error("Files were moved or missing")
+ raise ExportError(ExportStatus.ERROR_MISSING_FILES)
return archive_path
def _add_virtual_file_to_archive(
- cls, archive: tarfile.TarFile, filename: str, filedata: dict
+ self, archive: tarfile.TarFile, filename: str, filedata: dict
) -> None:
"""
Add filedata to a stream of in-memory bytes and add these bytes to the archive.
@@ -195,7 +349,7 @@ def _add_virtual_file_to_archive(
archive.addfile(tarinfo, filedata_bytes)
def _add_file_to_archive(
- cls, archive: tarfile.TarFile, filepath: str, prevent_name_collisions: bool = False
+ self, archive: tarfile.TarFile, filepath: str, prevent_name_collisions: bool = False
) -> None:
"""
Add the file to the archive. When the archive is extracted, the file should exist in a
@@ -206,7 +360,7 @@ def _add_file_to_archive(
filepath: The path to the file that will be added to the supplied archive.
"""
filename = os.path.basename(filepath)
- arcname = os.path.join(cls.DISK_EXPORT_DIR, filename)
+ arcname = os.path.join(self._DISK_EXPORT_DIR, filename)
if prevent_name_collisions:
(parent_path, _) = os.path.split(filepath)
grand_parent_path, parent_name = os.path.split(parent_path)
@@ -216,126 +370,3 @@ def _add_file_to_archive(
arcname = os.path.join("export_data", parent_name, filename)
archive.add(filepath, arcname=arcname, recursive=False)
-
- def _build_archive_and_export(
- self, metadata: dict, filename: str, filepaths: List[str] = []
- ) -> ExportStatus:
- """
- Build archive, run qrexec command and return resulting ExportStatus.
-
- ExportError may be raised during underlying _run_qrexec_export call,
- and is handled by the calling method.
- """
- with TemporaryDirectory() as tmp_dir:
- archive_path = self._create_archive(
- archive_dir=tmp_dir, archive_fn=filename, metadata=metadata, filepaths=filepaths
- )
- return self._run_qrexec_export(archive_path)
-
- @pyqtSlot()
- def run_preflight_checks(self) -> None:
- """
- Run preflight checks to verify that a valid USB device is connected.
- """
- try:
- logger.debug(
- "beginning preflight checks in thread {}".format(threading.current_thread().ident)
- )
-
- status = self._build_archive_and_export(
- metadata=self.USB_TEST_METADATA, filename=self.USB_TEST_FN
- )
-
- logger.debug("completed preflight checks: success")
- self.preflight_check_call_success.emit(status)
- except ExportError as e:
- logger.debug("completed preflight checks: failure")
- self.preflight_check_call_failure.emit(e)
-
- @pyqtSlot()
- def run_printer_preflight(self) -> None:
- """
- Make sure the Export VM is started.
- """
- try:
- status = self._build_archive_and_export(
- metadata=self.PRINTER_PREFLIGHT_METADATA, filename=self.PRINTER_PREFLIGHT_FN
- )
- self.printer_preflight_success.emit(status)
- except ExportError as e:
- logger.error("Export failed")
- logger.debug(f"Export failed: {e}")
- self.printer_preflight_failure.emit(e)
-
- @pyqtSlot(list, str)
- def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None:
- """
- Export the file to the luks-encrypted usb disk drive attached to the Export VM.
-
- Args:
- filepath: The path of file to export.
- passphrase: The passphrase to unlock the luks-encrypted usb disk drive.
- """
- try:
- logger.debug("beginning export from thread {}".format(threading.current_thread().ident))
- # Edit metadata template to include passphrase
- metadata = self.DISK_METADATA.copy()
- metadata[self.DISK_ENCRYPTION_KEY_NAME] = passphrase
- status = self._build_archive_and_export(
- metadata=metadata, filename=self.DISK_FN, filepaths=filepaths
- )
-
- self.export_usb_call_success.emit(status)
- logger.debug(f"Status {status}")
- except ExportError as e:
- logger.error("Export failed")
- logger.debug(f"Export failed: {e}")
- self.export_usb_call_failure.emit(e)
-
- self.export_completed.emit(filepaths)
-
- @pyqtSlot(list)
- def print(self, filepaths: List[str]) -> None:
- """
- Print the file to the printer attached to the Export VM.
-
- Args:
- filepath: The path of file to export.
- """
- try:
- logger.debug(
- "beginning printer from thread {}".format(threading.current_thread().ident)
- )
- status = self._build_archive_and_export(
- metadata=self.PRINT_METADATA, filename=self.PRINT_FN, filepaths=filepaths
- )
- self.print_call_success.emit(status)
- logger.debug(f"Status {status}")
- except ExportError as e:
- logger.error("Export failed")
- logger.debug(f"Export failed: {e}")
- self.print_call_failure.emit(e)
-
- self.export_completed.emit(filepaths)
-
-
-Service = Export
-
-# Store a singleton service instance.
-_service = Service()
-
-
-def resetService() -> None:
- """Replaces the existing sngleton service instance by a new one.
-
- Get the instance by using getService().
- """
- global _service
- _service = Service()
-
-
-def getService() -> Service:
- """All calls to this function return the same singleton service instance.
-
- Use resetService() to replace it by a new one."""
- return _service
diff --git a/client/securedrop_client/export_status.py b/client/securedrop_client/export_status.py
index 2c2a199246..da475c3fa3 100644
--- a/client/securedrop_client/export_status.py
+++ b/client/securedrop_client/export_status.py
@@ -1,11 +1,16 @@
from enum import Enum
+class ExportError(Exception):
+ def __init__(self, status: "ExportStatus"):
+ self.status: "ExportStatus" = status
+
+
class ExportStatus(Enum):
"""
All possible strings returned by the qrexec calls to sd-devices. These values come from
- `print/status.py` and `disk/status.py` in `https://github.com/freedomofpress/securedrop-export`
- and must only be changed in coordination with changes released in that repo.
+ `print/status.py` and `disk/status.py` in `securedrop-export`
+ and must only be changed in coordination with changes released in that component.
"""
# Export
@@ -53,3 +58,6 @@ class ExportStatus(Enum):
CALLED_PROCESS_ERROR = "CALLED_PROCESS_ERROR"
ERROR_USB_CONFIGURATION = "ERROR_USB_CONFIGURATION"
UNEXPECTED_RETURN_STATUS = "UNEXPECTED_RETURN_STATUS"
+
+ # Client-side error only
+ ERROR_MISSING_FILES = "ERROR_MISSING_FILES" # All files meant for export are missing
diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py
index c4dfd6a704..ff189f086a 100644
--- a/client/securedrop_client/gui/actions.py
+++ b/client/securedrop_client/gui/actions.py
@@ -12,18 +12,15 @@
from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtWidgets import QAction, QDialog, QMenu
-from securedrop_client import export, state
+from securedrop_client import state
from securedrop_client.conversation import Transcript as ConversationTranscript
from securedrop_client.db import Source
from securedrop_client.gui.base import ModalDialog
-from securedrop_client.gui.conversation import ExportDevice as ConversationExportDevice
-from securedrop_client.gui.conversation import ExportDialog as ExportConversationDialog
-from securedrop_client.gui.conversation import (
- ExportTranscriptDialog as ExportConversationTranscriptDialog,
-)
+from securedrop_client.gui.conversation import ExportDevice
from securedrop_client.gui.conversation import (
PrintTranscriptDialog as PrintConversationTranscriptDialog,
)
+from securedrop_client.gui.conversation.export import ExportWizard
from securedrop_client.logic import Controller
from securedrop_client.utils import safe_mkdir
@@ -160,8 +157,6 @@ def __init__(
self.controller = controller
self._source = source
- self._export_device = ConversationExportDevice(controller, export.getService())
-
self.triggered.connect(self._on_triggered)
@pyqtSlot()
@@ -189,8 +184,9 @@ def _on_triggered(self) -> None:
# out of scope, any pending file removal will be performed
# by the operating system.
with open(file_path, "r") as f:
+ export = ExportDevice()
dialog = PrintConversationTranscriptDialog(
- self._export_device, TRANSCRIPT_FILENAME, str(file_path)
+ export, TRANSCRIPT_FILENAME, [str(file_path)]
)
dialog.exec()
@@ -212,15 +208,12 @@ def __init__(
self.controller = controller
self._source = source
- self._export_device = ConversationExportDevice(controller, export.getService())
-
self.triggered.connect(self._on_triggered)
@pyqtSlot()
def _on_triggered(self) -> None:
"""
- (Re-)generates the conversation transcript and opens a confirmation dialog to export it,
- in the manner of the existing ExportFileDialog.
+ (Re-)generates the conversation transcript and opens export wizard.
"""
file_path = (
Path(self.controller.data_dir)
@@ -241,10 +234,9 @@ def _on_triggered(self) -> None:
# out of scope, any pending file removal will be performed
# by the operating system.
with open(file_path, "r") as f:
- dialog = ExportConversationTranscriptDialog(
- self._export_device, TRANSCRIPT_FILENAME, str(file_path)
- )
- dialog.exec()
+ export_device = ExportDevice()
+ wizard = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)])
+ wizard.exec()
class ExportConversationAction(QAction): # pragma: nocover
@@ -267,16 +259,13 @@ def __init__(
self._source = source
self._state = app_state
- self._export_device = ConversationExportDevice(controller, export.getService())
-
self.triggered.connect(self._on_triggered)
@pyqtSlot()
def _on_triggered(self) -> None:
"""
- (Re-)generates the conversation transcript and opens a confirmation dialog to export it
- alongside all the (attached) files that are downloaded, in the manner
- of the existing ExportFileDialog.
+ (Re-)generates the conversation transcript and opens export wizard to export it
+ alongside all the (attached) files that are downloaded.
"""
if self._state is not None:
id = self._state.selected_conversation
@@ -302,7 +291,7 @@ def _prepare_to_export(self) -> None:
"""
(Re-)generates the conversation transcript and opens a confirmation dialog to export it
alongside all the (attached) files that are downloaded, in the manner
- of the existing ExportFileDialog.
+ of the existing ExportWizard.
"""
transcript_location = (
Path(self.controller.data_dir)
@@ -331,6 +320,7 @@ def _prepare_to_export(self) -> None:
# out of scope, any pending file removal will be performed
# by the operating system.
with ExitStack() as stack:
+ export_device = ExportDevice()
files = [
stack.enter_context(open(file_location, "r")) for file_location in file_locations
]
@@ -341,12 +331,12 @@ def _prepare_to_export(self) -> None:
else:
summary = _("all files and transcript")
- dialog = ExportConversationDialog(
- self._export_device,
+ wizard = ExportWizard(
+ export_device,
summary,
[str(file_location) for file_location in file_locations],
)
- dialog.exec()
+ wizard.exec()
def _on_confirmation_dialog_accepted(self) -> None:
self._prepare_to_export()
diff --git a/client/securedrop_client/gui/conversation/__init__.py b/client/securedrop_client/gui/conversation/__init__.py
index 29142e98dc..219c004655 100644
--- a/client/securedrop_client/gui/conversation/__init__.py
+++ b/client/securedrop_client/gui/conversation/__init__.py
@@ -3,9 +3,7 @@
"""
# Import classes here to make possible to import them from securedrop_client.gui.conversation
from .delete import DeleteConversationDialog # noqa: F401
-from .export import Device as ExportDevice # noqa: F401
-from .export import Dialog as ExportDialog # noqa: F401
-from .export import FileDialog as ExportFileDialog # noqa: F401
+from .export import Export as ExportDevice # noqa: F401
+from .export import ExportWizard as ExportWizard # noqa: F401
from .export import PrintDialog as PrintFileDialog # noqa: F401
from .export import PrintTranscriptDialog # noqa: F401
-from .export import TranscriptDialog as ExportTranscriptDialog # noqa: F401
diff --git a/client/securedrop_client/gui/conversation/export/__init__.py b/client/securedrop_client/gui/conversation/export/__init__.py
index 7da54e94cc..328c19e436 100644
--- a/client/securedrop_client/gui/conversation/export/__init__.py
+++ b/client/securedrop_client/gui/conversation/export/__init__.py
@@ -1,6 +1,4 @@
-from .device import Device # noqa: F401
-from .dialog import Dialog # noqa: F401
-from .file_dialog import FileDialog # noqa: F401
+from ....export import Export # noqa: F401
+from .export_wizard import ExportWizard # noqa: F401
from .print_dialog import PrintDialog # noqa: F401
from .print_transcript_dialog import PrintTranscriptDialog # noqa: F401
-from .transcript_dialog import TranscriptDialog # noqa: F401
diff --git a/client/securedrop_client/gui/conversation/export/device.py b/client/securedrop_client/gui/conversation/export/device.py
deleted file mode 100644
index 9cf61dd06b..0000000000
--- a/client/securedrop_client/gui/conversation/export/device.py
+++ /dev/null
@@ -1,135 +0,0 @@
-import logging
-import os
-from typing import List
-
-from PyQt5.QtCore import QObject, pyqtSignal
-
-from securedrop_client.export import Export
-from securedrop_client.logic import Controller
-
-logger = logging.getLogger(__name__)
-
-
-class Device(QObject):
- """Abstracts an export service for use in GUI components.
-
- This class defines an interface for GUI components to have access
- to the status of an export device without needed to interact directly
- with the underlying export service.
- """
-
- export_preflight_check_requested = pyqtSignal()
- print_preflight_check_requested = pyqtSignal()
-
- # Emit ExportStatus
- export_preflight_check_succeeded = pyqtSignal(object)
- export_succeeded = pyqtSignal(object)
-
- print_preflight_check_succeeded = pyqtSignal(object)
- print_succeeded = pyqtSignal(object)
-
- # Emit ExportError(status=ExportStatus)
- export_preflight_check_failed = pyqtSignal(object)
- export_failed = pyqtSignal(object)
-
- print_preflight_check_failed = pyqtSignal(object)
- print_failed = pyqtSignal(object)
-
- # Emit List[str] filepaths
- export_requested = pyqtSignal(list, str)
- export_completed = pyqtSignal(list)
- print_requested = pyqtSignal(list)
-
- def __init__(self, controller: Controller, export_service: Export) -> None:
- super().__init__()
-
- self._controller = controller
- self._export_service = export_service
-
- self._export_service.connect_signals(
- self.export_preflight_check_requested,
- self.export_requested,
- self.print_preflight_check_requested,
- self.print_requested,
- )
-
- # Abstract the Export instance away from the GUI
- self._export_service.preflight_check_call_success.connect(
- self.export_preflight_check_succeeded
- )
- self._export_service.preflight_check_call_failure.connect(
- self.export_preflight_check_failed
- )
-
- self._export_service.export_usb_call_success.connect(self.export_succeeded)
- self._export_service.export_usb_call_failure.connect(self.export_failed)
- self._export_service.export_completed.connect(self.export_completed)
-
- self._export_service.printer_preflight_success.connect(self.print_preflight_check_succeeded)
- self._export_service.printer_preflight_failure.connect(self.print_preflight_check_failed)
-
- self._export_service.print_call_failure.connect(self.print_failed)
- self._export_service.print_call_success.connect(self.print_succeeded)
-
- def run_printer_preflight_checks(self) -> None:
- """
- Run preflight checks to make sure the Export VM is configured correctly.
- """
- logger.info("Running printer preflight check")
- self.print_preflight_check_requested.emit()
-
- def run_export_preflight_checks(self) -> None:
- """
- Run preflight checks to make sure the Export VM is configured correctly.
- """
- logger.info("Running export preflight check")
- self.export_preflight_check_requested.emit()
-
- def export_transcript(self, file_location: str, passphrase: str) -> None:
- """
- Send the transcript specified by file_location to the Export VM.
- """
- self.export_requested.emit([file_location], passphrase)
-
- def export_files(self, file_locations: List[str], passphrase: str) -> None:
- """
- Send the files specified by file_locations to the Export VM.
- """
- self.export_requested.emit(file_locations, passphrase)
-
- def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None:
- """
- Send the file specified by file_uuid to the Export VM with the user-provided passphrase for
- unlocking the attached transfer device. If the file is missing, update the db so that
- is_downloaded is set to False.
- """
- file = self._controller.get_file(file_uuid)
- file_location = file.location(self._controller.data_dir)
- logger.info("Exporting file in: {}".format(os.path.dirname(file_location)))
-
- if not self._controller.downloaded_file_exists(file):
- logger.warning(f"Cannot find file in {file_location}")
- return
-
- self.export_requested.emit([file_location], passphrase)
-
- def print_transcript(self, file_location: str) -> None:
- """
- Send the transcript specified by file_location to the Export VM.
- """
- self.print_requested.emit([file_location])
-
- def print_file(self, file_uuid: str) -> None:
- """
- Send the file specified by file_uuid to the Export VM. If the file is missing, update the db
- so that is_downloaded is set to False.
- """
- file = self._controller.get_file(file_uuid)
- file_location = file.location(self._controller.data_dir)
- logger.info("Printing file in: {}".format(os.path.dirname(file_location)))
-
- if not self._controller.downloaded_file_exists(file):
- logger.warning(f"Cannot find file in {file_location}")
- return
-
- self.print_requested.emit([file_location])
diff --git a/client/securedrop_client/gui/conversation/export/dialog.py b/client/securedrop_client/gui/conversation/export/dialog.py
deleted file mode 100644
index c71ebe2d84..0000000000
--- a/client/securedrop_client/gui/conversation/export/dialog.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from gettext import gettext as _
-from typing import List
-
-from PyQt5.QtCore import pyqtSlot
-
-from .device import Device
-from .file_dialog import FileDialog
-
-
-class Dialog(FileDialog):
- """Adapts the dialog used to export files to allow exporting a conversation.
-
- - Adjust the init arguments to export multiple files.
- - Adds a method to allow all those files to be exported.
- - Overrides the two slots that handles the export action to call said method.
- """
-
- def __init__(self, device: Device, summary: str, file_locations: List[str]) -> None:
- super().__init__(device, "", summary)
-
- self.file_locations = file_locations
-
- @pyqtSlot(bool)
- def _export_files(self, checked: bool = False) -> None:
- self.start_animate_activestate()
- self.cancel_button.setEnabled(False)
- self.passphrase_field.setDisabled(True)
- self._device.export_files(self.file_locations, self.passphrase_field.text())
-
- @pyqtSlot()
- def _show_passphrase_request_message(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._export_files)
- self.header.setText(self.passphrase_header)
- self.continue_button.setText(_("SUBMIT"))
- self.header_line.hide()
- self.error_details.hide()
- self.body.hide()
- self.passphrase_field.setFocus()
- self.passphrase_form.show()
- self.adjustSize()
-
- @pyqtSlot()
- def _show_passphrase_request_message_again(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._export_files)
- self.header.setText(self.passphrase_header)
- self.error_details.setText(self.passphrase_error_message)
- self.continue_button.setText(_("SUBMIT"))
- self.header_line.hide()
- self.body.hide()
- self.error_details.show()
- self.passphrase_field.setFocus()
- self.passphrase_form.show()
- self.adjustSize()
diff --git a/client/securedrop_client/gui/conversation/export/dialog_button.css b/client/securedrop_client/gui/conversation/export/dialog_button.css
new file mode 100644
index 0000000000..132952a4bd
--- /dev/null
+++ b/client/securedrop_client/gui/conversation/export/dialog_button.css
@@ -0,0 +1,29 @@
+#ModalDialog_button_box QPushButton#ModalDialog_primary_button {
+ background-color: #2a319d;
+ color: #fff;
+}
+
+#ModalDialog.dangerous #ModalDialog_button_box QPushButton {
+ border-color: #ff3366;
+ color: #ff3366;
+}
+
+#ModalDialog.dangerous #ModalDialog_button_box QPushButton#ModalDialog_primary_button {
+ background-color: #ff3366;
+ border-color: #ff3366;
+ color: #ffffff;
+}
+
+#ModalDialog_button_box QPushButton#ModalDialog_primary_button::disabled {
+ border: 2px solid #c2c4e3;
+ background-color: #c2c4e3;
+ color: #e1e2f1;
+}
+
+#ModalDialog_button_box QPushButton#ModalDialog_primary_button_active {
+ background-color: #f1f1f6;
+ color: #fff;
+ border: 2px solid #f1f1f6;
+ margin: 0;
+ height: 40px;
+}
diff --git a/client/securedrop_client/gui/conversation/export/dialog_message.css b/client/securedrop_client/gui/conversation/export/dialog_message.css
new file mode 100644
index 0000000000..20415fe9b9
--- /dev/null
+++ b/client/securedrop_client/gui/conversation/export/dialog_message.css
@@ -0,0 +1,13 @@
+#ModalDialog_error_details {
+ margin: 0px 40px 0px 36px;
+ font-family: 'Montserrat';
+ font-size: 16px;
+ color: #ff0064;
+}
+
+#ModalDialog_error_details_active {
+ margin: 0px 40px 0px 36px;
+ font-family: 'Montserrat';
+ font-size: 16px;
+ color: #ff66c4;
+}
diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py
new file mode 100644
index 0000000000..2630b3b981
--- /dev/null
+++ b/client/securedrop_client/gui/conversation/export/export_wizard.py
@@ -0,0 +1,222 @@
+import logging
+from gettext import gettext as _
+from typing import List
+
+from pkg_resources import resource_string
+from PyQt5.QtCore import QSize, Qt, pyqtSlot
+from PyQt5.QtGui import QIcon, QKeyEvent
+from PyQt5.QtWidgets import QApplication, QWizard, QWizardPage
+
+from securedrop_client.export import Export
+from securedrop_client.export_status import ExportStatus
+from securedrop_client.gui.base import SecureQLabel
+from securedrop_client.gui.conversation.export.export_wizard_constants import Pages, STATUS_MESSAGES
+from securedrop_client.gui.conversation.export.export_wizard_page import (
+ ErrorPage,
+ FinalPage,
+ InsertUSBPage,
+ PassphraseWizardPage,
+ PreflightPage,
+)
+from securedrop_client.resources import load_movie
+
+logger = logging.getLogger(__name__)
+
+
+class ExportWizard(QWizard):
+ """
+ Guide user through the steps of exporting to a USB.
+ """
+
+ PASSPHRASE_LABEL_SPACING = 0.5
+ NO_MARGIN = 0
+ FILENAME_WIDTH_PX = 260
+ BUTTON_CSS = resource_string(__name__, "dialog_button.css").decode("utf-8")
+
+ # If the drive is unlocked, we don't need a passphrase; if we do need one,
+ # it's populated later.
+ PASS_PLACEHOLDER_FIELD = ""
+
+ def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> None:
+ parent = QApplication.activeWindow()
+ super().__init__(parent)
+ self.export = export
+ self.summary_text = SecureQLabel(
+ summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX
+ ).text()
+ self.filepaths = filepaths
+ self.current_status = None # Optional[ExportStatus]
+
+ # Signal from qrexec command runner
+ self.export.export_state_changed.connect(self.on_status_received)
+
+ # Clean up export on dialog closed signal
+ self.finished.connect(self.export.end_process)
+
+ self._set_layout()
+ self._set_pages()
+ self._style_buttons()
+
+ def keyPressEvent(self, event: QKeyEvent) -> None:
+ if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return:
+ if self.cancel_button.hasFocus():
+ self.cancel_button.click()
+ else:
+ self.next_button.click()
+ else:
+ super().keyPressEvent(event)
+
+ def text(self) -> str:
+ """A text-only representation of the dialog."""
+ return self.body.text()
+
+ def _style_buttons(self) -> None:
+ self.next_button = self.button(QWizard.WizardButton.NextButton)
+ self.next_button.clicked.connect(self.request_export)
+ self.next_button.setStyleSheet(self.BUTTON_CSS)
+ self.cancel_button = self.button(QWizard.WizardButton.CancelButton)
+ self.cancel_button.setStyleSheet(self.BUTTON_CSS)
+
+ # Activestate animation
+ self.button_animation = load_movie("activestate-wide.gif")
+ self.button_animation.setScaledSize(QSize(32, 32))
+ self.button_animation.frameChanged.connect(self.animate_activestate)
+
+ def animate_activestate(self) -> None:
+ self.next_button.setIcon(QIcon(self.button_animation.currentPixmap()))
+
+ def start_animate_activestate(self) -> None:
+ self.button_animation.start()
+ self.next_button.setMinimumSize(QSize(142, 43))
+ # Reset widget stylesheets
+ self.next_button.setStyleSheet("")
+ self.next_button.setObjectName("ModalDialog_primary_button_active")
+ self.next_button.setStyleSheet(self.BUTTON_CSS)
+
+ def stop_animate_activestate(self) -> None:
+ self.next_button.setIcon(QIcon())
+ self.button_animation.stop()
+ # Reset widget stylesheets
+ self.next_button.setStyleSheet("")
+ self.next_button.setObjectName("ModalDialog_primary_button")
+ self.next_button.setStyleSheet(self.BUTTON_CSS)
+
+ def _set_layout(self) -> None:
+ self.setWindowTitle(f"Export {self.summary_text}")
+ self.setModal(False)
+ self.setOptions(
+ QWizard.NoBackButtonOnLastPage
+ | QWizard.NoCancelButtonOnLastPage
+ | QWizard.NoBackButtonOnStartPage
+ )
+
+ def _set_pages(self) -> None:
+ for id, page in [
+ (Pages.PREFLIGHT, self._create_preflight()),
+ (Pages.ERROR, self._create_errorpage()),
+ (Pages.INSERT_USB, self._create_insert_usb()),
+ (Pages.UNLOCK_USB, self._create_passphrase_prompt()),
+ (Pages.EXPORT_DONE, self._create_done()),
+ ]:
+ self.setPage(id, page)
+
+ # Nice to have, but steals the focus from the password field after 1 character is typed.
+ # Probably another way to have it be based on validating the status
+ # page.completeChanged.connect(lambda: self._set_focus(QWizard.WizardButton.NextButton))
+
+ @pyqtSlot(int)
+ def _set_focus(self, which: QWizard.WizardButton) -> None:
+ self.button(which).setFocus()
+
+ def request_export(self) -> None:
+ logger.debug("Request export")
+ # Registered fields let us access the passphrase field
+ # of the PassphraseRequestPage from the wizard parent
+ passphrase_untrusted = self.field("passphrase")
+ if str(passphrase_untrusted) is not None:
+ self.export.export(self.filepaths, str(passphrase_untrusted))
+ else:
+ self.export.export(self.filepaths, self.PASS_PLACEHOLDER_FIELD)
+
+ def request_export_preflight(self) -> None:
+ logger.debug("Request preflight check")
+ self.export.run_export_preflight_checks()
+
+ @pyqtSlot(object)
+ def on_status_received(self, status: ExportStatus) -> None:
+ """
+ Update the wizard position based on incoming ExportStatus.
+ If a status is shown that represents a removed device,
+ rewind the wizard to the appropriate pane.
+
+ To update the text on an individual page, the page listens
+ for this signal and can call `update_content` in the listener.
+ """
+ logger.debug(f"Wizard received {status.value}. Current page is {type(self.currentPage())}")
+
+ # Unrecoverable - end the wizard
+ if status in [
+ ExportStatus.ERROR_MOUNT,
+ ExportStatus.ERROR_EXPORT,
+ ExportStatus.ERROR_MISSING_FILES,
+ ExportStatus.DEVICE_ERROR,
+ ExportStatus.CALLED_PROCESS_ERROR,
+ ExportStatus.UNEXPECTED_RETURN_STATUS,
+ ]:
+ logger.error(f"Encountered {status.value}, cannot export")
+ self.end_wizard_with_error(status)
+ return
+
+ target = None # Optional[PageEnum]
+ if status in [
+ ExportStatus.NO_DEVICE_DETECTED,
+ ExportStatus.MULTI_DEVICE_DETECTED,
+ ExportStatus.INVALID_DEVICE_DETECTED,
+ ]:
+ target = Pages.INSERT_USB
+ elif status in [ExportStatus.DEVICE_LOCKED, ExportStatus.ERROR_UNLOCK_LUKS]:
+ target = Pages.UNLOCK_USB
+
+ # Someone may have yanked out or unmounted a USB
+ if target and self.currentId() > target:
+ self.rewind(target)
+
+ # Update status
+ self.current_status = status
+
+ def rewind(self, target: Pages) -> None:
+ """
+ Navigate back to target page.
+ """
+ logger.debug(f"Wizard: rewind from {self.currentId()} to {target}")
+ while self.currentId() > target:
+ self.back()
+
+ def end_wizard_with_error(self, error: ExportStatus) -> None:
+ """
+ If and end state is reached, display message and let user
+ end the wizard.
+ """
+ if isinstance(self.currentPage(), PreflightPage):
+ # Update its status so it shows error next self.currentPage()
+ logger.debug("On preflight page, no reordering needed")
+ else:
+ while self.currentId() > Pages.ERROR:
+ self.back()
+ page = self.currentPage()
+ page.update_content(error)
+
+ def _create_preflight(self) -> QWizardPage:
+ return PreflightPage(self.export, self.summary_text)
+
+ def _create_errorpage(self) -> QWizardPage:
+ return ErrorPage(self.export, "")
+
+ def _create_insert_usb(self) -> QWizardPage:
+ return InsertUSBPage(self.export, self.summary_text)
+
+ def _create_passphrase_prompt(self) -> QWizardPage:
+ return PassphraseWizardPage(self.export)
+
+ def _create_done(self) -> QWizardPage:
+ return FinalPage(self.export)
diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py
new file mode 100644
index 0000000000..eccb767a00
--- /dev/null
+++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py
@@ -0,0 +1,45 @@
+from enum import IntEnum
+from gettext import gettext as _
+
+from securedrop_client.export_status import ExportStatus
+
+"""
+Export wizard page ordering, human-readable status messages
+"""
+
+# Sequential list of pages (the enum value matters as a ranked ordering.)
+# The reason the 'error' page is second is because the other pages have
+# validation logic that means they can't be bypassed by QWizard::next.
+# When we need to show an error, it's easier to go 'back' to the error
+# page and set it to be a FinalPage than it is to try to skip the conditional
+# pages. PyQt6 introduces behaviour that may deprecate this requirement.
+class Pages(IntEnum):
+ PREFLIGHT = 0
+ ERROR = 1
+ INSERT_USB = 2
+ UNLOCK_USB = 3
+ EXPORT_DONE = 4
+
+# Human-readable status info
+STATUS_MESSAGES = {
+ ExportStatus.NO_DEVICE_DETECTED: _("No device detected"),
+ ExportStatus.MULTI_DEVICE_DETECTED: _("Too many USBs; please insert one supported device."),
+ ExportStatus.INVALID_DEVICE_DETECTED: _(
+ "Either the drive is not encrypted or there is something else wrong with it."
+ ),
+ ExportStatus.DEVICE_WRITABLE: _("The device is ready for export."),
+ ExportStatus.DEVICE_LOCKED: _("The device is locked."),
+ ExportStatus.ERROR_UNLOCK_LUKS: _("The passphrase provided did not work. Please try again."),
+ ExportStatus.ERROR_MOUNT: _("Error mounting drive"),
+ ExportStatus.ERROR_EXPORT: _("Error during export"),
+ ExportStatus.ERROR_EXPORT_CLEANUP: _(
+ "Files were exported succesfully, but the drive could not be unmounted"
+ ),
+ ExportStatus.SUCCESS_EXPORT: _("Export successful"),
+ ExportStatus.DEVICE_ERROR: _(
+ "Error encountered with this device. See your administrator for help."
+ ),
+ ExportStatus.ERROR_MISSING_FILES: _("Files were moved or missing and could not be exported."),
+ ExportStatus.CALLED_PROCESS_ERROR: _("Error encountered. Please contact support."),
+ ExportStatus.UNEXPECTED_RETURN_STATUS: _("Error encountered. Please contact support."),
+}
diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py
new file mode 100644
index 0000000000..a3b2cf93a2
--- /dev/null
+++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py
@@ -0,0 +1,459 @@
+import logging
+from gettext import gettext as _
+
+from pkg_resources import resource_string
+from PyQt5.QtCore import QSize, Qt, pyqtSlot
+from PyQt5.QtGui import QColor, QFont, QPixmap
+from PyQt5.QtWidgets import (
+ QApplication,
+ QGraphicsDropShadowEffect,
+ QHBoxLayout,
+ QLabel,
+ QLayout,
+ QLineEdit,
+ QSizePolicy,
+ QVBoxLayout,
+ QWidget,
+ QWizardPage,
+)
+
+from securedrop_client.export import Export
+from securedrop_client.export_status import ExportStatus
+from securedrop_client.gui.base import PasswordEdit, SecureQLabel
+from securedrop_client.gui.base.checkbox import SDCheckBox
+from securedrop_client.gui.base.misc import SvgLabel
+from securedrop_client.gui.conversation.export.export_wizard_constants import STATUS_MESSAGES, Pages
+from securedrop_client.resources import load_movie
+
+logger = logging.getLogger(__name__)
+
+
+class ExportWizardPage(QWizardPage):
+ """
+ Base class for all export wizard pages. Individual pages should inherit
+ from this class to:
+ * include additional layout items
+ * implement dynamic ordering (i.e., if the next window varies
+ depending on the result of the previous action, in which case the
+ `nextId()` method must be overwritten)
+ * implement custom validation (logic that prevents a user
+ from skipping to the next page until conditions are met)
+
+ Every wizard page has:
+ * A header (page title)
+ * Body (instructions)
+ * Optional error_instructions (Additional text that is hidden but
+ appears on recoverable error to help the user advance to the next stage)
+ * Directional buttons (continue/done, cancel)
+ """
+
+ DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8")
+ ERROR_DETAILS_CSS = resource_string(__name__, "dialog_message.css").decode("utf-8")
+
+ MARGIN = 40
+ PASSPHRASE_LABEL_SPACING = 0.5
+ NO_MARGIN = 0
+ FILENAME_WIDTH_PX = 260
+
+ def __init__(self, export: Export, header: str, body: str) -> None:
+ parent = QApplication.activeWindow()
+ super().__init__(parent)
+ self.export = export
+ self.header_text = header
+ self.body_text = body
+ self.status = None # Optional[ExportStatus]
+ self._is_complete = True # Won't override parent method unless explicitly set to False
+
+ self.setLayout(self._build_layout())
+
+ # Listen for export updates from export
+ self.export.export_state_changed.connect(self.on_status_received)
+
+ def set_complete(self, is_complete: bool) -> None:
+ """
+ Flag a page as being incomplete. (Disables Next button)
+ """
+ self._is_complete = is_complete
+
+ def isComplete(self) -> bool:
+ return self._is_complete and super().isComplete()
+
+ def _build_layout(self) -> QVBoxLayout:
+ """
+ Create parent layout, draw elements, return parent layout
+ """
+ self.setStyleSheet(self.DIALOG_CSS)
+ parent_layout = QVBoxLayout()
+ parent_layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN)
+
+ # Header for icon and task title
+ header_container = QWidget()
+ header_container_layout = QHBoxLayout()
+ header_container.setLayout(header_container_layout)
+ self.header_icon = SvgLabel("blank.svg", svg_size=QSize(64, 64))
+ self.header_icon.setObjectName("ModalDialog_header_icon")
+ self.header_spinner = QPixmap()
+ self.header_spinner_label = QLabel()
+ self.header_spinner_label.setObjectName("ModalDialog_header_spinner")
+ self.header_spinner_label.setMinimumSize(64, 64)
+ self.header_spinner_label.setVisible(False)
+ self.header_spinner_label.setPixmap(self.header_spinner)
+ self.header = QLabel()
+ self.header.setObjectName("ModalDialog_header")
+ header_container_layout.addWidget(self.header_icon)
+ header_container_layout.addWidget(self.header_spinner_label)
+ header_container_layout.addWidget(self.header, alignment=Qt.AlignLeft) # Prev: AlignCenter
+ header_container_layout.addStretch()
+ self.header_line = QWidget()
+ self.header_line.setObjectName("ModalDialog_header_line")
+
+ # Body to display instructions and forms
+ self.body = QLabel()
+ self.body.setObjectName("ModalDialog_body")
+ self.body.setWordWrap(True)
+ self.body.setScaledContents(True)
+
+ body_container = QWidget()
+ self.body_layout = QVBoxLayout()
+ self.body_layout.setContentsMargins(
+ self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN
+ )
+ body_container.setLayout(self.body_layout)
+ self.body_layout.addWidget(self.body)
+ self.body_layout.setSizeConstraint(QLayout.SetMinimumSize)
+
+ # TODO: it's either like this, or in the parent layout elements
+ self.body_layout.setSizeConstraint(QLayout.SetMinimumSize)
+
+ # Widget for displaying error messages (hidden by default)
+ self.error_details = QLabel()
+ self.error_details.setObjectName("ModalDialog_error_details")
+ self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS)
+ self.error_details.setWordWrap(True)
+ self.error_details.hide()
+
+ # Header animation
+ self.header_animation = load_movie("header_animation.gif")
+ self.header_animation.setScaledSize(QSize(64, 64))
+ self.header_animation.frameChanged.connect(self.animate_header)
+
+ # Populate text content
+ self.header.setText(self.header_text)
+ self.body.setText(self.body_text)
+
+ # Add all the layout elements
+ parent_layout.addWidget(header_container)
+ parent_layout.addWidget(self.header_line)
+ parent_layout.addWidget(body_container)
+ parent_layout.addWidget(self.error_details)
+ # parent_layout.setSizeConstraint(QLayout.SetFixedSize)
+
+ return parent_layout
+
+ def animate_header(self) -> None:
+ self.header_spinner_label.setPixmap(self.header_animation.currentPixmap())
+
+ def animate_activestate(self) -> None:
+ pass # Animation handled in parent
+
+ def start_animate_activestate(self) -> None:
+ self.error_details.setStyleSheet("")
+ self.error_details.setObjectName("ModalDialog_error_details_active")
+ self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS)
+
+ def start_animate_header(self) -> None:
+ self.header_icon.setVisible(False)
+ self.header_spinner_label.setVisible(True)
+ self.header_animation.start()
+
+ def stop_animate_activestate(self) -> None:
+ self.error_details.setStyleSheet("")
+ self.error_details.setObjectName("ModalDialog_error_details")
+ self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS)
+
+ def stop_animate_header(self) -> None:
+ self.header_icon.setVisible(True)
+ self.header_spinner_label.setVisible(False)
+ self.header_animation.stop()
+
+ @pyqtSlot(object)
+ def on_status_received(self, status: ExportStatus) -> None:
+ raise NotImplementedError("Children must implement")
+
+ def update_content(self, status: ExportStatus, should_show_hint: bool = False) -> None:
+ """
+ Update page's content based on new status.
+ Children may re-implement this method.
+ """
+ if not status:
+ logger.error("Empty status value given to update_content")
+ status = ExportStatus.UNEXPECTED_RETURN_STATUS
+
+ if should_show_hint:
+ self.error_details.setText(STATUS_MESSAGES.get(status))
+ self.error_details.show()
+ else:
+ self.error_details.hide()
+
+
+class PreflightPage(ExportWizardPage):
+ def __init__(self, export, summary):
+ self.summary = summary
+ header = _(
+ "Preparing to export:
" '{}'
+ ).format(summary)
+ body = _(
+ "
Understand the risks before exporting files
"
+ "Malware"
+ "
"
+ "This workstation lets you open files securely. If you open files on another "
+ "computer, any embedded malware may spread to your computer or network. If you are "
+ "unsure how to manage this risk, please print the file, or contact your "
+ "administrator."
+ "
"
+ "Anonymity"
+ "
"
+ "Files submitted by sources may contain information or hidden metadata that "
+ "identifies who they are. To protect your sources, please consider redacting files "
+ "before working with them on network-connected computers."
+ )
+
+ super().__init__(export, header=header, body=body)
+ self.start_animate_header()
+ self.export.run_export_preflight_checks()
+
+ def nextId(self):
+ """
+ Override builtin to allow bypassing the password page if device is unlocked.
+ """
+ if self.status == ExportStatus.DEVICE_WRITABLE:
+ logger.debug("Skip password prompt")
+ return Pages.EXPORT_DONE
+ elif self.status == ExportStatus.DEVICE_LOCKED:
+ logger.debug("Device locked - prompt for passphrase")
+ return Pages.UNLOCK_USB
+ elif self.status in (
+ ExportStatus.CALLED_PROCESS_ERROR,
+ ExportStatus.DEVICE_ERROR,
+ ExportStatus.UNEXPECTED_RETURN_STATUS,
+ ):
+ logger.debug("Error during preflight - show error page")
+ return Pages.ERROR
+ else:
+ return Pages.INSERT_USB
+
+ @pyqtSlot(object)
+ def on_status_received(self, status: ExportStatus):
+ self.stop_animate_header()
+ if status in (
+ ExportStatus.DEVICE_LOCKED,
+ ExportStatus.DEVICE_WRITABLE,
+ ExportStatus.NO_DEVICE_DETECTED,
+ ExportStatus.MULTI_DEVICE_DETECTED,
+ ExportStatus.INVALID_DEVICE_DETECTED,
+ ):
+ header = _(
+ "Ready to export:
" '{}'
+ ).format(self.summary)
+ self.header.setText(header)
+ self.status = status
+
+class ErrorPage(ExportWizardPage):
+ def __init__(self, export, summary):
+ header = _("Export Failed")
+ summary = "" # todo
+
+ super().__init__(export, header=header, body=summary)
+
+ def isComplete(self) -> bool:
+ return False
+
+ @pyqtSlot(object)
+ def on_status_received(self, status: ExportStatus):
+ pass
+
+class InsertUSBPage(ExportWizardPage):
+ def __init__(self, export, summary):
+ self.summary = summary
+ header = _("Ready to export:
" '{}').format(
+ summary
+ )
+ body = _(
+ "Please insert one of the export drives provisioned specifically "
+ "for the SecureDrop Workstation."
+ )
+ super().__init__(export, header=header, body=body)
+
+ @pyqtSlot(object)
+ def on_status_received(self, status: ExportStatus) -> None:
+ logger.debug(f"InsertUSB received {status.value}")
+ should_show_hint = status in (
+ ExportStatus.MULTI_DEVICE_DETECTED,
+ ExportStatus.INVALID_DEVICE_DETECTED,
+ ) or (self.status == status == ExportStatus.NO_DEVICE_DETECTED)
+ self.update_content(status, should_show_hint)
+ self.status = status
+ self.completeChanged.emit()
+ if status in (ExportStatus.DEVICE_LOCKED, ExportStatus.DEVICE_WRITABLE):
+ self.wizard().next()
+
+ def validatePage(self) -> bool:
+ """
+ Override method to implement custom validation logic, which
+ shows an error-specific hint to the user.
+ """
+ if self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.DEVICE_LOCKED):
+ self.error_details.hide()
+ return True
+ else:
+ logger.debug(f"Status is {self.status}")
+
+ # Show the user a hint
+ if self.status in (
+ ExportStatus.MULTI_DEVICE_DETECTED,
+ ExportStatus.NO_DEVICE_DETECTED,
+ ExportStatus.INVALID_DEVICE_DETECTED,
+ ):
+ self.update_content(self.status, should_show_hint=True)
+ return False
+ else:
+ # Status may be None here
+ logger.warning("InsertUSBPage encountered unexpected status")
+ return super().validatePage()
+
+
+ def nextId(self):
+ """
+ Override builtin to allow bypassing the password page if device unlocked
+ """
+ if self.status == ExportStatus.DEVICE_WRITABLE:
+ logger.debug("Skip password prompt")
+ return Pages.EXPORT_DONE
+ elif self.status == ExportStatus.DEVICE_LOCKED:
+ return Pages.UNLOCK_USB
+ elif self.status in (ExportStatus.UNEXPECTED_RETURN_STATUS, ExportStatus.DEVICE_ERROR):
+ return Pages.ERROR
+ else:
+ next = super().nextId()
+ logger.error("Unexpected status on InsertUSBPage {status.value}, nextID is {next}")
+ return next
+
+
+class FinalPage(ExportWizardPage):
+ def __init__(self, export: Export) -> None:
+ header = _("Export successful")
+ body = _(
+ "Remember to be careful when working with files outside of your Workstation machine."
+ )
+ super().__init__(export, header, body)
+
+ @pyqtSlot(object)
+ def on_status_received(self, status: ExportStatus) -> None:
+ logger.debug(f"Final page received status {status}")
+ self.update_content(status)
+ self.status = status
+
+ def update_content(self, status: ExportStatus, should_show_hint: bool = False):
+ header = None
+ body = None
+ if status == ExportStatus.SUCCESS_EXPORT:
+ header = _("Export successful")
+ body = _(
+ "Remember to be careful when working with files "
+ "outside of your Workstation machine."
+ )
+ elif status == ExportStatus.ERROR_EXPORT_CLEANUP:
+ header = header = _("Export sucessful, but drive was not locked")
+ body = STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP)
+
+ else:
+ header = _("Working...")
+
+ self.header.setText(header)
+ if body:
+ self.body.setText(body)
+
+
+class PassphraseWizardPage(ExportWizardPage):
+ """
+ Wizard page that includes a passphrase prompt field
+ """
+
+ def __init__(self, export):
+ header = _("Enter passphrase for USB drive")
+ super().__init__(export, header, body=None)
+
+ def _build_layout(self) -> QVBoxLayout:
+ layout = super()._build_layout()
+
+ # Passphrase Form
+ self.passphrase_form = QWidget()
+ self.passphrase_form.setObjectName("ModalDialog_passphrase_form")
+ passphrase_form_layout = QVBoxLayout()
+ passphrase_form_layout.setContentsMargins(
+ self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN
+ )
+ self.passphrase_form.setLayout(passphrase_form_layout)
+ passphrase_label = SecureQLabel(_("Passphrase"))
+ font = QFont()
+ font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING)
+ passphrase_label.setFont(font)
+ self.passphrase_field = PasswordEdit(self)
+ self.passphrase_field.setEchoMode(QLineEdit.Password)
+ effect = QGraphicsDropShadowEffect(self)
+ effect.setOffset(0, -1)
+ effect.setBlurRadius(4)
+ effect.setColor(QColor("#aaa"))
+ self.passphrase_field.setGraphicsEffect(effect)
+
+ # Makes the password text accessible outside of this panel
+ self.registerField("passphrase*", self.passphrase_field)
+
+ check = SDCheckBox()
+ check.checkbox.stateChanged.connect(self.passphrase_field.on_toggle_password_Action)
+
+ passphrase_form_layout.addWidget(passphrase_label)
+ passphrase_form_layout.addWidget(self.passphrase_field)
+ passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight)
+
+ layout.insertWidget(1, self.passphrase_form)
+ return layout
+
+ @pyqtSlot(object)
+ def on_status_received(self, status: ExportStatus) -> None:
+ logger.debug(f"Passphrase page rececived {status.value}")
+ should_show_hint = status in (
+ ExportStatus.ERROR_UNLOCK_LUKS,
+ ExportStatus.ERROR_UNLOCK_GENERIC,
+ )
+ self.update_content(status, should_show_hint)
+ self.status = status
+ self.completeChanged.emit()
+ if status in (ExportStatus.SUCCESS_EXPORT, ExportStatus.ERROR_EXPORT_CLEANUP):
+ self.wizard().next()
+
+ def validatePage(self):
+ # Also to add: DEVICE_BUSY for unmounting.
+ # This shouldn't stop us from going "back" to an error page
+ return self.status in (ExportStatus.DEVICE_WRITABLE, ExportStatus.SUCCESS_EXPORT, ExportStatus.ERROR_EXPORT_CLEANUP)
+
+ def nextId(self):
+ if self.status == ExportStatus.SUCCESS_EXPORT:
+ return Pages.EXPORT_DONE
+ elif self.status in (ExportStatus.ERROR_UNLOCK_LUKS, ExportStatus.ERROR_UNLOCK_GENERIC):
+ return Pages.UNLOCK_USB
+ elif self.status in (
+ ExportStatus.NO_DEVICE_DETECTED,
+ ExportStatus.MULTI_DEVICE_DETECTED,
+ ExportStatus.INVALID_DEVICE_DETECTED,
+ ):
+ return Pages.INSERT_USB
+ elif self.status in (
+ ExportStatus.ERROR_MOUNT,
+ ExportStatus.ERROR_EXPORT,
+ ExportStatus.ERROR_EXPORT_CLEANUP,
+ ExportStatus.UNEXPECTED_RETURN_STATUS,
+ ):
+ return Pages.ERROR
+ else:
+ return super().nextId()
diff --git a/client/securedrop_client/gui/conversation/export/file_dialog.py b/client/securedrop_client/gui/conversation/export/file_dialog.py
deleted file mode 100644
index 414d2c8b15..0000000000
--- a/client/securedrop_client/gui/conversation/export/file_dialog.py
+++ /dev/null
@@ -1,288 +0,0 @@
-"""
-A dialog that allows journalists to export sensitive files to a USB drive.
-"""
-from gettext import gettext as _
-from typing import Optional
-
-from pkg_resources import resource_string
-from PyQt5.QtCore import QSize, Qt, pyqtSlot
-from PyQt5.QtGui import QColor, QFont
-from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QLineEdit, QVBoxLayout, QWidget
-
-from securedrop_client.export import ExportError
-from securedrop_client.export_status import ExportStatus
-from securedrop_client.gui.base import ModalDialog, PasswordEdit, SecureQLabel
-from securedrop_client.gui.base.checkbox import SDCheckBox
-
-from .device import Device
-
-
-class FileDialog(ModalDialog):
- DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8")
-
- PASSPHRASE_LABEL_SPACING = 0.5
- NO_MARGIN = 0
- FILENAME_WIDTH_PX = 260
-
- def __init__(self, device: Device, file_uuid: str, file_name: str) -> None:
- super().__init__()
- self.setStyleSheet(self.DIALOG_CSS)
-
- self._device = device
- self.file_uuid = file_uuid
- self.file_name = SecureQLabel(
- file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX
- ).text()
- # Hold onto the error status we receive from the Export VM
- self.error_status: Optional[ExportStatus] = None
-
- # Connect device signals to slots
- self._device.export_preflight_check_succeeded.connect(
- self._on_export_preflight_check_succeeded
- )
- self._device.export_preflight_check_failed.connect(self._on_export_preflight_check_failed)
- self._device.export_succeeded.connect(self._on_export_succeeded)
- self._device.export_failed.connect(self._on_export_failed)
-
- # Connect parent signals to slots
- self.continue_button.setEnabled(False)
- self.continue_button.clicked.connect(self._run_preflight)
-
- # Dialog content
- self.starting_header = _(
- "Preparing to export:
" '{}'
- ).format(self.file_name)
- self.ready_header = _(
- "Ready to export:
" '{}'
- ).format(self.file_name)
- self.insert_usb_header = _("Insert encrypted USB drive")
- self.passphrase_header = _("Enter passphrase for USB drive")
- self.success_header = _("Export successful")
- self.error_header = _("Export failed")
- self.starting_message = _(
- "Understand the risks before exporting files
"
- "Malware"
- "
"
- "This workstation lets you open files securely. If you open files on another "
- "computer, any embedded malware may spread to your computer or network. If you are "
- "unsure how to manage this risk, please print the file, or contact your "
- "administrator."
- "
"
- "Anonymity"
- "
"
- "Files submitted by sources may contain information or hidden metadata that "
- "identifies who they are. To protect your sources, please consider redacting files "
- "before working with them on network-connected computers."
- )
- self.exporting_message = _("Exporting: {}").format(self.file_name)
- self.insert_usb_message = _(
- "Please insert one of the export drives provisioned specifically "
- "for the SecureDrop Workstation."
- )
- self.usb_error_message = _(
- "Either the drive is not encrypted or there is something else wrong with it."
- )
- self.passphrase_error_message = _("The passphrase provided did not work. Please try again.")
- self.generic_error_message = _("See your administrator for help.")
- self.success_message = _(
- "Remember to be careful when working with files outside of your Workstation machine."
- )
-
- # Passphrase Form
- self.passphrase_form = QWidget()
- self.passphrase_form.setObjectName("FileDialog_passphrase_form")
- passphrase_form_layout = QVBoxLayout()
- passphrase_form_layout.setContentsMargins(
- self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN
- )
- self.passphrase_form.setLayout(passphrase_form_layout)
- passphrase_label = SecureQLabel(_("Passphrase"))
- font = QFont()
- font.setLetterSpacing(QFont.AbsoluteSpacing, self.PASSPHRASE_LABEL_SPACING)
- passphrase_label.setFont(font)
- self.passphrase_field = PasswordEdit(self)
- self.passphrase_field.setEchoMode(QLineEdit.Password)
- effect = QGraphicsDropShadowEffect(self)
- effect.setOffset(0, -1)
- effect.setBlurRadius(4)
- effect.setColor(QColor("#aaa"))
- self.passphrase_field.setGraphicsEffect(effect)
-
- check = SDCheckBox()
- check.checkbox.stateChanged.connect(self.passphrase_field.on_toggle_password_Action)
-
- passphrase_form_layout.addWidget(passphrase_label)
- passphrase_form_layout.addWidget(self.passphrase_field)
- passphrase_form_layout.addWidget(check, alignment=Qt.AlignRight)
- self.body_layout.addWidget(self.passphrase_form)
- self.passphrase_form.hide()
-
- self._show_starting_instructions()
- self.start_animate_header()
- self._run_preflight()
-
- def _show_starting_instructions(self) -> None:
- self.header.setText(self.starting_header)
- self.body.setText(self.starting_message)
- self.adjustSize()
-
- def _show_passphrase_request_message(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._export_file)
- self.header.setText(self.passphrase_header)
- self.continue_button.setText(_("SUBMIT"))
- self.header_line.hide()
- self.error_details.hide()
- self.body.hide()
- self.passphrase_field.setFocus()
- self.passphrase_form.show()
- self.adjustSize()
-
- def _show_passphrase_request_message_again(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._export_file)
- self.header.setText(self.passphrase_header)
- self.error_details.setText(self.passphrase_error_message)
- self.continue_button.setText(_("SUBMIT"))
- self.header_line.hide()
- self.body.hide()
- self.error_details.show()
- self.passphrase_field.setFocus()
- self.passphrase_form.show()
- self.adjustSize()
-
- def _show_success_message(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self.close)
- self.header.setText(self.success_header)
- self.continue_button.setText(_("DONE"))
- self.body.setText(self.success_message)
- self.cancel_button.hide()
- self.error_details.hide()
- self.passphrase_form.hide()
- self.header_line.show()
- self.body.show()
- self.adjustSize()
-
- def _show_insert_usb_message(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._run_preflight)
- self.header.setText(self.insert_usb_header)
- self.continue_button.setText(_("CONTINUE"))
- self.body.setText(self.insert_usb_message)
- self.error_details.hide()
- self.passphrase_form.hide()
- self.header_line.show()
- self.body.show()
- self.adjustSize()
-
- def _show_insert_encrypted_usb_message(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._run_preflight)
- self.header.setText(self.insert_usb_header)
- self.error_details.setText(self.usb_error_message)
- self.continue_button.setText(_("CONTINUE"))
- self.body.setText(self.insert_usb_message)
- self.passphrase_form.hide()
- self.header_line.show()
- self.error_details.show()
- self.body.show()
- self.adjustSize()
-
- def _show_generic_error_message(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self.close)
- self.continue_button.setText(_("DONE"))
- self.header.setText(self.error_header)
- self.body.setText( # nosemgrep: semgrep.untranslated-gui-string
- "{}: {}".format(self.error_status, self.generic_error_message)
- )
- self.error_details.hide()
- self.passphrase_form.hide()
- self.header_line.show()
- self.body.show()
- self.adjustSize()
-
- @pyqtSlot()
- def _run_preflight(self) -> None:
- self._device.run_export_preflight_checks()
-
- @pyqtSlot()
- def _export_file(self, checked: bool = False) -> None:
- self.start_animate_activestate()
- self.cancel_button.setEnabled(False)
- self.passphrase_field.setDisabled(True)
-
- # TODO: If the drive is already unlocked, the passphrase field will be empty.
- # This is ok, but could violate expectations. The password should be passed
- # via qrexec in future, to avoid writing it to even a temporary file at all.
- self._device.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text())
-
- @pyqtSlot(object)
- def _on_export_preflight_check_succeeded(self, result: ExportStatus) -> None:
- # If the continue button is disabled then this is the result of a background preflight check
- self.stop_animate_header()
- self.header_icon.update_image("savetodisk.svg", QSize(64, 64))
- self.header.setText(self.ready_header)
- if not self.continue_button.isEnabled():
- self.continue_button.clicked.disconnect()
- if result == ExportStatus.DEVICE_WRITABLE:
- # Skip password prompt, we're there
- self.continue_button.clicked.connect(self._export_file)
- else: # result == ExportStatus.DEVICE_LOCKED
- self.continue_button.clicked.connect(self._show_passphrase_request_message)
- self.continue_button.setEnabled(True)
- self.continue_button.setFocus()
- return
-
- # Skip passphrase prompt if device is unlocked
- if result == ExportStatus.DEVICE_WRITABLE:
- self._export_file()
- else:
- self._show_passphrase_request_message()
-
- @pyqtSlot(object)
- def _on_export_preflight_check_failed(self, error: ExportError) -> None:
- self.stop_animate_header()
- self.header_icon.update_image("savetodisk.svg", QSize(64, 64))
- self._update_dialog(error.status)
-
- @pyqtSlot(object)
- def _on_export_succeeded(self, status: ExportStatus) -> None:
- self.stop_animate_activestate()
- self._show_success_message()
-
- @pyqtSlot(object)
- def _on_export_failed(self, error: ExportError) -> None:
- self.stop_animate_activestate()
- self.cancel_button.setEnabled(True)
- self.passphrase_field.setDisabled(False)
- self._update_dialog(error.status)
-
- def _update_dialog(self, error_status: ExportStatus) -> None:
- self.error_status = error_status
- # If the continue button is disabled then this is the result of a background preflight check
- if not self.continue_button.isEnabled():
- self.continue_button.clicked.disconnect()
- if self.error_status == ExportStatus.ERROR_UNLOCK_LUKS:
- self.continue_button.clicked.connect(self._show_passphrase_request_message_again)
- elif self.error_status == ExportStatus.NO_DEVICE_DETECTED: # fka USB_NOT_CONNECTED
- self.continue_button.clicked.connect(self._show_insert_usb_message)
- elif (
- self.error_status == ExportStatus.INVALID_DEVICE_DETECTED
- ): # fka DISK_ENCRYPTION_NOT_SUPPORTED_ERROR
- self.continue_button.clicked.connect(self._show_insert_encrypted_usb_message)
- else:
- self.continue_button.clicked.connect(self._show_generic_error_message)
-
- self.continue_button.setEnabled(True)
- self.continue_button.setFocus()
- else:
- if self.error_status == ExportStatus.ERROR_UNLOCK_LUKS:
- self._show_passphrase_request_message_again()
- elif self.error_status == ExportStatus.NO_DEVICE_DETECTED:
- self._show_insert_usb_message()
- elif self.error_status == ExportStatus.INVALID_DEVICE_DETECTED:
- self._show_insert_encrypted_usb_message()
- else:
- self._show_generic_error_message()
diff --git a/client/securedrop_client/gui/conversation/export/print_dialog.py b/client/securedrop_client/gui/conversation/export/print_dialog.py
index 32e160bd1c..40eaa7c887 100644
--- a/client/securedrop_client/gui/conversation/export/print_dialog.py
+++ b/client/securedrop_client/gui/conversation/export/print_dialog.py
@@ -1,23 +1,22 @@
from gettext import gettext as _
-from typing import Optional
+from typing import List, Optional
from PyQt5.QtCore import QSize, pyqtSlot
-from securedrop_client.export import ExportError
-from securedrop_client.export_status import ExportStatus
+from securedrop_client.export_status import ExportError, ExportStatus
from securedrop_client.gui.base import ModalDialog, SecureQLabel
-from .device import Device
+from ....export import Export
class PrintDialog(ModalDialog):
FILENAME_WIDTH_PX = 260
- def __init__(self, device: Device, file_uuid: str, file_name: str) -> None:
+ def __init__(self, device: Export, file_name: str, filepaths: List[str]) -> None:
super().__init__()
self._device = device
- self.file_uuid = file_uuid
+ self.filepaths = filepaths
self.file_name = SecureQLabel(
file_name, wordwrap=False, max_length=self.FILENAME_WIDTH_PX
).text()
@@ -95,7 +94,7 @@ def _run_preflight(self) -> None:
@pyqtSlot()
def _print_file(self) -> None:
- self._device.print_file(self.file_uuid)
+ self._device.print(self.filepaths)
self.close()
@pyqtSlot()
diff --git a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py
index 9f47735ce3..b6508fa06f 100644
--- a/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py
+++ b/client/securedrop_client/gui/conversation/export/print_transcript_dialog.py
@@ -1,8 +1,10 @@
+from typing import List
+
from PyQt5.QtCore import QSize, pyqtSlot
from securedrop_client.gui.conversation.export import PrintDialog
-from .device import Device
+from ....export import Export
class PrintTranscriptDialog(PrintDialog):
@@ -13,13 +15,15 @@ class PrintTranscriptDialog(PrintDialog):
- Overrides the slot that handles the printing action to call said method.
"""
- def __init__(self, device: Device, file_name: str, transcript_location: str) -> None:
- super().__init__(device, "", file_name)
+ def __init__(self, device: Export, file_name: str, filepath: List[str]) -> None:
+ super().__init__(device, file_name, filepath)
- self.transcript_location = transcript_location
+ # List might seem like an odd choice for this, but this is on the
+ # way to standardizing one export/print dialog that can send multiple items
+ self.transcript_location = filepath
def _print_transcript(self) -> None:
- self._device.print_transcript(self.transcript_location)
+ self._device.print(self.transcript_location)
self.close()
@pyqtSlot()
diff --git a/client/securedrop_client/gui/conversation/export/transcript_dialog.py b/client/securedrop_client/gui/conversation/export/transcript_dialog.py
deleted file mode 100644
index 3318197076..0000000000
--- a/client/securedrop_client/gui/conversation/export/transcript_dialog.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""
-A dialog that allows journalists to export sensitive files to a USB drive.
-"""
-from gettext import gettext as _
-
-from PyQt5.QtCore import pyqtSlot
-
-from .device import Device
-from .file_dialog import FileDialog
-
-
-class TranscriptDialog(FileDialog):
- """Adapts the dialog used to export files to allow exporting a conversation transcript.
-
- - Adjust the init arguments to the needs of conversation transcript export.
- - Adds a method to allow a transcript to be exported.
- - Overrides the two slots that handles the export action to call said method.
- """
-
- def __init__(self, device: Device, file_name: str, transcript_location: str) -> None:
- super().__init__(device, "", file_name)
-
- self.transcript_location = transcript_location
-
- def _export_transcript(self, checked: bool = False) -> None:
- self.start_animate_activestate()
- self.cancel_button.setEnabled(False)
- self.passphrase_field.setDisabled(True)
- self._device.export_transcript(self.transcript_location, self.passphrase_field.text())
-
- @pyqtSlot()
- def _show_passphrase_request_message(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._export_transcript)
- self.header.setText(self.passphrase_header)
- self.continue_button.setText(_("SUBMIT"))
- self.header_line.hide()
- self.error_details.hide()
- self.body.hide()
- self.passphrase_field.setFocus()
- self.passphrase_form.show()
- self.adjustSize()
-
- @pyqtSlot()
- def _show_passphrase_request_message_again(self) -> None:
- self.continue_button.clicked.disconnect()
- self.continue_button.clicked.connect(self._export_transcript)
- self.header.setText(self.passphrase_header)
- self.error_details.setText(self.passphrase_error_message)
- self.continue_button.setText(_("SUBMIT"))
- self.header_line.hide()
- self.body.hide()
- self.error_details.show()
- self.passphrase_field.setFocus()
- self.passphrase_form.show()
- self.adjustSize()
diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py
index a03f5b905d..19b2f789ca 100644
--- a/client/securedrop_client/gui/widgets.py
+++ b/client/securedrop_client/gui/widgets.py
@@ -60,7 +60,7 @@
QWidget,
)
-from securedrop_client import export, state
+from securedrop_client import state
from securedrop_client.db import (
DraftReply,
File,
@@ -81,6 +81,7 @@
)
from securedrop_client.gui.base import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton
from securedrop_client.gui.conversation import DeleteConversationDialog
+from securedrop_client.gui.conversation.export import ExportWizard
from securedrop_client.gui.datetime_helpers import format_datetime_local
from securedrop_client.gui.source import DeleteSourceDialog
from securedrop_client.logic import Controller
@@ -2255,8 +2256,6 @@ def __init__(
self.controller = controller
- self._export_device = conversation.ExportDevice(controller, export.getService())
-
self.file = self.controller.get_file(file_uuid)
self.uuid = file_uuid
self.index = index
@@ -2455,13 +2454,16 @@ def _on_export_clicked(self) -> None:
"""
Called when the export button is clicked.
"""
+ file_location = self.file.location(self.controller.data_dir)
+
if not self.controller.downloaded_file_exists(self.file):
+ logger.debug("Clicked export but file not downloaded")
return
- self.export_dialog = conversation.ExportFileDialog(
- self._export_device, self.uuid, self.file.filename
- )
- self.export_dialog.show()
+ export_device = conversation.ExportDevice()
+
+ self.export_wizard = ExportWizard(export_device, self.file.filename, [file_location])
+ self.export_wizard.show()
@pyqtSlot()
def _on_print_clicked(self) -> None:
@@ -2469,9 +2471,14 @@ def _on_print_clicked(self) -> None:
Called when the print button is clicked.
"""
if not self.controller.downloaded_file_exists(self.file):
+ logger.debug("Clicked print but file not downloaded")
return
- dialog = conversation.PrintFileDialog(self._export_device, self.uuid, self.file.filename)
+ filepath = self.file.location(self.controller.data_dir)
+
+ export_device = conversation.ExportDevice()
+
+ dialog = conversation.PrintFileDialog(export_device, self.file.filename, [filepath])
dialog.exec()
def _on_left_click(self) -> None:
diff --git a/client/tests/conftest.py b/client/tests/conftest.py
index a7918bbc1a..8607ce8269 100644
--- a/client/tests/conftest.py
+++ b/client/tests/conftest.py
@@ -4,14 +4,14 @@
import tempfile
from configparser import ConfigParser
from datetime import datetime
-from typing import List
from uuid import uuid4
+from unittest import mock
import pytest
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMainWindow
-from securedrop_client import export, state
+from securedrop_client import state
from securedrop_client.app import configure_locale_and_language
from securedrop_client.config import Config
from securedrop_client.db import (
@@ -23,7 +23,7 @@
Source,
make_session_maker,
)
-from securedrop_client.export import ExportStatus
+from securedrop_client.export_status import ExportStatus
from securedrop_client.gui import conversation
from securedrop_client.gui.main import Window
from securedrop_client.logic import Controller
@@ -49,7 +49,7 @@
TIME_CLICK_ACTION = 1000
TIME_RENDER_SOURCE_LIST = 20000
TIME_RENDER_CONV_VIEW = 1000
-TIME_RENDER_EXPORT_DIALOG = 1000
+TIME_RENDER_EXPORT_WIZARD = 1000
TIME_FILE_DOWNLOAD = 5000
@@ -80,7 +80,7 @@ def print_dialog(mocker, homedir):
export_device = mocker.MagicMock(spec=conversation.ExportDevice)
- dialog = conversation.PrintFileDialog(export_device, "file_UUID", "file123.jpg")
+ dialog = conversation.PrintFileDialog(export_device, "file123.jpg", ["/mock/path/to/file"])
yield dialog
@@ -92,46 +92,46 @@ def print_transcript_dialog(mocker, homedir):
export_device = mocker.MagicMock(spec=conversation.ExportDevice)
dialog = conversation.PrintTranscriptDialog(
- export_device, "transcript.txt", "some/path/transcript.txt"
+ export_device, "transcript.txt", ["some/path/transcript.txt"]
)
yield dialog
@pytest.fixture(scope="function")
-def export_dialog(mocker, homedir):
+def export_wizard_multifile(mocker, homedir):
mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow())
export_device = mocker.MagicMock(spec=conversation.ExportDevice)
- dialog = conversation.ExportDialog(
+ wizard = conversation.ExportWizard(
export_device,
"3 files",
["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"],
)
- yield dialog
+ yield wizard
@pytest.fixture(scope="function")
-def export_file_dialog(mocker, homedir):
+def export_wizard(mocker, homedir):
mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow())
export_device = mocker.MagicMock(spec=conversation.ExportDevice)
- dialog = conversation.ExportFileDialog(export_device, "file_UUID", "file123.jpg")
+ dialog = conversation.ExportWizard(export_device, "file123.jpg", ["/mock/path/to/file"])
yield dialog
@pytest.fixture(scope="function")
-def export_transcript_dialog(mocker, homedir):
+def export_transcript_wizard(mocker, homedir):
mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow())
export_device = mocker.MagicMock(spec=conversation.ExportDevice)
- dialog = conversation.ExportTranscriptDialog(
- export_device, "transcript.txt", "/some/path/transcript.txt"
+ dialog = conversation.ExportWizard(
+ export_device, "transcript.txt", ["/some/path/transcript.txt"]
)
yield dialog
@@ -169,39 +169,108 @@ def homedir(i18n):
yield tmpdir
-class MockExportService(export.Service):
- """An export service that assumes the Qubes RPC calls are successful and skips them."""
+@pytest.fixture(scope="function")
+def mock_export_locked():
+ """
+ Represents the following scenario:
+ * Locked USB already inserted
+ * "Export" clicked, export wizard launched
+ * Passphrase successfully entered on first attempt (and export suceeeds)
+ """
+ device = conversation.ExportDevice()
+
+ device.run_export_preflight_checks = lambda: device.export_state_changed.emit(
+ ExportStatus.DEVICE_LOCKED
+ )
+ device.run_printer_preflight_checks = lambda: None
+ device.print = lambda filepaths: None
+ device.export = mock.MagicMock()
+ device.export.side_effect = [
+ lambda filepaths, passphrase: device.export_state_changed.emit(
+ ExportStatus.DEVICE_WRITABLE
+ ),
+ lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.SUCCESS_EXPORT),
+ ]
- def __init__(self, unlocked: bool):
- super().__init__()
- if unlocked:
- self.preflight_response = ExportStatus.DEVICE_WRITABLE
- else:
- self.preflight_response = ExportStatus.DEVICE_LOCKED
+ return device
- def run_preflight_checks(self) -> None:
- self.preflight_check_call_success.emit(self.preflight_response)
- def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None:
- self.export_usb_call_success.emit(ExportStatus.SUCCESS_EXPORT)
- self.export_completed.emit(filepaths)
+@pytest.fixture(scope="function")
+def mock_export_unlocked():
+ """
+ Represents the following scenario:
+ * USB already inserted and unlocked by the user
+ * Export wizard launched
+ * Export succeeds
+ """
+ device = conversation.ExportDevice()
- def run_printer_preflight(self) -> None:
- self.printer_preflight_success.emit(ExportStatus.PRINT_PREFLIGHT_SUCCESS)
+ device.run_export_preflight_checks = lambda: device.export_state_changed.emit(
+ ExportStatus.DEVICE_WRITABLE
+ )
+ device.run_printer_preflight_checks = lambda: None
+ device.print = lambda filepaths: None
+ device.export = lambda filepaths, passphrase: device.export_state_changed.emit(
+ ExportStatus.SUCCESS_EXPORT
+ )
- def print(self, filepaths: List[str]) -> None:
- self.print_call_success.emit(ExportStatus.PRINT_SUCCESS)
- self.export_completed.emit(filepaths)
+ return device
@pytest.fixture(scope="function")
-def mock_export_service():
- return MockExportService(unlocked=False)
+def mock_export_no_usb_then_bad_passphrase_then_fail():
+ """
+ Represents the following scenario:
+ * Export wizard launched
+ * Locked USB inserted
+ * Mistyped Passphrase
+ * Correct passphrase
+ * Export fails
+ """
+ device = conversation.ExportDevice()
+
+ device.run_export_preflight_checks = lambda: device.export_state_changed.emit(
+ ExportStatus.NO_DEVICE_DETECTED
+ )
+ device.run_printer_preflight_checks = lambda: None
+ device.print = lambda filepaths: None
+ device.export = mock.MagicMock()
+ device.export.side_effect = [
+ lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.DEVICE_LOCKED),
+ lambda filepaths, passphrase: device.export_state_changed.emit(
+ ExportStatus.ERROR_UNLOCK_LUKS
+ ),
+ lambda filepaths, passphrase: device.export_state_changed.emit(
+ ExportStatus.DEVICE_WRITABLE
+ ),
+ lambda filepaths, passphrase: device.export_state_changed.emit(ExportStatus.ERROR_EXPORT),
+ ]
+
+ return device
@pytest.fixture(scope="function")
-def mock_export_service_unlocked_device():
- return MockExportService(unlocked=True)
+def mock_export_fail_early():
+ """
+ Represents the following scenario:
+ * Locked USB inserted
+ * Export wizard launched
+ * Unrecoverable error before export happens
+ (eg, mount error)
+ """
+ device = conversation.ExportDevice()
+
+ device.run_export_preflight_checks = lambda: device.export_state_changed.emit(
+ ExportStatus.DEVICE_LOCKED
+ )
+ device.run_printer_preflight_checks = lambda: None
+ device.print = lambda filepaths: None
+ device.export = mock.MagicMock()
+ device.export = lambda filepaths, passphrase: device.export_state_changed.emit(
+ ExportStatus.ERROR_MOUNT
+ )
+
+ return device
@pytest.fixture(scope="function")
diff --git a/client/tests/functional/cassettes/test_export_file_dialog_locked.yaml b/client/tests/functional/cassettes/test_export_wizard_device_locked.yaml
similarity index 100%
rename from client/tests/functional/cassettes/test_export_file_dialog_locked.yaml
rename to client/tests/functional/cassettes/test_export_wizard_device_locked.yaml
diff --git a/client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml b/client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml
similarity index 100%
rename from client/tests/functional/cassettes/test_export_file_dialog_device_already_unlocked.yaml
rename to client/tests/functional/cassettes/test_export_wizard_dialog_device_already_unlocked.yaml
diff --git a/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml b/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml
new file mode 100644
index 0000000000..d59c25ebbe
--- /dev/null
+++ b/client/tests/functional/cassettes/test_export_wizard_no_device_then_fail.yaml
@@ -0,0 +1,1518 @@
+interactions:
+- request:
+ body: '{"username": "journalist", "passphrase": "correct horse battery staple
+ profanity oil chewy", "one_time_code": "123456"}'
+ headers:
+ Accept:
+ - '*/*'
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '119'
+ User-Agent:
+ - python-requests/2.31.0
+ method: POST
+ uri: http://localhost:8081/api/v1/token
+ response:
+ body:
+ string: '{"expiration":"2023-12-08T21:31:36.503560Z","journalist_first_name":null,"journalist_last_name":null,"journalist_uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9","token":"IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM"}
+
+ '
+ headers:
+ Connection:
+ - close
+ Content-Length:
+ - '265'
+ Content-Type:
+ - application/json
+ Date:
+ - Fri, 08 Dec 2023 19:31:36 GMT
+ Server:
+ - Werkzeug/2.2.3 Python/3.8.10
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate
+ Authorization:
+ - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ User-Agent:
+ - python-requests/2.31.0
+ method: GET
+ uri: http://localhost:8081/api/v1/users
+ response:
+ body:
+ string: '{"users":[{"first_name":null,"last_name":null,"username":"journalist","uuid":"c63874d0-0723-475e-8773-a5a0eeaaa4f9"},{"first_name":null,"last_name":null,"username":"dellsberg","uuid":"ac647c21-82f5-4d19-8350-6657a7d32f6b"},{"first_name":null,"last_name":null,"username":"deleted","uuid":"200a587e-b40c-48eb-b18a-0d1263f8af2e"}]}
+
+ '
+ headers:
+ Connection:
+ - close
+ Content-Length:
+ - '329'
+ Content-Type:
+ - application/json
+ Date:
+ - Fri, 08 Dec 2023 19:31:36 GMT
+ Server:
+ - Werkzeug/2.2.3 Python/3.8.10
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate
+ Authorization:
+ - Token IjkwQVJGa05CWDRQd3hwZVBWVTZfakI5Y3RxRy1JeWZNa3g2MkRGNmNlX2ci.ZXNvGA.4vDAGIjsM4zouaM3IhIBR3jzIrM
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ User-Agent:
+ - python-requests/2.31.0
+ method: GET
+ uri: http://localhost:8081/api/v1/sources
+ response:
+ body:
+ string: '{"sources":[{"add_star_url":"/api/v1/sources/1924d581-a3af-45c6-a3c9-0ec2f1205bc1/add_star","interaction_count":6,"is_flagged":false,"is_starred":false,"journalist_designation":"oriental
+ hutch","key":{"fingerprint":"DF4DC2E19F0A6A304C8C3188AEF8C5E2BD8AE199","public":"-----BEGIN
+ PGP PUBLIC KEY BLOCK-----\nComment: DF4D C2E1 9F0A 6A30 4C8C 3188 AEF8 C5E2
+ BD8A E199\nComment: Source Key "
- '3 files'
- )
- assert (
- export_dialog.body.text() == "Understand the risks before exporting files
"
- "Malware"
- "
"
- "This workstation lets you open files securely. If you open files on another "
- "computer, any embedded malware may spread to your computer or network. If you are "
- "unsure how to manage this risk, please print the file, or contact your "
- "administrator."
- "
"
- "Anonymity"
- "
"
- "Files submitted by sources may contain information or hidden metadata that "
- "identifies who they are. To protect your sources, please consider redacting files "
- "before working with them on network-connected computers."
- )
- assert not export_dialog.header.isHidden()
- assert not export_dialog.header_line.isHidden()
- assert export_dialog.error_details.isHidden()
- assert not export_dialog.body.isHidden()
- assert export_dialog.passphrase_form.isHidden()
- assert not export_dialog.continue_button.isHidden()
- assert not export_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog___show_passphrase_request_message(mocker, export_dialog):
- export_dialog._show_passphrase_request_message()
-
- assert export_dialog.header.text() == "Enter passphrase for USB drive"
- assert not export_dialog.header.isHidden()
- assert export_dialog.header_line.isHidden()
- assert export_dialog.error_details.isHidden()
- assert export_dialog.body.isHidden()
- assert not export_dialog.passphrase_form.isHidden()
- assert not export_dialog.continue_button.isHidden()
- assert not export_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_passphrase_request_message_again(mocker, export_dialog):
- export_dialog._show_passphrase_request_message_again()
-
- assert export_dialog.header.text() == "Enter passphrase for USB drive"
- assert (
- export_dialog.error_details.text()
- == "The passphrase provided did not work. Please try again."
- )
- assert export_dialog.body.isHidden()
- assert not export_dialog.header.isHidden()
- assert export_dialog.header_line.isHidden()
- assert not export_dialog.error_details.isHidden()
- assert export_dialog.body.isHidden()
- assert not export_dialog.passphrase_form.isHidden()
- assert not export_dialog.continue_button.isHidden()
- assert not export_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_success_message(mocker, export_dialog):
- export_dialog._show_success_message()
-
- assert export_dialog.header.text() == "Export successful"
- assert (
- export_dialog.body.text()
- == "Remember to be careful when working with files outside of your Workstation machine."
- )
- assert not export_dialog.header.isHidden()
- assert not export_dialog.header_line.isHidden()
- assert export_dialog.error_details.isHidden()
- assert not export_dialog.body.isHidden()
- assert export_dialog.passphrase_form.isHidden()
- assert not export_dialog.continue_button.isHidden()
- assert export_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_insert_usb_message(mocker, export_dialog):
- export_dialog._show_insert_usb_message()
-
- assert export_dialog.header.text() == "Insert encrypted USB drive"
- assert (
- export_dialog.body.text()
- == "Please insert one of the export drives provisioned specifically "
- "for the SecureDrop Workstation."
- )
- assert not export_dialog.header.isHidden()
- assert not export_dialog.header_line.isHidden()
- assert export_dialog.error_details.isHidden()
- assert not export_dialog.body.isHidden()
- assert export_dialog.passphrase_form.isHidden()
- assert not export_dialog.continue_button.isHidden()
- assert not export_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_dialog):
- export_dialog._show_insert_encrypted_usb_message()
-
- assert export_dialog.header.text() == "Insert encrypted USB drive"
- assert (
- export_dialog.error_details.text()
- == "Either the drive is not encrypted or there is something else wrong with it."
- )
- assert (
- export_dialog.body.text()
- == "Please insert one of the export drives provisioned specifically for the SecureDrop "
- "Workstation."
- )
- assert not export_dialog.header.isHidden()
- assert not export_dialog.header_line.isHidden()
- assert not export_dialog.error_details.isHidden()
- assert not export_dialog.body.isHidden()
- assert export_dialog.passphrase_form.isHidden()
- assert not export_dialog.continue_button.isHidden()
- assert not export_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_generic_error_message(mocker, export_dialog):
- export_dialog.error_status = "mock_error_status"
-
- export_dialog._show_generic_error_message()
-
- assert export_dialog.header.text() == "Export failed"
- assert export_dialog.body.text() == "mock_error_status: See your administrator for help."
- assert not export_dialog.header.isHidden()
- assert not export_dialog.header_line.isHidden()
- assert export_dialog.error_details.isHidden()
- assert not export_dialog.body.isHidden()
- assert export_dialog.passphrase_form.isHidden()
- assert not export_dialog.continue_button.isHidden()
- assert not export_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__export_files(mocker, export_dialog):
- device = mocker.MagicMock()
- device.export_file_to_usb_drive = mocker.MagicMock()
- export_dialog._device = device
- export_dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase")
-
- export_dialog._export_files()
-
- device.export_files.assert_called_once_with(
- ["/some/path/file123.jpg", "/some/path/memo.txt", "/some/path/transcript.txt"],
- "mock_passphrase",
- )
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_dialog):
- export_dialog._show_passphrase_request_message = mocker.MagicMock()
- export_dialog.continue_button = mocker.MagicMock()
- export_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False)
-
- export_dialog._on_export_preflight_check_succeeded(ExportStatus.PRINT_PREFLIGHT_SUCCESS)
-
- export_dialog._show_passphrase_request_message.assert_not_called()
- export_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_dialog._show_passphrase_request_message
- )
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled(
- mocker, export_dialog
-):
- export_dialog._show_passphrase_request_message = mocker.MagicMock()
- export_dialog.continue_button.setEnabled(True)
-
- export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
-
- export_dialog._show_passphrase_request_message.assert_called_once_with()
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_continue_enabled_and_device_unlocked(
- mocker, export_dialog
-):
- export_dialog._export_file = mocker.MagicMock()
- export_dialog.continue_button.setEnabled(True)
-
- export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE)
-
- export_dialog._export_file.assert_called_once_with()
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success(
- mocker, export_dialog
-):
- assert not export_dialog.continue_button.isEnabled()
- export_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
- assert export_dialog.continue_button.isEnabled()
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure(
- mocker, export_dialog
-):
- assert not export_dialog.continue_button.isEnabled()
- export_dialog._on_export_preflight_check_failed(mocker.MagicMock())
- assert export_dialog.continue_button.isEnabled()
-
-
-def test_ExportDialog__on_export_preflight_check_failed(mocker, export_dialog):
- export_dialog._update_dialog = mocker.MagicMock()
-
- error = ExportError("mock_error_status")
- export_dialog._on_export_preflight_check_failed(error)
-
- export_dialog._update_dialog.assert_called_with("mock_error_status")
-
-
-def test_ExportDialog__on_export_succeeded(mocker, export_dialog):
- export_dialog._show_success_message = mocker.MagicMock()
-
- export_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT)
-
- export_dialog._show_success_message.assert_called_once_with()
-
-
-def test_ExportDialog__on_export_failed(mocker, export_dialog):
- export_dialog._update_dialog = mocker.MagicMock()
-
- error = ExportError("mock_error_status")
- export_dialog._on_export_failed(error)
-
- export_dialog._update_dialog.assert_called_with("mock_error_status")
-
-
-def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker, export_dialog):
- export_dialog._show_insert_usb_message = mocker.MagicMock()
- export_dialog.continue_button = mocker.MagicMock()
- export_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED)
- export_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_dialog._show_insert_usb_message
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True)
- export_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED)
- export_dialog._show_insert_usb_message.assert_called_once_with()
-
-
-def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_dialog):
- export_dialog._show_passphrase_request_message_again = mocker.MagicMock()
- export_dialog.continue_button = mocker.MagicMock()
- export_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS)
- export_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_dialog._show_passphrase_request_message_again
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True)
- export_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS) # fka BAD_PASSPHRASE
- export_dialog._show_passphrase_request_message_again.assert_called_once_with()
-
-
-def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(
- mocker, export_dialog
-):
- export_dialog._show_insert_encrypted_usb_message = mocker.MagicMock()
- export_dialog.continue_button = mocker.MagicMock()
- export_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_dialog._update_dialog(
- ExportStatus.INVALID_DEVICE_DETECTED
- ) # DISK_ENCRYPTION_NOT_SUPPORTED_ERROR
- export_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_dialog._show_insert_encrypted_usb_message
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True)
- export_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED)
- export_dialog._show_insert_encrypted_usb_message.assert_called_once_with()
-
-
-def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(mocker, export_dialog):
- export_dialog._show_generic_error_message = mocker.MagicMock()
- export_dialog.continue_button = mocker.MagicMock()
- export_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR)
- export_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_dialog._show_generic_error_message
- )
- assert export_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True)
- export_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR)
- export_dialog._show_generic_error_message.assert_called_once_with()
- assert export_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR
-
-
-def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_dialog):
- export_dialog._show_generic_error_message = mocker.MagicMock()
- export_dialog.continue_button = mocker.MagicMock()
- export_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_dialog._update_dialog("Some Unknown Error Status")
- export_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_dialog._show_generic_error_message
- )
- assert export_dialog.error_status == "Some Unknown Error Status"
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_dialog.continue_button, "isEnabled", return_value=True)
- export_dialog._update_dialog("Some Unknown Error Status")
- export_dialog._show_generic_error_message.assert_called_once_with()
- assert export_dialog.error_status == "Some Unknown Error Status"
diff --git a/client/tests/gui/conversation/export/test_export_wizard.py b/client/tests/gui/conversation/export/test_export_wizard.py
new file mode 100644
index 0000000000..24124e72d7
--- /dev/null
+++ b/client/tests/gui/conversation/export/test_export_wizard.py
@@ -0,0 +1,154 @@
+from unittest import mock
+
+from securedrop_client.export_status import ExportStatus
+from securedrop_client.gui.conversation.export import Export, ExportWizard
+from securedrop_client.gui.conversation.export.export_wizard_constants import STATUS_MESSAGES, Pages
+from securedrop_client.gui.conversation.export.export_wizard_page import (
+ ErrorPage,
+ FinalPage,
+ InsertUSBPage,
+ PassphraseWizardPage,
+ PreflightPage,
+)
+from tests import factory
+
+
+class TestExportWizard:
+ @classmethod
+ def _mock_export_preflight_success(cls) -> Export:
+ export = Export()
+ export.run_export_preflight_checks = lambda: export.export_state_changed.emit(
+ ExportStatus.DEVICE_LOCKED
+ )
+ export.export = (
+ mock.MagicMock()
+ ) # We will choose different signals and emit them during testing
+ return export
+
+ @classmethod
+ def setup_class(cls):
+ cls.mock_controller = mock.MagicMock()
+ cls.mock_controller.data_dir = "/pretend/data-dir/"
+ cls.mock_source = factory.Source()
+ cls.mock_export = cls._mock_export_preflight_success()
+ cls.mock_file = factory.File(source=cls.mock_source)
+ cls.filepath = cls.mock_file.location(cls.mock_controller.data_dir)
+
+ @classmethod
+ def setup_method(cls):
+ cls.wizard = ExportWizard(cls.mock_export, cls.mock_file.filename, [cls.filepath])
+
+ @classmethod
+ def teardown_method(cls):
+ cls.wizard.destroy()
+ cls.wizard = None
+
+ def test_wizard_setup(self, qtbot):
+ self.wizard.show()
+ qtbot.addWidget(self.wizard)
+
+ assert len(self.wizard.pageIds()) == len(Pages._member_names_), self.wizard.pageIds()
+ assert isinstance(self.wizard.currentPage(), PreflightPage)
+
+ def test_wizard_skips_insert_page_when_device_found_preflight(self, qtbot):
+ self.wizard.show()
+ qtbot.addWidget(self.wizard)
+
+ self.wizard.next()
+
+ assert isinstance(self.wizard.currentPage(), PassphraseWizardPage)
+
+ def test_wizard_exports_directly_to_unlocked_device(self, qtbot):
+ self.wizard.show()
+ qtbot.addWidget(self.wizard)
+
+ # Simulate an unlocked device
+ self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_WRITABLE)
+ self.wizard.next()
+
+ assert isinstance(
+ self.wizard.currentPage(), FinalPage
+ ), f"Actually, f{type(self.wizard.currentPage())}"
+
+ def test_wizard_rewinds_if_device_removed(self, qtbot):
+ self.wizard.show()
+ qtbot.addWidget(self.wizard)
+
+ self.wizard.next()
+ assert isinstance(self.wizard.currentPage(), PassphraseWizardPage)
+
+ self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED)
+ self.wizard.next()
+ assert isinstance(self.wizard.currentPage(), InsertUSBPage)
+
+ def test_wizard_all_steps(self, qtbot):
+ self.wizard.show()
+ qtbot.addWidget(self.wizard)
+
+ self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED)
+ self.wizard.next()
+ assert isinstance(self.wizard.currentPage(), InsertUSBPage)
+
+ self.mock_export.export_state_changed.emit(ExportStatus.MULTI_DEVICE_DETECTED)
+ self.wizard.next()
+ assert isinstance(self.wizard.currentPage(), InsertUSBPage)
+ assert self.wizard.currentPage().error_details.isVisible()
+
+ self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_LOCKED)
+ self.wizard.next()
+ page = self.wizard.currentPage()
+ assert isinstance(page, PassphraseWizardPage)
+
+ # No password entered, we shouldn't be able to advance
+ self.wizard.next()
+ assert isinstance(page, PassphraseWizardPage)
+
+ # Type a passphrase. According to pytest-qt's own documentation, using
+ # qtbot.keyClicks and other interactions can lead to flaky tests,
+ # so using the setText method is fine, esp for unit testing.
+ page.passphrase_field.setText("correct horse battery staple!")
+
+ # How dare you try a commonly-used password like that
+ self.mock_export.export_state_changed.emit(ExportStatus.ERROR_UNLOCK_LUKS)
+
+ assert isinstance(page, PassphraseWizardPage)
+ assert page.error_details.isVisible()
+
+ self.wizard.next()
+
+ # Ok
+ page.passphrase_field.setText("substantial improvements encrypt accordingly")
+ self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_WRITABLE)
+
+ self.wizard.next()
+ self.mock_export.export_state_changed.emit(ExportStatus.ERROR_EXPORT_CLEANUP)
+
+ page = self.wizard.currentPage()
+ assert isinstance(page, FinalPage)
+ assert page.body.text() == STATUS_MESSAGES.get(ExportStatus.ERROR_EXPORT_CLEANUP)
+
+ def test_wizard_hides_error_details_on_success(self, qtbot):
+ self.wizard.show()
+ qtbot.addWidget(self.wizard)
+
+ self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED)
+ self.wizard.next()
+ assert isinstance(self.wizard.currentPage(), InsertUSBPage)
+ assert self.wizard.currentPage().error_details.isVisible()
+
+ self.mock_export.export_state_changed.emit(ExportStatus.DEVICE_LOCKED)
+ self.wizard.next()
+ self.wizard.back()
+ assert not self.wizard.currentPage().error_details.isVisible()
+
+ def test_wizard_only_shows_error_page_on_unrecoverable_error(self, qtbot):
+ self.wizard.show()
+ qtbot.addWidget(self.wizard)
+
+ self.mock_export.export_state_changed.emit(ExportStatus.NO_DEVICE_DETECTED)
+ self.wizard.next()
+ assert isinstance(self.wizard.currentPage(), InsertUSBPage)
+
+ self.mock_export.export_state_changed.emit(ExportStatus.UNEXPECTED_RETURN_STATUS)
+ self.wizard.next()
+ assert isinstance(self.wizard.currentPage(), ErrorPage)
diff --git a/client/tests/gui/conversation/export/test_file_dialog.py b/client/tests/gui/conversation/export/test_file_dialog.py
deleted file mode 100644
index e0b6101550..0000000000
--- a/client/tests/gui/conversation/export/test_file_dialog.py
+++ /dev/null
@@ -1,368 +0,0 @@
-from securedrop_client.export import ExportError, ExportStatus
-from securedrop_client.gui.conversation import ExportFileDialog
-from tests.helper import app # noqa: F401
-
-
-def test_ExportDialog_init(mocker):
- _show_starting_instructions_fn = mocker.patch(
- "securedrop_client.gui.conversation.ExportFileDialog._show_starting_instructions"
- )
-
- export_file_dialog = ExportFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg")
-
- _show_starting_instructions_fn.assert_called_once_with()
- assert export_file_dialog.passphrase_form.isHidden()
-
-
-def test_ExportDialog_init_sanitizes_filename(mocker):
- secure_qlabel = mocker.patch(
- "securedrop_client.gui.conversation.export.file_dialog.SecureQLabel"
- )
- mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget")
- filename = ''
-
- ExportFileDialog(mocker.MagicMock(), "mock_uuid", filename)
-
- secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260)
-
-
-def test_ExportDialog__show_starting_instructions(mocker, export_file_dialog):
- export_file_dialog._show_starting_instructions()
-
- # file123.jpg comes from the export_file_dialog fixture
- assert (
- export_file_dialog.header.text() == "Preparing to export:"
- "
"
- 'file123.jpg'
- )
- assert (
- export_file_dialog.body.text() == "Understand the risks before exporting files
"
- "Malware"
- "
"
- "This workstation lets you open files securely. If you open files on another "
- "computer, any embedded malware may spread to your computer or network. If you are "
- "unsure how to manage this risk, please print the file, or contact your "
- "administrator."
- "
"
- "Anonymity"
- "
"
- "Files submitted by sources may contain information or hidden metadata that "
- "identifies who they are. To protect your sources, please consider redacting files "
- "before working with them on network-connected computers."
- )
- assert not export_file_dialog.header.isHidden()
- assert not export_file_dialog.header_line.isHidden()
- assert export_file_dialog.error_details.isHidden()
- assert not export_file_dialog.body.isHidden()
- assert export_file_dialog.passphrase_form.isHidden()
- assert not export_file_dialog.continue_button.isHidden()
- assert not export_file_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog___show_passphrase_request_message(mocker, export_file_dialog):
- export_file_dialog._show_passphrase_request_message()
-
- assert export_file_dialog.header.text() == "Enter passphrase for USB drive"
- assert not export_file_dialog.header.isHidden()
- assert export_file_dialog.header_line.isHidden()
- assert export_file_dialog.error_details.isHidden()
- assert export_file_dialog.body.isHidden()
- assert not export_file_dialog.passphrase_form.isHidden()
- assert not export_file_dialog.continue_button.isHidden()
- assert not export_file_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_passphrase_request_message_again(mocker, export_file_dialog):
- export_file_dialog._show_passphrase_request_message_again()
-
- assert export_file_dialog.header.text() == "Enter passphrase for USB drive"
- assert (
- export_file_dialog.error_details.text()
- == "The passphrase provided did not work. Please try again."
- )
- assert export_file_dialog.body.isHidden()
- assert not export_file_dialog.header.isHidden()
- assert export_file_dialog.header_line.isHidden()
- assert not export_file_dialog.error_details.isHidden()
- assert export_file_dialog.body.isHidden()
- assert not export_file_dialog.passphrase_form.isHidden()
- assert not export_file_dialog.continue_button.isHidden()
- assert not export_file_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_success_message(mocker, export_file_dialog):
- export_file_dialog._show_success_message()
-
- assert export_file_dialog.header.text() == "Export successful"
- assert (
- export_file_dialog.body.text()
- == "Remember to be careful when working with files outside of your Workstation machine."
- )
- assert not export_file_dialog.header.isHidden()
- assert not export_file_dialog.header_line.isHidden()
- assert export_file_dialog.error_details.isHidden()
- assert not export_file_dialog.body.isHidden()
- assert export_file_dialog.passphrase_form.isHidden()
- assert not export_file_dialog.continue_button.isHidden()
- assert export_file_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_insert_usb_message(mocker, export_file_dialog):
- export_file_dialog._show_insert_usb_message()
-
- assert export_file_dialog.header.text() == "Insert encrypted USB drive"
- assert (
- export_file_dialog.body.text()
- == "Please insert one of the export drives provisioned specifically "
- "for the SecureDrop Workstation."
- )
- assert not export_file_dialog.header.isHidden()
- assert not export_file_dialog.header_line.isHidden()
- assert export_file_dialog.error_details.isHidden()
- assert not export_file_dialog.body.isHidden()
- assert export_file_dialog.passphrase_form.isHidden()
- assert not export_file_dialog.continue_button.isHidden()
- assert not export_file_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_insert_encrypted_usb_message(mocker, export_file_dialog):
- export_file_dialog._show_insert_encrypted_usb_message()
-
- assert export_file_dialog.header.text() == "Insert encrypted USB drive"
- assert (
- export_file_dialog.error_details.text()
- == "Either the drive is not encrypted or there is something else wrong with it."
- )
- assert (
- export_file_dialog.body.text()
- == "Please insert one of the export drives provisioned specifically for the SecureDrop "
- "Workstation."
- )
- assert not export_file_dialog.header.isHidden()
- assert not export_file_dialog.header_line.isHidden()
- assert not export_file_dialog.error_details.isHidden()
- assert not export_file_dialog.body.isHidden()
- assert export_file_dialog.passphrase_form.isHidden()
- assert not export_file_dialog.continue_button.isHidden()
- assert not export_file_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__show_generic_error_message(mocker, export_file_dialog):
- export_file_dialog.error_status = "mock_error_status"
-
- export_file_dialog._show_generic_error_message()
-
- assert export_file_dialog.header.text() == "Export failed"
- assert export_file_dialog.body.text() == "mock_error_status: See your administrator for help."
- assert not export_file_dialog.header.isHidden()
- assert not export_file_dialog.header_line.isHidden()
- assert export_file_dialog.error_details.isHidden()
- assert not export_file_dialog.body.isHidden()
- assert export_file_dialog.passphrase_form.isHidden()
- assert not export_file_dialog.continue_button.isHidden()
- assert not export_file_dialog.cancel_button.isHidden()
-
-
-def test_ExportDialog__export_file(mocker, export_file_dialog):
- device = mocker.MagicMock()
- device.export_file_to_usb_drive = mocker.MagicMock()
- export_file_dialog._device = device
- export_file_dialog.passphrase_field.text = mocker.MagicMock(return_value="mock_passphrase")
-
- export_file_dialog._export_file()
-
- device.export_file_to_usb_drive.assert_called_once_with(
- export_file_dialog.file_uuid, "mock_passphrase"
- )
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded(mocker, export_file_dialog):
- export_file_dialog._show_passphrase_request_message = mocker.MagicMock()
- export_file_dialog.continue_button = mocker.MagicMock()
- export_file_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False)
-
- export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
-
- export_file_dialog._show_passphrase_request_message.assert_not_called()
- export_file_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_file_dialog._show_passphrase_request_message
- )
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_device_unlocked(
- mocker, export_file_dialog
-):
- export_file_dialog._export_file = mocker.MagicMock()
- export_file_dialog.continue_button = mocker.MagicMock()
- export_file_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False)
-
- export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE)
-
- export_file_dialog._export_file.assert_not_called()
- export_file_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_file_dialog._export_file
- )
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_when_continue_enabled(
- mocker, export_file_dialog
-):
- export_file_dialog._show_passphrase_request_message = mocker.MagicMock()
- export_file_dialog.continue_button.setEnabled(True)
-
- export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
-
- export_file_dialog._show_passphrase_request_message.assert_called_once_with()
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_unlocked_device_when_continue_enabled(
- mocker, export_file_dialog
-):
- export_file_dialog._export_file = mocker.MagicMock()
- export_file_dialog.continue_button.setEnabled(True)
-
- export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_WRITABLE)
-
- export_file_dialog._export_file.assert_called_once_with()
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success(
- mocker, export_file_dialog
-):
- assert not export_file_dialog.continue_button.isEnabled()
- export_file_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
- assert export_file_dialog.continue_button.isEnabled()
-
-
-def test_ExportDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure(
- mocker, export_file_dialog
-):
- assert not export_file_dialog.continue_button.isEnabled()
- export_file_dialog._on_export_preflight_check_failed(mocker.MagicMock())
- assert export_file_dialog.continue_button.isEnabled()
-
-
-def test_ExportDialog__on_export_preflight_check_failed(mocker, export_file_dialog):
- export_file_dialog._update_dialog = mocker.MagicMock()
-
- error = ExportError("mock_error_status")
- export_file_dialog._on_export_preflight_check_failed(error)
-
- export_file_dialog._update_dialog.assert_called_with("mock_error_status")
-
-
-def test_ExportDialog__on_export_succeeded(mocker, export_file_dialog):
- export_file_dialog._show_success_message = mocker.MagicMock()
-
- export_file_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT)
-
- export_file_dialog._show_success_message.assert_called_once_with()
-
-
-def test_ExportDialog__on_export_failed(mocker, export_file_dialog):
- export_file_dialog._update_dialog = mocker.MagicMock()
-
- error = ExportError("mock_error_status")
- export_file_dialog._on_export_failed(error)
-
- export_file_dialog._update_dialog.assert_called_with("mock_error_status")
-
-
-def test_ExportDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(mocker, export_file_dialog):
- export_file_dialog._show_insert_usb_message = mocker.MagicMock()
- export_file_dialog.continue_button = mocker.MagicMock()
- export_file_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_file_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED)
- export_file_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_file_dialog._show_insert_usb_message
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True)
- export_file_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED)
- export_file_dialog._show_insert_usb_message.assert_called_once_with()
-
-
-def test_ExportDialog__update_dialog_when_status_is_BAD_PASSPHRASE(mocker, export_file_dialog):
- export_file_dialog._show_passphrase_request_message_again = mocker.MagicMock()
- export_file_dialog.continue_button = mocker.MagicMock()
- export_file_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_file_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS)
- export_file_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_file_dialog._show_passphrase_request_message_again
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True)
- export_file_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS)
- export_file_dialog._show_passphrase_request_message_again.assert_called_once_with()
-
-
-def test_ExportDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(
- mocker, export_file_dialog
-):
- export_file_dialog._show_insert_encrypted_usb_message = mocker.MagicMock()
- export_file_dialog.continue_button = mocker.MagicMock()
- export_file_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_file_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED)
- export_file_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_file_dialog._show_insert_encrypted_usb_message
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True)
- export_file_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED)
- export_file_dialog._show_insert_encrypted_usb_message.assert_called_once_with()
-
-
-def test_ExportDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(
- mocker, export_file_dialog
-):
- export_file_dialog._show_generic_error_message = mocker.MagicMock()
- export_file_dialog.continue_button = mocker.MagicMock()
- export_file_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_file_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR)
- export_file_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_file_dialog._show_generic_error_message
- )
- assert export_file_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True)
- export_file_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR)
- export_file_dialog._show_generic_error_message.assert_called_once_with()
- assert export_file_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR
-
-
-def test_ExportDialog__update_dialog_when_status_is_unknown(mocker, export_file_dialog):
- export_file_dialog._show_generic_error_message = mocker.MagicMock()
- export_file_dialog.continue_button = mocker.MagicMock()
- export_file_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_file_dialog._update_dialog("Some Unknown Error Status")
- export_file_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_file_dialog._show_generic_error_message
- )
- assert export_file_dialog.error_status == "Some Unknown Error Status"
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_file_dialog.continue_button, "isEnabled", return_value=True)
- export_file_dialog._update_dialog("Some Unknown Error Status")
- export_file_dialog._show_generic_error_message.assert_called_once_with()
- assert export_file_dialog.error_status == "Some Unknown Error Status"
diff --git a/client/tests/gui/conversation/export/test_print_dialog.py b/client/tests/gui/conversation/export/test_print_dialog.py
index d21765fdbc..0bd4836f8e 100644
--- a/client/tests/gui/conversation/export/test_print_dialog.py
+++ b/client/tests/gui/conversation/export/test_print_dialog.py
@@ -1,4 +1,4 @@
-from securedrop_client.export import ExportError, ExportStatus
+from securedrop_client.export_status import ExportError, ExportStatus
from securedrop_client.gui.conversation import PrintFileDialog
from tests.helper import app # noqa: F401
@@ -8,7 +8,7 @@ def test_PrintFileDialog_init(mocker):
"securedrop_client.gui.conversation.PrintFileDialog._show_starting_instructions"
)
- PrintFileDialog(mocker.MagicMock(), "mock_uuid", "mock.jpg")
+ PrintFileDialog(mocker.MagicMock(), "mock.jpg", ["/mock/path/to/file"])
_show_starting_instructions_fn.assert_called_once_with()
@@ -19,7 +19,7 @@ def test_PrintFileDialog_init_sanitizes_filename(mocker):
)
filename = ''
- PrintFileDialog(mocker.MagicMock(), "mock_uuid", filename)
+ PrintFileDialog(mocker.MagicMock(), filename, ["/mock/path/to/file"])
secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260)
diff --git a/client/tests/gui/conversation/export/test_print_transcript_dialog.py b/client/tests/gui/conversation/export/test_print_transcript_dialog.py
index a59a8e4410..af86797842 100644
--- a/client/tests/gui/conversation/export/test_print_transcript_dialog.py
+++ b/client/tests/gui/conversation/export/test_print_transcript_dialog.py
@@ -1,4 +1,4 @@
-from securedrop_client.export import ExportError, ExportStatus
+from securedrop_client.export_status import ExportError, ExportStatus
from securedrop_client.gui.conversation import PrintTranscriptDialog
from tests.helper import app # noqa: F401
diff --git a/client/tests/gui/conversation/export/test_transcript_dialog.py b/client/tests/gui/conversation/export/test_transcript_dialog.py
deleted file mode 100644
index f0abfa859d..0000000000
--- a/client/tests/gui/conversation/export/test_transcript_dialog.py
+++ /dev/null
@@ -1,351 +0,0 @@
-from securedrop_client.export import ExportError, ExportStatus
-from securedrop_client.gui.conversation import ExportTranscriptDialog
-from tests.helper import app # noqa: F401
-
-
-def test_TranscriptDialog_init(mocker):
- _show_starting_instructions_fn = mocker.patch(
- "securedrop_client.gui.conversation.ExportTranscriptDialog._show_starting_instructions"
- )
-
- export_transcript_dialog = ExportTranscriptDialog(
- mocker.MagicMock(), "transcript.txt", "/some/path/transcript.txt"
- )
-
- _show_starting_instructions_fn.assert_called_once_with()
- assert export_transcript_dialog.passphrase_form.isHidden()
-
-
-def test_TranscriptDialog_init_sanitizes_filename(mocker):
- secure_qlabel = mocker.patch(
- "securedrop_client.gui.conversation.export.file_dialog.SecureQLabel"
- )
- mocker.patch("securedrop_client.gui.widgets.QVBoxLayout.addWidget")
- filename = ''
-
- ExportTranscriptDialog(mocker.MagicMock(), filename, "/some/path/transcript.txt")
-
- secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260)
-
-
-def test_TranscriptDialog__show_starting_instructions(mocker, export_transcript_dialog):
- export_transcript_dialog._show_starting_instructions()
-
- # transcript.txt comes from the export_transcript_dialog fixture
- assert (
- export_transcript_dialog.header.text() == "Preparing to export:"
- "
"
- 'transcript.txt'
- )
- assert (
- export_transcript_dialog.body.text()
- == "Understand the risks before exporting files
"
- "Malware"
- "
"
- "This workstation lets you open files securely. If you open files on another "
- "computer, any embedded malware may spread to your computer or network. If you are "
- "unsure how to manage this risk, please print the file, or contact your "
- "administrator."
- "
"
- "Anonymity"
- "
"
- "Files submitted by sources may contain information or hidden metadata that "
- "identifies who they are. To protect your sources, please consider redacting files "
- "before working with them on network-connected computers."
- )
- assert not export_transcript_dialog.header.isHidden()
- assert not export_transcript_dialog.header_line.isHidden()
- assert export_transcript_dialog.error_details.isHidden()
- assert not export_transcript_dialog.body.isHidden()
- assert export_transcript_dialog.passphrase_form.isHidden()
- assert not export_transcript_dialog.continue_button.isHidden()
- assert not export_transcript_dialog.cancel_button.isHidden()
-
-
-def test_TranscriptDialog___show_passphrase_request_message(mocker, export_transcript_dialog):
- export_transcript_dialog._show_passphrase_request_message()
-
- assert export_transcript_dialog.header.text() == "Enter passphrase for USB drive"
- assert not export_transcript_dialog.header.isHidden()
- assert export_transcript_dialog.header_line.isHidden()
- assert export_transcript_dialog.error_details.isHidden()
- assert export_transcript_dialog.body.isHidden()
- assert not export_transcript_dialog.passphrase_form.isHidden()
- assert not export_transcript_dialog.continue_button.isHidden()
- assert not export_transcript_dialog.cancel_button.isHidden()
-
-
-def test_TranscriptDialog__show_passphrase_request_message_again(mocker, export_transcript_dialog):
- export_transcript_dialog._show_passphrase_request_message_again()
-
- assert export_transcript_dialog.header.text() == "Enter passphrase for USB drive"
- assert (
- export_transcript_dialog.error_details.text()
- == "The passphrase provided did not work. Please try again."
- )
- assert export_transcript_dialog.body.isHidden()
- assert not export_transcript_dialog.header.isHidden()
- assert export_transcript_dialog.header_line.isHidden()
- assert not export_transcript_dialog.error_details.isHidden()
- assert export_transcript_dialog.body.isHidden()
- assert not export_transcript_dialog.passphrase_form.isHidden()
- assert not export_transcript_dialog.continue_button.isHidden()
- assert not export_transcript_dialog.cancel_button.isHidden()
-
-
-def test_TranscriptDialog__show_success_message(mocker, export_transcript_dialog):
- export_transcript_dialog._show_success_message()
-
- assert export_transcript_dialog.header.text() == "Export successful"
- assert (
- export_transcript_dialog.body.text()
- == "Remember to be careful when working with files outside of your Workstation machine."
- )
- assert not export_transcript_dialog.header.isHidden()
- assert not export_transcript_dialog.header_line.isHidden()
- assert export_transcript_dialog.error_details.isHidden()
- assert not export_transcript_dialog.body.isHidden()
- assert export_transcript_dialog.passphrase_form.isHidden()
- assert not export_transcript_dialog.continue_button.isHidden()
- assert export_transcript_dialog.cancel_button.isHidden()
-
-
-def test_TranscriptDialog__show_insert_usb_message(mocker, export_transcript_dialog):
- export_transcript_dialog._show_insert_usb_message()
-
- assert export_transcript_dialog.header.text() == "Insert encrypted USB drive"
- assert (
- export_transcript_dialog.body.text()
- == "Please insert one of the export drives provisioned specifically "
- "for the SecureDrop Workstation."
- )
- assert not export_transcript_dialog.header.isHidden()
- assert not export_transcript_dialog.header_line.isHidden()
- assert export_transcript_dialog.error_details.isHidden()
- assert not export_transcript_dialog.body.isHidden()
- assert export_transcript_dialog.passphrase_form.isHidden()
- assert not export_transcript_dialog.continue_button.isHidden()
- assert not export_transcript_dialog.cancel_button.isHidden()
-
-
-def test_TranscriptDialog__show_insert_encrypted_usb_message(mocker, export_transcript_dialog):
- export_transcript_dialog._show_insert_encrypted_usb_message()
-
- assert export_transcript_dialog.header.text() == "Insert encrypted USB drive"
- assert (
- export_transcript_dialog.error_details.text()
- == "Either the drive is not encrypted or there is something else wrong with it."
- )
- assert (
- export_transcript_dialog.body.text()
- == "Please insert one of the export drives provisioned specifically for the SecureDrop "
- "Workstation."
- )
- assert not export_transcript_dialog.header.isHidden()
- assert not export_transcript_dialog.header_line.isHidden()
- assert not export_transcript_dialog.error_details.isHidden()
- assert not export_transcript_dialog.body.isHidden()
- assert export_transcript_dialog.passphrase_form.isHidden()
- assert not export_transcript_dialog.continue_button.isHidden()
- assert not export_transcript_dialog.cancel_button.isHidden()
-
-
-def test_TranscriptDialog__show_generic_error_message(mocker, export_transcript_dialog):
- export_transcript_dialog.error_status = "mock_error_status"
-
- export_transcript_dialog._show_generic_error_message()
-
- assert export_transcript_dialog.header.text() == "Export failed"
- assert (
- export_transcript_dialog.body.text()
- == "mock_error_status: See your administrator for help."
- )
- assert not export_transcript_dialog.header.isHidden()
- assert not export_transcript_dialog.header_line.isHidden()
- assert export_transcript_dialog.error_details.isHidden()
- assert not export_transcript_dialog.body.isHidden()
- assert export_transcript_dialog.passphrase_form.isHidden()
- assert not export_transcript_dialog.continue_button.isHidden()
- assert not export_transcript_dialog.cancel_button.isHidden()
-
-
-def test_TranscriptDialog__export_transcript(mocker, export_transcript_dialog):
- device = mocker.MagicMock()
- device.export_transcript = mocker.MagicMock()
- export_transcript_dialog._device = device
- export_transcript_dialog.passphrase_field.text = mocker.MagicMock(
- return_value="mock_passphrase"
- )
-
- export_transcript_dialog._export_transcript()
-
- device.export_transcript.assert_called_once_with("/some/path/transcript.txt", "mock_passphrase")
-
-
-def test_TranscriptDialog__on_export_preflight_check_succeeded(mocker, export_transcript_dialog):
- export_transcript_dialog._show_passphrase_request_message = mocker.MagicMock()
- export_transcript_dialog.continue_button = mocker.MagicMock()
- export_transcript_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False)
-
- export_transcript_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
-
- export_transcript_dialog._show_passphrase_request_message.assert_not_called()
- export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_transcript_dialog._show_passphrase_request_message
- )
-
-
-def test_TranscriptDialog__on_export_preflight_check_succeeded_when_continue_enabled(
- mocker, export_transcript_dialog
-):
- export_transcript_dialog._show_passphrase_request_message = mocker.MagicMock()
- export_transcript_dialog.continue_button.setEnabled(True)
-
- export_transcript_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
-
- export_transcript_dialog._show_passphrase_request_message.assert_called_once_with()
-
-
-def test_TranscriptDialog__on_export_preflight_check_succeeded_enabled_after_preflight_success(
- mocker, export_transcript_dialog
-):
- assert not export_transcript_dialog.continue_button.isEnabled()
- export_transcript_dialog._on_export_preflight_check_succeeded(ExportStatus.DEVICE_LOCKED)
- assert export_transcript_dialog.continue_button.isEnabled()
-
-
-def test_TranscriptDialog__on_export_preflight_check_succeeded_enabled_after_preflight_failure(
- mocker, export_transcript_dialog
-):
- assert not export_transcript_dialog.continue_button.isEnabled()
- export_transcript_dialog._on_export_preflight_check_failed(mocker.MagicMock())
- assert export_transcript_dialog.continue_button.isEnabled()
-
-
-def test_TranscriptDialog__on_export_preflight_check_failed(mocker, export_transcript_dialog):
- export_transcript_dialog._update_dialog = mocker.MagicMock()
-
- error = ExportError("mock_error_status")
- export_transcript_dialog._on_export_preflight_check_failed(error)
-
- export_transcript_dialog._update_dialog.assert_called_with("mock_error_status")
-
-
-def test_TranscriptDialog__on_export_succeeded(mocker, export_transcript_dialog):
- export_transcript_dialog._show_success_message = mocker.MagicMock()
-
- export_transcript_dialog._on_export_succeeded(ExportStatus.SUCCESS_EXPORT)
-
- export_transcript_dialog._show_success_message.assert_called_once_with()
-
-
-def test_TranscriptDialog__on_export_failed(mocker, export_transcript_dialog):
- export_transcript_dialog._update_dialog = mocker.MagicMock()
-
- error = ExportError("mock_error_status")
- export_transcript_dialog._on_export_failed(error)
-
- export_transcript_dialog._update_dialog.assert_called_with("mock_error_status")
-
-
-def test_TranscriptDialog__update_dialog_when_status_is_USB_NOT_CONNECTED(
- mocker, export_transcript_dialog
-):
- export_transcript_dialog._show_insert_usb_message = mocker.MagicMock()
- export_transcript_dialog.continue_button = mocker.MagicMock()
- export_transcript_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_transcript_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED)
- export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_transcript_dialog._show_insert_usb_message
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True)
- export_transcript_dialog._update_dialog(ExportStatus.NO_DEVICE_DETECTED)
- export_transcript_dialog._show_insert_usb_message.assert_called_once_with()
-
-
-def test_TranscriptDialog__update_dialog_when_status_is_BAD_PASSPHRASE(
- mocker, export_transcript_dialog
-):
- export_transcript_dialog._show_passphrase_request_message_again = mocker.MagicMock()
- export_transcript_dialog.continue_button = mocker.MagicMock()
- export_transcript_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_transcript_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS)
- export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_transcript_dialog._show_passphrase_request_message_again
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True)
- export_transcript_dialog._update_dialog(ExportStatus.ERROR_UNLOCK_LUKS)
- export_transcript_dialog._show_passphrase_request_message_again.assert_called_once_with()
-
-
-def test_TranscriptDialog__update_dialog_when_status_DISK_ENCRYPTION_NOT_SUPPORTED_ERROR(
- mocker, export_transcript_dialog
-):
- export_transcript_dialog._show_insert_encrypted_usb_message = mocker.MagicMock()
- export_transcript_dialog.continue_button = mocker.MagicMock()
- export_transcript_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_transcript_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED)
- export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_transcript_dialog._show_insert_encrypted_usb_message
- )
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True)
- export_transcript_dialog._update_dialog(ExportStatus.INVALID_DEVICE_DETECTED)
- export_transcript_dialog._show_insert_encrypted_usb_message.assert_called_once_with()
-
-
-def test_TranscriptDialog__update_dialog_when_status_is_CALLED_PROCESS_ERROR(
- mocker, export_transcript_dialog
-):
- export_transcript_dialog._show_generic_error_message = mocker.MagicMock()
- export_transcript_dialog.continue_button = mocker.MagicMock()
- export_transcript_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_transcript_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR)
- export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_transcript_dialog._show_generic_error_message
- )
- assert export_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True)
- export_transcript_dialog._update_dialog(ExportStatus.CALLED_PROCESS_ERROR)
- export_transcript_dialog._show_generic_error_message.assert_called_once_with()
- assert export_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR
-
-
-def test_TranscriptDialog__update_dialog_when_status_is_unknown(mocker, export_transcript_dialog):
- export_transcript_dialog._show_generic_error_message = mocker.MagicMock()
- export_transcript_dialog.continue_button = mocker.MagicMock()
- export_transcript_dialog.continue_button.clicked = mocker.MagicMock()
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=False)
-
- # When the continue button is enabled, ensure clicking continue will show next instructions
- export_transcript_dialog._update_dialog("Some Unknown Error Status")
- export_transcript_dialog.continue_button.clicked.connect.assert_called_once_with(
- export_transcript_dialog._show_generic_error_message
- )
- assert export_transcript_dialog.error_status == "Some Unknown Error Status"
-
- # When the continue button is enabled, ensure next instructions are shown
- mocker.patch.object(export_transcript_dialog.continue_button, "isEnabled", return_value=True)
- export_transcript_dialog._update_dialog("Some Unknown Error Status")
- export_transcript_dialog._show_generic_error_message.assert_called_once_with()
- assert export_transcript_dialog.error_status == "Some Unknown Error Status"
diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py
index 8f3bde2cb0..82f4a9f387 100644
--- a/client/tests/gui/test_actions.py
+++ b/client/tests/gui/test_actions.py
@@ -275,12 +275,13 @@ def test_trigger(self, _):
return_value="☠ A string with unicode characters."
)
- action._export_device.run_printer_preflight_checks = (
- lambda: action._export_device.print_preflight_check_succeeded.emit()
- )
- action._export_device.print_transcript = (
- lambda transcript: action._export_device.print_succeeded.emit()
- )
+ # TODO: these are now accessible through the Device or the Dialog.
+ # action._export_device.run_printer_preflight_checks = (
+ # lambda: action._export_device.print_preflight_check_succeeded.emit()
+ # )
+ # action._export_device.print = (
+ # lambda transcript: action._export_device.print_succeeded.emit()
+ # )
action.trigger()
@@ -288,7 +289,7 @@ def test_trigger(self, _):
class TestExportConversationTranscriptAction(unittest.TestCase):
- @patch("securedrop_client.gui.actions.ExportConversationTranscriptDialog")
+ @patch("securedrop_client.gui.actions.ExportWizard")
def test_trigger(self, _):
with managed_locale():
locale.setlocale(locale.LC_ALL, ("en_US", "latin-1"))
@@ -303,12 +304,13 @@ def test_trigger(self, _):
return_value="☠ A string with unicode characters."
)
- action._export_device.run_printer_preflight_checks = (
- lambda: action._export_device.print_preflight_check_succeeded.emit()
- )
- action._export_device.print_transcript = (
- lambda transcript: action._export_device.print_succeeded.emit()
- )
+ # TODO: these are now accessible through the Device or the Dialog.
+ # action._export_device.run_printer_preflight_checks = (
+ # lambda: action._export_device.print_preflight_check_succeeded.emit()
+ # )
+ # action._export_device.print_transcript = (
+ # lambda transcript: action._export_device.print_succeeded.emit()
+ # )
action.trigger()
@@ -316,7 +318,7 @@ def test_trigger(self, _):
class TestExportConversationAction(unittest.TestCase):
- @patch("securedrop_client.gui.actions.ExportConversationDialog")
+ @patch("securedrop_client.gui.actions.ExportWizard")
def test_trigger(self, _):
with managed_locale():
locale.setlocale(locale.LC_ALL, ("en_US", "latin-1"))
@@ -336,12 +338,13 @@ def test_trigger(self, _):
return_value="☠ A string with unicode characters."
)
- action._export_device.run_printer_preflight_checks = (
- lambda: action._export_device.print_preflight_check_succeeded.emit()
- )
- action._export_device.print_transcript = (
- lambda transcript: action._export_device.print_succeeded.emit()
- )
+ # TODO: preflight checks now belong to Device
+ # action._export_device.run_printer_preflight_checks = (
+ # lambda: action._export_device.print_preflight_check_succeeded.emit()
+ # )
+ # action._export_device.print = (
+ # lambda transcript: action._export_device.print_succeeded.emit()
+ # )
action.trigger()
diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py
index 11b4234bce..0c3d49b8cc 100644
--- a/client/tests/gui/test_widgets.py
+++ b/client/tests/gui/test_widgets.py
@@ -3587,6 +3587,9 @@ def test_FileWidget__on_export_clicked(mocker, session, source):
get_file = mocker.MagicMock(return_value=file)
controller = mocker.MagicMock(get_file=get_file)
+ file_location = file.location(controller.data_dir)
+
+ # It doesn't live here, but see __init__.py
export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice")
fw = FileWidget(
@@ -3597,10 +3600,12 @@ def test_FileWidget__on_export_clicked(mocker, session, source):
controller.run_export_preflight_checks = mocker.MagicMock()
controller.downloaded_file_exists = mocker.MagicMock(return_value=True)
- dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog")
+ wizard = mocker.patch("securedrop_client.gui.conversation.export.ExportWizard")
fw._on_export_clicked()
- dialog.assert_called_once_with(export_device(), file.uuid, file.filename)
+ wizard.assert_called_once_with(
+ export_device(), file.filename, [file_location]
+ ), f"{wizard.call_args}"
def test_FileWidget__on_export_clicked_missing_file(mocker, session, source):
@@ -3627,17 +3632,17 @@ def test_FileWidget__on_export_clicked_missing_file(mocker, session, source):
mocker.patch("PyQt5.QtWidgets.QDialog.exec")
controller.run_export_preflight_checks = mocker.MagicMock()
controller.downloaded_file_exists = mocker.MagicMock(return_value=False)
- dialog = mocker.patch("securedrop_client.gui.conversation.ExportFileDialog")
+ wizard = mocker.patch("securedrop_client.gui.conversation.ExportWizard")
fw._on_export_clicked()
controller.run_export_preflight_checks.assert_not_called()
- dialog.assert_not_called()
+ wizard.assert_not_called()
def test_FileWidget__on_print_clicked(mocker, session, source):
"""
- Ensure print_file is called when the PRINT button is clicked
+ Ensure print() is called when the PRINT button is clicked
"""
file = factory.File(source=source["source"], is_downloaded=True)
session.add(file)
@@ -3646,6 +3651,7 @@ def test_FileWidget__on_print_clicked(mocker, session, source):
get_file = mocker.MagicMock(return_value=file)
controller = mocker.MagicMock(get_file=get_file)
export_device = mocker.patch("securedrop_client.gui.conversation.ExportDevice")
+ file_location = file.location(controller.data_dir)
fw = FileWidget(
file.uuid,
@@ -3665,7 +3671,7 @@ def test_FileWidget__on_print_clicked(mocker, session, source):
fw._on_print_clicked()
- dialog.assert_called_once_with(export_device(), file.uuid, file.filename)
+ dialog.assert_called_once_with(export_device(), file.filename, [file_location])
def test_FileWidget__on_print_clicked_missing_file(mocker, session, source):
diff --git a/client/tests/integration/conftest.py b/client/tests/integration/conftest.py
index 80d3a4911d..c00880041f 100644
--- a/client/tests/integration/conftest.py
+++ b/client/tests/integration/conftest.py
@@ -1,22 +1,18 @@
import pytest
from PyQt5.QtWidgets import QApplication
-from securedrop_client import export
from securedrop_client.app import threads
-from securedrop_client.export import ExportStatus
+from securedrop_client.export_status import ExportStatus
from securedrop_client.gui import conversation
from securedrop_client.gui.base import ModalDialog
+from securedrop_client.gui.conversation.export import Export
from securedrop_client.gui.main import Window
from securedrop_client.logic import Controller
from tests import factory
@pytest.fixture(scope="function")
-def main_window(mocker, homedir, mock_export_service):
- mocker.patch(
- "securedrop_client.export.getService",
- return_value=mock_export_service,
- )
+def main_window(mocker, homedir):
# Setup
app = QApplication([])
gui = Window()
@@ -68,11 +64,7 @@ def main_window(mocker, homedir, mock_export_service):
@pytest.fixture(scope="function")
-def main_window_no_key(mocker, homedir, mock_export_service):
- mocker.patch(
- "securedrop_client.export.getService",
- return_value=mock_export_service,
- )
+def main_window_no_key(mocker, homedir):
# Setup
app = QApplication([])
gui = Window()
@@ -155,23 +147,20 @@ def modal_dialog(mocker, homedir):
@pytest.fixture(scope="function")
-def mock_export_service():
- """An export service that assumes the Qubes RPC calls are successful and skips them."""
- export_service = export.Service()
- # Ensure the export_service doesn't rely on Qubes OS:
- export_service.run_preflight_checks = lambda: ExportStatus.DEVICE_LOCKED
- export_service.send_file_to_usb_device = lambda paths, passphrase: ExportStatus.SUCCESS_EXPORT
- export_service.run_printer_preflight = lambda: ExportStatus.PRINT_PREFLIGHT_SUCCESS
- export_service.run_print = lambda paths: ExportStatus.PRINT_SUCCESS
- return export_service
+def mock_export(mocker):
+ device = Export()
+
+ """A export that assumes the Qubes RPC calls are successful and skips them."""
+ device.run_preflight_checks = lambda: ExportStatus.DEVICE_LOCKED
+ device.send_file_to_usb_device = lambda paths, passphrase: ExportStatus.SUCCESS_EXPORT
+ device.run_printer_preflight = lambda: ExportStatus.PRINT_PREFLIGHT_SUCCESS
+ device.run_print = lambda paths: ExportStatus.PRINT_SUCCESS
+ return device
@pytest.fixture(scope="function")
-def print_dialog(mocker, homedir, mock_export_service):
- mocker.patch(
- "securedrop_client.export.getService",
- return_value=mock_export_service,
- )
+def print_dialog(mocker, homedir):
+ mocker.patch("securedrop_client.export.Export", return_value=mock_export)
app = QApplication([])
gui = Window()
app.setActiveWindow(gui)
@@ -193,10 +182,10 @@ def print_dialog(mocker, homedir, mock_export_service):
)
controller.authenticated_user = factory.User()
controller.qubes = False
- export_device = conversation.ExportDevice(controller, mock_export_service)
gui.setup(controller)
gui.login_dialog.close()
- dialog = conversation.PrintFileDialog(export_device, "file_uuid", "file_name")
+ export_device = conversation.ExportDevice()
+ dialog = conversation.PrintFileDialog(export_device, "file_name", ["/mock/export/file"])
yield dialog
@@ -206,11 +195,8 @@ def print_dialog(mocker, homedir, mock_export_service):
@pytest.fixture(scope="function")
-def export_file_dialog(mocker, homedir, mock_export_service):
- mocker.patch(
- "securedrop_client.export.getService",
- return_value=mock_export_service,
- )
+def export_file_dialog(mocker, homedir):
+ mocker.patch("securedrop_client.export.Export", return_value=mock_export)
app = QApplication([])
gui = Window()
app.setActiveWindow(gui)
@@ -229,10 +215,10 @@ def export_file_dialog(mocker, homedir, mock_export_service):
)
controller.authenticated_user = factory.User()
controller.qubes = False
- export_device = conversation.ExportDevice(controller, mock_export_service)
gui.setup(controller)
gui.login_dialog.close()
- dialog = conversation.ExportFileDialog(export_device, "file_uuid", "file_name")
+ export_device = conversation.ExportDevice()
+ dialog = conversation.ExportDialog(export_device, "file_name", ["/mock/export/filepath"])
dialog.show()
yield dialog
diff --git a/client/tests/integration/test_styles_sdclient.py b/client/tests/integration/test_styles_sdclient.py
index 3fab23e9eb..fa5d0484ab 100644
--- a/client/tests/integration/test_styles_sdclient.py
+++ b/client/tests/integration/test_styles_sdclient.py
@@ -130,8 +130,8 @@ def test_class_name_matches_css_object_name_for_print_dialog(print_dialog):
def test_class_name_matches_css_object_name_for_export_file_dialog(export_file_dialog):
- assert "FileDialog" == export_file_dialog.__class__.__name__
- assert "FileDialog" in export_file_dialog.passphrase_form.objectName()
+ assert "ExportDialog" == export_file_dialog.__class__.__name__
+ assert "ExportDialog" in export_file_dialog.passphrase_form.objectName()
def test_class_name_matches_css_object_name_for_modal_dialog(modal_dialog):
diff --git a/client/tests/test_export.py b/client/tests/test_export.py
deleted file mode 100644
index d5e43b4f3e..0000000000
--- a/client/tests/test_export.py
+++ /dev/null
@@ -1,453 +0,0 @@
-import os
-import subprocess
-import unittest
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-
-from securedrop_client import export
-from securedrop_client.export import Export, ExportError, ExportStatus
-
-
-class TestService(unittest.TestCase):
- def tearDown(self):
- # ensure any changes to the export.Service instance are reset
- # export.resetService()
- pass
-
- def test_service_is_unique(self):
- service = export.getService()
- same_service = export.getService() # Act.
-
- self.assertTrue(
- service is same_service,
- "expected successive calls to getService to return the same service, got different services", # noqa: E501
- )
-
- def test_service_can_be_reset(self):
- service = export.getService()
- export.resetService() # Act.
- different_service = export.getService()
-
- self.assertTrue(
- different_service is not service,
- "expected resetService to reset the service, got same service after reset",
- )
-
-
-def test_run_printer_preflight(mocker):
- """
- Ensure TemporaryDirectory is used when creating and sending the archives during the preflight
- checks and that the success signal is emitted by Export.
- """
-
- export = Export()
- mocker.patch.object(
- export, "_build_archive_and_export", return_value=ExportStatus.PRINT_PREFLIGHT_SUCCESS
- )
- export.printer_preflight_success = mocker.MagicMock()
- export.printer_preflight_success.emit = mocker.MagicMock()
-
- export.run_printer_preflight()
- export.printer_preflight_success.emit.assert_called_once_with(
- ExportStatus.PRINT_PREFLIGHT_SUCCESS
- )
-
-
-def test_run_printer_preflight_error(mocker):
- """
- Ensure TemporaryDirectory is used when creating and sending the archives during the preflight
- checks and that the failure signal is emitted by Export.
- """
-
- export = Export()
- error = ExportError("bang!")
- mocker.patch.object(export, "_build_archive_and_export", side_effect=error)
-
- export.printer_preflight_failure = mocker.MagicMock()
- export.printer_preflight_failure.emit = mocker.MagicMock()
-
- export.run_printer_preflight()
-
- export.printer_preflight_failure.emit.assert_called_once_with(error)
-
-
-def test_print(mocker):
- export = Export()
-
- mock_qrexec_call = mocker.patch.object(
- export, "_build_archive_and_export", return_value=ExportStatus.PRINT_SUCCESS
- )
-
- export.print_call_success = mocker.MagicMock()
- export.print_call_success.emit = mocker.MagicMock()
- export.export_completed = mocker.MagicMock()
- export.export_completed.emit = mocker.MagicMock()
-
- export.print(["path1", "path2"])
-
- mock_qrexec_call.assert_called_once_with(
- metadata=export.PRINT_METADATA, filename=export.PRINT_FN, filepaths=["path1", "path2"]
- )
- export.print_call_success.emit.assert_called_once_with(ExportStatus.PRINT_SUCCESS)
- export.export_completed.emit.assert_called_once_with(["path1", "path2"])
-
-
-def test_print_error(mocker):
- """
- Ensure TemporaryDirectory is used when creating and sending the archive containing the file to
- print and that the failure signal is emitted.
- """
- mock_temp_dir = mocker.MagicMock()
- mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir")
- mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir)
-
- export = Export()
- export.print_call_failure = mocker.MagicMock()
- export.print_call_failure.emit = mocker.MagicMock()
- export.export_completed = mocker.MagicMock()
- export.export_completed.emit = mocker.MagicMock()
- error = ExportError("oh no!")
- _run_print = mocker.patch.object(export, "_build_archive_and_export", side_effect=error)
- mocker.patch("os.path.exists", return_value=True)
-
- export.print(["path1", "path2"])
-
- _run_print.assert_called_once_with(
- metadata=export.PRINT_METADATA, filename=export.PRINT_FN, filepaths=["path1", "path2"]
- )
- export.print_call_failure.emit.assert_called_once_with(error)
- export.export_completed.emit.assert_called_once_with(["path1", "path2"])
-
-
-def test_send_file_to_usb_device(mocker):
- """
- Ensure TemporaryDirectory is used when creating and sending the archive containing the export
- file and that the success signal is emitted.
- """
- mock_temp_dir = mocker.MagicMock()
- mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir")
- mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir)
-
- export = Export()
- export.export_usb_call_success = mocker.MagicMock()
- export.export_usb_call_success.emit = mocker.MagicMock()
- export.export_completed = mocker.MagicMock()
- export.export_completed.emit = mocker.MagicMock()
- _run_disk_export = mocker.patch.object(
- export, "_build_archive_and_export", return_value=ExportStatus.SUCCESS_EXPORT
- )
- mocker.patch("os.path.exists", return_value=True)
-
- metadata = export.DISK_METADATA
- metadata[export.DISK_ENCRYPTION_KEY_NAME] = "mock passphrase"
-
- export.send_file_to_usb_device(["path1", "path2"], "mock passphrase")
-
- _run_disk_export.assert_called_once_with(
- metadata=metadata, filename=export.DISK_FN, filepaths=["path1", "path2"]
- )
- export.export_usb_call_success.emit.assert_called_once_with(ExportStatus.SUCCESS_EXPORT)
- export.export_completed.emit.assert_called_once_with(["path1", "path2"])
-
-
-def test_send_file_to_usb_device_error(mocker):
- """
- Ensure TemporaryDirectory is used when creating and sending the archive containing the export
- file and that the failure signal is emitted.
- """
- export = Export()
-
- mock_temp_dir = mocker.MagicMock()
- mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir")
- mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir)
-
- export.export_usb_call_failure = mocker.MagicMock()
- export.export_usb_call_failure.emit = mocker.MagicMock()
- export.export_completed = mocker.MagicMock()
- export.export_completed.emit = mocker.MagicMock()
- error = ExportError("ohno")
- _run_disk_export = mocker.patch.object(export, "_build_archive_and_export", side_effect=error)
-
- metadata = export.DISK_METADATA
- metadata[export.DISK_ENCRYPTION_KEY_NAME] = "mock passphrase"
-
- export.send_file_to_usb_device(["path1", "path2"], "mock passphrase")
-
- _run_disk_export.assert_called_once_with(
- metadata=metadata, filename=export.DISK_FN, filepaths=["path1", "path2"]
- )
- export.export_usb_call_failure.emit.assert_called_once_with(error)
- export.export_completed.emit.assert_called_once_with(["path1", "path2"])
-
-
-def test_run_usb_preflight_checks(mocker):
- """
- Ensure TemporaryDirectory is used when creating and sending the archives during the preflight
- checks and that the success signal is emitted by Export.
- """
- mock_temp_dir = mocker.MagicMock()
- mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir")
- mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir)
- export = Export()
-
- export.preflight_check_call_success = mocker.MagicMock()
- export.preflight_check_call_success.emit = mocker.MagicMock()
- _run_export = mocker.patch.object(
- export, "_build_archive_and_export", return_value=ExportStatus.DEVICE_LOCKED
- )
-
- export.run_preflight_checks()
-
- _run_export.assert_called_once_with(
- metadata=export.USB_TEST_METADATA, filename=export.USB_TEST_FN
- )
- export.preflight_check_call_success.emit.assert_called_once_with(ExportStatus.DEVICE_LOCKED)
-
-
-def test_run_usb_preflight_checks_error(mocker):
- """
- Ensure TemporaryDirectory is used when creating and sending the archives during the preflight
- checks and that the failure signal is emitted by Export.
- """
-
- mock_temp_dir = mocker.MagicMock()
- mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir")
- mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir)
-
- export = Export()
- export.preflight_check_call_failure = mocker.MagicMock()
- export.preflight_check_call_failure.emit = mocker.MagicMock()
- error = ExportError("bang!")
- _run_export = mocker.patch.object(export, "_build_archive_and_export", side_effect=error)
-
- export.run_preflight_checks()
-
- _run_export.assert_called_once_with(
- metadata=export.USB_TEST_METADATA, filename=export.USB_TEST_FN
- )
- export.preflight_check_call_failure.emit.assert_called_once_with(error)
-
-
-@pytest.mark.parametrize("success_qrexec", [e.value for e in ExportStatus])
-def test__build_archive_and_export_success(mocker, success_qrexec):
- """
- Test the command that calls out to underlying qrexec service.
- """
- export = Export()
-
- mock_temp_dir = mocker.MagicMock()
- mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir")
- mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir)
-
- mock_qrexec_call = mocker.patch.object(
- export, "_run_qrexec_export", return_value=bytes(success_qrexec, "utf-8")
- )
- mocker.patch.object(export, "_create_archive", return_value="mock_archive_path")
-
- metadata = {"device": "pretend", "encryption_method": "transparent"}
-
- result = export._build_archive_and_export(
- metadata=metadata, filename="mock_filename", filepaths=["mock_filepath"]
- )
- mock_qrexec_call.assert_called_once()
-
- assert result == bytes(success_qrexec, "utf-8")
-
-
-def test__build_archive_and_export_error(mocker):
- """
- Test the command that calls out to underlying qrexec service.
- """
- export = Export()
- mock_temp_dir = mocker.MagicMock()
- mock_temp_dir.__enter__ = mocker.MagicMock(return_value="mock_temp_dir")
- mocker.patch("securedrop_client.export.TemporaryDirectory", return_value=mock_temp_dir)
-
- mocker.patch.object(export, "_create_archive", return_value="mock_archive_path")
-
- mock_qrexec_call = mocker.patch.object(
- export, "_run_qrexec_export", side_effect=ExportError(ExportStatus.UNEXPECTED_RETURN_STATUS)
- )
-
- metadata = {"device": "pretend", "encryption_method": "transparent"}
-
- with pytest.raises(ExportError):
- result = export._build_archive_and_export(
- metadata=metadata, filename="mock_filename", filepaths=["mock_filepath"]
- )
- assert result == ExportStatus.UNEXPECTED_RETURN_STATUS
-
- mock_qrexec_call.assert_called_once()
-
-
-def test__create_archive(mocker):
- """
- Ensure _create_archive creates an archive in the supplied directory.
- """
- export = Export()
- archive_path = None
- with TemporaryDirectory() as temp_dir:
- archive_path = export._create_archive(
- archive_dir=temp_dir, archive_fn="mock.sd-export", metadata={}, filepaths=[]
- )
- assert archive_path == os.path.join(temp_dir, "mock.sd-export")
- assert os.path.exists(archive_path) # sanity check
-
- assert not os.path.exists(archive_path)
-
-
-def test__create_archive_with_an_export_file(mocker):
- export = Export()
- archive_path = None
- with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file:
- archive_path = export._create_archive(
- archive_dir=temp_dir,
- archive_fn="mock.sd-export",
- metadata={},
- filepaths=[export_file.name],
- )
- assert archive_path == os.path.join(temp_dir, "mock.sd-export")
- assert os.path.exists(archive_path) # sanity check
-
- assert not os.path.exists(archive_path)
-
-
-def test__create_archive_with_multiple_export_files(mocker):
- """
- Ensure an archive
- """
- export = Export()
- archive_path = None
- with TemporaryDirectory() as temp_dir, NamedTemporaryFile() as export_file_one, NamedTemporaryFile() as export_file_two: # noqa
- transcript_path = os.path.join(temp_dir, "transcript.txt")
- with open(transcript_path, "a+") as transcript:
- archive_path = export._create_archive(
- temp_dir,
- "mock.sd-export",
- {},
- [export_file_one.name, export_file_two.name, transcript.name],
- )
- assert archive_path == os.path.join(temp_dir, "mock.sd-export")
- assert os.path.exists(archive_path) # sanity check
-
- assert not os.path.exists(archive_path)
-
-
-@pytest.mark.parametrize("qrexec_return_value_success", [e.value for e in ExportStatus])
-def test__run_qrexec_export(mocker, qrexec_return_value_success):
- """
- Ensure the subprocess call returns the expected output.
- """
- export = Export()
- qrexec_mocker = mocker.patch(
- "subprocess.check_output", return_value=bytes(qrexec_return_value_success, "utf-8")
- )
- result = export._run_qrexec_export("mock.sd-export")
-
- qrexec_mocker.assert_called_once_with(
- [
- "qrexec-client-vm",
- "--",
- "sd-devices",
- "qubes.OpenInVM",
- "/usr/lib/qubes/qopen-in-vm",
- "--view-only",
- "--",
- "mock.sd-export",
- ],
- stderr=-2,
- )
-
- assert ExportStatus(result)
-
-
-@pytest.mark.parametrize(
- "qrexec_return_value_error", [b"", b"qrexec not connected", b"DEVICE_UNLOCKED\nERROR_WRITE"]
-)
-def test__run_qrexec_export_returns_bad_data(mocker, qrexec_return_value_error):
- """
- Ensure the subprocess call returns the expected output.
- """
- export = Export()
- qrexec_mocker = mocker.patch("subprocess.check_output", return_value=qrexec_return_value_error)
-
- with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"):
- export._run_qrexec_export("mock.sd-export")
-
- qrexec_mocker.assert_called_once_with(
- [
- "qrexec-client-vm",
- "--",
- "sd-devices",
- "qubes.OpenInVM",
- "/usr/lib/qubes/qopen-in-vm",
- "--view-only",
- "--",
- "mock.sd-export",
- ],
- stderr=-2,
- )
-
-
-def test__run_qrexec_export_does_not_raise_ExportError_when_CalledProcessError(mocker):
- """
- Ensure ExportError is raised if a CalledProcessError is encountered.
- """
- mock_error = subprocess.CalledProcessError(cmd=["mock_cmd"], returncode=123)
- mocker.patch("subprocess.check_output", side_effect=mock_error)
-
- export = Export()
-
- with pytest.raises(ExportError, match="CALLED_PROCESS_ERROR"):
- export._run_qrexec_export("mock.sd-export")
-
-
-def test__run_qrexec_export_with_evil_command(mocker):
- """
- Ensure shell command is shell-escaped.
- """
- export = Export()
- check_output = mocker.patch("subprocess.check_output", return_value=b"ERROR_FILE_NOT_FOUND")
-
- with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"):
- export._run_qrexec_export("somefile; ls -la ~")
-
- check_output.assert_called_once_with(
- [
- "qrexec-client-vm",
- "--",
- "sd-devices",
- "qubes.OpenInVM",
- "/usr/lib/qubes/qopen-in-vm",
- "--view-only",
- "--",
- "'somefile; ls -la ~'",
- ],
- stderr=-2,
- )
-
-
-def test__run_qrexec_export_error_on_empty_return_value(mocker):
- """
- Ensure an error is raised when qrexec call returns empty string,
- """
- export = Export()
- check_output = mocker.patch("subprocess.check_output", return_value=b"")
-
- with pytest.raises(ExportError, match="UNEXPECTED_RETURN_STATUS"):
- export._run_qrexec_export("somefile.sd-export")
-
- check_output.assert_called_once_with(
- [
- "qrexec-client-vm",
- "--",
- "sd-devices",
- "qubes.OpenInVM",
- "/usr/lib/qubes/qopen-in-vm",
- "--view-only",
- "--",
- "somefile.sd-export",
- ],
- stderr=-2,
- )
diff --git a/export/tests/disk/test_service.py b/export/tests/disk/test_service.py
index 13a729507f..73dc0210ac 100644
--- a/export/tests/disk/test_service.py
+++ b/export/tests/disk/test_service.py
@@ -60,8 +60,7 @@ def _setup_submission(cls) -> Archive:
metadata = os.path.join(temp_folder, Metadata.METADATA_FILE)
with open(metadata, "w") as f:
f.write(
- '{"device": "disk", "encryption_method":'
- ' "luks", "encryption_key": "hunter1"}'
+ '{"device": "disk", "encryption_key": "hunter1"}'
)
return submission.set_metadata(Metadata(temp_folder).validate())
diff --git a/export/tests/test_archive.py b/export/tests/test_archive.py
index 7c09b83d67..37510f0c84 100644
--- a/export/tests/test_archive.py
+++ b/export/tests/test_archive.py
@@ -436,7 +436,7 @@ def test_invalid_config(capsys):
temp_folder = tempfile.mkdtemp()
metadata = os.path.join(temp_folder, Metadata.METADATA_FILE)
with open(metadata, "w") as f:
- f.write('{"device": "asdf", "encryption_method": "OHNO"}')
+ f.write('{"device": "asdf"}')
with pytest.raises(ExportException) as ex:
Metadata(temp_folder).validate()
@@ -450,7 +450,7 @@ def test_malformed_config(capsys):
temp_folder = tempfile.mkdtemp()
metadata = os.path.join(temp_folder, Metadata.METADATA_FILE)
with open(metadata, "w") as f:
- f.write('{"device": "asdf", "encryption_method": {"OHNO", "MALFORMED"}')
+ f.write('{"device": {"OHNO", "MALFORMED"}')
with pytest.raises(ExportException) as ex:
Metadata(temp_folder).validate()