diff --git a/README.md b/README.md index 02c3bafdc..80121b06d 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ This client is under active development and currently supports a minimal feature - the download and decryption of files, messages, and replies (using [Qubes split-gpg](https://www.qubes-os.org/doc/split-gpg/)) - the display of decrypted messages and replies in a new conversation view - the opening of all files in individual, non-networked, Qubes disposable VMs +- replying to sources +- deleting sources Features to be added include: -- Reply to sources (encrypted client-side) - tracked in https://github.com/freedomofpress/securedrop-client/issues/16. These replies will be encrypted both to individual sources, and to the submission key of the instance. Source public keys are provided by the journalist API. -- Deletion of source collection - tracked in https://github.com/freedomofpress/securedrop-client/issues/18. This will delete all files associated with a source both locally and on the server. - Export workflows - tracked in https://github.com/freedomofpress/securedrop-client/issues/21. These workflows (initially a USB drive) enable a journalist to transfer a document out of the Qubes workstation and to another computer for further analysis or sharing with the rest of the newsroom. ## Getting Started diff --git a/securedrop_client/db.py b/securedrop_client/db.py index 3d8368d32..f9982a237 100644 --- a/securedrop_client/db.py +++ b/securedrop_client/db.py @@ -120,20 +120,12 @@ class Reply(Base): "User", backref=backref('replies', order_by=id)) filename = Column(String(255), nullable=False) - size = Column(Integer, nullable=False) + size = Column(Integer) # This is whether the reply has been downloaded in the local database. is_downloaded = Column(Boolean(name='ck_replies_is_downloaded'), default=False) - def __init__(self, uuid, journalist, source, filename, size): - self.uuid = uuid - self.journalist_id = journalist.id - self.source_id = source.id - self.filename = filename - self.size = size - self.is_downloaded = False - def __repr__(self): return ''.format(self.filename) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index af0154f0c..dc772a045 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -23,7 +23,8 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \ QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, \ - QMessageBox, QToolButton, QSizePolicy + QMessageBox, QToolButton, QSizePolicy, QTextEdit +from uuid import uuid4 from securedrop_client.db import Source from securedrop_client.logic import Client @@ -37,8 +38,6 @@ class ToolBar(QWidget): """ Represents the tool bar across the top of the user interface. - - ToDo: this is a work in progress and will be updated soon. """ def __init__(self, parent): @@ -505,7 +504,7 @@ def __init__(self, message_id: str, text: str, update_signal) -> None: def _update_text(self, message_id: str, text: str) -> None: """ Conditionally update this SpeechBubble's text if and only if the message_id of the emitted - signal matche the message_id of this speech bubble. + signal matches the message_id of this speech bubble. """ if message_id == self.message_id: self.message.setText(html.escape(text, quote=False)) @@ -568,14 +567,45 @@ class ReplyWidget(ConversationWidget): Represents a reply to a source. """ - def __init__(self, message_id: str, message: str, update_signal) -> None: + def __init__( + self, + message_id: str, + message: str, + update_signal, + message_succeeded_signal, + message_failed_signal, + ) -> None: super().__init__(message_id, message, update_signal, align="right") + self.message_id = message_id self.setStyleSheet(""" background-color: #2299EE; """) + message_succeeded_signal.connect(self._on_reply_success) + message_failed_signal.connect(self._on_reply_failure) + + @pyqtSlot(str) + def _on_reply_success(self, message_id: str) -> None: + """ + Conditionally update this ReplyWidget's state if and only if the message_id of the emitted + signal matches the message_id of this widget. + """ + if message_id == self.message_id: + logger.debug('Message {} succeeded'.format(message_id)) + + @pyqtSlot(str) + def _on_reply_failure(self, message_id: str) -> None: + """ + Conditionally update this ReplyWidget's state if and only if the message_id of the emitted + signal matches the message_id of this widget. + """ + if message_id == self.message_id: + logger.debug('Message {} failed'.format(message_id)) + self.setStyleSheet(""" + background-color: #FF3E3C; + """) class FileWidget(QWidget): @@ -725,7 +755,12 @@ def add_reply(self, message_id: str, reply: str, files=None) -> None: Add a reply from a journalist. """ self.conversation_layout.addWidget( - ReplyWidget(message_id, reply, self.controller.reply_sync.reply_downloaded)) + ReplyWidget(message_id, + reply, + self.controller.reply_sync.reply_downloaded, + self.controller.reply_succeeded, + self.controller.reply_failed, + )) class SourceConversationWrapper(QWidget): @@ -736,14 +771,50 @@ class SourceConversationWrapper(QWidget): def __init__(self, source: Source, sdc_home: str, controller: Client, parent=None) -> None: super().__init__(parent) + self.source = source + self.controller = controller self.layout = QVBoxLayout() self.setLayout(self.layout) - self.conversation = ConversationView(source, sdc_home, controller, parent=self) - self.source_profile = SourceProfileShortWidget(source, controller) + self.conversation = ConversationView(self.source, sdc_home, self.controller, parent=self) + self.source_profile = SourceProfileShortWidget(self.source, self.controller) + self.reply_box = ReplyBoxWidget(self) self.layout.addWidget(self.source_profile) self.layout.addWidget(self.conversation) + self.layout.addWidget(self.reply_box) + + def send_reply(self, message: str) -> None: + msg_uuid = str(uuid4()) + self.conversation.add_reply(msg_uuid, message) + self.controller.send_reply(self.source.uuid, msg_uuid, message) + + +class ReplyBoxWidget(QWidget): + """ + A textbox where a journalist can enter a reply. + """ + + def __init__(self, conversation: SourceConversationWrapper) -> None: + super().__init__() + self.conversation = conversation + + self.text_edit = QTextEdit() + + self.send_button = QPushButton('Send') + self.send_button.clicked.connect(self.send_reply) + + layout = QHBoxLayout() + layout.addWidget(self.text_edit) + layout.addWidget(self.send_button) + self.setLayout(layout) + + def send_reply(self) -> None: + msg = self.text_edit.toPlainText().strip() + if not msg: + return + self.conversation.send_reply(msg) + self.text_edit.clear() class DeleteSourceAction(QAction): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 25dee0dbc..70fd04a4c 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -16,11 +16,12 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -import os +import arrow import logging +import os import sdclientapi import shutil -import arrow +import traceback import uuid from securedrop_client import storage from securedrop_client import db @@ -89,6 +90,18 @@ class Client(QObject): sync_events = pyqtSignal(str) + """ + Signal that notifies that a reply was accepted by the server. Emits the reply's UUID as a + string. + """ + reply_succeeded = pyqtSignal(str) + + """ + Signal that notifies that a reply failed to be accepted by the server. Emits the reply's UUID + as a string. + """ + reply_failed = pyqtSignal(str) + def __init__(self, hostname, gui, session, home: str, proxy: bool = True) -> None: """ @@ -642,3 +655,48 @@ def delete_source(self, source): self._on_delete_action_timeout, source ) + + def send_reply(self, source_uuid: str, msg_uuid: str, message: str) -> None: + sdk_source = sdclientapi.Source(uuid=source_uuid) + + try: + encrypted_reply = self.gpg.encrypt_to_source(source_uuid, message) + except Exception: + tb = traceback.format_exc() + logger.error('Failed to encrypt to source {}:\n'.format(source_uuid, tb)) + self.reply_failed.emit(msg_uuid) + else: + # Guard against calling the API if we're not logged in + if self.api: + self.call_api( + self.api.reply_source, + self._on_reply_complete, + self._on_reply_timeout, + sdk_source, + encrypted_reply, + msg_uuid, + current_object=(source_uuid, msg_uuid), + ) + else: + logger.error('not logged in - not implemented!') # pragma: no cover + self.reply_failed.emit(msg_uuid) # pragma: no cover + + def _on_reply_complete(self, result, current_object: (str, str)) -> None: + source_uuid, reply_uuid = current_object + source = self.session.query(db.Source).filter_by(uuid=source_uuid).one() + if isinstance(result, sdclientapi.Reply): + reply_db_object = db.Reply( + uuid=result.uuid, + source_id=source.id, + journalist_id=self.api.token['journalist_uuid'], + filename=result.filename, + ) + self.session.add(reply_db_object) + self.session.commit() + self.reply_succeeded.emit(reply_uuid) + else: + self.reply_failed.emit(reply_uuid) + + def _on_reply_timeout(self, current_object: (str, str)) -> None: + _, reply_uuid = current_object + self.reply_failed.emit(reply_uuid) diff --git a/securedrop_client/message_sync.py b/securedrop_client/message_sync.py index 41a8597a7..274bdd03b 100644 --- a/securedrop_client/message_sync.py +++ b/securedrop_client/message_sync.py @@ -20,6 +20,7 @@ import time import logging +import traceback import sdclientapi.sdlocalobjects as sdkobjects from PyQt5.QtCore import QObject, pyqtSignal @@ -88,10 +89,9 @@ def run(self, loop=True): storage.mark_file_as_downloaded) self.message_downloaded.emit(db_submission.uuid, get_data(self.home, db_submission.filename)) - except Exception as e: - logger.critical( - "Exception while downloading submission! {}".format(e) - ) + except Exception: + tb = traceback.format_exc() + logger.critical("Exception while downloading submission!\n{}".format(tb)) logger.debug('Completed message sync.') @@ -139,10 +139,9 @@ def run(self, loop=True): storage.mark_reply_as_downloaded) self.reply_downloaded.emit(db_reply.uuid, get_data(self.home, db_reply.filename)) - except Exception as e: - logger.critical( - "Exception while downloading reply! {}".format(e) - ) + except Exception: + tb = traceback.format_exc() + logger.critical("Exception while downloading reply!\n{}".format(tb)) logger.debug('Completed reply sync.') diff --git a/securedrop_client/storage.py b/securedrop_client/storage.py index 199e2fa9f..8949a4803 100644 --- a/securedrop_client/storage.py +++ b/securedrop_client/storage.py @@ -220,7 +220,11 @@ def update_replies(remote_replies, local_replies, session, data_dir): source = session.query(Source).filter_by(uuid=source_uuid)[0] user = find_or_create_user(reply.journalist_uuid, reply.journalist_username, session) - nr = Reply(reply.uuid, user, source, reply.filename, reply.size) + nr = Reply(uuid=reply.uuid, + journalist_id=user.id, + source_id=source.id, + filename=reply.filename, + size=reply.size) session.add(nr) logger.info('Added new reply {}'.format(reply.uuid)) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 4e7fe7eb9..c6a07a2cd 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -8,7 +8,8 @@ from securedrop_client import logic from securedrop_client.gui.widgets import ToolBar, MainView, SourceList, SourceWidget, \ LoginDialog, SpeechBubble, ConversationWidget, MessageWidget, ReplyWidget, FileWidget, \ - ConversationView, DeleteSourceMessageBox, DeleteSourceAction, SourceMenu + ConversationView, DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, \ + SourceConversationWrapper, ReplyBoxWidget app = QApplication([]) @@ -648,15 +649,31 @@ def test_ReplyWidget_init(mocker): """ Check the CSS is set as expected. """ - mock_signal = mocker.Mock() - mock_connected = mocker.Mock() - mock_signal.connect = mock_connected + mock_update_signal = mocker.Mock() + mock_update_connected = mocker.Mock() + mock_update_signal.connect = mock_update_connected - rw = ReplyWidget('mock id', 'hello', mock_signal) + mock_success_signal = mocker.MagicMock() + mock_success_connected = mocker.Mock() + mock_success_signal.connect = mock_success_connected + + mock_failure_signal = mocker.MagicMock() + mock_failure_connected = mocker.Mock() + mock_failure_signal.connect = mock_failure_connected + + rw = ReplyWidget( + 'mock id', + 'hello', + mock_update_signal, + mock_success_signal, + mock_failure_signal, + ) ss = rw.styleSheet() assert 'background-color' in ss - assert mock_connected.called + assert mock_update_connected.called + assert mock_success_connected.called + assert mock_failure_connected.called def test_FileWidget_init_left(mocker): @@ -1026,3 +1043,76 @@ def test_DeleteSource_from_source_widget_when_user_is_loggedout(mocker): source_widget.setup(mock_controller) source_widget.delete_source(mock_event) mock_delete_source_message_box_obj.launch.assert_not_called() + + +def test_SourceConversationWrapper_send_reply(mocker): + mock_source = mocker.Mock() + mock_source.uuid = 'abc123' + mock_source.collection = [] + mock_uuid = '456xyz' + mocker.patch('securedrop_client.gui.widgets.uuid4', return_value=mock_uuid) + mock_controller = mocker.MagicMock() + + cw = SourceConversationWrapper(mock_source, 'mock home', mock_controller) + mock_add_reply = mocker.Mock() + cw.conversation.add_reply = mock_add_reply + + msg = 'Alles für Alle' + cw.send_reply(msg) + + mock_add_reply.assert_called_once_with(mock_uuid, msg) + mock_controller.send_reply.assert_called_once_with(mock_source.uuid, mock_uuid, msg) + + +def test_ReplyBoxWidget_send_reply(mocker): + mock_conversation = mocker.Mock() + rw = ReplyBoxWidget(mock_conversation) + + # when empty, don't sent message + assert not rw.text_edit.toPlainText() # precondition + rw.send_reply() + assert not mock_conversation.send_reply.called + + # when only whitespace, don't sent message + rw.text_edit.setText(' \n\n ') + rw.send_reply() + assert not mock_conversation.send_reply.called + + # send send send send + msg = 'nein' + rw.text_edit.setText(msg) + rw.send_reply() + mock_conversation.send_reply.assert_called_once_with(msg) + + +def test_ReplyWidget_success_failure_slots(mocker): + mock_update_signal = mocker.Mock() + mock_success_signal = mocker.Mock() + mock_failure_signal = mocker.Mock() + msg_id = 'abc123' + + widget = ReplyWidget(msg_id, + 'lol', + mock_update_signal, + mock_success_signal, + mock_failure_signal) + + # ensure we have connected the slots + mock_success_signal.connect.assert_called_once_with(widget._on_reply_success) + mock_failure_signal.connect.assert_called_once_with(widget._on_reply_failure) + assert mock_update_signal.connect.called # to ensure no stale mocks + + # check the success slog + mock_logger = mocker.patch('securedrop_client.gui.widgets.logger') + widget._on_reply_success(msg_id + "x") + assert not mock_logger.debug.called + widget._on_reply_success(msg_id) + assert mock_logger.debug.called + mock_logger.reset_mock() + + # check the failure slot + mock_logger = mocker.patch('securedrop_client.gui.widgets.logger') + widget._on_reply_failure(msg_id + "x") + assert not mock_logger.debug.called + widget._on_reply_failure(msg_id) + assert mock_logger.debug.called diff --git a/tests/test_logic.py b/tests/test_logic.py index 2dfd8c9c1..5f08d8db1 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -5,6 +5,7 @@ import arrow import os import pytest +from sdclientapi import sdlocalobjects from tests import factory from securedrop_client import storage, db from securedrop_client.crypto import CryptoError @@ -1088,8 +1089,11 @@ def test_Client_on_file_download_Reply(homedir, config, mocker): cl = Client('http://localhost', mock_gui, mock_session, homedir) source = factory.Source() journalist = db.User('Testy mcTestface') - reply = db.Reply('reply-uuid', journalist, source, - 'my-reply.gpg', 123) # Not a sdclientapi.Submission + reply = db.Reply(uuid='reply-uuid', + journalist=journalist, + source=source, + filename='my-reply.gpg', + size=123) # Not a sdclientapi.Submission cl.call_api = mocker.MagicMock() cl.api = mocker.MagicMock() reply_sdk_object = mocker.MagicMock() @@ -1176,3 +1180,149 @@ def test_Client_delete_source(homedir, config, mocker): cl._on_delete_action_timeout, mock_source ) + + +def test_Client_send_reply_success(homedir, mocker): + ''' + Check that the "happy path" of encrypting a message and sending it to the sever behaves as + expected. + ''' + mock_gui = mocker.MagicMock() + mock_session = mocker.MagicMock() + + cl = Client('http://localhost', mock_gui, mock_session, homedir) + + cl.call_api = mocker.Mock() + cl.api = mocker.Mock() + encrypted_reply = 's3kr1t m3ss1dg3' + mock_encrypt = mocker.patch.object(cl.gpg, 'encrypt_to_source', return_value=encrypted_reply) + source_uuid = 'abc123' + msg_uuid = 'xyz456' + msg = 'wat' + mock_sdk_source = mocker.Mock() + mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source', + return_value=mock_sdk_source) + + cl.send_reply(source_uuid, msg_uuid, msg) + + # ensure message is encrypted + mock_encrypt.assert_called_once_with(source_uuid, msg) + + # ensure api is called + cl.call_api.assert_called_once_with( + cl.api.reply_source, + cl._on_reply_complete, + cl._on_reply_timeout, + mock_sdk_source, + encrypted_reply, + msg_uuid, + current_object=(source_uuid, msg_uuid), + ) + + assert mock_source_init.called # to prevent stale mocks + + +def test_Client_send_reply_gpg_error(homedir, mocker): + ''' + Check that if gpg fails when sending a message, we alert the UI and do *not* call the API. + ''' + mock_gui = mocker.MagicMock() + mock_session = mocker.MagicMock() + + cl = Client('http://localhost', mock_gui, mock_session, homedir) + + cl.call_api = mocker.Mock() + cl.api = mocker.Mock() + mock_encrypt = mocker.patch.object(cl.gpg, 'encrypt_to_source', side_effect=Exception) + source_uuid = 'abc123' + msg_uuid = 'xyz456' + msg = 'wat' + mock_sdk_source = mocker.Mock() + mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source', + return_value=mock_sdk_source) + mock_reply_failed = mocker.patch.object(cl, 'reply_failed') + + cl.send_reply(source_uuid, msg_uuid, msg) + + # ensure there is an attempt to encrypt the message + mock_encrypt.assert_called_once_with(source_uuid, msg) + + # ensure we emit a failure on gpg errors + mock_reply_failed.emit.assert_called_once_with(msg_uuid) + + # ensure api not is called after a gpg error + assert not cl.call_api.called + + assert mock_source_init.called # to prevent stale mocks + + +def test_Client_on_reply_complete_success(homedir, mocker): + ''' + Check that when the result is a success, the client emits the correct signal. + ''' + mock_gui = mocker.MagicMock() + mock_session = mocker.MagicMock() + mock_reply_init = mocker.patch('securedrop_client.logic.db.Reply') + + cl = Client('http://localhost', mock_gui, mock_session, homedir) + cl.api = mocker.Mock() + journalist_uuid = 'abc123' + cl.api.token = {'journalist_uuid': journalist_uuid} + mock_reply_succeeded = mocker.patch.object(cl, 'reply_succeeded') + mock_reply_failed = mocker.patch.object(cl, 'reply_failed') + + reply = sdlocalobjects.Reply(uuid='xyz456', filename='1-wat.gpg') + + source_uuid = 'foo111' + msg_uuid = 'bar222' + current_object = (source_uuid, msg_uuid) + cl._on_reply_complete(reply, current_object) + cl.session.commit.assert_called_once_with() + mock_reply_succeeded.emit.assert_called_once_with(msg_uuid) + assert not mock_reply_failed.emit.called + + assert mock_reply_init.called # to prevent stale mocks + + +def test_Client_on_reply_complete_failure(homedir, mocker): + ''' + Check that when the result is a failure, the client emits the correct signal. + ''' + mock_gui = mocker.MagicMock() + mock_session = mocker.MagicMock() + + cl = Client('http://localhost', mock_gui, mock_session, homedir) + cl.api = mocker.Mock() + journalist_uuid = 'abc123' + cl.api.token = {'journalist_uuid': journalist_uuid} + mock_reply_succeeded = mocker.patch.object(cl, 'reply_succeeded') + mock_reply_failed = mocker.patch.object(cl, 'reply_failed') + + source_uuid = 'foo111' + msg_uuid = 'bar222' + current_object = (source_uuid, msg_uuid) + cl._on_reply_complete(Exception, current_object) + mock_reply_failed.emit.assert_called_once_with(msg_uuid) + assert not mock_reply_succeeded.emit.called + + +def test_Client_on_reply_timeout(homedir, mocker): + ''' + Check that when the reply timesout, the correct signal is emitted. + ''' + mock_gui = mocker.MagicMock() + mock_session = mocker.MagicMock() + + cl = Client('http://localhost', mock_gui, mock_session, homedir) + cl.api = mocker.Mock() + journalist_uuid = 'abc123' + cl.api.token = {'journalist_uuid': journalist_uuid} + mock_reply_succeeded = mocker.patch.object(cl, 'reply_succeeded') + mock_reply_failed = mocker.patch.object(cl, 'reply_failed') + + source_uuid = 'foo111' + msg_uuid = 'bar222' + current_object = (source_uuid, msg_uuid) + cl._on_reply_timeout(current_object) + mock_reply_failed.emit.assert_called_once_with(msg_uuid) + assert not mock_reply_succeeded.emit.called