From fe703fe9ab0b63ab26ba41de3c5e9d935f5dba4d Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Tue, 29 Sep 2020 00:38:26 -0700 Subject: [PATCH] keep user auth and reply badges up-to-date --- securedrop_client/db.py | 16 +- securedrop_client/gui/widgets.py | 174 ++++++- securedrop_client/logic.py | 23 +- .../resources/css/reply_status_bar.css | 14 + securedrop_client/resources/css/sdclient.css | 1 + .../resources/css/sender_icon.css | 49 ++ .../resources/images/deleted-user.png | Bin 0 -> 8938 bytes tests/gui/test_widgets.py | 467 +++++++++++++++--- tests/integration/conftest.py | 5 + .../integration/test_styles_reply_message.py | 4 +- .../test_styles_reply_status_bar.py | 33 +- tests/test_logic.py | 111 ++++- tests/test_storage.py | 10 + 13 files changed, 803 insertions(+), 104 deletions(-) create mode 100644 securedrop_client/resources/css/sender_icon.css create mode 100644 securedrop_client/resources/images/deleted-user.png diff --git a/securedrop_client/db.py b/securedrop_client/db.py index c766aee105..da1bbd5c65 100644 --- a/securedrop_client/db.py +++ b/securedrop_client/db.py @@ -1,7 +1,7 @@ import datetime import os from enum import Enum -from typing import Any, List, Union # noqa: F401 +from typing import Any, List, Optional, Union # noqa: F401 from sqlalchemy import ( Boolean, @@ -280,7 +280,7 @@ class Reply(Base): ) journalist_id = Column(Integer, ForeignKey("users.id")) - journalist = relationship("User", backref=backref("replies", order_by=id)) + journalist = relationship("User", backref=backref("replies", order_by=id), lazy="joined") filename = Column(String(255), nullable=False) file_counter = Column(Integer, nullable=False) @@ -462,7 +462,13 @@ def __repr__(self) -> str: return "".format(self.uuid, self.username) @property - def fullname(self) -> str: + def deleted(self) -> bool: + return True if self.uuid == "deleted" else False + + @property + def fullname(self) -> Optional[str]: + if self.deleted: + return None if self.firstname and self.lastname: return self.firstname + " " + self.lastname elif self.firstname: @@ -473,7 +479,9 @@ def fullname(self) -> str: return self.username @property - def initials(self) -> str: + def initials(self) -> Optional[str]: + if self.deleted: + return None if self.firstname and self.lastname: return self.firstname[0].lower() + self.lastname[0].lower() elif self.firstname and len(self.firstname) >= 2: diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 5774ceb9fe..884666ff7f 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -427,11 +427,17 @@ def __init__(self): layout.addWidget(self.user_button, alignment=Qt.AlignTop) def setup(self, window, controller): + self.controller = controller + self.controller.update_authenticated_user.connect(self._on_update_authenticated_user) self.user_button.setup(controller) self.login_button.setup(window) + @pyqtSlot(User) + def _on_update_authenticated_user(self, db_user: User) -> None: + self.set_user(db_user) + def set_user(self, db_user: User): - self.user_icon.setText(_(db_user.initials)) + self.user_icon.setText(db_user.initials) self.user_button.set_username(db_user.fullname) def show(self): @@ -1639,6 +1645,59 @@ def validate(self): self.error(_("Please enter a username, passphrase and " "two-factor code.")) +class SenderIcon(QWidget): + """ + Represents a reply to a source. + """ + + SENDER_ICON_CSS = load_css("sender_icon.css") + + def __init__(self) -> None: + super().__init__() + self.sender_is_current_user = False + self.setObjectName("SenderIcon") + self.setStyleSheet(self.SENDER_ICON_CSS) + self.setFixedSize(QSize(48, 48)) + font = QFont() + font.setLetterSpacing(QFont.AbsoluteSpacing, 0.58) + self.label = QLabel() + self.label.setAlignment(Qt.AlignCenter) + self.label.setFont(font) + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.label) + self.setLayout(layout) + + def set_sender(self, sender: User, sender_is_current_user: bool) -> None: + self.sender_is_current_user = sender_is_current_user + if not sender or not sender.initials: + self.label.setPixmap(load_image("deleted-user.png")) + else: + self.label.setText(sender.initials) + + def set_normal_styles(self): + self.setStyleSheet("") + if self.sender_is_current_user: + self.setObjectName("SenderIcon_current_user") + else: + self.setObjectName("SenderIcon") + self.setStyleSheet(self.SENDER_ICON_CSS) + + def set_failed_styles(self): + self.setStyleSheet("") + self.setObjectName("SenderIcon_failed") + self.setStyleSheet(self.SENDER_ICON_CSS) + + def set_pending_styles(self): + self.setStyleSheet("") + if self.sender_is_current_user: + self.setObjectName("SenderIcon_current_user_pending") + else: + self.setObjectName("SenderIcon_pending") + self.setStyleSheet(self.SENDER_ICON_CSS) + + class SpeechBubble(QWidget): """ Represents a speech bubble that's part of a conversation between a source @@ -1682,6 +1741,10 @@ def __init__( self.color_bar.setObjectName("SpeechBubble_status_bar") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) + # User icon + self.sender_icon = SenderIcon() + self.sender_icon.hide() + # Speech bubble self.speech_bubble = QWidget() self.speech_bubble.setObjectName("SpeechBubble_container") @@ -1698,6 +1761,7 @@ def __init__( self.bubble_area_layout = QHBoxLayout() self.bubble_area_layout.setContentsMargins(0, self.TOP_MARGIN, 0, self.BOTTOM_MARGIN) bubble_area.setLayout(self.bubble_area_layout) + self.bubble_area_layout.addWidget(self.sender_icon, alignment=Qt.AlignBottom) self.bubble_area_layout.addWidget(self.speech_bubble) # Add widget to layout @@ -1779,6 +1843,7 @@ class ReplyWidget(SpeechBubble): def __init__( self, + controller: Controller, message_uuid: str, message: str, reply_status: str, @@ -1787,11 +1852,16 @@ def __init__( message_succeeded_signal, message_failed_signal, index: int, + sender: User, + sender_is_current_user: bool, error: bool = False, ) -> None: super().__init__(message_uuid, message, update_signal, download_error_signal, index, error) + self.controller = controller + self.status = reply_status self.uuid = message_uuid - + self.sender = sender + self.sender_is_current_user = sender_is_current_user self.error = QWidget() error_layout = QHBoxLayout() error_layout.setContentsMargins(0, 0, 0, 0) @@ -1806,23 +1876,21 @@ def __init__( self.error.hide() self.bubble_area_layout.addWidget(self.error) + self.sender_icon.set_sender(sender, self.sender_is_current_user) + self.sender_icon.show() message_succeeded_signal.connect(self._on_reply_success) message_failed_signal.connect(self._on_reply_failure) + self.controller.update_authenticated_user.connect(self._on_update_authenticated_user) - self._set_reply_state(reply_status) + self._set_reply_state() - def _set_reply_state(self, status: str) -> None: - logger.debug(f"Setting ReplyWidget state: {status}") - - if status == "SUCCEEDED": - self.set_normal_styles() - self.error.hide() - elif status == "FAILED": - self.set_failed_styles() - self.error.show() - elif status == "PENDING": - self.set_pending_styles() + @pyqtSlot(User) + def _on_update_authenticated_user(self, db_user: User) -> None: + sender_is_current_user = True if db_user.uuid == self.sender.uuid else False + self.sender_is_current_user = sender_is_current_user + self.sender_icon.sender_is_current_user = sender_is_current_user + self._set_reply_state() @pyqtSlot(str, str, str) def _on_reply_success(self, source_id: str, message_uuid: str, content: str) -> None: @@ -1831,7 +1899,8 @@ def _on_reply_success(self, source_id: str, message_uuid: str, content: str) -> signal matches the uuid of this widget. """ if message_uuid == self.uuid: - self._set_reply_state("SUCCEEDED") + self.status = "SUCCEEDED" + self._set_reply_state() @pyqtSlot(str) def _on_reply_failure(self, message_uuid: str) -> None: @@ -1840,30 +1909,54 @@ def _on_reply_failure(self, message_uuid: str) -> None: signal matches the uuid of this widget. """ if message_uuid == self.uuid: - self._set_reply_state("FAILED") + self.status = "FAILED" + self._set_reply_state() + + def _set_reply_state(self) -> None: + if self.status == "SUCCEEDED": + self.set_normal_styles() + self.error.hide() + elif self.status == "PENDING": + self.set_pending_styles() + elif self.status == "FAILED": + self.set_failed_styles() + self.error.show() def set_normal_styles(self): self.message.setStyleSheet("") self.message.setObjectName("ReplyWidget_message") self.message.setStyleSheet(self.MESSAGE_CSS) + + self.sender_icon.set_normal_styles() + self.color_bar.setStyleSheet("") - self.color_bar.setObjectName("ReplyWidget_status_bar") + if self.sender_is_current_user: + self.color_bar.setObjectName("ReplyWidget_status_bar_current_user") + else: + self.color_bar.setObjectName("ReplyWidget_status_bar") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) - def set_failed_styles(self): + def set_pending_styles(self): self.message.setStyleSheet("") - self.message.setObjectName("ReplyWidget_message_failed") + self.message.setObjectName("ReplyWidget_message_pending") self.message.setStyleSheet(self.MESSAGE_CSS) + + self.sender_icon.set_pending_styles() + self.color_bar.setStyleSheet("") - self.color_bar.setObjectName("ReplyWidget_status_bar_failed") + if self.sender_is_current_user: + self.color_bar.setObjectName("ReplyWidget_status_bar_pending_current_user") + else: + self.color_bar.setObjectName("ReplyWidget_status_bar_pending") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) - def set_pending_styles(self): + def set_failed_styles(self): self.message.setStyleSheet("") - self.message.setObjectName("ReplyWidget_message_pending") + self.message.setObjectName("ReplyWidget_message_failed") self.message.setStyleSheet(self.MESSAGE_CSS) + self.sender_icon.set_failed_styles() self.color_bar.setStyleSheet("") - self.color_bar.setObjectName("ReplyWidget_status_bar_pending") + self.color_bar.setObjectName("ReplyWidget_status_bar_failed") self.color_bar.setStyleSheet(self.STATUS_BAR_CSS) @@ -2778,12 +2871,22 @@ def update_conversation(self, collection: list) -> None: item_widget.message.text() != conversation_item.content ) and conversation_item.content: item_widget.message.setText(conversation_item.content) + + # Keep reply sender information up-to-date + if isinstance(item_widget, ReplyWidget): + self.controller.session.refresh(conversation_item.journalist) + if self.controller.authenticated_user == conversation_item.journalist: + current_user = True + self.controller.update_authenticated_user.emit(conversation_item.journalist) + else: + current_user = False + item_widget.sender_icon.set_sender(conversation_item.journalist, current_user) else: # add a new item to be displayed. if isinstance(conversation_item, Message): self.add_message(conversation_item, index) elif isinstance(conversation_item, (DraftReply, Reply)): - self.add_reply(conversation_item, index) + self.add_reply(conversation_item, conversation_item.journalist, index) else: self.add_file(conversation_item, index) @@ -2839,7 +2942,7 @@ def add_message(self, message: Message, index) -> None: self.current_messages[message.uuid] = conversation_item self.conversation_updated.emit() - def add_reply(self, reply: Union[DraftReply, Reply], index) -> None: + def add_reply(self, reply: Union[DraftReply, Reply], sender: User, index: int) -> None: """ Add a reply from a journalist to the source. """ @@ -2848,8 +2951,16 @@ def add_reply(self, reply: Union[DraftReply, Reply], index) -> None: except AttributeError: send_status = "SUCCEEDED" - logger.debug("adding reply: with status {}".format(send_status)) + if ( + self.controller.authenticated_user + and self.controller.authenticated_user.id == reply.journalist_id + ): + sender_is_current_user = True + else: + sender_is_current_user = False + conversation_item = ReplyWidget( + self.controller, reply.uuid, str(reply), send_status, @@ -2858,8 +2969,11 @@ def add_reply(self, reply: Union[DraftReply, Reply], index) -> None: self.controller.reply_succeeded, self.controller.reply_failed, index, + sender, + sender_is_current_user, getattr(reply, "download_error", None) is not None, ) + self.scroll.add_widget_to_conversation(index, conversation_item, Qt.AlignRight) self.current_messages[reply.uuid] = conversation_item @@ -2867,8 +2981,13 @@ def add_reply_from_reply_box(self, uuid: str, content: str) -> None: """ Add a reply from the reply box. """ + if not self.controller.authenticated_user: + logger.error("User is no longer authenticated so cannot send reply.") + return + index = len(self.current_messages) conversation_item = ReplyWidget( + self.controller, uuid, content, "PENDING", @@ -2877,6 +2996,8 @@ def add_reply_from_reply_box(self, uuid: str, content: str) -> None: self.controller.reply_succeeded, self.controller.reply_failed, index, + self.controller.authenticated_user, + True, ) self.scroll.add_widget_to_conversation(index, conversation_item, Qt.AlignRight) self.current_messages[uuid] = conversation_item @@ -3049,6 +3170,7 @@ def send_reply(self) -> None: self.controller.send_reply(self.source.uuid, reply_uuid, reply_text) self.reply_sent.emit(self.source.uuid, reply_uuid, reply_text) + @pyqtSlot(bool) def _on_authentication_changed(self, authenticated: bool) -> None: try: self.update_authentication_state(authenticated) diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 46dbac9af9..fa0ddb2fbc 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -131,11 +131,20 @@ class Controller(QObject): """ A signal that emits a signal when the authentication state changes. - - `True` when the client becomes authenticated - - `False` when the client becomes unauthenticated + + Emits: + bool: is_authenticated """ authentication_state = pyqtSignal(bool) + """ + A signal that emits a signal when the authenticated user name changes. + + Emits: + db.User: the authenticated user + """ + update_authenticated_user = pyqtSignal(db.User) + """ This signal indicates that a reply was successfully sent and received by the server. @@ -469,6 +478,7 @@ def on_authenticate_success(self, result): ) self.authenticated_user = user + self.update_authenticated_user.emit(user) # Clear clipboard contents in case of previously pasted creds self.gui.clear_clipboard() @@ -551,6 +561,15 @@ def on_sync_success(self) -> None: self.download_new_messages() self.download_new_replies() self.sync_events.emit("synced") + + if self.authenticated_user: + fullname = self.authenticated_user.fullname + username = self.authenticated_user.username + self.session.refresh(self.authenticated_user) + user = self.session.query(db.User).filter_by(uuid=self.authenticated_user.uuid).one() + if user.fullname != fullname or user.username != username: + self.update_authenticated_user.emit(self.authenticated_user) + self.resume_queues() def on_sync_failure(self, result: Exception) -> None: diff --git a/securedrop_client/resources/css/reply_status_bar.css b/securedrop_client/resources/css/reply_status_bar.css index 698310889b..c7b657d339 100644 --- a/securedrop_client/resources/css/reply_status_bar.css +++ b/securedrop_client/resources/css/reply_status_bar.css @@ -17,4 +17,18 @@ max-height: 5px; background-color: #ff3366; border: 0px; +} + +#ReplyWidget_status_bar_current_user { + min-height: 5px; + max-height: 5px; + background-color: #9211ff; + border: 0px; +} + +#ReplyWidget_status_bar_pending_current_user { + min-height: 5px; + max-height: 5px; + background-color: #9211ff; + border: 0px; } \ No newline at end of file diff --git a/securedrop_client/resources/css/sdclient.css b/securedrop_client/resources/css/sdclient.css index 308d09847c..b700f60aaa 100644 --- a/securedrop_client/resources/css/sdclient.css +++ b/securedrop_client/resources/css/sdclient.css @@ -400,6 +400,7 @@ QWidget#FileWidget_horizontal_line { #SpeechBubble_container { min-width: 540px; max-width: 540px; + min-height: 64px; background-color: #fff; } diff --git a/securedrop_client/resources/css/sender_icon.css b/securedrop_client/resources/css/sender_icon.css new file mode 100644 index 0000000000..e4f0980aff --- /dev/null +++ b/securedrop_client/resources/css/sender_icon.css @@ -0,0 +1,49 @@ +#SenderIcon QLabel { + border: none; + background-color: #0065db; + padding-right: 1px; + font-family: 'Source Sans Pro'; + font-weight: 500; + font-size: 23px; + color: #fff; +} + +#SenderIcon_pending QLabel { + border: none; + background-color: #0065db; + padding-right: 1px; + font-family: 'Source Sans Pro'; + font-weight: 500; + font-size: 23px; + color: #fff; +} + +#SenderIcon_failed QLabel { + border: none; + background-color: #ff3366; + padding-right: 1px; + font-family: 'Source Sans Pro'; + font-weight: 500; + font-size: 23px; + color: #fff; +} + +#SenderIcon_current_user QLabel { + border: none; + background-color: #9211ff; + padding-right: 1px; + font-family: 'Source Sans Pro'; + font-weight: 500; + font-size: 23px; + color: #fff; +} + +#SenderIcon_current_user_pending QLabel { + border: none; + background-color: #9211ff; + padding-right: 1px; + font-family: 'Source Sans Pro'; + font-weight: 500; + font-size: 23px; + color: #fff; +} \ No newline at end of file diff --git a/securedrop_client/resources/images/deleted-user.png b/securedrop_client/resources/images/deleted-user.png new file mode 100644 index 0000000000000000000000000000000000000000..0f87b2d5d33d0b100496c31627eb7cdf32e1b95e GIT binary patch literal 8938 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", "SUCCEEDED", @@ -3715,6 +3877,8 @@ def test_ConversationView_add_reply_no_content(mocker, session, source): mock_reply_succeeded_signal, mock_reply_failed_signal, 0, + sender, + False, False, ) @@ -3724,6 +3888,63 @@ def test_ConversationView_add_reply_no_content(mocker, session, source): ) +def test_ConversationView_add_reply_that_has_current_user_as_sender(mocker, session, source): + """ + Adding a reply from a source results in a new ReplyWidget added to the layout. + """ + source = source["source"] # grab the source from the fixture dict for simplicity + + mock_reply_ready_signal = mocker.MagicMock() + mock_reply_download_failed_signal = mocker.MagicMock() + mock_reply_succeeded_signal = mocker.MagicMock() + mock_reply_failed_signal = mocker.MagicMock() + authenticated_user = factory.User() + controller = mocker.MagicMock( + session=session, + reply_ready=mock_reply_ready_signal, + reply_download_failed=mock_reply_download_failed_signal, + reply_succeeded=mock_reply_succeeded_signal, + reply_failed=mock_reply_failed_signal, + authenticated_user=authenticated_user, + authentication_state=mocker.MagicMock(), + ) + + reply = factory.Reply(source=source, content=">^..^<") + session.add(reply) + session.commit() + + cv = ConversationView(source, controller) + cv.scroll.conversation_layout = mocker.MagicMock() + # this is the Reply that __init__() would return + reply_widget_res = mocker.MagicMock() + # mock the actual MessageWidget so we can inspect the __init__ call + mock_reply_widget = mocker.patch( + "securedrop_client.gui.widgets.ReplyWidget", return_value=reply_widget_res + ) + + cv.add_reply(reply=reply, sender=authenticated_user, index=0) + + # check that we built the widget was called with the correct args + mock_reply_widget.assert_called_once_with( + controller, + reply.uuid, + ">^..^<", + "SUCCEEDED", + mock_reply_ready_signal, + mock_reply_download_failed_signal, + mock_reply_succeeded_signal, + mock_reply_failed_signal, + 0, + authenticated_user, + True, + False, + ) + # check that we added the correct widget to the layout + cv.scroll.conversation_layout.insertWidget.assert_called_once_with( + 0, reply_widget_res, alignment=Qt.AlignRight + ) + + def test_ConversationView_add_downloaded_file(mocker, homedir, source, session): """ Adding a file results in a new FileWidget added to the layout with the @@ -4068,39 +4289,43 @@ def pretend_source_was_deleted(self): def test_ReplyWidget_success_failure_slots(mocker): mock_update_signal = mocker.Mock() - mock_download_failed_signal = mocker.Mock() + mock_download_failure_signal = mocker.Mock() mock_success_signal = mocker.Mock() mock_failure_signal = mocker.Mock() - msg_id = "abc123" - + controller = mocker.MagicMock(authentication_state=mocker.MagicMock()) + sender = factory.User() widget = ReplyWidget( - msg_id, - "lol", - "PENDING", - mock_update_signal, - mock_download_failed_signal, - mock_success_signal, - mock_failure_signal, - 0, + controller=controller, + message_uuid="dummy_uuid", + message="dummy_message", + reply_status="PENDING", + update_signal=mock_update_signal, + download_error_signal=mock_download_failure_signal, + message_succeeded_signal=mock_success_signal, + message_failed_signal=mock_failure_signal, + index=0, + sender=sender, + sender_is_current_user=False, + error=False, ) # 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 - assert mock_download_failed_signal.connect.called + assert mock_download_failure_signal.connect.called # check the success slog - widget._on_reply_success("mock_source_id", msg_id + "x", "lol") + widget._on_reply_success("mock_source_id", "dummy_uuid" + "x", "dummy_message") assert widget.error.isHidden() - widget._on_reply_success("mock_source_id", msg_id, "lol") + widget._on_reply_success("mock_source_id", "dummy_uuid", "dummy_message") assert widget.error.isHidden() # check the failure slot where message id does not match - widget._on_reply_failure(msg_id + "x") + widget._on_reply_failure("dummy_uuid" + "x") assert widget.error.isHidden() # check the failure slot where message id matches - widget._on_reply_failure(msg_id) + widget._on_reply_failure("dummy_uuid") assert not widget.error.isHidden() @@ -4120,16 +4345,15 @@ def test_ReplyBoxWidget__on_authentication_changed(mocker, homedir): def test_ReplyBoxWidget_on_authentication_changed_source_deleted(mocker, source): s = source["source"] - controller = mocker.MagicMock() - rb = ReplyBoxWidget(s, controller) + co = mocker.MagicMock() + rb = ReplyBoxWidget(s, co) error_logger = mocker.patch("securedrop_client.gui.widgets.logger.debug") - def pretend_source_was_deleted(self): - raise sqlalchemy.orm.exc.ObjectDeletedError(attributes.instance_state(s), None) - - with patch.object(ReplyBoxWidget, "update_authentication_state") as uas: - uas.side_effect = pretend_source_was_deleted + with mocker.patch( + "securedrop_client.gui.widgets.ReplyBoxWidget.update_authentication_state", + side_effect=sqlalchemy.orm.exc.ObjectDeletedError(attributes.instance_state(s), None), + ): rb._on_authentication_changed(True) error_logger.assert_called_once_with( "On authentication change, ReplyBoxWidget found its source had been deleted." @@ -4650,6 +4874,80 @@ def test_update_conversation_content_updates(mocker, session): assert mock_msg_widget_res.message.setText.call_args[0][0] == expected_content +def test_update_conversation_calls_updates_sender_icon(mocker, homedir, session_maker, session): + """ + Ensure reply sender badge is updated when the sender is not the authenticated user. + """ + source = factory.Source() + session.add(source) + reply = factory.Reply(filename="3-source-reply.gpg", source=source) + reply.journalist = factory.User() + session.add(reply.journalist) + session.add(reply) + session.commit() + controller = logic.Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + controller.authenticated_user = factory.User() + controller.update_authenticated_user = mocker.MagicMock() + mocker.patch("securedrop_client.gui.widgets.SenderIcon.set_sender") + cv = ConversationView(source, controller) + + cv.update_conversation(cv.source.collection) + + cv.current_messages[reply.uuid].sender_icon.set_sender.assert_called_with( + reply.journalist, False + ) + + +def test_update_conversation_calls_updates_sender_icon_for_authenticated_user( + mocker, homedir, session_maker, session +): + """ + Ensure reply sender badge is updated when the sender is the authenticated user. + """ + source = factory.Source() + session.add(source) + reply = factory.Reply(filename="3-source-reply.gpg", source=source) + reply.journalist = factory.User() + session.add(reply.journalist) + session.add(reply) + session.commit() + controller = logic.Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + controller.authenticated_user = reply.journalist + controller.update_authenticated_user = mocker.MagicMock() + mocker.patch("securedrop_client.gui.widgets.SenderIcon.set_sender") + cv = ConversationView(source, controller) + + cv.update_conversation(cv.source.collection) + + cv.controller.update_authenticated_user.emit.assert_called_once_with(reply.journalist) + cv.current_messages[reply.uuid].sender_icon.set_sender.assert_called_with( + reply.journalist, True + ) + + +def test_update_conversation_calls_update_authenticated_user( + mocker, homedir, session_maker, session +): + """ + Ensure reply sender name is updated. + """ + source = factory.Source() + session.add(source) + reply = factory.Reply(filename="3-source-reply.gpg", source=source) + reply.journalist = factory.User() + session.add(reply.journalist) + session.add(reply) + session.commit() + controller = logic.Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + controller.authenticated_user = reply.journalist + controller.update_authenticated_user = mocker.MagicMock() + cv = ConversationView(source, controller) + + cv.update_conversation(cv.source.collection) + + cv.controller.update_authenticated_user.emit.assert_called_once_with(reply.journalist) + + def test_SourceProfileShortWidget_update_timestamp(mocker): """ Ensure the update_timestamp slot actually updates the LastUpdatedLabel @@ -4665,3 +4963,60 @@ def test_SourceProfileShortWidget_update_timestamp(mocker): spsw.updated.setText.assert_called_once_with( arrow.get(mock_source.last_updated).format("DD MMM") ) + + +def test_SenderIcon_for_deleted_user(mocker): + """ + Ensure reply sender badge shows image instead of initials for delted user. + """ + sender = factory.User(uuid="deleted") + si = SenderIcon() + si.label.setPixmap = mocker.MagicMock() + si.set_sender(sender, False) + assert si.label.setPixmap.call_count == 1 + + +def test_SenderIcon_sets_text_to_initials(mocker): + """ + Ensure reply sender badge sets label to initials of the sender. + """ + sender = factory.User() + si = SenderIcon() + si.label.setText = mocker.MagicMock() + si.set_sender(sender, False) + si.label.setText.assert_called_once_with(sender.initials) + + +def test_SenderIcon_sets_text_to_initials_for_authenticated_user(mocker): + """ + Ensure reply sender badge sets label to initials of the sender for authnenticated user. + """ + sender = factory.User() + si = SenderIcon() + si.label.setText = mocker.MagicMock() + si.set_sender(sender, True) + si.label.setText.assert_called_once_with(sender.initials) + + +def test_SenderIcon_set_pending_styles_purple_for_authenticated_user(mocker): + """ + Ensure reply sender badge is blue when the sender is not the authenticated user. + """ + sender = factory.User() + si = SenderIcon() + si.setObjectName = mocker.MagicMock() + si.set_sender(sender, True) + si.set_pending_styles() + si.setObjectName.assert_called_once_with("SenderIcon_current_user_pending") + + +def test_SenderIcon_set_normal_styles_purple_for_authenticated_user(mocker): + """ + Ensure reply sender badge is purple when the sender is the authenticated user. + """ + sender = factory.User() + si = SenderIcon() + si.setObjectName = mocker.MagicMock() + si.set_sender(sender, True) + si.set_normal_styles() + si.setObjectName.assert_called_once_with("SenderIcon_current_user") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ad953000c5..a9888fbcd4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -15,6 +15,7 @@ def main_window(mocker, homedir): app.setActiveWindow(gui) gui.show() controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) + controller.authenticated_user = factory.User() controller.qubes = False gui.setup(controller) @@ -53,6 +54,7 @@ def main_window_no_key(mocker, homedir): app.setActiveWindow(gui) gui.show() controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) + controller.authenticated_user = factory.User() controller.qubes = False gui.setup(controller) @@ -89,6 +91,7 @@ def modal_dialog(mocker, homedir): gui = Window() gui.show() controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) + controller.authenticated_user = factory.User() controller.qubes = False gui.setup(controller) gui.login_dialog.close() @@ -107,6 +110,7 @@ def print_dialog(mocker, homedir): gui = Window() gui.show() controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) + controller.authenticated_user = factory.User() controller.qubes = False gui.setup(controller) gui.login_dialog.close() @@ -125,6 +129,7 @@ def export_dialog(mocker, homedir): gui = Window() gui.show() controller = Controller("http://localhost", gui, mocker.MagicMock(), homedir, proxy=False) + controller.authenticated_user = factory.User() controller.qubes = False gui.setup(controller) gui.login_dialog.close() diff --git a/tests/integration/test_styles_reply_message.py b/tests/integration/test_styles_reply_message.py index 2710b3796a..3c2008440d 100644 --- a/tests/integration/test_styles_reply_message.py +++ b/tests/integration/test_styles_reply_message.py @@ -12,7 +12,7 @@ def test_styles(mocker, main_window): assert "#3b3b3b" == reply_widget.message.palette().color(QPalette.Foreground).name() assert "#ffffff" == reply_widget.message.palette().color(QPalette.Background).name() - reply_widget._set_reply_state("PENDING") + reply_widget.set_pending_styles() assert "Source Sans Pro" == reply_widget.message.font().family() assert QFont.Normal == reply_widget.message.font().weight() @@ -20,7 +20,7 @@ def test_styles(mocker, main_window): assert "#a9aaad" == reply_widget.message.palette().color(QPalette.Foreground).name() assert "#f7f8fc" == reply_widget.message.palette().color(QPalette.Background).name() - reply_widget._set_reply_state("FAILED") + reply_widget.set_failed_styles() assert "Source Sans Pro" == reply_widget.message.font().family() assert QFont.Normal == reply_widget.message.font().weight() diff --git a/tests/integration/test_styles_reply_status_bar.py b/tests/integration/test_styles_reply_status_bar.py index 27cbb390e6..474476ceb1 100644 --- a/tests/integration/test_styles_reply_status_bar.py +++ b/tests/integration/test_styles_reply_status_bar.py @@ -5,20 +5,47 @@ def test_styles(mocker, main_window): wrapper = main_window.main_view.view_layout.itemAt(0).widget() conversation_scrollarea = wrapper.conversation_view.scroll reply_widget = conversation_scrollarea.widget().layout().itemAt(2).widget() + reply_widget.sender_is_current_user = False assert 5 == reply_widget.color_bar.minimumSize().height() assert 5 == reply_widget.color_bar.maximumSize().height() - assert "#0065db" == reply_widget.color_bar.palette().color(QPalette.Background).name() + assert "#9211ff" == reply_widget.color_bar.palette().color(QPalette.Background).name() # assert border: 0px; - reply_widget._set_reply_state("PENDING") + reply_widget.set_pending_styles() assert 5 == reply_widget.color_bar.minimumSize().height() assert 5 == reply_widget.color_bar.maximumSize().height() assert "#0065db" == reply_widget.color_bar.palette().color(QPalette.Background).name() # assert border: 0px; - reply_widget._set_reply_state("FAILED") + reply_widget.set_failed_styles() + + assert 5 == reply_widget.color_bar.minimumSize().height() + assert 5 == reply_widget.color_bar.maximumSize().height() + assert "#ff3366" == reply_widget.color_bar.palette().color(QPalette.Background).name() + # assert border: 0px; + + +def test_styles_for_replies_from_authenticated_user(mocker, main_window): + wrapper = main_window.main_view.view_layout.itemAt(0).widget() + conversation_scrollarea = wrapper.conversation_view.scroll + reply_widget = conversation_scrollarea.widget().layout().itemAt(2).widget() + reply_widget.sender_is_current_user = True + + assert 5 == reply_widget.color_bar.minimumSize().height() + assert 5 == reply_widget.color_bar.maximumSize().height() + assert "#9211ff" == reply_widget.color_bar.palette().color(QPalette.Background).name() + # assert border: 0px; + + reply_widget.set_pending_styles() + + assert 5 == reply_widget.color_bar.minimumSize().height() + assert 5 == reply_widget.color_bar.maximumSize().height() + assert "#9211ff" == reply_widget.color_bar.palette().color(QPalette.Background).name() + # assert border: 0px; + + reply_widget.set_failed_styles() assert 5 == reply_widget.color_bar.minimumSize().height() assert 5 == reply_widget.color_bar.maximumSize().height() diff --git a/tests/test_logic.py b/tests/test_logic.py index fb21401a3a..c482d08ff9 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -203,31 +203,37 @@ def test_Controller_on_authenticate_failure(homedir, config, mocker, session_mak def test_Controller_on_authenticate_success(homedir, config, mocker, session_maker, session): """ - Ensure the client syncs when the user successfully logs in, and that the - system clipboard is cleared prior to display of the main window. + Ensure upon successfully login that the client: + * starts the sync + * starts the job queues + * clears the clipboard + * emits the update_authenticated_user signal - Using the `config` fixture to ensure the config is written to disk. + Note: Using the `config` fixture ensure sthe config is written to disk """ - user = factory.User() mock_gui = mocker.MagicMock() co = Controller("http://localhost", mock_gui, session_maker, homedir) + co.authenticated_user = factory.User() co.api_sync.start = mocker.MagicMock() co.api_job_queue.start = mocker.MagicMock() co.update_sources = mocker.MagicMock() - co.session.add(user) + co.session.add(co.authenticated_user) co.session.commit() co.api = mocker.MagicMock() - co.api.token_journalist_uuid = user.uuid - co.api.username = user.username - co.api.first_name = user.firstname - co.api.last_name = user.lastname + co.api.token_journalist_uuid = co.authenticated_user.uuid + co.api.username = co.authenticated_user.username + co.api.first_name = co.authenticated_user.firstname + co.api.last_name = co.authenticated_user.lastname co.resume_queues = mocker.MagicMock() + co.update_authenticated_user = mocker.MagicMock() co.on_authenticate_success(True) - co.gui.assert_has_calls([call.clear_clipboard(), call.show_main_window(user)]) + + co.gui.assert_has_calls([call.clear_clipboard(), call.show_main_window(co.authenticated_user)]) co.api_sync.start.assert_called_once_with(co.api) co.api_job_queue.start.assert_called_once_with(co.api) assert co.is_authenticated + co.update_authenticated_user.emit.assert_called_once_with(co.authenticated_user) def test_Controller_completed_api_call_without_current_object( @@ -480,6 +486,7 @@ def test_Controller_on_sync_success(homedir, config, mocker): mock_session_maker = mocker.MagicMock(return_value=mock_session) co = Controller("http://localhost", mock_gui, mock_session_maker, homedir) + co.authenticated_user = factory.User() co.update_sources = mocker.MagicMock() co.download_new_messages = mocker.MagicMock() co.download_new_replies = mocker.MagicMock() @@ -501,6 +508,87 @@ def test_Controller_on_sync_success(homedir, config, mocker): co.file_missing.emit.assert_called_once_with(missing.source.uuid, missing.uuid, str(missing)) +def test_Controller_on_sync_success_username_change(homedir, session, config, mocker): + """ + If there's a result to syncing, then update local storage. + Using the `config` fixture to ensure the config is written to disk. + """ + mock_gui = mocker.MagicMock() + + co = Controller("http://localhost", mock_gui, mocker.MagicMock(), homedir) + user = factory.User(uuid="abc123", username="foo") + co.authenticated_user = user + co.session.refresh = mocker.MagicMock() + co.update_authenticated_user = mocker.MagicMock() + co.session.query().filter_by().one_or_none.return_value = user + + user = co.session.query(db.User).filter_by(uuid="abc123").one() + user.username = "baz" + co.session.commit() + + co.on_sync_success() + + co.session.query.reset_mock() + + co.authenticated_user.username == "baz" + co.session.refresh.assert_called_once_with(co.authenticated_user) + co.update_authenticated_user.emit.assert_called_once_with(co.authenticated_user) + + +def test_Controller_on_sync_success_firstname_change(homedir, session, config, mocker): + """ + If there's a result to syncing, then update local storage. + Using the `config` fixture to ensure the config is written to disk. + """ + mock_gui = mocker.MagicMock() + + co = Controller("http://localhost", mock_gui, mocker.MagicMock(), homedir) + user = factory.User(uuid="abc123", firstname="foo") + co.authenticated_user = user + co.session.refresh = mocker.MagicMock() + co.update_authenticated_user = mocker.MagicMock() + co.session.query().filter_by().one_or_none.return_value = user + + user = co.session.query(db.User).filter_by(uuid="abc123").one() + user.username = "baz" + co.session.commit() + + co.on_sync_success() + + co.session.query.reset_mock() + + co.authenticated_user.firstname == "baz" + co.session.refresh.assert_called_once_with(co.authenticated_user) + co.update_authenticated_user.emit.assert_called_once_with(co.authenticated_user) + + +def test_Controller_on_sync_success_lastname_change(homedir, session, config, mocker): + """ + If there's a result to syncing, then update local storage. + Using the `config` fixture to ensure the config is written to disk. + """ + mock_gui = mocker.MagicMock() + + co = Controller("http://localhost", mock_gui, mocker.MagicMock(), homedir) + user = factory.User(uuid="abc123", lastname="foo") + co.authenticated_user = user + co.session.refresh = mocker.MagicMock() + co.update_authenticated_user = mocker.MagicMock() + co.session.query().filter_by().one_or_none.return_value = user + + user = co.session.query(db.User).filter_by(uuid="abc123").one() + user.username = "baz" + co.session.commit() + + co.on_sync_success() + + co.session.query.reset_mock() + + co.authenticated_user.lastname == "baz" + co.session.refresh.assert_called_once_with(co.authenticated_user) + co.update_authenticated_user.emit.assert_called_once_with(co.authenticated_user) + + def test_Controller_show_last_sync(homedir, config, mocker, session_maker): """ Ensure we get the last sync time when we show it. @@ -1141,7 +1229,7 @@ def test_Controller_on_reply_downloaded_success(mocker, homedir, session_maker): """ co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) reply_ready = mocker.patch.object(co, "reply_ready") - reply = factory.Message(source=factory.Source()) + reply = factory.Reply(source=factory.Source()) mocker.patch("securedrop_client.storage.get_reply", return_value=reply) co.on_reply_download_success(reply.uuid) @@ -1625,6 +1713,7 @@ def test_Controller_is_authenticated_property(homedir, mocker, session_maker): mock_gui = mocker.MagicMock() co = Controller("http://localhost", mock_gui, session_maker, homedir) + # co.authentication_user = factory.User() mock_signal = mocker.patch.object(co, "authentication_state") # default state is unauthenticated diff --git a/tests/test_storage.py b/tests/test_storage.py index 821078455e..e2e513dbb3 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -872,6 +872,16 @@ def test_update_replies_missing_source(homedir, mocker, session): error_logger.assert_called_once_with(f"No source found for reply {remote_reply.uuid}") +def test_User_deleted(mocker, session): + """ + Test deleted User.. + """ + user = create_or_update_user("deleted", "mock", "mock", "mock", session) + assert not user.initials + assert not user.fullname + assert user.deleted + + def test_create_or_update_user_existing_uuid(mocker): """ Return an existing user object with the referenced uuid.