Skip to content

Commit 5a18a6d

Browse files
author
Allie Crevier
committed
add SendReplyJobException
1 parent 321846e commit 5a18a6d

File tree

6 files changed

+89
-75
lines changed

6 files changed

+89
-75
lines changed

securedrop_client/api_jobs/downloads.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def get_db_object(self, session: Session) -> Union[File, Message]:
5757

5858
def call_api(self, api_client: API, session: Session) -> Any:
5959
'''
60+
Override ApiJob.
61+
6062
Download and decrypt the file associated with the database object.
6163
'''
6264
db_object = self.get_db_object(session)

securedrop_client/api_jobs/uploads.py

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,59 @@
11
import logging
22
import sdclientapi
3-
import traceback
43

54
from sdclientapi import API
65
from sqlalchemy.orm.session import Session
76

87
from securedrop_client.api_jobs.base import ApiJob
9-
from securedrop_client.crypto import GpgHelper
8+
from securedrop_client.crypto import GpgHelper, CryptoError
109
from securedrop_client.db import Reply, Source
1110

1211
logger = logging.getLogger(__name__)
1312

1413

1514
class SendReplyJob(ApiJob):
16-
def __init__(
17-
self,
18-
source_uuid: str,
19-
reply_uuid: str,
20-
message: str,
21-
gpg: GpgHelper,
22-
) -> None:
15+
def __init__(self, source_uuid: str, reply_uuid: str, message: str, gpg: GpgHelper) -> None:
2316
super().__init__()
2417
self.source_uuid = source_uuid
2518
self.reply_uuid = reply_uuid
2619
self.message = message
2720
self.gpg = gpg
2821

2922
def call_api(self, api_client: API, session: Session) -> str:
23+
'''
24+
Override ApiJob.
25+
26+
Encrypt the reply and send it to the server. If the call is successful, add it to the local
27+
database. Otherwise raise a SendReplyJobException.
28+
'''
3029
try:
31-
encrypted_reply = self.gpg.encrypt_to_source(self.source_uuid,
32-
self.message)
33-
except Exception:
34-
tb = traceback.format_exc()
35-
logger.error('Failed to encrypt to source {}:\n'.format(
36-
self.source_uuid, tb))
37-
# We raise the exception as it will get handled in ApiJob._do_call_api
38-
# Exceptions must be raised for the failure signal to be emitted.
39-
raise
40-
else:
30+
encrypted_reply = self.gpg.encrypt_to_source(self.source_uuid, self.message)
4131
sdk_reply = self._make_call(encrypted_reply, api_client)
42-
43-
# Now that the call was successful, add the reply to the database locally.
44-
source = session.query(Source).filter_by(uuid=self.source_uuid).one()
45-
46-
reply_db_object = Reply(
47-
uuid=self.reply_uuid,
48-
source_id=source.id,
49-
journalist_id=api_client.token_journalist_uuid,
50-
filename=sdk_reply.filename,
51-
content=self.message,
52-
is_downloaded=True,
53-
is_decrypted=True
54-
)
55-
session.add(reply_db_object)
56-
session.commit()
57-
return reply_db_object.uuid
32+
# Now that the call was successful, add the reply to the database locally.
33+
source = session.query(Source).filter_by(uuid=self.source_uuid).one()
34+
reply_db_object = Reply(
35+
uuid=self.reply_uuid,
36+
source_id=source.id,
37+
journalist_id=api_client.token_journalist_uuid,
38+
filename=sdk_reply.filename,
39+
content=self.message,
40+
is_downloaded=True,
41+
is_decrypted=True
42+
)
43+
session.add(reply_db_object)
44+
session.commit()
45+
return reply_db_object.uuid
46+
except CryptoError as ce:
47+
raise SendReplyJobException(str(ce), self.reply_uuid)
48+
except Exception as e:
49+
raise SendReplyJobException(str(e), self.reply_uuid)
5850

5951
def _make_call(self, encrypted_reply: str, api_client: API) -> sdclientapi.Reply:
6052
sdk_source = sdclientapi.Source(uuid=self.source_uuid)
61-
return api_client.reply_source(sdk_source, encrypted_reply,
62-
self.reply_uuid)
53+
return api_client.reply_source(sdk_source, encrypted_reply, self.reply_uuid)
54+
55+
56+
class SendReplyJobException(Exception):
57+
def __init__(self, message: str, reply_uuid: str):
58+
super().__init__(message)
59+
self.reply_uuid = reply_uuid

