Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add SendReplyJob and use it for sending replies to the general queue #401

Merged
merged 3 commits into from
Jun 6, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ mypy: ## Run static type checker
securedrop_client/queue.py \
securedrop_client/api_jobs/__init__.py \
securedrop_client/api_jobs/base.py \
securedrop_client/api_jobs/downloads.py
securedrop_client/api_jobs/downloads.py \
securedrop_client/api_jobs/uploads.py

.PHONY: clean
clean: ## Clean the workspace of generated resources
Expand Down
2 changes: 1 addition & 1 deletion securedrop_client/api_jobs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def call_api(self, api_client: API, session: Session) -> Any:
Method for making the actual API call and handling the result.

This MUST resturn a value if the API call and other tasks were successful and MUST raise
an exception if and only iff the tasks failed. Presence of a raise exception indicates a
an exception if and only if the tasks failed. Presence of a raise exception indicates a
failure.
'''
raise NotImplementedError
60 changes: 60 additions & 0 deletions securedrop_client/api_jobs/uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import logging
import sdclientapi
import traceback

from sdclientapi import API
from sqlalchemy.orm.session import Session

from securedrop_client.api_jobs.base import ApiJob
from securedrop_client.crypto import GpgHelper
from securedrop_client.db import Reply, Source

logger = logging.getLogger(__name__)


class SendReplyJob(ApiJob):
def __init__(
self,
source_uuid: str,
reply_uuid: str,
message: str,
gpg: GpgHelper,
) -> None:
super().__init__()
self.source_uuid = source_uuid
self.reply_uuid = reply_uuid
self.message = message
self.gpg = gpg

def call_api(self, api_client: API, session: Session) -> str:
try:
encrypted_reply = self.gpg.encrypt_to_source(self.source_uuid,
self.message)
except Exception:
tb = traceback.format_exc()
logger.error('Failed to encrypt to source {}:\n'.format(
self.source_uuid, tb))
# We raise the exception as it will get handled in ApiJob._do_call_api
# Exceptions must be raised for the failure signal to be emitted.
raise
else:
sdk_reply = self._make_call(encrypted_reply, api_client)

# Now that the call was successful, add the reply to the database locally.
source = session.query(Source).filter_by(uuid=self.source_uuid).one()

reply_db_object = Reply(
uuid=self.reply_uuid,
source_id=source.id,
journalist_id=api_client.token_journalist_uuid,
filename=sdk_reply.filename,
)
session.add(reply_db_object)
session.commit()

return reply_db_object.uuid

def _make_call(self, encrypted_reply: str, api_client: API) -> sdclientapi.Reply:
sdk_source = sdclientapi.Source(uuid=self.source_uuid)
return api_client.reply_source(sdk_source, encrypted_reply,
self.reply_uuid)
58 changes: 18 additions & 40 deletions securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import logging
import os
import sdclientapi
import traceback
import uuid

from gettext import gettext as _
Expand All @@ -32,9 +31,11 @@

from securedrop_client import storage
from securedrop_client import db
from securedrop_client.api_jobs.downloads import DownloadSubmissionJob
from securedrop_client.api_jobs.uploads import SendReplyJob
from securedrop_client.crypto import GpgHelper, CryptoError
from securedrop_client.message_sync import MessageSync, ReplySync
from securedrop_client.queue import ApiJobQueue, DownloadSubmissionJob
from securedrop_client.queue import ApiJobQueue
from securedrop_client.utils import check_dir_permissions

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -578,48 +579,25 @@ def delete_source(self, source):
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_success,
self.on_reply_failure,
sdk_source,
encrypted_reply,
msg_uuid,
current_object=(source_uuid, msg_uuid),
)
else: # pragma: no cover
logger.error('not logged in - not implemented!')
self.reply_failed.emit(msg_uuid)

def on_reply_success(self, result, current_object: Tuple[str, str]) -> None:
source_uuid, reply_uuid = current_object
source = self.session.query(db.Source).filter_by(uuid=source_uuid).one()

reply_db_object = db.Reply(
uuid=result.uuid,
source_id=source.id,
journalist_id=self.api.token_journalist_uuid,
filename=result.filename,
def send_reply(self, source_uuid: str, reply_uuid: str, message: str) -> None:
"""
Send a reply to a source.
"""
job = SendReplyJob(
source_uuid,
reply_uuid,
message,
self.gpg,
)
self.session.add(reply_db_object)
self.session.commit()
job.success_signal.connect(self.on_reply_success, type=Qt.QueuedConnection)
job.failure_signal.connect(self.on_reply_failure, type=Qt.QueuedConnection)

self.api_job_queue.enqueue(job)

def on_reply_success(self, reply_uuid: str) -> None:
self.reply_succeeded.emit(reply_uuid)

def on_reply_failure(self, result, current_object: Tuple[str, str]) -> None:
source_uuid, reply_uuid = current_object
def on_reply_failure(self, reply_uuid: str) -> None:
self.reply_failed.emit(reply_uuid)

def get_file(self, file_uuid: str) -> db.File:
Expand Down
97 changes: 97 additions & 0 deletions tests/api_jobs/test_uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import pytest
import sdclientapi

from securedrop_client import db
from securedrop_client.api_jobs.uploads import SendReplyJob
from securedrop_client.crypto import GpgHelper, CryptoError
from tests import factory


def test_send_reply_success(homedir, mocker, session, session_maker):
'''
Check that the "happy path" of encrypting a message and sending it to the
server behaves as expected.
'''
source = factory.Source()
session.add(source)
session.commit()

gpg = GpgHelper(homedir, session_maker, is_qubes=False)

api_client = mocker.MagicMock()
api_client.token_journalist_uuid = 'journalist ID sending the reply'

encrypted_reply = 's3kr1t m3ss1dg3'
mock_encrypt = mocker.patch.object(gpg, 'encrypt_to_source', return_value=encrypted_reply)
msg_uuid = 'xyz456'
msg = 'wat'

mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename='5-dummy-reply')
api_client.reply_source = mocker.MagicMock()
api_client.reply_source.return_value = mock_reply_response

mock_sdk_source = mocker.Mock()
mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source',
return_value=mock_sdk_source)

job = SendReplyJob(
source.uuid,
msg_uuid,
msg,
gpg,
)

job.call_api(api_client, session)

# ensure message gets encrypted
mock_encrypt.assert_called_once_with(source.uuid, msg)
mock_source_init.assert_called_once_with(uuid=source.uuid)

# assert reply got added to db
reply = session.query(db.Reply).filter_by(uuid=msg_uuid).one()
assert reply.journalist_id == api_client.token_journalist_uuid


def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker):
'''
Check that if gpg fails when sending a message, we do not call the API.
'''
source = factory.Source()
session.add(source)
session.commit()

gpg = GpgHelper(homedir, session_maker, is_qubes=False)

api_client = mocker.MagicMock()
api_client.token_journalist_uuid = 'journalist ID sending the reply'

mock_encrypt = mocker.patch.object(gpg, 'encrypt_to_source', side_effect=CryptoError)
msg_uuid = 'xyz456'
msg = 'wat'

mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename='5-dummy-reply')
api_client.reply_source = mocker.MagicMock()
api_client.reply_source.return_value = mock_reply_response

mock_sdk_source = mocker.Mock()
mock_source_init = mocker.patch('securedrop_client.logic.sdclientapi.Source',
return_value=mock_sdk_source)

job = SendReplyJob(
source.uuid,
msg_uuid,
msg,
gpg,
)

# Ensure that the CryptoError is raised so we can handle it in ApiJob._do_call_api
with pytest.raises(CryptoError):
job.call_api(api_client, session)

# Ensure we attempted to encrypt the message
mock_encrypt.assert_called_once_with(source.uuid, msg)
assert mock_source_init.call_count == 0

# Ensure reply did not get added to db
replies = session.query(db.Reply).filter_by(uuid=msg_uuid).all()
assert len(replies) == 0
Loading