diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 8478fa6e54..2e231f949a 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -17,10 +17,10 @@ from securedrop_client.db import Source from securedrop_client.gui.base import ModalDialog from securedrop_client.gui.conversation import ExportDevice -from securedrop_client.gui.conversation.export import ExportWizard 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 diff --git a/client/securedrop_client/gui/conversation/export/__init__.py b/client/securedrop_client/gui/conversation/export/__init__.py index 0c53d0ff16..c929b33ab4 100644 --- a/client/securedrop_client/gui/conversation/export/__init__.py +++ b/client/securedrop_client/gui/conversation/export/__init__.py @@ -1,5 +1,5 @@ from ....export import Export # noqa: F401 from .export_dialog import ExportDialog # noqa: F401 +from .export_wizard import ExportWizard # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 from .print_transcript_dialog import PrintTranscriptDialog # noqa: F401 -from .export_wizard import ExportWizard # noqa: F401 diff --git a/client/securedrop_client/gui/conversation/export/export_wizard.py b/client/securedrop_client/gui/conversation/export/export_wizard.py index 1f080e0e9b..853f270479 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard.py @@ -1,25 +1,22 @@ import logging - from gettext import gettext as _ from typing import List -from PyQt5.QtWidgets import QApplication, QWizard, QWizardPage -from PyQt5.QtCore import pyqtSlot -from PyQt5.QtCore import QSize -from PyQt5.QtGui import QIcon - from pkg_resources import resource_string +from PyQt5.QtCore import QSize, pyqtSlot +from PyQt5.QtGui import QIcon +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 from securedrop_client.gui.conversation.export.export_wizard_page import ( - PreflightPage, + FinalPage, InsertUSBPage, PassphraseWizardPage, - FinalPage, + PreflightPage, ) -from securedrop_client.gui.conversation.export.export_wizard_constants import Pages logger = logging.getLogger(__name__) diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py index b21dfd5111..7236350ec2 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_constants.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_constants.py @@ -1,5 +1,6 @@ -from gettext import gettext as _ from enum import IntEnum +from gettext import gettext as _ + from securedrop_client.export_status import ExportStatus """ diff --git a/client/securedrop_client/gui/conversation/export/export_wizard_page.py b/client/securedrop_client/gui/conversation/export/export_wizard_page.py index f14cf3560c..2375ce76d9 100644 --- a/client/securedrop_client/gui/conversation/export/export_wizard_page.py +++ b/client/securedrop_client/gui/conversation/export/export_wizard_page.py @@ -1,15 +1,13 @@ import logging from gettext import gettext as _ -from PyQt5.QtCore import pyqtSlot from pkg_resources import resource_string -from PyQt5.QtGui import QColor, QFont -from PyQt5.QtCore import QSize, Qt -from PyQt5.QtGui import QKeyEvent, QPixmap +from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtGui import QColor, QFont, QKeyEvent, QPixmap from PyQt5.QtWidgets import ( QApplication, - QHBoxLayout, QGraphicsDropShadowEffect, + QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, @@ -19,11 +17,11 @@ from securedrop_client.export import Export from securedrop_client.export_status import ExportStatus -from securedrop_client.gui.base.misc import SvgLabel 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 -from securedrop_client.gui.conversation.export.export_wizard_constants import Pages, STATUS_MESSAGES logger = logging.getLogger(__name__) @@ -245,7 +243,7 @@ def nextId(self): """ if self.status == ExportStatus.DEVICE_WRITABLE: logger.debug("Skip password prompt") - return Pages.EXPORT + return Pages.EXPORT_DONE elif self.status == ExportStatus.DEVICE_LOCKED: logger.debug("Device locked - prompt for passphrase") return Pages.UNLOCK_USB @@ -392,3 +390,14 @@ def _build_layout(self) -> QVBoxLayout: def on_status_received(self, status: ExportStatus) -> None: super().on_status_received(status) self.update_content(status) + + def update_content(self, status: ExportStatus) -> None: + if not status: + logger.error("Empty status value given to update_content") + status = ExportStatus.UNEXPECTED_RETURN_STATUS + + if status in super().ERROR_HINT_MESSAGE: + self.error_details.setText(STATUS_MESSAGES.get(status)) + self.error_details.show() + else: + self.body.setText(STATUS_MESSAGES.get(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..cc916a19e3 --- /dev/null +++ b/client/tests/gui/conversation/export/test_export_wizard.py @@ -0,0 +1,141 @@ +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 ( + 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()