def test_send_reply_failure_timeout_error( homedir, mocker, session, session_maker, reply_status_codes, exception ): """ Check that if the SendReplyJob api call fails because of a RequestTimeoutError or ServerConnectionError that a SendReplyJobTimeoutError is raised. """ source = factory.Source() session.add(source) draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() api_client = mocker.MagicMock() mocker.patch.object(api_client, "reply_source", side_effect=exception) gpg = GpgHelper(homedir, session_maker, is_qubes=False) encrypt_fn = mocker.patch.object(gpg, "encrypt_to_source") job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) with pytest.raises(SendReplyJobTimeoutError): job.call_api(api_client, session) encrypt_fn.assert_called_once_with(source.uuid, "mock_message") replies = session.query(db.Reply).filter_by(uuid="mock_reply_uuid").all() assert len(replies) == 0 # Ensure that the draft reply is still in the db drafts = session.query(db.DraftReply).filter_by(uuid="mock_reply_uuid").all() assert len(drafts) == 1
def test_send_reply_failure_unknown_error( homedir, mocker, session, session_maker, reply_status_codes ): """ Check that if the SendReplyJob api call fails when sending a message that SendReplyJobError is raised and the reply is not added to the local database. """ source = factory.Source() session.add(source) draft_reply = factory.DraftReply(uuid="mock_reply_uuid") session.add(draft_reply) session.commit() api_client = mocker.MagicMock() mocker.patch.object(api_client, "reply_source", side_effect=Exception) gpg = GpgHelper(homedir, session_maker, is_qubes=False) encrypt_fn = mocker.patch.object(gpg, "encrypt_to_source") job = SendReplyJob(source.uuid, "mock_reply_uuid", "mock_message", gpg) with pytest.raises(Exception): job.call_api(api_client, session) encrypt_fn.assert_called_once_with(source.uuid, "mock_message") replies = session.query(db.Reply).filter_by(uuid="mock_reply_uuid").all() assert len(replies) == 0 # Ensure that the draft reply is still in the db drafts = session.query(db.DraftReply).filter_by(uuid="mock_reply_uuid").all() assert len(drafts) == 1
def test_send_reply_failure_gpg_error(homedir, mocker, session, session_maker, reply_status_codes): ''' Check that if gpg fails when sending a message, we do not call the API, and ensure that SendReplyJobError is raised when there is a CryptoError so we can handle it in ApiJob._do_call_api. ''' source = factory.Source() session.add(source) msg_uuid = 'xyz456' draft_reply = factory.DraftReply(uuid=msg_uuid) session.add(draft_reply) 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 = '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, ) with pytest.raises(SendReplyJobError): 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 # Ensure that the draft reply is still in the db drafts = session.query(db.DraftReply).filter_by(uuid=msg_uuid).all() assert len(drafts) == 1
def test_send_reply_success(homedir, mocker, session, session_maker, reply_status_codes): ''' Check that the "happy path" of encrypting a message and sending it to the server behaves as expected. ''' source = factory.Source() session.add(source) msg_uuid = 'xyz456' draft_reply = factory.DraftReply(uuid=msg_uuid) session.add(draft_reply) 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 = '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_source_deleted_locally(homedir, mocker, session, session_maker, reply_status_codes): """ Check that if a source has been deleted then raise a SendReplyJobException so that the job is skipped. """ draft = factory.DraftReply() session.add(draft) session.commit() job = SendReplyJob("nonexistent_id", draft.uuid, "mock reply message", mocker.MagicMock()) error = "Failed to send reply {} for source {} due to Exception".format( draft.uuid, "nonexistent_id" ) with pytest.raises(SendReplyJobError, match=error): job.call_api(mocker.MagicMock(), session)
def test_pending_replies_are_marked_as_failed_on_logout_login( mocker, session, reply_status_codes): source = factory.Source() pending_status = (session.query(db.ReplySendStatus).filter_by( name=db.ReplySendStatusCodes.PENDING.value).one()) failed_status = (session.query(db.ReplySendStatus).filter_by( name=db.ReplySendStatusCodes.FAILED.value).one()) pending_draft_reply = factory.DraftReply(source=source, send_status=pending_status) session.add(source) session.add(pending_draft_reply) mark_all_pending_drafts_as_failed(session) for draft in session.query(db.DraftReply).all(): assert draft.send_status == failed_status
def test_send_reply_unexpected_exception_during_failure( homedir, mocker, session, session_maker, reply_status_codes): ''' Check that we do not raise an unhandled exception when we set the draft reply status to failed in the except block if there is an unexpected exception. ''' source = factory.Source() session.add(source) draft_reply = factory.DraftReply(uuid='mock_reply_uuid') session.add(draft_reply) session.commit() # session.commit() is called when we try to set the status to failed. session.commit = mocker.MagicMock(side_effect=Exception("BOOM")) gpg = GpgHelper(homedir, session_maker, is_qubes=False) job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) # This should not raise an exception job._set_status_to_failed(session)
def test_send_reply_failure_when_repr_is_none(homedir, mocker, session, session_maker, reply_status_codes): ''' Check that the SendReplyJob api call results in a SendReplyJobError and nothing else, e.g. no TypeError, when an api call results in an exception that returns None for __repr__ (regression test). ''' class MockException(Exception): def __repr__(self): return None source = factory.Source(uuid='mock_reply_uuid') session.add(source) draft_reply = factory.DraftReply(uuid='mock_reply_uuid') session.add(draft_reply) session.commit() api_client = mocker.MagicMock() mocker.patch.object(api_client, 'reply_source', side_effect=MockException('mock')) gpg = GpgHelper(homedir, session_maker, is_qubes=False) encrypt_fn = mocker.patch.object(gpg, 'encrypt_to_source') job = SendReplyJob(source.uuid, 'mock_reply_uuid', 'mock_message', gpg) error = 'Failed to send reply mock_reply_uuid for source {} due to Exception: mock'.format( source.uuid) with pytest.raises(SendReplyJobError, match=error): job.call_api(api_client, session) encrypt_fn.assert_called_once_with(source.uuid, 'mock_message') replies = session.query(db.Reply).filter_by(uuid='mock_reply_uuid').all() assert len(replies) == 0 # Ensure that the draft reply is still in the db drafts = session.query( db.DraftReply).filter_by(uuid='mock_reply_uuid').all() assert len(drafts) == 1
def test_drafts_ordering(homedir, mocker, session, session_maker, reply_status_codes): """ Check that if a reply is successful, drafts sent before and after continue to appear in the same order. """ initial_interaction_count = 1 source_uuid = "foo" source = factory.Source(uuid=source_uuid, interaction_count=initial_interaction_count) session.add(source) msg_uuid = "xyz456" draft_reply = factory.DraftReply(uuid=msg_uuid, file_counter=1) session.add(draft_reply) session.commit() # Draft reply from the previous queue job. draft_reply_before = factory.DraftReply( timestamp=draft_reply.timestamp - datetime.timedelta(minutes=1), source_id=source.id, file_counter=draft_reply.file_counter, uuid="foo", ) session.add(draft_reply_before) # Draft reply that the queue will operate on next. draft_reply_after = factory.DraftReply( timestamp=draft_reply.timestamp + datetime.timedelta(minutes=1), source_id=source.id, file_counter=draft_reply.file_counter, uuid="bar", ) session.add(draft_reply_after) 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 = "wat" mock_reply_response = sdclientapi.Reply(uuid=msg_uuid, filename="2-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 # We use the file_counter on each Reply, Message, File, and DraftReply # object to order the conversation view. We expect a unique file_counter # for Reply, Message, and File objects since they are retrieved from # the server. # For DraftReply, we don't have that unique constraint, and we instead expect # the file_counter to be the interaction_count at the time of send. # If we do not update the interaction_count after each successful reply send, # future drafts will have an interaction_count that is too low, leading # to incorrectly ordered drafts until a metadata sync completes (the metadata # sync is the place where the source object is updated from the server). source = session.query(db.Source).filter_by(uuid=source_uuid).one() assert source.interaction_count == initial_interaction_count + 1 # Check the ordering displayed to the user assert source.collection[0] == draft_reply_before assert source.collection[1] == reply assert source.collection[2] == draft_reply_after