securedrop_client/logic.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
GNU Affero General Public License for more details.
1515
1616
You should have received a copy of the GNU Affero General Public License
17-
along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
along with this program. If not, see <fhttp://www.gnu.org/licenses/>.
1818
"""
1919
import arrow
2020
import inspect
@@ -33,7 +33,7 @@
3333
from securedrop_client import db
3434
from securedrop_client.api_jobs.downloads import FileDownloadJob, MessageDownloadJob, \
3535
ReplyDownloadJob
36-
from securedrop_client.api_jobs.uploads import SendReplyJob
36+
from securedrop_client.api_jobs.uploads import SendReplyJob, SendReplyJobException
3737
from securedrop_client.crypto import GpgHelper, CryptoError
3838
from securedrop_client.queue import ApiJobQueue
3939
from securedrop_client.utils import check_dir_permissions
@@ -619,13 +619,12 @@ def send_reply(self, source_uuid: str, reply_uuid: str, message: str) -> None:
619619
self.api_job_queue.enqueue(job)
620620

621621
def on_reply_success(self, reply_uuid: str) -> None:
622-
logger.debug('Reply send success: {}'.format(reply_uuid))
622+
logger.debug('{} sent successfully'.format(reply_uuid))
623623
self.reply_succeeded.emit(reply_uuid)
624624

625-
def on_reply_failure(self, reply_uuid: str) -> None:
626-
self.set_status(_('Reply failed to send'))
627-
logger.debug('Reply send failure: {}'.format(reply_uuid))
628-
self.reply_failed.emit(reply_uuid)
625+
def on_reply_failure(self, exception: SendReplyJobException) -> None:
626+
logger.debug('{} failed to send'.format(exception.reply_uuid))
627+
self.reply_failed.emit(exception.reply_uuid)
629628

630629
def get_file(self, file_uuid: str) -> db.File:
631630
file = storage.get_file(self.session, file_uuid)

tests/api_jobs/test_downloads.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,6 @@ def test_MessageDownloadJob_with_base_error(mocker, homedir, session, session_ma
224224
gpg = GpgHelper(homedir, session_maker, is_qubes=False)
225225
job = MessageDownloadJob(message.uuid, homedir, gpg)
226226
api_client = mocker.MagicMock()
227-
api_client = mocker.MagicMock()
228227
mocker.patch.object(api_client, 'download_submission', side_effect=BaseError)
229228
decrypt_fn = mocker.patch.object(job.gpg, 'decrypt_submission_or_reply')
230229

@@ -460,8 +459,7 @@ def test_FileDownloadJob_decryption_error(mocker, homedir, session, session_make
460459
session.commit()
461460

462461
gpg = GpgHelper(homedir, session_maker, is_qubes=False)
463-
mock_decrypt = mocker.patch.object(gpg, 'decrypt_submission_or_reply',
464-
side_effect=CryptoError)
462+
mock_decrypt = mocker.patch.object(gpg, 'decrypt_submission_or_reply', side_effect=CryptoError)
465463

466464
def fake_download(sdk_obj: SdkSubmission) -> Tuple[str, str]:
467465
'''

tests/api_jobs/test_uploads.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sdclientapi
33

44
from securedrop_client import db
5-
from securedrop_client.api_jobs.uploads import SendReplyJob
5+
from securedrop_client.api_jobs.uploads import SendReplyJob, SendReplyJobException
66
from securedrop_client.crypto import GpgHelper, CryptoError
77
from tests import factory
88

@@ -54,7 +54,9 @@ def test_send_reply_success(homedir, mocker, session, session_maker):
5454

5555
def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker):
5656
'''
57-
Check that if gpg fails when sending a message, we do not call the API.
57+
Check that if gpg fails when sending a message, we do not call the API, and ensure that
58+
SendReplyJobException is raised when there is a CryptoError so we can handle it in
59+
ApiJob._do_call_api.
5860
'''
5961
source = factory.Source()
6062
session.add(source)
@@ -84,8 +86,7 @@ def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker):
8486
gpg,
8587
)
8688

87-
# Ensure that the CryptoError is raised so we can handle it in ApiJob._do_call_api
88-
with pytest.raises(CryptoError):
89+
with pytest.raises(SendReplyJobException):
8990
job.call_api(api_client, session)
9091

9192
# Ensure we attempted to encrypt the message
@@ -95,3 +96,25 @@ def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker):
9596
# Ensure reply did not get added to db
9697
replies = session.query(db.Reply).filter_by(uuid=msg_uuid).all()
9798
assert len(replies) == 0
99+
100+
101+
def test_send_reply_failure_unknown_error(homedir, mocker, session, session_maker):
102+
'''
103+
Check that if the reply_source api call fails when sending a message that SendReplyJobException
104+
is raised and the reply is not added to the local database.
105+
'''
106+
source = factory.Source()
107+
session.add(source)
108+
session.commit()
109+
api_client = mocker.MagicMock()
110+
mocker.patch.object(api_client, 'reply_source', side_effect=Exception)
111+
gpg = GpgHelper(homedir, session_maker, is_qubes=False)
112+
encrypt_fn = mocker.patch.object(gpg, 'encrypt_to_source')
113+
job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg)
114+
115+
with pytest.raises(Exception):
116+
job.call_api(api_client, session)
117+
118+
encrypt_fn.assert_called_once_with(source.uuid, 'mock_message')
119+
replies = session.query(db.Reply).filter_by(uuid='mock_reply_uuid').all()
120+
assert len(replies) == 0

tests/test_logic.py

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import arrow
66
import os
77
import pytest
8-
import sdclientapi
98

109
from PyQt5.QtCore import Qt
1110
from sdclientapi import RequestTimeoutError
@@ -14,6 +13,7 @@
1413
from securedrop_client import storage, db
1514
from securedrop_client.crypto import CryptoError
1615
from securedrop_client.logic import APICallRunner, Controller
16+
from securedrop_client.api_jobs.uploads import SendReplyJobException
1717

1818
with open(os.path.join(os.path.dirname(__file__), 'files', 'test-key.gpg.pub.asc')) as f:
1919
PUB_KEY = f.read()
@@ -1152,39 +1152,34 @@ def test_Controller_on_reply_success(homedir, mocker, session_maker, session):
11521152
'''
11531153
Check that when the method is called, the client emits the correct signal.
11541154
'''
1155-
1156-
mock_gui = mocker.MagicMock()
1157-
1158-
co = Controller('http://localhost', mock_gui, session_maker, homedir)
1159-
co.api = mocker.Mock()
1160-
1161-
reply = sdclientapi.Reply(uuid='wat', filename='1-lol')
1162-
1163-
mock_reply_succeeded = mocker.patch.object(co, 'reply_succeeded')
1164-
mock_reply_failed = mocker.patch.object(co, 'reply_failed')
1155+
co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir)
1156+
reply_succeeded = mocker.patch.object(co, 'reply_succeeded')
1157+
reply_failed = mocker.patch.object(co, 'reply_failed')
1158+
reply = factory.Reply(source=factory.Source())
1159+
debug_logger = mocker.patch('securedrop_client.logic.logger.debug')
11651160

11661161
co.on_reply_success(reply.uuid)
1167-
mock_reply_succeeded.emit.assert_called_once_with(reply.uuid)
1168-
assert not mock_reply_failed.emit.called
1162+
1163+
debug_logger.assert_called_once_with('{} sent successfully'.format(reply.uuid))
1164+
reply_succeeded.emit.assert_called_once_with(reply.uuid)
1165+
reply_failed.emit.assert_not_called()
11691166

11701167

11711168
def test_Controller_on_reply_failure(homedir, mocker, session_maker):
11721169
'''
11731170
Check that when the method is called, the client emits the correct signal.
11741171
'''
1175-
mock_gui = mocker.MagicMock()
1176-
1177-
co = Controller('http://localhost', mock_gui, session_maker, homedir)
1178-
co.api = mocker.Mock()
1179-
1180-
reply = sdclientapi.Reply(uuid='wat', filename='1-lol')
1172+
co = Controller('http://localhost', mocker.MagicMock(), session_maker, homedir)
1173+
reply_succeeded = mocker.patch.object(co, 'reply_succeeded')
1174+
reply_failed = mocker.patch.object(co, 'reply_failed')
1175+
debug_logger = mocker.patch('securedrop_client.logic.logger.debug')
11811176

1182-
mock_reply_succeeded = mocker.patch.object(co, 'reply_succeeded')
1183-
mock_reply_failed = mocker.patch.object(co, 'reply_failed')
1177+
exception = SendReplyJobException('mock_error_message', 'mock_reply_uuid')
1178+
co.on_reply_failure(exception)
11841179

1185-
co.on_reply_failure(reply.uuid)
1186-
mock_reply_failed.emit.assert_called_once_with(reply.uuid)
1187-
assert not mock_reply_succeeded.emit.called
1180+
debug_logger.assert_called_once_with('{} failed to send'.format('mock_reply_uuid'))
1181+
reply_failed.emit.assert_called_once_with('mock_reply_uuid')
1182+
reply_succeeded.emit.assert_not_called()
11881183

11891184

11901185
def test_Controller_is_authenticated_property(homedir, mocker, session_maker):

0 commit comments

Comments
 (0)