Skip to content

Commit

Permalink
Export Wizard unit tests (WIP)
Browse files Browse the repository at this point in the history
  • Loading branch information
rocodes committed Jan 31, 2024
1 parent a3c8b47 commit 5e2c8c1
Show file tree
Hide file tree
Showing 6 changed files with 168 additions and 20 deletions.
2 changes: 1 addition & 1 deletion client/securedrop_client/gui/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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__)

Expand Down
Original file line number Diff line number Diff line change
@@ -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

"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
141 changes: 141 additions & 0 deletions client/tests/gui/conversation/export/test_export_wizard.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 5e2c8c1

Please sign in to comment.