diff --git a/securedrop_client/export.py b/securedrop_client/export.py index 56dace778..be371f0d1 100644 --- a/securedrop_client/export.py +++ b/securedrop_client/export.py @@ -256,4 +256,5 @@ def send_file_to_usb_device(self, filepaths: List[str], passphrase: str) -> None logger.debug('Export successful') self.export_usb_call_success.emit(filepaths) except ExportError as e: - self.export_usb_call_failure.emit(e.status) + logger.error(e) + self.export_usb_call_failure.emit(filepaths) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 52abf66f3..074d52cb5 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1799,6 +1799,10 @@ def _on_export_clicked(self): """ Called when the export button is clicked. """ + if not self.controller.downloaded_file_exists(self.file.uuid): + self.controller.sync_api() + return + dialog = ExportDialog(self.controller, self.file.uuid) # The underlying function of the `export` method makes a blocking call that can potentially # take a long time to run (if the Export VM is not already running and needs to start, this diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index edce4f60e..c1497f281 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -21,9 +21,8 @@ import logging import os import sdclientapi -import threading import uuid -from typing import Dict, Tuple, Union, Any, Type # noqa: F401 +from typing import Dict, Tuple, Union, Any, List, Type # noqa: F401 from gettext import gettext as _ from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer, QProcess, Qt @@ -181,6 +180,8 @@ def __init__(self, hostname: str, gui, session_maker: sessionmaker, self.gpg = GpgHelper(home, self.session_maker, proxy) self.export = Export() + self.export.export_usb_call_success.connect(self.on_export_usb_call_success) + self.export.export_usb_call_failure.connect(self.on_export_usb_call_failure) self.sync_flag = os.path.join(home, 'sync_flag') @@ -397,9 +398,15 @@ def last_sync(self): def on_sync_success(self, result) -> None: """ - Called when syncronisation of data via the API succeeds + Called when syncronisation of data via the API succeeds. + + * Update db with new metadata + * Set last sync flag + * Import keys into keyring + * Display the last sync time and updated list of sources in GUI + * Download new messages and replies + * Update missing files so that they can be re-downloaded """ - # Update db with new metadata remote_sources, remote_submissions, remote_replies = result storage.update_local_storage(self.session, remote_sources, @@ -407,11 +414,9 @@ def on_sync_success(self, result) -> None: remote_replies, self.data_dir) - # Set last sync flag with open(self.sync_flag, 'w') as f: f.write(arrow.now().format()) - # Import keys into keyring for source in remote_sources: if source.key and source.key.get('type', None) == 'PGP': pub_key = source.key.get('public', None) @@ -423,6 +428,7 @@ def on_sync_success(self, result) -> None: except CryptoError: logger.warning('Failed to import key for source {}'.format(source.uuid)) + storage.update_missing_files(self.data_dir, self.session) self.update_sources() self.download_new_messages() self.download_new_replies() @@ -573,40 +579,60 @@ def on_reply_download_failure(self, exception: Exception) -> None: logger.debug('Failure due to checksum mismatch, retrying {}'.format(exception.uuid)) self._submit_download_job(exception.object_type, exception.uuid) + def downloaded_file_exists(self, file_uuid: str) -> bool: + ''' + Check if the file specified by file_uuid exists. If it doesn't sync the api so that any + missing files, including this one, are updated to be re-downloaded. + ''' + file = self.get_file(file_uuid) + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(self.data_dir, fn_no_ext) + if not os.path.exists(filepath): + self.gui.update_error_status(_( + 'File does not exist in the data directory. Please try re-downloading.')) + logger.debug('Cannot find {} in the data directory. File does not exist.'.format( + file.original_filename)) + return False + return True + def on_file_open(self, file_uuid: str) -> None: - """ - Open the already downloaded file associated with the message (which is a `File`). - """ - # Once downloaded, submissions are stored in the data directory - # with the same filename as the server, except with the .gz.gpg - # stripped off. + ''' + Open the file specified by file_uuid. + + Once a file is downloaded, it exists in the data directory with the same filename as the + server, except with the .gz.gpg stripped off. In order for the Display VM to know which + application to open the file in, we create a hard link to this file with the original file + name, including its extension. + + If the file is missing, update the db so that is_downloaded is set to False. + ''' file = self.get_file(file_uuid) - fn_no_ext, _ = os.path.splitext(os.path.splitext(file.filename)[0]) - submission_filepath = os.path.join(self.data_dir, fn_no_ext) - original_filepath = os.path.join(self.data_dir, file.original_filename) - - if os.path.exists(original_filepath): - os.remove(original_filepath) - os.link(submission_filepath, original_filepath) - if self.proxy or self.qubes: - # Running on Qubes. - command = "qvm-open-in-vm" - args = ['@dispvm:sd-svs-disp', original_filepath] - - # QProcess (Qt) or Python's subprocess? Who cares? They do the - # same thing. :-) - process = QProcess(self) - process.start(command, args) - else: # pragma: no cover - # Non Qubes OS. Just log the event for now. - logger.info('Opening file "{}".'.format(original_filepath)) + logger.info('Opening file "{}".'.format(file.original_filename)) + + if not self.downloaded_file_exists(file.uuid): + self.sync_api() + return + + path_to_file_with_original_name = os.path.join(self.data_dir, file.original_filename) + + if not os.path.exists(path_to_file_with_original_name): + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(self.data_dir, fn_no_ext) + os.link(filepath, path_to_file_with_original_name) + + if not self.qubes: + return + + command = "qvm-open-in-vm" + args = ['$dispvm:sd-svs-disp', path_to_file_with_original_name] + process = QProcess(self) + process.start(command, args) def run_export_preflight_checks(self): ''' - Run preflight checks to make sure the Export VM is configured correctly + Run preflight checks to make sure the Export VM is configured correctly. ''' - logger.debug('Calling export preflight checks from thread {}'.format( - threading.current_thread().ident)) + logger.info('Running export preflight checks') if not self.qubes: return @@ -614,17 +640,51 @@ def run_export_preflight_checks(self): self.export.begin_preflight_check.emit() def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: + ''' + Send the file specified by file_uuid to the Export VM with the user-provided passphrase for + unlocking the attached transfer device. + + Once a file is downloaded, it exists in the data directory with the same filename as the + server, except with the .gz.gpg stripped off. In order for the user to know which + application to open the file in, we export the file with a different name: the original + filename which includes the file extesion. + + If the file is missing, update the db so that is_downloaded is set to False. + ''' file = self.get_file(file_uuid) + logger.info('Exporting file {}'.format(file.original_filename)) - logger.debug('Exporting {} from thread {}'.format( - file.original_filename, threading.current_thread().ident)) + if not self.downloaded_file_exists(file.uuid): + self.sync_api() + return + + path_to_file_with_original_name = os.path.join(self.data_dir, file.original_filename) + + if not os.path.exists(path_to_file_with_original_name): + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(self.data_dir, fn_no_ext) + os.link(filepath, path_to_file_with_original_name) if not self.qubes: return - filepath = os.path.join(self.data_dir, file.original_filename) + self.export.begin_usb_export.emit([path_to_file_with_original_name], passphrase) - self.export.begin_usb_export.emit([filepath], passphrase) + def on_export_usb_call_success(self, filepaths: List[str]): + ''' + Clean export files that are hard links to the file on disk. + ''' + for filepath in filepaths: + if os.path.exists(filepath): + os.remove(filepath) + + def on_export_usb_call_failure(self, filepaths: List[str]): + ''' + Clean export files that are hard links to the file on disk. + ''' + for filepath in filepaths: + if os.path.exists(filepath): + os.remove(filepath) def on_submission_download( self, @@ -657,7 +717,7 @@ def on_file_download_failure(self, exception: Exception) -> None: logger.debug('Failure due to checksum mismatch, retrying {}'.format(exception.uuid)) self._submit_download_job(exception.object_type, exception.uuid) else: - self.set_status(_('The file download failed. Please try again.')) + self.gui.update_error_status(_('The file download failed. Please try again.')) def on_delete_source_success(self, result) -> None: """ diff --git a/securedrop_client/storage.py b/securedrop_client/storage.py index 7d6c47ba3..0c0dbb4ba 100644 --- a/securedrop_client/storage.py +++ b/securedrop_client/storage.py @@ -332,6 +332,22 @@ def update_and_get_user(uuid: str, return user +def update_missing_files(data_dir: str, session: Session) -> None: + ''' + Update files that are marked as downloaded yet missing from the filesystem. + ''' + files_that_have_been_downloaded = session.query(File).filter_by(is_downloaded=True).all() + for file in files_that_have_been_downloaded: + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(data_dir, fn_no_ext) + if not os.path.exists(filepath): + mark_as_not_downloaded(file.uuid, session) + + +def find_new_files(session: Session) -> List[File]: + return session.query(File).filter_by(is_downloaded=False).all() + + def find_new_messages(session: Session) -> List[Message]: """ Find messages to process. Those messages are those where one of the following @@ -347,10 +363,6 @@ def find_new_messages(session: Session) -> List[Message]: Message.is_decrypted == None)).all() # noqa: E711 -def find_new_files(session: Session) -> List[File]: - return session.query(File).filter_by(is_downloaded=False).all() - - def find_new_replies(session: Session) -> List[Reply]: """ Find replies to process. Those replies are those where one of the following @@ -366,6 +378,17 @@ def find_new_replies(session: Session) -> List[Reply]: Reply.is_decrypted == None)).all() # noqa: E711 +def mark_as_not_downloaded(uuid: str, session: Session) -> None: + """ + Mark File as not downloaded in the database. + """ + db_obj = session.query(File).filter_by(uuid=uuid).one() + db_obj.is_downloaded = False + db_obj.is_decrypted = None + session.add(db_obj) + session.commit() + + def mark_as_downloaded( model_type: Union[Type[File], Type[Message], Type[Reply]], uuid: str, diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 75b970d50..332369690 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -1434,11 +1434,41 @@ def test_FileWidget__on_export_clicked(mocker, session, source): fw.update = mocker.MagicMock() mocker.patch('securedrop_client.gui.widgets.QDialog.exec') controller.run_export_preflight_checks = mocker.MagicMock() + controller.downloaded_file_exists = mocker.MagicMock(return_value=True) fw._on_export_clicked() controller.run_export_preflight_checks.assert_called_once_with() + # Also assert that the dialog is initialized + dialog = mocker.patch('securedrop_client.gui.widgets.ExportDialog') + fw._on_export_clicked() + dialog.assert_called_once_with(controller, file.uuid) + + +def test_FileWidget__on_export_clicked_missing_file(mocker, session, source): + """ + Ensure dialog does not open when the EXPORT button is clicked yet the file to export is missing + """ + file = factory.File(source=source['source'], is_downloaded=True) + session.add(file) + session.commit() + + get_file = mocker.MagicMock(return_value=file) + controller = mocker.MagicMock(get_file=get_file) + + fw = FileWidget(file.uuid, controller, mocker.MagicMock()) + fw.update = mocker.MagicMock() + mocker.patch('securedrop_client.gui.widgets.QDialog.exec') + controller.run_export_preflight_checks = mocker.MagicMock() + controller.downloaded_file_exists = mocker.MagicMock(return_value=False) + dialog = mocker.patch('securedrop_client.gui.widgets.ExportDialog') + + fw._on_export_clicked() + + controller.run_export_preflight_checks.assert_not_called() + dialog.assert_not_called() + def test_ExportDialog_export(mocker): """ diff --git a/tests/test_export.py b/tests/test_export.py index 659c2b03e..af735a914 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -36,13 +36,13 @@ def test_send_file_to_usb_device_error(mocker): export = Export() export.export_usb_call_failure = mocker.MagicMock() export.export_usb_call_failure.emit = mocker.MagicMock() - error = ExportError('bang') + error = ExportError('[mock_filepath]') _run_disk_export = mocker.patch.object(export, '_run_disk_export', side_effect=error) export.send_file_to_usb_device(['mock_filepath'], 'mock passphrase') _run_disk_export.assert_called_once_with('mock_temp_dir', ['mock_filepath'], 'mock passphrase') - export.export_usb_call_failure.emit.assert_called_once_with(error.status) + export.export_usb_call_failure.emit.assert_called_once_with(['mock_filepath']) def test_run_preflight_checks(mocker): diff --git a/tests/test_logic.py b/tests/test_logic.py index 2188d662c..7092c7a4d 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -592,8 +592,7 @@ def test_Controller_update_sync(homedir, config, mocker, session_maker): Cause the UI to update with the result of self.last_sync(). Using the `config` fixture to ensure the config is written to disk. """ - mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) co.last_sync = mocker.MagicMock() co.update_sync() assert co.last_sync.call_count == 1 @@ -884,12 +883,12 @@ def test_Controller_on_file_downloaded_api_failure(homedir, config, mocker, sess # signal when file is downloaded mock_file_ready = mocker.patch.object(co, 'file_ready') - mock_set_status = mocker.patch.object(co, 'set_status') + mock_update_error_status = mocker.patch.object(mock_gui, 'update_error_status') result_data = Exception('error message') co.on_file_download_failure(result_data) - mock_set_status.assert_called_once_with("The file download failed. Please try again.") + mock_update_error_status.assert_called_once_with("The file download failed. Please try again.") mock_file_ready.emit.assert_not_called() @@ -925,55 +924,179 @@ def test_Controller_on_file_downloaded_checksum_failure(homedir, config, mocker, def test_Controller_on_file_open(homedir, config, mocker, session, session_maker, source): """ - If running on Qubes, a new QProcess with the expected command and args - should be started. + If running on Qubes, a new QProcess with the expected command and args should be started when + the path to original_file does not exist. + Using the `config` fixture to ensure the config is written to disk. """ - mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) - co.proxy = True - - submission = factory.File(source=source['source']) - session.add(submission) + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.qubes = True + file = factory.File(source=source['source']) + file.original_filename = 'original_filename.mock' + session.add(file) session.commit() - + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) mock_subprocess = mocker.MagicMock() mock_process = mocker.MagicMock(return_value=mock_subprocess) mocker.patch('securedrop_client.logic.QProcess', mock_process) mock_link = mocker.patch('os.link') - co.on_file_open(submission.uuid) + + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(homedir, 'data', fn_no_ext) + with open(filepath, 'w'): + pass + + co.on_file_open(file.uuid) + + co.get_file.assert_called_with(file.uuid) mock_process.assert_called_once_with(co) assert mock_subprocess.start.call_count == 1 assert mock_link.call_count == 1 -def test_Controller_on_file_open_existing_link_problem( - homedir, config, mocker, session, session_maker, source, caplog -): +def test_Controller_on_file_open_not_qubes(homedir, config, mocker, session, session_maker, source): """ - Test that open works if the link to the original filename exists. + If not running on Qubes, a hard link to the file in the data dir should be created using the + original filename. """ - mock_gui = mocker.MagicMock() - co = Controller('http://localhost', mock_gui, session_maker, homedir) - co.proxy = True - - submission = factory.File(source=source['source']) - session.add(submission) + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.qubes = False + file = factory.File(source=source['source']) + file.original_filename = 'original_filename.mock' + session.add(file) session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mock_link = mocker.patch('os.link') + + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(homedir, 'data', fn_no_ext) + with open(filepath, 'w'): + pass + co.on_file_open(file.uuid) + + co.get_file.assert_called_with(file.uuid) + assert mock_link.call_count == 1 + + +def test_Controller_on_file_open_when_orig_file_already_exists( + homedir, config, mocker, session, session_maker, source +): + """ + If running on Qubes, a new QProcess with the expected command and args should be started when + the path to original_file already exists. + + Using the `config` fixture to ensure the config is written to disk. + """ + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.qubes = True + file = factory.File(source=source['source']) + file.original_filename = 'original_filename.mock' + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) mock_subprocess = mocker.MagicMock() mock_process = mocker.MagicMock(return_value=mock_subprocess) - mock_exists = mocker.patch('os.path.exists', return_value=True) - mock_link = mocker.patch('os.link') mocker.patch('securedrop_client.logic.QProcess', mock_process) - mock_remove = mocker.patch('os.remove') + mock_link = mocker.patch('os.link') + + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(homedir, 'data', fn_no_ext) + with open(filepath, 'w'): + pass - co.on_file_open(submission.uuid) + original_filepath = os.path.join(homedir, 'data', file.original_filename) + with open(original_filepath, 'w'): + pass + + co.on_file_open(file.uuid) + + co.get_file.assert_called_with(file.uuid) mock_process.assert_called_once_with(co) assert mock_subprocess.start.call_count == 1 - assert mock_exists.call_count == 1 - assert mock_link.call_count == 1 - assert mock_remove.call_count == 1 + assert mock_link.call_count == 0 + + +def test_Controller_on_file_open_when_orig_file_already_exists_not_qubes( + homedir, config, mocker, session, session_maker, source +): + """ + If not running on Qubes, a hard link to the file in the data dir should be created using the + original filename. + """ + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.qubes = False + file = factory.File(source=source['source']) + file.original_filename = 'original_filename.mock' + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mock_link = mocker.patch('os.link') + + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(homedir, 'data', fn_no_ext) + with open(filepath, 'w'): + pass + + original_filepath = os.path.join(homedir, 'data', file.original_filename) + with open(original_filepath, 'w'): + pass + + co.on_file_open(file.uuid) + + co.get_file.assert_called_with(file.uuid) + assert mock_link.call_count == 0 + + +def test_Controller_on_file_open_file_missing(mocker, homedir, session_maker, session, source): + """ + When file does not exist, test that we log and send status update to user. + """ + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.sync_api = mocker.MagicMock() + file = factory.File(source=source['source']) + file.original_filename = 'original_filename.mock' + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + debug_logger = mocker.patch('securedrop_client.logic.logger.debug') + co.sync_api = mocker.MagicMock() + + co.on_file_open(file.uuid) + + user_error = 'File does not exist in the data directory. Please try re-downloading.' + log_msg = 'Cannot find {} in the data directory. File does not exist.'.format( + file.original_filename) + co.gui.update_error_status.assert_called_once_with(user_error) + debug_logger.assert_called_once_with(log_msg) + co.sync_api.assert_called_once_with() + + +def test_Controller_on_file_open_file_missing_not_qubes( + mocker, homedir, session_maker, session, source +): + """ + When file does not exist on a non-qubes system, test that we log and send status update to user. + """ + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.qubes = False + co.sync_api = mocker.MagicMock() + file = factory.File(source=source['source']) + file.original_filename = 'original_filename.mock' + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + debug_logger = mocker.patch('securedrop_client.logic.logger.debug') + co.sync_api = mocker.MagicMock() + + co.on_file_open(file.uuid) + + user_error = 'File does not exist in the data directory. Please try re-downloading.' + log_msg = 'Cannot find {} in the data directory. File does not exist.'.format( + file.original_filename) + co.gui.update_error_status.assert_called_once_with(user_error) + debug_logger.assert_called_once_with(log_msg) + co.sync_api.assert_called_once_with() def test_Controller_download_new_replies_with_new_reply(mocker, session, session_maker, homedir): @@ -1418,30 +1541,43 @@ def test_Controller_call_update_star_success(homedir, config, mocker, session_ma co.on_update_star_failure, type=Qt.QueuedConnection) -def test_Controller_run_export_preflight_checks(homedir, mocker): +def test_Controller_run_export_preflight_checks(homedir, mocker, session, source): co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_preflight_check = mocker.MagicMock() co.export.begin_preflight_check.emit = mocker.MagicMock() + file = factory.File(source=source['source']) + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + co.run_export_preflight_checks() co.export.begin_usb_export.emit.call_count == 1 -def test_Controller_run_export_preflight_checks_not_qubes(homedir, mocker): +def test_Controller_run_export_preflight_checks_not_qubes(homedir, mocker, session, source): co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() co.export.begin_preflight_check = mocker.MagicMock() co.export.begin_preflight_check.emit = mocker.MagicMock() + file = factory.File(source=source['source']) + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + co.run_export_preflight_checks() co.export.begin_usb_export.emit.call_count == 0 def test_Controller_export_file_to_usb_drive(homedir, mocker, session): + """ + The signal `begin_usb_export` should be emmited during export_file_to_usb_drive. + """ co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.export = mocker.MagicMock() co.export.begin_usb_export = mocker.MagicMock() @@ -1450,14 +1586,23 @@ def test_Controller_export_file_to_usb_drive(homedir, mocker, session): session.add(file) session.commit() mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mock_link = mocker.patch('os.link') + + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(homedir, 'data', fn_no_ext) + with open(filepath, 'w'): + pass co.export_file_to_usb_drive(file.uuid, 'mock passphrase') - # Signal to begin the USB export should be emitted co.export.begin_usb_export.emit.call_count == 1 + assert mock_link.call_count == 1 def test_Controller_export_file_to_usb_drive_not_qubes(homedir, mocker, session): + """ + The signal `begin_usb_export` should be emmited during export_file_to_usb_drive. + """ co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) co.qubes = False co.export = mocker.MagicMock() @@ -1467,8 +1612,162 @@ def test_Controller_export_file_to_usb_drive_not_qubes(homedir, mocker, session) session.add(file) session.commit() mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mock_link = mocker.patch('os.link') + + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(homedir, 'data', fn_no_ext) + with open(filepath, 'w'): + pass co.export_file_to_usb_drive(file.uuid, 'mock passphrase') co.export.send_file_to_usb_device.assert_not_called() co.export.begin_usb_export.emit.call_count == 0 + assert mock_link.call_count == 1 + + +def test_Controller_export_file_to_usb_drive_file_missing(homedir, mocker, session, session_maker): + """ + If the file is missing from the data dir, is_downloaded should be set to False and the failure + should be communicated to the user. + """ + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.sync_api = mocker.MagicMock() + file = factory.File(source=factory.Source(), original_filename='mock_filename') + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + debug_logger = mocker.patch('securedrop_client.logic.logger.debug') + co.sync_api = mocker.MagicMock() + + co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + + user_error = 'File does not exist in the data directory. Please try re-downloading.' + log_msg = 'Cannot find {} in the data directory. File does not exist.'.format( + file.original_filename) + co.gui.update_error_status.assert_called_once_with(user_error) + debug_logger.assert_called_once_with(log_msg) + co.sync_api.assert_called_once_with() + + +def test_Controller_export_file_to_usb_drive_file_missing_not_qubes( + homedir, mocker, session, session_maker +): + """ + If the file is missing from the data dir, is_downloaded should be set to False and the failure + should be communicated to the user. + """ + co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir) + co.qubes = False + co.sync_api = mocker.MagicMock() + file = factory.File(source=factory.Source(), original_filename='mock_filename') + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + debug_logger = mocker.patch('securedrop_client.logic.logger.debug') + co.sync_api = mocker.MagicMock() + + co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + + user_error = 'File does not exist in the data directory. Please try re-downloading.' + log_msg = 'Cannot find {} in the data directory. File does not exist.'.format( + file.original_filename) + co.gui.update_error_status.assert_called_once_with(user_error) + debug_logger.assert_called_once_with(log_msg) + co.sync_api.assert_called_once_with() + + +def test_Controller_export_file_to_usb_drive_when_orig_file_already_exists( + homedir, config, mocker, session, session_maker, source +): + """ + The signal `begin_usb_export` should still be emmited if the original file already exists. + """ + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co.export = mocker.MagicMock() + co.export.begin_usb_export = mocker.MagicMock() + co.export.begin_usb_export.emit = mocker.MagicMock() + file = factory.File(source=factory.Source(), original_filename='mock_filename') + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch('os.path.exists', return_value=True) + mock_link = mocker.patch('os.link') + + co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + + co.export.begin_usb_export.emit.call_count == 1 + co.get_file.assert_called_with(file.uuid) + assert mock_link.call_count == 0 + + +def test_Controller_export_file_to_usb_drive_when_orig_file_already_exists_not_qubes( + homedir, config, mocker, session, session_maker, source +): + """ + The signal `begin_usb_export` should still be emmited if the original file already exists. + """ + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + co.qubes = False + co.export = mocker.MagicMock() + co.export.begin_usb_export = mocker.MagicMock() + co.export.begin_usb_export.emit = mocker.MagicMock() + file = factory.File(source=factory.Source(), original_filename='mock_filename') + session.add(file) + session.commit() + mocker.patch('securedrop_client.logic.Controller.get_file', return_value=file) + mocker.patch('os.path.exists', return_value=True) + mock_link = mocker.patch('os.link') + + fn_no_ext, dummy = os.path.splitext(os.path.splitext(file.filename)[0]) + filepath = os.path.join(homedir, 'data', fn_no_ext) + with open(filepath, 'w'): + pass + + original_filepath = os.path.join(homedir, 'data', file.original_filename) + with open(original_filepath, 'w'): + pass + + co.export_file_to_usb_drive(file.uuid, 'mock passphrase') + + co.export.begin_usb_export.emit.call_count == 1 + co.get_file.assert_called_with(file.uuid) + assert mock_link.call_count == 0 + + +def test_on_export_usb_call_success(mocker, homedir): + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + mocker.patch('os.path.exists', return_value=True) + os_remove = mocker.patch('os.remove') + + co.on_export_usb_call_success(['mock_filepath_1', 'mock_filepath_2']) + + assert os_remove.call_count == 2 + assert os_remove.call_args_list[0][0][0] == 'mock_filepath_1' + assert os_remove.call_args_list[1][0][0] == 'mock_filepath_2' + + +def test_on_export_usb_call_failure(mocker, homedir): + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + mocker.patch('os.path.exists', return_value=True) + os_remove = mocker.patch('os.remove') + + co.on_export_usb_call_failure(['mock_filepath_1', 'mock_filepath_2']) + + assert os_remove.call_count == 2 + assert os_remove.call_args_list[0][0][0] == 'mock_filepath_1' + assert os_remove.call_args_list[1][0][0] == 'mock_filepath_2' + + +def test_get_file(mocker, session, homedir): + co = Controller('http://localhost', mocker.MagicMock(), mocker.MagicMock(), homedir) + storage = mocker.patch('securedrop_client.logic.storage') + file = factory.File(source=factory.Source(), original_filename='mock_filename') + session.add(file) + session.commit() + storage.get_file = mocker.MagicMock(return_value=file) + + obj = co.get_file(file.uuid) + + storage.get_file.assert_called_once_with(co.session, file.uuid) + assert obj == file diff --git a/tests/test_storage.py b/tests/test_storage.py index 520d1a7d0..160e9096f 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -15,7 +15,7 @@ update_replies, find_or_create_user, find_new_messages, find_new_replies, \ delete_single_submission_or_reply_on_disk, rename_file, get_local_files, find_new_files, \ source_exists, set_message_or_reply_content, mark_as_downloaded, mark_as_decrypted, get_file, \ - get_message, get_reply, update_and_get_user + get_message, get_reply, update_and_get_user, update_missing_files, mark_as_not_downloaded from securedrop_client import db from tests import factory @@ -778,6 +778,22 @@ def test_find_new_messages(mocker, session): assert message.is_downloaded is False or message.is_decrypted is not True +def test_update_missing_files(mocker, homedir): + session = mocker.MagicMock() + file = mocker.MagicMock() + file.is_downloaded = True + files = [file] + session.query().filter_by().all.return_value = files + data_dir = os.path.join(homedir, 'data') + mocker.patch('os.path.splitext', return_value=('mock_filename', 'dummy')) + mocker.patch('os.path.exists', return_value=False) + mark_as_not_downloaded_fn = mocker.patch('securedrop_client.storage.mark_as_not_downloaded') + + update_missing_files(data_dir, session) + + mark_as_not_downloaded_fn.assert_called_once_with(file.uuid, session) + + def test_find_new_files(mocker, session): mock_session = mocker.MagicMock() mock_submission = mocker.MagicMock() @@ -865,6 +881,17 @@ def test_set_message_decryption_status_with_content_with_content(session, source assert message.content == 'mock_content' +def test_mark_file_as_not_downloaded(mocker): + session = mocker.MagicMock() + file = factory.File(source=factory.Source(), is_downloaded=True, is_decrypted=True) + session.query().filter_by().one.return_value = file + mark_as_not_downloaded('mock_uuid', session) + assert file.is_downloaded is False + assert file.is_decrypted is None + session.add.assert_called_once_with(file) + session.commit.assert_called_once_with() + + def test_mark_file_as_downloaded(mocker): session = mocker.MagicMock() file = factory.File(source=factory.Source(), is_downloaded=False)